Skip to content

fix: claim late-streamed fragments in chained async memos during hydration#2792

Open
brenelz wants to merge 3 commits into
solidjs:nextfrom
brenelz:fix/loading-late-fragment-hydration
Open

fix: claim late-streamed fragments in chained async memos during hydration#2792
brenelz wants to merge 3 commits into
solidjs:nextfrom
brenelz:fix/loading-late-fragment-hydration

Conversation

@brenelz

@brenelz brenelz commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes an intermittent hydration error that occurs with chained async memos inside a <Loading> boundary under renderToStream:

Hydration completed with 1 unclaimed server-rendered node(s):
  <div _hk="3300000">item 1</div>

Root cause

When a chained async memo (e.g. b = createMemo(() => fetchItems(m())) where m reads a pending async memo a) recomputes after the shell finishes hydrating (sharedConfig.hydrating flips to false) but before the <Loading> boundary's fragment resume fires, readSerializedOrCompute would re-run the compute function (fetchItems(...)) instead of subscribing to the server's serialized deferred Promise.

The new client-side Promise resolves after the boundary's resume window closes, so the <For> renders with hydrating=false and creates a fresh node — orphaning the server fragment that \$df swaps in.

Reproduction:

function Home() {
  const a = createMemo(async () => {
    await sleep(1000);
    return [1];
  });
  const m = createMemo(() => a()[0]);
  const b = createMemo(() => fetchItems(m()));

  return (
    <Loading fallback={<div>loading</div>}>
      <For each={b()}>{(x) => <div>{x}</div>}</For>
    </Loading>
  );
}

Fix

In readSerializedOrCompute (packages/solid/src/client/hydration.ts), track owners whose serialized value has been consumed via a WeakSet. When hydrating is false but the owner's serialized key has not been consumed yet, still read it so the memo subscribes to the server's deferred Promise via handleAsync. The serialized value is only used once; subsequent recomputes (e.g. user interactions) use compute(prev) normally.

Test

Adds packages/solid-web/test/hydration/loading-late-fragment.spec.tsx which reproduces the streaming scenario (shell → hydrate → mid chunk resolves a → late chunk swaps fragment + resolves 3_fr) and asserts:

  • only one <div> is present (no orphan)
  • no unclaimed server-rendered node warning fires

All existing tests pass:

  • solid-web hydration suite: 18 passed
  • solid-web server suite: 86 passed
  • solid-web main suite: 298 passed
  • solid-signals: 751 passed

brenelz added 3 commits June 24, 2026 06:51
…ation

When a chained async memo (e.g. b = createMemo(() => fetchItems(m()))
where m reads a pending async memo a) recomputes after the shell finishes
hydrating (sharedConfig.hydrating flips to false) but before the Loading
boundary's fragment resume fires, readSerializedOrCompute would re-run the
compute function instead of subscribing to the server's serialized deferred
Promise. The new client-side Promise resolves after the resume window closes,
so the For renders with hydrating=false and creates a fresh node — orphaning
the server fragment that $df swaps in:

  "Hydration completed with 1 unclaimed server-rendered node(s):
   <div _hk=\"...\">item 1</div>"

Fix: track owners whose serialized value has been consumed via a WeakSet.
When hydrating is false but the owner's serialized key has not been consumed
yet, still read it so the memo subscribes to the server's deferred Promise
via handleAsync. The serialized value is only used once; subsequent
recomputes use compute(prev) normally.
@changeset-bot

changeset-bot Bot commented Jun 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fd8fceb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
solid-js Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
@solidjs/web Patch
test-integration Patch
babel-preset-solid Patch
@solidjs/signals Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed-hq

codspeed-hq Bot commented Jun 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 118 untouched benchmarks


Comparing brenelz:fix/loading-late-fragment-hydration (fd8fceb) with next (c943c5c)

Open in CodSpeed

ryansolid added a commit that referenced this pull request Jun 24, 2026
…stream chunks

A chained async memo that recomputes between a streamed <Loading> section's
chunks ran its real client body (committing a fresh Promise) because the
per-pass `sharedConfig.hydrating` flag is false in that gap. The fresh Promise
resolved after the resume window closed, orphaning/duplicating the
server-streamed fragment. Treat a computation as still hydrating while the
lifecycle is in progress (`!done`) and it has an unconsumed serialized value,
so it short-circuits to the server value. Supersedes the WeakSet approach in
PR #2792 with a smaller, lifecycle-bound guard.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant