diff --git a/.changeset/fix-loading-late-fragment-hydration.md b/.changeset/fix-loading-late-fragment-hydration.md new file mode 100644 index 000000000..158eae6f9 --- /dev/null +++ b/.changeset/fix-loading-late-fragment-hydration.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +fix late-streamed fragment orphaning when a chained async memo recomputes after the shell finishes hydrating 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..c9d4aeda3 --- /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 : `