Skip to content

Keep a late-arriving server-component boundary claimable and its slot range live - #2967

Merged
ryansolid merged 1 commit into
solidjs:nextfrom
brenelz:fix/late-boundary-claim-and-live-slot-range
Aug 4, 2026
Merged

Keep a late-arriving server-component boundary claimable and its slot range live#2967
ryansolid merged 1 commit into
solidjs:nextfrom
brenelz:fix/late-boundary-claim-and-live-slot-range

Conversation

@brenelz

@brenelz brenelz commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #2964. With a 2s delay added to noteView in the notes example, /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. Two independent causes, both in the frames client.

1. _$HY.done is no longer "the page is complete"

Once post-done swaps became held-until-claimed, 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. documentStreaming() read done as "never", so a boundary rendering in that window (a frames slot fill, or a lazy route module running after the root pass) mounted a fresh frame and orphaned the markup the replay then delivered. Captured from a live run:

documentBoundary 279061e8-0-noteView {claimed: false, found: false, streaming: false}
fresh frame      279061e8-0-noteView
fe               sc-84ece717-0-appView-children-0     ← the swap, afterwards

The region is visible but owned by nothing, and because the id is never claimed, intercept answers every later call for that function with the document placeholder instead of fetching — dynamic's equals-gate then makes each navigation a no-op.

boundaryMayArrive() replaces documentStreaming(): still streaming, or an unresolved pl-* placeholder is in the page. So a waiter cannot strand a region forever, a reveal that exhausts the page's deferred fragments releases waiters to mount fresh.

2. The claim scope severed the slot range's reactivity

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 insert's accessor, the binding's first read happened in that untracked window, so it stayed reactive only by accident: when the read returned another accessor, insert re-read it and picked up the dependency. A <Loading> answering a still-pending streamed fragment returns its fallback nodes instead, so 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 — the region looks right — but nothing downstream ever re-rendered it. Instrumented: the slot accessor ran once, at claim time, and never again; a route change out of the note produced zero further evaluations.

The claim now wraps the insert call. The first evaluation is the render effect's own synchronous compute — still under the producer's keys, but tracked.

Tests

  • frames-late-boundary-client: a held fragment still owes its element after done (asserting the follow-up call actually reaches the network — the user-visible symptom), plus the give-up path when no deferred fragment remains.
  • test/hydration/adopted-slot-live.spec.tsx: the live slot range. It lives in the hydrate config because claimRender bails out entirely without sharedConfig.getNextContextId, so the untracked window never opens in the plain client config — hence the one alias line added to vite.config.hydrate.mjs.

All three fail on the prior code. Suites green: 340 client, 77 hydrate, 201 server.

Verified end to end in the notes example (dev and a production build, fresh origins, real clicks): /notes/0 → other notes each fetch and morph, and New/Edit/home all navigate and render.

🤖 Generated with Claude Code

… range live

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 (solidjs#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 <Loading>
   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 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 70d0da6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@solidjs/web Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
solid-js Patch
babel-preset-solid Patch
@solidjs/universal Patch
@solidjs/signals Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed-hq

codspeed-hq Bot commented Aug 3, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 7 untouched benchmarks
⏩ 117 skipped benchmarks1


Comparing brenelz:fix/late-boundary-claim-and-live-slot-range (70d0da6) with next (edb3e36)

Open in CodSpeed

Footnotes

  1. 117 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@ryansolid
ryansolid merged commit 4efa346 into solidjs:next Aug 4, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants