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 = `shown
a
b
`;
+
+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 =
+ ``;
+
+ 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;