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(); + }); });