diff --git a/.changeset/fix-ssr-sync-memo-child-id-leak.md b/.changeset/fix-ssr-sync-memo-child-id-leak.md new file mode 100644 index 000000000..9be87f7e6 --- /dev/null +++ b/.changeset/fix-ssr-sync-memo-child-id-leak.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +Fix hydration key drift when a compiler-emitted expression memo reads a pending async source during streamed SSR (#2801). The server's lean sync memo re-runs its compute on every pull after a `NotReadyError`, but did so without resetting the owner's child state — each failed pull leaked the child-id slots it consumed (e.g. the inner condition memo of `{data().value &&

...

}`), so hydration keys produced by the eventual successful pull drifted ahead of the client's single successful compute and the affected nodes went unclaimed (duplicated in prod, "unclaimed server-rendered node" warning in dev). The sync memo now disposes children and resets `_childCount` before each re-pull, mirroring how the client resets an owner on recompute, so every pull emits the same ids. diff --git a/packages/solid-web/test/hydration/async-condition-keys.spec.tsx b/packages/solid-web/test/hydration/async-condition-keys.spec.tsx new file mode 100644 index 000000000..76482a3dc --- /dev/null +++ b/packages/solid-web/test/hydration/async-condition-keys.spec.tsx @@ -0,0 +1,141 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Regression (#2801 bug 2): `{data().value &&

...

}` before a , + * where the condition reads an async memo inside a streamed . + * + * Hydration keys are owner-path child-id slots. The server's lean sync memo + * re-runs its compute on every pull after a `NotReadyError`, and previously + * did so without resetting the owner's child state — each failed pull leaked + * the child-id slots it consumed (the compiler-emitted inner memo of the `&&` + * expression), so the keys produced by the eventual successful pull drifted + * ahead of the client's single successful compute. The server wrote + * `

`; the client claimed `10001`; the h4 went unclaimed + * (duplicated in prod, dev warns "unclaimed server-rendered node"). + */ +import { describe, expect, test, vi } from "vitest"; +import { createMemo, createSignal, flush, Loading, enableHydration } from "solid-js"; +import { For, hydrate } from "@solidjs/web"; + +enableHydration(); + +function setupHydration() { + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; +} + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +// Exact renderToStream chunks for the async component below (server build). +const SHELL_HTML = `
loading
`; +const SHELL_SCRIPT = `(self.$R=self.$R||{})[""]=[];_$HY.r["0"]=$R[0]=($R[1]=($R[2]=() => { + const resolver = { + p: 0, + s: 0, + f: 0 + }; + resolver.p = new Promise((resolve, reject) => { + resolver.s = resolve; + resolver.f = reject; + }); + return resolver; +})()).p;_$HY.r["1_fr"]=$R[3]=($R[4]=$R[2]()).p;`; + +const LATE_TEMPLATE = ``; + +const LATE_SCRIPT = `($R[6]=(resolver, data) => { + resolver.s(data); + resolver.p.s = 1; + resolver.p.v = data; +})($R[1],$R[5]={value:"shown"});$df("1");function $df(e,n,o,t){if(!(n=document.getElementById(e))||!(o=document.getElementById("pl-"+e)))return 0;for(;o&&8!==o.nodeType&&o.nodeValue!=="pl-"+e;)t=o.nextSibling,o.remove(),o=t;_$HY.done?o.remove():o.replaceWith(n.content),n.remove(),_$HY.fe(e);return 1};$R[6]($R[4],!0);`; + +describe("#2801: && expression before across hydration", () => { + test("async condition — streamed fragment is fully claimed", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + setupHydration(); + container.innerHTML = SHELL_HTML; + (0, eval)(SHELL_SCRIPT); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + const dispose = hydrate(() => { + const data = createMemo(async () => { + await sleep(5); + return { value: "shown" }; + }); + const [items] = createSignal(["a", "b"]); + return ( + loading}> + {data().value &&

{data().value}

} + {x =>
{x}
}
+
+ ); + }, container); + + flush(); + await sleep(10); + // stream the late fragment + resolver script + container.insertAdjacentHTML("beforeend", LATE_TEMPLATE); + (0, eval)(LATE_SCRIPT); + await sleep(30); + flush(); + await sleep(30); + + const messages = [...warn.mock.calls, ...error.mock.calls].map(c => String(c[0])); + const problems = messages.filter(m => /unclaimed|mismatch|hydration/i.test(m)); + + expect(container.querySelectorAll("h4").length).toBe(1); + expect(container.querySelectorAll("div:not([id])").length).toBe(2); + expect(container.textContent).toBe("shownab"); + expect(problems).toEqual([]); + + dispose(); + warn.mockRestore(); + error.mockRestore(); + }); + + test("sync condition — server nodes are claimed in place", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + setupHydration(); + // Exact renderToStream output for the sync component below (server build) + container.innerHTML = + `

shown

` + + `
a
b
`; + + const serverH4 = container.querySelector("h4")!; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + const dispose = hydrate(() => { + const [show] = createSignal(true); + const [items] = createSignal(["a", "b"]); + return ( +
+ {show() &&

shown

} + {x =>
{x}
}
+
+ ); + }, container); + + flush(); + await sleep(50); + + const messages = [...warn.mock.calls, ...error.mock.calls].map(c => String(c[0])); + const problems = messages.filter(m => /unclaimed|mismatch|hydration/i.test(m)); + + expect(container.querySelectorAll("h4").length).toBe(1); + const appDiv = container.firstElementChild!; + expect(appDiv.querySelectorAll("div").length).toBe(2); + expect(container.textContent).toBe("shownab"); + // the server-rendered h4 must be claimed, not replaced + expect(container.querySelector("h4")).toBe(serverH4); + expect(problems).toEqual([]); + + dispose(); + warn.mockRestore(); + error.mockRestore(); + }); +}); diff --git a/packages/solid-web/test/server/ssr-stream.spec.tsx b/packages/solid-web/test/server/ssr-stream.spec.tsx index 55cac32ef..ff5172b29 100644 --- a/packages/solid-web/test/server/ssr-stream.spec.tsx +++ b/packages/solid-web/test/server/ssr-stream.spec.tsx @@ -744,6 +744,27 @@ describe("SSR Streaming — Pending reads must not loop the boundary (#2801)", ( expect(effectValue).toBe("render-effect-content"); }); + test("failed pulls do not leak hydration key slots (async && before )", async () => { + function App() { + const data = createMemo(async () => asyncValue({ value: "shown" }, 20)); + const [items] = createSignal(["a", "b"]); + return ( + loading}> + {data().value &&

{data().value}

} + {x =>
{x}
}
+
+ ); + } + const { chunks } = await collectChunks(() => ); + const full = chunks.join(""); + // Each NotReadyError pull of the compiler-emitted condition memo must not + // consume a child-id slot, or the h4's key drifts ahead of the client's + // single successful compute (was _hk=10003) and the node goes unclaimed. + expect(full).toContain("

shown

"); + expect(full).toContain("
a
"); + expect(full).toContain("
b
"); + }); + test("top-level render effect holds shell flush until its async source settles", async () => { let effectValue: any; let effectRanBeforeShell = false; diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 9bdeb9cc5..59fff19fb 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -622,6 +622,15 @@ function createSyncMemo( // memo + one pull is paid per row. const prev = currentOwner; currentOwner = owner as unknown as SSROwner; + // A pull after `NotReadyError` re-runs the compute under the same owner. + // The client resets an owner's child state on every recompute (dispose + + // `_childCount = 0`), so mirror that here — otherwise every failed pull + // leaks the child-id slots it consumed (e.g. the compiler-emitted inner + // memo of `{cond && }`) and the hydration keys of everything created + // by the eventual successful pull drift ahead of the client's (#2801). + const o = owner as unknown as SSROwner; + if (o._firstChild || o._disposal) disposeOwner(owner, false); + o._childCount = 0; try { value = compute(value) as T; error = undefined;