Skip to content

Commit 98a7385

Browse files
fix(hydration): adopt serialized value for memos recomputing between stream chunks
A chained async memo that recomputes between a streamed <Loading> section's chunks ran its real client body (committing a fresh Promise) because the per-pass `sharedConfig.hydrating` flag is false in that gap. The fresh Promise resolved after the resume window closed, orphaning/duplicating the server-streamed fragment. Treat a computation as still hydrating while the lifecycle is in progress (`!done`) and it has an unconsumed serialized value, so it short-circuits to the server value. Supersedes the WeakSet approach in PR #2792 with a smaller, lifecycle-bound guard. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4f14a34 commit 98a7385

3 files changed

Lines changed: 159 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"solid-js": patch
3+
---
4+
5+
Fix late-streamed `<Loading>` fragments being orphaned/duplicated during hydration when a chained async memo recomputes between stream chunks. A computation is now treated as still hydrating while the overall lifecycle is in progress (`!done`) and it has an unconsumed serialized value, so it short-circuits to the server's deferred value instead of re-running its async body on the client.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @jsxImportSource @solidjs/web
3+
* @vitest-environment jsdom
4+
*
5+
* Regression: a chained async memo must not run real client work (and must not
6+
* orphan/duplicate the server fragment) when it recomputes between a streamed
7+
* <Loading> section's chunks.
8+
*
9+
* `a` (async) is serialized as a deferred Promise; `b = createMemo(() => fetchItems(m()))`
10+
* depends on it through `m`. On the client, when `a` resolves from its serialized
11+
* value, `b` recomputes — but that recompute lands *between* boundary-resume
12+
* windows, where the per-pass `sharedConfig.hydrating` flag is false. The
13+
* computation is still hydrating (its serialized value `"2"` is unconsumed and
14+
* hydration is not `done`), so it must short-circuit to the server's serialized
15+
* Promise instead of running a fresh `fetchItems(...)`. Otherwise the fresh
16+
* client Promise resolves after the resume window closes, <For> renders a fresh
17+
* node, and the server-streamed fragment is orphaned (duplicated in prod, dev
18+
* warns: "unclaimed server-rendered node").
19+
*/
20+
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
21+
import { createMemo, flush, Loading } from "solid-js";
22+
import { For, hydrate } from "@solidjs/web";
23+
24+
function setupHydration() {
25+
(globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} };
26+
}
27+
28+
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
29+
30+
// Real client fetches are counted here. `subFetch` swaps `window.fetch` for a
31+
// mock during dep-collection, so this spy only fires if the body performed real
32+
// client work — i.e. the short-circuit did NOT engage.
33+
let realFetchCalls = 0;
34+
(globalThis as any).fetch = async () => {
35+
realFetchCalls++;
36+
return { ok: true };
37+
};
38+
const fetchItems = async (id: number) => {
39+
await fetch("/items/" + id);
40+
return ["item " + id];
41+
};
42+
43+
// Streamed chunks for `<Home/>`:
44+
// shell : boundary fallback + pending `3_fr` + pending `0` (a). `b`'s key `2`
45+
// is not registered yet (it serializes only after `a` resolves).
46+
// mid : resolves `0` -> [1] and registers `2` (pending).
47+
// late : <template id="3"> + $df swap + resolves `2` and `3_fr`.
48+
const RESOLVER_FN =
49+
"($R[6]=(resolver, data) => { resolver.s(data); resolver.p.s = 1; resolver.p.v = data; })";
50+
const DEFERRED =
51+
"($R[2]=() => { const resolver = { p: 0, s: 0, f: 0 }; resolver.p = new Promise((resolve, reject) => { resolver.s = resolve; resolver.f = reject; }); return resolver; })";
52+
53+
const SHELL =
54+
`<template id="pl-3"></template><div _hk=30>loading</div><!--pl-3-->` +
55+
`<script>(self.$R=self.$R||{})[""]=[];` +
56+
`_$HY.r["0"]=$R[0]=($R[1]=${DEFERRED}()).p;` +
57+
`_$HY.r["3_fr"]=$R[3]=($R[4]=${DEFERRED}()).p;` +
58+
`</script>`;
59+
60+
const MID =
61+
`<script>${RESOLVER_FN}($R[1],$R[5]=[1]);` +
62+
`_$HY.r["2"]=$R[7]=($R[8]=${DEFERRED}()).p;` +
63+
`</script>`;
64+
65+
const LATE_TEMPLATE = `<template id="3"><div _hk=300000>item 1</div></template>`;
66+
67+
const LATE_SCRIPT =
68+
`<script>$R[6]($R[8],$R[9]=["item 1"]);$df("3");` +
69+
`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}` +
70+
`;$R[6]($R[4],!0);</script>`;
71+
72+
function applyChunk(container: HTMLDivElement, chunk: string, first: boolean) {
73+
const scriptRe = /<script(?:[^>]*)>([\s\S]*?)<\/script>/g;
74+
const scripts = [...chunk.matchAll(scriptRe)].map(m => m[1]);
75+
const stripped = chunk.replace(scriptRe, "");
76+
if (first) container.innerHTML = stripped;
77+
else container.insertAdjacentHTML("beforeend", stripped);
78+
for (const s of scripts) (0, eval)(s);
79+
}
80+
81+
describe("Loading boundary — late-streamed fragment (chained async memos)", () => {
82+
const container = document.createElement("div");
83+
document.body.appendChild(container);
84+
let dispose: (() => void) | undefined;
85+
86+
beforeEach(async () => {
87+
if (dispose) dispose();
88+
await new Promise(r => setTimeout(r, 0));
89+
setupHydration();
90+
container.innerHTML = "";
91+
realFetchCalls = 0;
92+
});
93+
94+
afterEach(() => {
95+
if (dispose) {
96+
dispose();
97+
dispose = undefined;
98+
}
99+
});
100+
101+
function Home() {
102+
const a = createMemo(async () => {
103+
await sleep(10);
104+
return [1];
105+
});
106+
const m = createMemo(() => a()[0]);
107+
const b = createMemo(() => fetchItems(m()));
108+
return (
109+
<Loading fallback={<div>loading</div>}>
110+
<For each={b()}>{x => <div>{x}</div>}</For>
111+
</Loading>
112+
);
113+
}
114+
115+
test("claims the streamed fragment, no duplicate, no client work", async () => {
116+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
117+
118+
applyChunk(container, SHELL, true);
119+
dispose = hydrate(() => <Home />, container);
120+
await Promise.resolve();
121+
flush();
122+
applyChunk(container, MID, false);
123+
await Promise.resolve();
124+
flush();
125+
applyChunk(container, LATE_TEMPLATE, false);
126+
applyChunk(container, LATE_SCRIPT, false);
127+
await Promise.resolve();
128+
await Promise.resolve();
129+
flush();
130+
await new Promise(r => setTimeout(r, 50));
131+
132+
// server fragment claimed, not duplicated
133+
expect(container.querySelectorAll("div").length).toBe(1);
134+
expect(container.textContent).toBe("item 1");
135+
// cause is fixed: the memo short-circuited to the server value, so no real
136+
// client fetch ran during hydration
137+
expect(realFetchCalls).toBe(0);
138+
139+
const orphanWarns = warn.mock.calls.filter(
140+
(c: unknown[]) => typeof c[0] === "string" && c[0].includes("unclaimed server-rendered node")
141+
);
142+
expect(orphanWarns).toHaveLength(0);
143+
warn.mockRestore();
144+
});
145+
});

packages/solid/src/client/hydration.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,16 @@ function readHydratedValue(initP: any, refresh: () => void) {
276276

277277
/** Shared “serialized init or run compute” path for memo/signal/optimistic/effect under hydration. */
278278
function readSerializedOrCompute(compute: (prev: any) => any, prev: any) {
279-
if (!sharedConfig.hydrating) return compute(prev);
280279
const o = getOwner()!;
281-
let initP: any;
282-
if (sharedConfig.has!(o.id!)) initP = sharedConfig.load!(o.id!);
280+
// A computation must adopt its serialized server value for the whole
281+
// hydration lifecycle (`!done`), not just inside a synchronous resume window.
282+
// A streamed section can recompute between chunks; running the client body
283+
// there would commit a fresh Promise and orphan the server-streamed fragment.
284+
// So short-circuit to the server value whenever one is still waiting; once
285+
// hydration is `done`, always compute.
286+
const hasSerialized = !sharedConfig.done && sharedConfig.has!(o.id!);
287+
if (!sharedConfig.hydrating && !hasSerialized) return compute(prev);
288+
const initP = hasSerialized ? sharedConfig.load!(o.id!) : undefined;
283289
const init = readHydratedValue(initP, () => subFetch(compute, prev));
284290
return init !== NO_HYDRATED_VALUE ? init : compute(prev);
285291
}

0 commit comments

Comments
 (0)