Skip to content

renderToStream hangs when a nested <Loading> settles before its parent fragment fails #3478

Closed

Description

@ryansolid

Summary

renderToStream never ends when a nested <Loading> settles before a sibling async read in the same parent fragment rejects. The outer fragment's failure abandons its subtree (#3165), but a nested fragment that has already settled — its content template and $df emitted — leaves something the response's completion still waits on. Reproduced against next (3ae9e921b) with the web and solid server entries from source.

Reproduction

function Fast() {
  const fast = createMemo(async () => {
    await delay(5);
    return "early";
  });
  return <span>{fast()}</span>;
}
function Child() {
  const bad = createMemo(async () => {
    await delay(60);
    throw new Error("boom");
  });
  return (
    <div>
      <Loading fallback={<i>inner</i>}>
        <Fast />
      </Loading>
      {bad()}
    </div>
  );
}

// never resolves — `end()` is never called on the sink
await new Promise(resolve =>
  renderToStream(() => (
    <Loading fallback={<i>outer</i>}>
      <Child />
    </Loading>
  )).pipe({ write() {}, end: resolve })
);

Two variations that do complete, which bound the bug:

So the case that hangs is specifically: inner fragment settled first, then the outer fragment that contains it fails. Argument order matters — the nested <Loading> must be created before the failing read throws NotReadyError on the first pass (here it precedes {bad()} in the template), which is an ordinary app shape.

Where to look

renderToStream's fragment registry and abandonSubtree (packages/web/src/server.ts): abandonment walks registry for keys under the failed one and pendingSerialized for ids under it. A nested fragment that already settled has left the registry; whatever it still holds open — its reveal-group registration, a blockingPromises/flushEnd gate, its _fr stub batched pre-shell — is not in either table when the parent fails. SSR_RENDER_ERROR_CONTAINED (handling: "client") is recorded for the outer fragment, so the failure itself is seen; it is the completion accounting that is wrong.

Found while writing the tier fixture for #3477; not fixed there. Refs #3165.

Activity

  1. added 2 commits that reference this issue on Sep 15, 2026
  2. ryansolid commented on Sep 15, 2026

    @ryansolid
    MemberAuthor

    Fixed in #3480 (thanks @brenelz).

    Root cause: when a nested <Loading> settled while its parent fragment was still pending, its markup was parked on the parent's registry entry to be spliced in on resolve. When the parent then failed, the splice ran over an undefined value, replacePlaceholder threw out of the fragment resolver before item.resolve(error) could reject <key>_fr, and the response waited on it forever — which is also why no SSR_SUBTREE_ABANDONED finding appeared and the boundary recorded a second SSR_RENDER_ERROR_CONTAINED. The error path now drops the parked children; the client re-renders the whole subtree off the outer rejection.

    — Claude via Cursor

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