Skip to content

Commit e040dae

Browse files
ryansolidClaude via Cursorcursoragent
committed
Server serialize reads its own render's closed latch, not the global context
Both server `serialize` implementations gated writes on `sharedConfig.context.noHydrate`, which is the module global and not the render's own context. `renderToString` set `noHydrate = true` on its context once it finished and left that context as the global. So when another request's sync render completed while a stream was in flight, the stream's later writes were silently dropped: a library write from IO (the solid-query provider shape) and the runtime's own chained async memo on a `<Loading>` retry pass both lost their records, and the client recomputed instead of adopting. `renderToString` now keeps a closure-local `closed` flag, set where the latch was set, so late writes after it closes are still dropped, but only for that render. `renderToStream` never set the latch on its own context, so its check could only fire on another render's flag; it is removed. Nothing in the server runtime writes `noHydrate` on the hydration context anymore. The dead `ctx.noHydrate` read in frames' `armDocumentLiveHoles` goes too (only a stream context carries `live`, and a stream never set it), as do the meaningless `noHydrate: false` entries in two solid server test fixtures. `NoHydrateContext` is untouched. concurrent-render-serialize.spec.tsx pins both stream cases with and without an interleaved `renderToString`, plus a finished string render dropping a late write while another stream owns the global. The three interleaved cases fail on the parent commit. Co-authored-by: Claude via Cursor <[email protected]> Co-authored-by: Cursor <[email protected]>
1 parent fb4e637 commit e040dae

6 files changed

Lines changed: 136 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
Server `serialize` checks its own render's closed state instead of the global context, so a `renderToString` finishing during another request's stream no longer drops that stream's later hydration records

‎packages/solid/test/server/async-retry-child-reset.spec.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ function createMockSSRContext() {
9898
async: true,
9999
assets: [],
100100
nonce: undefined,
101-
noHydrate: false,
102101
escape,
103102
resolve: resolveSSRNode,
104103
ssr,

‎packages/solid/test/server/ssr-async.spec.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,6 @@ function createMockSSRContext(
166166
async: options.async !== false,
167167
assets: [],
168168
nonce: undefined,
169-
noHydrate: false,
170169
escape,
171170
resolve: resolveSSRNode,
172171
ssr,

‎packages/web/frames/src/frame-sink.ts‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,13 +1713,7 @@ function armDocumentLiveHoles(ctx) {
17131713
// documents (no data channel), and hostless environments latch at t=0:
17141714
// mark nothing. `null` records the decision (the arming checks are
17151715
// `!== undefined`).
1716-
if (
1717-
!live ||
1718-
!ctx.async ||
1719-
ctx.noHydrate ||
1720-
!ctx.serialize ||
1721-
typeof ReadableStream !== "function"
1722-
) {
1716+
if (!live || !ctx.async || !ctx.serialize || typeof ReadableStream !== "function") {
17231717
ctx.liveHoles = null;
17241718
if (live) live.holes = null;
17251719
return;

‎packages/web/src/server.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1725,6 +1725,9 @@ export function renderToString(code, options = {}) {
17251725
});
17261726
const tracking = createAssetTracking();
17271727
const headRegistry = createHeadRegistry();
1728+
// Render-local, never on the context: the finished context lingers as the
1729+
// module global, and another request's writes must not read this latch.
1730+
let closed = false;
17281731
sharedConfig.context = {
17291732
nonce: options.nonce,
17301733
escape: escape,
@@ -1736,7 +1739,7 @@ export function renderToString(code, options = {}) {
17361739
registerHeadTags(headRegistry, sharedConfig.context, tracking, null, nonce, tags);
17371740
},
17381741
serialize(id, p) {
1739-
if (sharedConfig.context.noHydrate) return;
1742+
if (closed) return;
17401743
if (
17411744
p != null &&
17421745
typeof p === "object" &&
@@ -1792,7 +1795,7 @@ export function renderToString(code, options = {}) {
17921795
{ id: renderId }
17931796
);
17941797
serializeFragmentAssets("", tracking.boundaryModules, sharedConfig.context, renderId);
1795-
sharedConfig.context.noHydrate = true;
1798+
closed = true;
17961799
serializer.close();
17971800
const head = renderShellHead(
17981801
headRegistry,
@@ -2697,7 +2700,6 @@ export function renderToStream(code, options = {}) {
26972700
html.slice(last + placeholder.length + 1);
26982701
},
26992702
serialize(id, p, deferStream) {
2700-
if (sharedConfig.context.noHydrate) return;
27012703
// The channels the runtime opens to the client — an async source's
27022704
// promise, a live source's iterable — reject or throw with the RAW
27032705
// failure, and seroval encodes that reason as a value for the client
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @jsxImportSource @solidjs/web
3+
*
4+
* Concurrent renders × each render's `serialize`. A finished `renderToString`
5+
* context lingers as the module-global `sharedConfig.context`, so a write
6+
* that lands later for another in-flight render must be judged by the render
7+
* it belongs to — never by whatever context the global happens to hold.
8+
*/
9+
import { describe, expect, test } from "vitest";
10+
import { renderToString, renderToStream, Loading } from "@solidjs/web";
11+
import { createMemo, getOwner } from "solid-js";
12+
import { sharedConfig } from "solid-js/internal";
13+
import { hydrationRecordKeys } from "../harness/hydration-records.js";
14+
15+
function delay(ms: number) {
16+
return new Promise(r => setTimeout(r, ms));
17+
}
18+
19+
function renderComplete(code: () => any, options: any = {}): Promise<string> {
20+
return new Promise(resolve => {
21+
renderToStream(code, options).then(resolve);
22+
});
23+
}
24+
25+
function otherRequestCompletes(streamContext: unknown) {
26+
renderToString(() => <p>other request</p>);
27+
expect(sharedConfig.context).not.toBe(streamContext);
28+
}
29+
30+
describe("a stream's writes after another request's renderToString finished", () => {
31+
for (const interleave of [false, true]) {
32+
test(`library write from IO after the render pass (interleaved renderToString: ${interleave})`, async () => {
33+
let lateWrite!: () => void;
34+
const gate = new Promise<void>(r => (lateWrite = r));
35+
const hold = delay(30);
36+
let streamContext: unknown;
37+
const streamed = renderComplete(() => {
38+
// Captured like solid-query's provider: written later from a cache event.
39+
const ctx = (streamContext = sharedConfig.context) as any;
40+
gate.then(() => ctx.serialize("lib:late", { v: "late" }));
41+
function Held() {
42+
const m = createMemo(async () => {
43+
await hold;
44+
return "held";
45+
});
46+
return <b>{m()}</b>;
47+
}
48+
return (
49+
<div>
50+
<Loading fallback={<i>...</i>}>
51+
<Held />
52+
</Loading>
53+
</div>
54+
);
55+
});
56+
if (interleave) otherRequestCompletes(streamContext);
57+
lateWrite();
58+
expect(hydrationRecordKeys(await streamed)).toContain("lib:late");
59+
});
60+
61+
test(`runtime write: chained async memo on a Loading retry pass (interleaved renderToString: ${interleave})`, async () => {
62+
const hold = delay(20);
63+
let bOwner: string | undefined;
64+
let streamContext: unknown;
65+
const streamed = renderComplete(() => {
66+
streamContext = sharedConfig.context;
67+
function Chained() {
68+
const a = createMemo(async () => {
69+
await hold;
70+
return 1;
71+
});
72+
return (() => {
73+
const v = a();
74+
const b = createMemo(async () => {
75+
await delay(5);
76+
return "b" + v;
77+
});
78+
bOwner = getOwner()?.id;
79+
return <b>{b()}</b>;
80+
})();
81+
}
82+
return (
83+
<div>
84+
<Loading fallback={<i>...</i>}>
85+
<Chained />
86+
</Loading>
87+
</div>
88+
);
89+
});
90+
if (interleave) otherRequestCompletes(streamContext);
91+
const html = await streamed;
92+
expect(html).toContain("b1");
93+
// b's record: the client adopts it instead of recomputing b.
94+
expect(hydrationRecordKeys(html)).toContain(bOwner + "1");
95+
});
96+
}
97+
});
98+
99+
describe("a finished renderToString's late writes", () => {
100+
test("are dropped by its own closed latch, not the global context", async () => {
101+
let captured: any;
102+
renderToString(() => {
103+
captured = sharedConfig.context;
104+
return <div />;
105+
});
106+
const hold = delay(10);
107+
const streamed = renderComplete(() => {
108+
const m = createMemo(async () => {
109+
await hold;
110+
return "held";
111+
});
112+
return (
113+
<Loading fallback={<i>...</i>}>
114+
<b>{m()}</b>
115+
</Loading>
116+
);
117+
});
118+
// Another request's stream now owns the module global.
119+
expect(sharedConfig.context).not.toBe(captured);
120+
// An async value would throw if the closed render still accepted writes.
121+
expect(() => captured.serialize("late", Promise.resolve(1))).not.toThrow();
122+
expect(() => captured.serialize("late-sync", { v: 1 })).not.toThrow();
123+
expect(await streamed).toContain("held");
124+
});
125+
});

0 commit comments

Comments
 (0)