From db8cdd3c1d72b95488107036990377ea7bab591b Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Fri, 7 Aug 2026 08:27:21 -0500 Subject: [PATCH] fix(frames): drain records when a fragment reveals into an adopted region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slot invoked inside a server 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 --- .../fix-adopted-region-late-slot-records.md | 5 ++ packages/solid-web/frames/src/client.ts | 32 +++++++++---- .../frames-adopted-region-fragments.spec.tsx | 47 +++++++++++++++++++ 3 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-adopted-region-late-slot-records.md diff --git a/.changeset/fix-adopted-region-late-slot-records.md b/.changeset/fix-adopted-region-late-slot-records.md new file mode 100644 index 000000000..d874a8fb9 --- /dev/null +++ b/.changeset/fix-adopted-region-late-slot-records.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Drain hydration records when a fragment reveals into an adopted server-component region. A slot invoked inside a server `` 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. diff --git a/packages/solid-web/frames/src/client.ts b/packages/solid-web/frames/src/client.ts index 395f4e369..9a4442d52 100644 --- a/packages/solid-web/frames/src/client.ts +++ b/packages/solid-web/frames/src/client.ts @@ -883,16 +883,30 @@ function adoptBoundary( } }; claimRegionFragments(el); - // The cascade: a reveal into this region can itself carry a pl-* (nested - // server async). Scoped to the revealed parent, so each sweep is - // proportional to what just landed. const fr = (globalThis as any)._$HY?.fr; - const unsubscribe = - fr && fr.claim - ? fr.subscribe((_fragId: string, parent?: ParentNode) => { - if (parent && el.contains(parent as Node)) claimRegionFragments(parent); - }) - : undefined; + const unsubscribe = fr + ? fr.subscribe((_fragId: string, parent?: ParentNode) => { + // The cascade: a reveal into this region can itself carry a pl-* + // (nested server async). Scoped to the revealed parent, so each + // sweep is proportional to what just landed. + if (fr.claim && parent && el.contains(parent as Node)) claimRegionFragments(parent); + // A revealed fragment also brings its occurrences' ARGS RECORDS: a + // slot invoked inside a server `` ships its `sc:slot:` + // script with the fragment, ~the async's own delay after this + // boundary adopted — long after the adopt-time drain below ran. The + // reveal is the one moment that record is both present and newly + // relevant, and it is NOT self-healing: the #2968 defer loop is the + // only other re-drain, and it arms on `recordsPending()`, which this + // very reveal flips false (a revealed fragment is no longer + // pending). Without a drain here the record stays stranded in + // hydration data, and the next full sync — a refetch's stream apply + // — finds a recordless occurrence, classifies the render prop as + // direct-insert, and evaluates it as a zero-arg accessor: a props + // read that halts the reactive system. Re-drainable by design (each + // key applies once), so this is a cheap no-op once caught up. + drainRecords(); + }) + : undefined; onCleanup(() => { unsubscribe && unsubscribe(); if (fr && fr.release) for (const fragId of claimedFragments) fr.release(fragId); diff --git a/packages/solid-web/test/frames-adopted-region-fragments.spec.tsx b/packages/solid-web/test/frames-adopted-region-fragments.spec.tsx index 5393afdc4..cfddcdd35 100644 --- a/packages/solid-web/test/frames-adopted-region-fragments.spec.tsx +++ b/packages/solid-web/test/frames-adopted-region-fragments.spec.tsx @@ -139,6 +139,7 @@ describe("deferred fragments inside an adopted region (#2978)", () => { boundaryHtml("deadlock/page", "11", "loading-fallback") + boundaryHtml("deadlock/nested", "21", "outer-fallback") + boundaryHtml("deadlock/replaced", "31", "replaced-fallback") + + boundaryHtml("deadlock/records", "41", "records-fallback") + ""; (window as any)._$HY = { r: {}, fe() {} }; enableHydration(); @@ -268,4 +269,50 @@ describe("deferred fragments inside an adopted region (#2978)", () => { dispose(); }); + + // A slot invoked inside the server `` ships its args record with + // the FRAGMENT — long after the adopt-time drain ran. The reveal is the one + // moment that record is both present and newly relevant, and it is not + // self-healing: the classification gate's only other re-drain arms on + // `fr.pending()`, which this very reveal flips false. Undrained, the + // occurrence reads recordless and a render prop classifies as a + // direct-insert VALUE — evaluated as a zero-arg accessor, whose props read + // halts the reactive system. + test("a record delivered with the fragment reaches the frame, so its render prop keeps its args", async () => { + declareFragment("41"); + + const Page = (window as any)._$SC.r("deadlock/records"); + const appEl = document.getElementById("app") as HTMLElement; + let mount!: HTMLDivElement; + const dispose = createRoot(d => { +
+ shell-fallback}> + row:{p.label}} /> + +
; + appEl.appendChild(mount); + return d; + }); + flush(); + await settle(); + flush(); + + completeHydrationPass(); + await settle(); + + // The fragment's chunk carries BOTH the occurrence's markers and the + // record naming its args — the producer writes the data script with the + // markup it belongs to. + (window as any)._$HY.r["sc:slot:deadlock/records:row#0"] = { label: "settled" }; + deliverFragment("41", ""); + flush(); + await settle(); + flush(); + + const frameEl = document.querySelector('[data-fid="deadlock/records"]') as HTMLElement; + expect(frameEl.textContent).toContain("row:settled"); + expect(frameEl.textContent).not.toContain("records-fallback"); + + dispose(); + }); });