Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 `<Loading>` 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.
57 changes: 57 additions & 0 deletions packages/solid-web/test/server/ssr-stream.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest";
import {
renderToString,
renderToStream,
renderToStringAsync,
Loading,
Reveal,
Show,
Expand Down Expand Up @@ -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 fallback={<div>loading</div>}>
<For each={b()}>{x => <div>{x}</div>}</For>
</Loading>
);
}

test("serializes chained memo value (single boundary)", async () => {
const html = await renderComplete(() => <ChainedInner />);
expect(html).toMatch(/=\[1\]/);
expect(html).toContain(`["item 1"]`);
});

test("serializes chained memo value (nested boundary)", async () => {
const html = await renderComplete(() => (
<Loading fallback={<div>outer</div>}>
<ChainedInner />
</Loading>
));
expect(html).toMatch(/=\[1\]/);
expect(html).toContain(`["item 1"]`);
});

test("serializes chained memo value (deeply nested boundaries)", async () => {
const html = await renderComplete(() => (
<Loading fallback={<div>l1</div>}>
<Loading fallback={<div>l2</div>}>
<ChainedInner />
</Loading>
</Loading>
));
expect(html).toContain(`["item 1"]`);
});

test("serializes chained memo value (nested boundary, renderToStringAsync)", async () => {
const html = await renderToStringAsync(() => (
<Loading fallback={<div>outer</div>}>
<ChainedInner />
</Loading>
));
expect(html).toContain(`["item 1"]`);
});
});

describe("SSR Streaming — Edge Cases", () => {
Expand Down
11 changes: 10 additions & 1 deletion packages/solid/src/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,24 @@ export function createLoadingBoundary(
let handledRenderError: any;
let retryPromise: Promise<any> | 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() {
Expand Down
Loading