diff --git a/.changeset/fix-nested-loading-boundary-chained-memo-serialization.md b/.changeset/fix-nested-loading-boundary-chained-memo-serialization.md new file mode 100644 index 000000000..2495a08c0 --- /dev/null +++ b/.changeset/fix-nested-loading-boundary-chained-memo-serialization.md @@ -0,0 +1,9 @@ +--- +"solid-js": patch +--- + +fix(server): serialize chained async memo values resolved after a nested Loading boundary commits + +A chained async memo reached through a synchronous derived memo (e.g. `a` async → `m = createMemo(() => a()[0])` → `b = createMemo(() => fetchItems(m()))`) resolves only after its dependency, so inside a nested `` boundary it serializes *after* the surrounding boundary has already flushed and committed. That late serialization landed in a buffer that never flushed again, so the value was dropped — only the dependency's value survived. On the client the memo then re-ran its compute and orphaned the server-streamed fragment ("Hydration completed with N unclaimed server-rendered node(s)"). This is the shape produced when route content is nested in a root layout's boundary (e.g. TanStack Start). + +Once a boundary has flushed, later serializations now write through to the parent context instead of being buffered into a buffer that will never flush again. diff --git a/packages/solid-web/test/server/ssr-stream.spec.tsx b/packages/solid-web/test/server/ssr-stream.spec.tsx index 8fc7401b4..aca47ef19 100644 --- a/packages/solid-web/test/server/ssr-stream.spec.tsx +++ b/packages/solid-web/test/server/ssr-stream.spec.tsx @@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest"; import { renderToString, renderToStream, + renderToStringAsync, Loading, Reveal, Show, @@ -667,6 +668,62 @@ describe("SSR Streaming — Chained Async", () => { expect(shell).toContain("Loading..."); expect(full).toContain("Hello world"); }); + + // A chained async memo reached through a SYNC derived memo must serialize its + // resolved VALUE, including inside a NESTED Loading boundary. `b` only resolves + // after `a`, so when nested it serializes *after* the surrounding boundary has + // already flushed/committed. Previously that late serialization landed in a + // buffer that never flushed again, dropping `b`'s value — the client then + // re-ran the compute and orphaned the server fragment ("unclaimed + // server-rendered node"). This is the shape TanStack Start produces (route + // content nested in the root layout's boundary). + const fetchItems = async (id: number) => ["item " + id]; + function ChainedInner() { + const a = createMemo(async () => asyncValue([1], 10)); + const m = createMemo(() => a()[0]); // sync — re-throws while a is pending + const b = createMemo(() => fetchItems(m())); // body throws synchronously first pass + return ( + loading}> + {x =>
{x}
}
+
+ ); + } + + test("serializes chained memo value (single boundary)", async () => { + const html = await renderComplete(() => ); + expect(html).toMatch(/=\[1\]/); + expect(html).toContain(`["item 1"]`); + }); + + test("serializes chained memo value (nested boundary)", async () => { + const html = await renderComplete(() => ( + outer}> + + + )); + expect(html).toMatch(/=\[1\]/); + expect(html).toContain(`["item 1"]`); + }); + + test("serializes chained memo value (deeply nested boundaries)", async () => { + const html = await renderComplete(() => ( + l1}> + l2}> + + + + )); + expect(html).toContain(`["item 1"]`); + }); + + test("serializes chained memo value (nested boundary, renderToStringAsync)", async () => { + const html = await renderToStringAsync(() => ( + outer}> + + + )); + expect(html).toContain(`["item 1"]`); + }); }); describe("SSR Streaming — Edge Cases", () => { diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index 5e3a74203..790601cde 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -86,15 +86,24 @@ export function createLoadingBoundary( let handledRenderError: any; let retryPromise: Promise | undefined; let serializeBuffer: [string, any, boolean?][] = []; + // Once this boundary has flushed, it never buffers again (resets only happen + // during retry discovery, before the first flush). A chained async source can + // resolve *after* the boundary commits — e.g. `b` depends on `a`, so `b` + // serializes only once `a` settled and the boundary already flushed. Those + // late serializations must write through to the parent ctx instead of landing + // in a buffer that will never flush again (which would orphan the fragment). + let flushed = false; const bufferedCtx = Object.create(ctx) as typeof ctx; bufferedCtx.serialize = (id: string, value: any, deferStream?: boolean) => { - serializeBuffer.push([id, value, deferStream]); + if (flushed) ctx.serialize(id, value, deferStream); + else serializeBuffer.push([id, value, deferStream]); }; bufferedCtx._currentBoundaryId = id; function flushSerializeBuffer() { for (const args of serializeBuffer) ctx.serialize(args[0], args[1], args[2]); serializeBuffer = []; + flushed = true; } function commitBoundaryState() {