Skip to content

Commit db8cdd3

Browse files
brenelzclaude
andcommitted
fix(frames): drain records when a fragment reveals into an adopted region
A slot invoked inside a server <Loading> ships its `sc:slot:` args record with the deferred fragment — ~the async's own delay after the boundary adopted, long after the adopt-time drain ran. Nothing picked it up: the #2968 classification gate's only other re-drain arms on `_$HY.fr.pending()`, and the very reveal that DELIVERS the record is what flips that false (post-#2978 a revealed fragment is no longer pending). The event that makes the record available is the event that disarms the mechanism that would fetch it. So the record stranded in hydration data, and the next full sync — a refetch's stream apply — found a recordless occurrence, classified the region's render prop as a direct-insert VALUE, and handed it to insert as a zero-arg accessor: a props read off `undefined` that halts the reactive system. The reveal callback already exists for the nested-placeholder cascade; it now drains there too (re-drainable by design, so it is a cheap no-op once caught up), and the subscription is no longer gated on `fr.claim`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 43b5aaf commit db8cdd3

3 files changed

Lines changed: 75 additions & 9 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+
Drain hydration records when a fragment reveals into an adopted server-component region. A slot invoked inside a server `<Loading>` ships its `sc:slot:` record with the deferred fragment — about the async's own delay after the boundary adopted, long after the adopt-time drain ran. That record was then stranded: the classification gate's only other re-drain arms on `_$HY.fr.pending()`, and the very reveal that delivers the record is what flips it false (a revealed fragment is no longer pending). The occurrence stayed recordless, so the next full sync — a refetch's stream apply — classified the region's render prop as a direct-insert value and evaluated it as a zero-arg accessor, whose props read halted the reactive system.

packages/solid-web/frames/src/client.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -883,16 +883,30 @@ function adoptBoundary(
883883
}
884884
};
885885
claimRegionFragments(el);
886-
// The cascade: a reveal into this region can itself carry a pl-* (nested
887-
// server async). Scoped to the revealed parent, so each sweep is
888-
// proportional to what just landed.
889886
const fr = (globalThis as any)._$HY?.fr;
890-
const unsubscribe =
891-
fr && fr.claim
892-
? fr.subscribe((_fragId: string, parent?: ParentNode) => {
893-
if (parent && el.contains(parent as Node)) claimRegionFragments(parent);
894-
})
895-
: undefined;
887+
const unsubscribe = fr
888+
? fr.subscribe((_fragId: string, parent?: ParentNode) => {
889+
// The cascade: a reveal into this region can itself carry a pl-*
890+
// (nested server async). Scoped to the revealed parent, so each
891+
// sweep is proportional to what just landed.
892+
if (fr.claim && parent && el.contains(parent as Node)) claimRegionFragments(parent);
893+
// A revealed fragment also brings its occurrences' ARGS RECORDS: a
894+
// slot invoked inside a server `<Loading>` ships its `sc:slot:`
895+
// script with the fragment, ~the async's own delay after this
896+
// boundary adopted — long after the adopt-time drain below ran. The
897+
// reveal is the one moment that record is both present and newly
898+
// relevant, and it is NOT self-healing: the #2968 defer loop is the
899+
// only other re-drain, and it arms on `recordsPending()`, which this
900+
// very reveal flips false (a revealed fragment is no longer
901+
// pending). Without a drain here the record stays stranded in
902+
// hydration data, and the next full sync — a refetch's stream apply
903+
// — finds a recordless occurrence, classifies the render prop as
904+
// direct-insert, and evaluates it as a zero-arg accessor: a props
905+
// read that halts the reactive system. Re-drainable by design (each
906+
// key applies once), so this is a cheap no-op once caught up.
907+
drainRecords();
908+
})
909+
: undefined;
896910
onCleanup(() => {
897911
unsubscribe && unsubscribe();
898912
if (fr && fr.release) for (const fragId of claimedFragments) fr.release(fragId);

packages/solid-web/test/frames-adopted-region-fragments.spec.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ describe("deferred fragments inside an adopted region (#2978)", () => {
139139
boundaryHtml("deadlock/page", "11", "loading-fallback") +
140140
boundaryHtml("deadlock/nested", "21", "outer-fallback") +
141141
boundaryHtml("deadlock/replaced", "31", "replaced-fallback") +
142+
boundaryHtml("deadlock/records", "41", "records-fallback") +
142143
"</div>";
143144
(window as any)._$HY = { r: {}, fe() {} };
144145
enableHydration();
@@ -268,4 +269,50 @@ describe("deferred fragments inside an adopted region (#2978)", () => {
268269

269270
dispose();
270271
});
272+
273+
// A slot invoked inside the server `<Loading>` ships its args record with
274+
// the FRAGMENT — long after the adopt-time drain ran. The reveal is the one
275+
// moment that record is both present and newly relevant, and it is not
276+
// self-healing: the classification gate's only other re-drain arms on
277+
// `fr.pending()`, which this very reveal flips false. Undrained, the
278+
// occurrence reads recordless and a render prop classifies as a
279+
// direct-insert VALUE — evaluated as a zero-arg accessor, whose props read
280+
// halts the reactive system.
281+
test("a record delivered with the fragment reaches the frame, so its render prop keeps its args", async () => {
282+
declareFragment("41");
283+
284+
const Page = (window as any)._$SC.r("deadlock/records");
285+
const appEl = document.getElementById("app") as HTMLElement;
286+
let mount!: HTMLDivElement;
287+
const dispose = createRoot(d => {
288+
<div ref={mount}>
289+
<Loading fallback={<span>shell-fallback</span>}>
290+
<Page row={(p: { label: string }) => <b>row:{p.label}</b>} />
291+
</Loading>
292+
</div>;
293+
appEl.appendChild(mount);
294+
return d;
295+
});
296+
flush();
297+
await settle();
298+
flush();
299+
300+
completeHydrationPass();
301+
await settle();
302+
303+
// The fragment's chunk carries BOTH the occurrence's markers and the
304+
// record naming its args — the producer writes the data script with the
305+
// markup it belongs to.
306+
(window as any)._$HY.r["sc:slot:deadlock/records:row#0"] = { label: "settled" };
307+
deliverFragment("41", "<!--slot:row#0:start--><!--slot:row#0:end-->");
308+
flush();
309+
await settle();
310+
flush();
311+
312+
const frameEl = document.querySelector('[data-fid="deadlock/records"]') as HTMLElement;
313+
expect(frameEl.textContent).toContain("row:settled");
314+
expect(frameEl.textContent).not.toContain("records-fallback");
315+
316+
dispose();
317+
});
271318
});

0 commit comments

Comments
 (0)