From 70d0da67cbe44a188b0b39f83668b1ead158e615 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Mon, 3 Aug 2026 12:44:59 -0500 Subject: [PATCH] Keep a late-arriving server-component boundary claimable and its slot range live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same failure in the notes example: with a 2s delay in noteView, /notes/0 renders correctly and then the app stops responding — clicking another note changes the URL and fetches nothing, and New/Edit/home change the URL and nothing else. 1. documentStreaming -> boundaryMayArrive `_$HY.done` stopped meaning "the page is complete" once post-done swaps became held-until-claimed (#2964): a fragment settling after global hydration keeps its placeholder, fallback and template in place until its boundary registers as claimant, and the replay that follows is what delivers the boundary element. A boundary rendering in that window (a frames slot fill or lazy route module running after the root pass) read done as "never", mounted a fresh frame, and orphaned the markup the replay then delivered — visible but owned by nothing. Worse, the id was never claimed, so `intercept` answered every later call for that function with the document placeholder instead of fetching, and dynamic's equals-gate made each navigation a no-op. An unresolved `pl-*` placeholder now keeps the answer "not yet". So the waiter can no longer strand a region forever, a reveal that exhausts the page's deferred fragments releases waiters to mount fresh. 2. The claim scope wraps the insert CALL, not insert's accessor A slot fill claims under the producer's hydration keys, and claiming runs through runWithOwner — which clears `tracking` along with the owner. With the claim inside the accessor, the binding's first read was untracked, so it stayed reactive only by accident: when that read returned another accessor, insert re-read it and picked the dependency up. A answering a still-pending streamed fragment returns its fallback NODES instead, leaving the effect with no dependency at all and the range permanently inert — the boundary's own resume still claimed the swapped-in server markup, so the region looked right, but nothing downstream ever re-rendered it. Claiming around the insert call makes the first evaluation the render effect's own compute: still under the producer's keys, but tracked. Tests: two cases in frames-late-boundary-client (held fragment still owes the element after done, including that the next call reaches the network; and the give-up path), plus a hydrate-config spec for the live slot range — that claim path bails out entirely without hydratable JSX, so the untracked window never opens in the plain client config. All three fail on the prior code. Co-Authored-By: Claude Opus 5 --- .changeset/adopted-slot-range-stays-live.md | 5 + .../late-boundary-held-fragment-wait.md | 5 + packages/solid-web/frames/src/client.ts | 107 +++++++++----- .../test/frames-late-boundary-client.spec.tsx | 139 +++++++++++++++++- .../test/hydration/adopted-slot-live.spec.tsx | 97 ++++++++++++ packages/solid-web/vite.config.hydrate.mjs | 5 + 6 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 .changeset/adopted-slot-range-stays-live.md create mode 100644 .changeset/late-boundary-held-fragment-wait.md create mode 100644 packages/solid-web/test/hydration/adopted-slot-live.spec.tsx diff --git a/.changeset/adopted-slot-range-stays-live.md b/.changeset/adopted-slot-range-stays-live.md new file mode 100644 index 000000000..bc9857bc6 --- /dev/null +++ b/.changeset/adopted-slot-range-stays-live.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: keep an adopted boundary's slot range reactive. The claim scope wrapped insert's accessor, so the binding's first read ran inside `runWithOwner`'s untracked window — reactive only by accident, via the re-read of whatever accessor that first read returned. A `` answering a still-pending streamed fragment returns fallback NODES instead, leaving the effect with no dependency at all and the range permanently inert: the boundary's own resume still claimed the swapped-in server markup, so the region looked right, but nothing downstream ever re-rendered it (in the notes example, every navigation out of a late-settling note changed the URL and nothing else). The claim now wraps the insert CALL, so the first evaluation is the render effect's own compute — still under the producer's hydration keys, but tracked. diff --git a/.changeset/late-boundary-held-fragment-wait.md b/.changeset/late-boundary-held-fragment-wait.md new file mode 100644 index 000000000..c5ca03a5f --- /dev/null +++ b/.changeset/late-boundary-held-fragment-wait.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: keep waiting for a document boundary whose element is still held by a deferred fragment. `_$HY.done` stopped meaning "the page is complete" once post-done swaps became held-until-claimed (#2964) — a boundary rendering in that window mounted a fresh frame, orphaning the markup the replay then delivered, and left the id unclaimed so every later call resolved back to the document placeholder instead of fetching (a server-component region that never updates again). An unresolved `pl-*` placeholder now keeps the answer "not yet"; a reveal that exhausts the page's deferred fragments releases the waiter to mount fresh. diff --git a/packages/solid-web/frames/src/client.ts b/packages/solid-web/frames/src/client.ts index 43502ed4e..ca448ea7e 100644 --- a/packages/solid-web/frames/src/client.ts +++ b/packages/solid-web/frames/src/client.ts @@ -308,22 +308,25 @@ function slotsFor(props: Record) { // positions) and return undefined so the frame leaves the interior // alone. `existing` seeds insert's tracked array: an accessor that // yields the claimed nodes reconciles to a zero-mutation no-op, one - // that yields new content swaps it in place. The accessor's FIRST - // evaluation re-enters the claim scope — boundary-deferred children - // (route content behind ) create on that read and must see - // the producer's hydration keys. + // that yields new content swaps it in place. + // + // The claim scope wraps the insert CALL, not the accessor: the + // binding's first evaluation is insert's own render effect computing + // synchronously, so it still creates under the producer's hydration + // keys — boundary-deferred children (route content behind + // ) create on that read — while the reads it makes belong + // to the effect and stay tracked. Claiming inside the accessor + // instead put that first read inside runWithOwner's UNTRACKED window + // (it clears `tracking` along with the owner). Whenever the value it + // returned was not itself an accessor for insert to re-read — a + // answering a still-pending streamed fragment returns its + // fallback NODES — the effect ended up with no dependency at all and + // the range went permanently inert: the boundary's own resume still + // claimed the swapped-in server markup, so the region looked right, + // but nothing downstream (a route change out of it) ever re-rendered + // it again. if (ctx && ctx.range) { - let claim = adopted; const source = value; - const accessor = () => { - if (claim) { - claim = false; - return claimRender(prefix, ctx.existing, () => - typeof source === "function" ? source() : source - ); - } - return typeof source === "function" ? source() : source; - }; const owner = createOwner(); bindings.set(key, owner); ctx.onCleanup(() => { @@ -331,9 +334,14 @@ function slotsFor(props: Record) { owner.dispose(); }); const end = ctx.range.end; - runWithOwner(owner, () => - insert(end.parentNode as any, accessor, end, [...ctx.existing]) - ); + const bind = () => + insert( + end.parentNode as any, + () => (typeof source === "function" ? source() : source), + end, + [...ctx.existing] + ); + runWithOwner(owner, () => (adopted ? claimRender(prefix, ctx.existing, bind) : bind())); return undefined; } // No range handle (a consumer-constructed frame without markers): @@ -455,12 +463,31 @@ function findBoundaryElement(id: string): Element | undefined { // that carries it. One waiter per id: a second mount while the first is still // waiting takes the fresh-frame path, since only one frame may adopt an // element. -const boundaryWaiters = new Map void>(); +const boundaryWaiters = new Map void>(); + +/** An unresolved deferred fragment (`