From 9f601d9dce1826d80654d81bc57ddb2ae19c4f2f Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Wed, 24 Jun 2026 06:51:29 -0500 Subject: [PATCH 1/3] fix: claim late-streamed fragments in chained async memos during hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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):
item 1
" 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. --- .../hydration/loading-late-fragment.spec.tsx | 136 ++++++++++++++++++ packages/solid/src/client/hydration.ts | 17 ++- 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 packages/solid-web/test/hydration/loading-late-fragment.spec.tsx diff --git a/packages/solid-web/test/hydration/loading-late-fragment.spec.tsx b/packages/solid-web/test/hydration/loading-late-fragment.spec.tsx new file mode 100644 index 000000000..8e97cf384 --- /dev/null +++ b/packages/solid-web/test/hydration/loading-late-fragment.spec.tsx @@ -0,0 +1,136 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Regression test: a boundary whose streamed fragment arrives AFTER + * the shell has hydrated (chained async memos whose serialized values are not + * in the shell) must claim the late-streamed server node, not orphan it. + * + * Root cause: when a chained async memo (e.g. `b = createMemo(() => fetchItems(m()))` + * where `m` reads a pending async memo `a`) recomputes after `sharedConfig.hydrating` + * flips to false but before the boundary's fragment resume fires, the memo's + * `readSerializedOrCompute` would re-run its compute function (e.g. `fetchItems(...)`) + * 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` swapped in. + * "Hydration completed with 1 unclaimed server-rendered node(s): + *
item 1
" + */ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; +import { createMemo, flush, Loading } from "solid-js"; +import { For, hydrate } from "@solidjs/web"; + +function setupHydration() { + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const fetchItems = async (id: number) => { + await sleep(10); + return ["item " + id]; +}; + +// Streamed chunks for `` with sleep(1000)-equivalent timing: +// shell : boundary fallback + pending `3_fr` + pending `0` (a). key `2` (b) +// is NOT registered yet (b only serializes after a resolves). +// mid : defines the resolver, resolves `0` -> [1], registers `2` (pending). +// late : `