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 (`