Skip to content

[2.0 rc.10 – rc.19] A re-created subtree never commits when two boundaries read isPending() of the same async memo and that memo lands last #4338

Closed

Description

@xiom84

Describe the bug

Since 2.0.0-rc.10 an update that creates a new subtree never commits when, inside that subtree, isPending() of the same async memo is read from two places (here: a <Show> nested in one <Errored> and a <Show> in a sibling <Errored>) and that memo lands after another async memo of the same subtree. The old subtree stays on screen forever, no error is thrown, the reactive system is not halted (later unrelated writes still work), the new section just never lands.

The same tree mounts fine on first render. Only the re-creation inside an update (a keyed <For> row swap in the repro, a route/param change in the real app) hangs. The hang goes away with any one of:

  • removing the second isPending(() => slow()) reader (only one boundary reads it),
  • making the twice-read memo land first (fast slow, slow fast),
  • reading isPending once in an effect and fanning it out through a signal (our workaround).

2.0.0-rc.9 passes the repro; rc.10, rc.11, rc.13, rc.15, rc.17 and rc.19 all hang (bisected with the matching @solidjs/web and @solidjs/vite-plugin for each).

We hit this in an app where a team picker swaps a section that fetches the standings and the matches of the selected group: the switch commits or hangs depending on which of the two requests answers first.

Your Example Website or App

Minimal test (vitest + jsdom + @solidjs/testing-library), fails on rc.19, passes on rc.9:

import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library";
import { createMemo, createSignal, Errored, For, isPending, latest, Loading, Show } from "solid-js";
import { expect, it } from "vitest";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const latestOr = <T,>(read: () => T, fallback: T): T => {
  try {
    return latest(read) ?? fallback;
  } catch {
    return fallback;
  }
};

function Section(props: { k: string }) {
  const fast = createMemo(async () => {
    await sleep(5);
    return "fast-" + props.k;
  });
  const slow = createMemo(async () => {
    await sleep(30);
    return "slow-" + props.k;
  });
  return (
    <div>
      <Errored fallback={<i>err1</i>}>
        <Show when={!isPending(() => fast())}>
          <p>{latestOr(() => fast(), "")}</p>
          <Show when={!isPending(() => slow())}>
            <b data-testid="slow">{latestOr(() => slow(), "")}</b>
          </Show>
        </Show>
      </Errored>
      {/* second reader of the same memo in a sibling boundary: remove it and the update commits */}
      <Errored fallback={<i>err2</i>}>
        <Show when={!isPending(() => slow())}>
          <span>side</span>
        </Show>
      </Errored>
    </div>
  );
}

it("commits the re-created section", async () => {
  const [key, setKey] = createSignal("a");
  const list = createMemo(() => [key()]);
  render(() => (
    <Loading fallback={<i>loading</i>}>
      <button type="button" onClick={() => setKey("b")}>toggle</button>
      <For each={list()}>{(k) => <Section k={k} />}</For>
    </Loading>
  ));
  await waitFor(() => expect(screen.getByTestId("slow")).toHaveTextContent("slow-a"));
  fireEvent.click(screen.getByText("toggle"));
  // rc.9: passes. rc.10 … rc.19: times out, the DOM still shows slow-a.
  await waitFor(() => expect(screen.getByTestId("slow")).toHaveTextContent("slow-b"), { timeout: 1500 });
});

The result is the same without the <Loading> boundary (the ASYNC_OUTSIDE_LOADING_BOUNDARY path).

Steps to Reproduce the Bug or Issue

  1. Put the test above in a Vite + vitest (jsdom) project with solid-js@2.0.0-rc.19, @solidjs/web@2.0.0-rc.19, @solidjs/vite-plugin@3.0.0-next.51, @solidjs/testing-library@1.0.0-rc.2.
  2. Run it: the second waitFor times out; screen.debug() shows the old row (slow-a) still mounted.
  3. Delete the second <Errored> block (the sibling reader of slow) or swap the two sleep durations: the test passes.
  4. Downgrade to solid-js@2.0.0-rc.9 / @solidjs/web@2.0.0-rc.9 / @solidjs/vite-plugin@3.0.0-next.44: the test passes unchanged.

Expected behavior

The new Section commits once both of its async memos have settled, independent of how many boundaries read isPending of each memo and of the order in which the memos land, as it did on rc.9 and as it does on first mount.

Screenshots or Videos

No response

Platform

  • OS: macOS 27
  • Node: 24.16
  • solid-js / @solidjs/web: 2.0.0-rc.19 (regression since rc.10)
  • @solidjs/vite-plugin: 3.0.0-next.51
  • vitest 5.0.5, jsdom 30.1.3, @solidjs/testing-library 1.0.0-rc.2

Additional context

Workaround that we ship: read isPending once per memo in a createEffect and mirror it into createSignals that the <Show>s gate on. One memo read many times inside a single boundary does not trigger it, and a section with only one async memo does not either.

Activity

  1. ryansolid commented on Oct 28, 2026

    @ryansolid
    Member

    Fixed on next by #4347 (cddc7c3); it ships in the next RC.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions