Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-adopted-region-late-slot-records.md
Original file line number Diff line number Diff line change
@@ -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 `<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.
32 changes: 23 additions & 9 deletions packages/solid-web/frames/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Loading>` 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);
Expand Down
47 changes: 47 additions & 0 deletions packages/solid-web/test/frames-adopted-region-fragments.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") +
"</div>";
(window as any)._$HY = { r: {}, fe() {} };
enableHydration();
Expand Down Expand Up @@ -268,4 +269,50 @@ describe("deferred fragments inside an adopted region (#2978)", () => {

dispose();
});

// A slot invoked inside the server `<Loading>` 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 => {
<div ref={mount}>
<Loading fallback={<span>shell-fallback</span>}>
<Page row={(p: { label: string }) => <b>row:{p.label}</b>} />
</Loading>
</div>;
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", "<!--slot:row#0:start--><!--slot:row#0:end-->");
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();
});
});
Loading