Skip to content

fix(frames): drain records when a fragment reveals into an adopted region (#2978 follow-up) - #2979

Merged
ryansolid merged 1 commit into
solidjs:nextfrom
brenelz:fix/adopted-region-late-slot-records
Aug 7, 2026
Merged

fix(frames): drain records when a fragment reveals into an adopted region (#2978 follow-up)#2979
ryansolid merged 1 commit into
solidjs:nextfrom
brenelz:fix/adopted-region-late-slot-records

Conversation

@brenelz

@brenelz brenelz commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #2978. A <Loading> inside a server component now reveals correctly, but the first refetch after that reveal crashes the page:

TypeError: Cannot read properties of undefined (reading 'text2')
[REACTIVITY_HALTED] An uncaught error halted the reactive system.

Repro

examples/playground, a server component whose slot is invoked inside its own <Loading>:

export const getServerComp = async (count: number) => {
  "use server";
  return (props: { text: (p: { text2: number }) => any }) => {
    const test = createMemo(async () => {
      await new Promise(r => setTimeout(r, 1000));
      return Math.random();
    });
    return (
      <div>
        <h1>Playground2</h1>
        <Loading fallback={<div>loading new value…</div>}>
          <props.text text2={test()} />
        </Loading>
      </div>
    );
  };
};

Load the page, wait for the value, click a button that switches the call's args. The reactive system halts.

Root cause: the delivery disarms its own pickup

A slot invoked inside a server <Loading> ships its sc:slot: args record with the deferred fragment — about the async's own delay after the boundary adopted, long after adoptBoundary's drain ran. (Confirmed on the wire: in the document the record's script sits at byte 2708, its $df(...) reveal at 2785 — the record is present and unread.)

Nothing ever picks it up:

The event that makes the record available is the event that disarms the mechanism that would fetch it. The record strands in _$HY.r for the life of the page, and the next full sync — a refetch's stream apply — finds a recordless occurrence, classifies the region's render prop as a direct-insert value, and hands it to insert as a zero-arg accessor. props.text2 on undefined halts everything.

Fix

The reveal callback already exists in adoptBoundary for the nested-placeholder cascade; it now drains there too. A revealed fragment brings its occurrences' records with it, so the reveal is exactly the right moment. drainRecords is re-drainable by design (each key applies once), so this is a cheap no-op once caught up. The subscription is also no longer gated on fr.claim — the drain is useful whether or not the ledger exposes claiming.

Verification

  • New test in frames-adopted-region-fragments.spec.tsx, red without the fix and green with it: a record delivered alongside its fragment reaches the frame, so the region's render prop keeps its args.
  • @solidjs/web suite: 400/400. dom-expressions runtime suite: 1114/1114.
  • Live playground: before, every increment halted the page; after, five consecutive increments run clean.

Note two things this does not fix, both filed/PR'd separately:

  1. The render prop's async arg (text2={test()}) is the DR-2 value tier, and the frame-client mishandles it on repeat responses — fix(frames): wait for a slot record's data refs; async refs compare by identity ryansolid/dom-expressions#565.
  2. The document's occurrence counter drifts across render attempts (doc ships text#1, a fresh stream ships text#0), so the first switch re-mounts instead of updating and blanks for the server's wait. $key on the slot avoids it entirely; the underlying determinism question looks like a design call.

🤖 Generated with Claude Code

…gion

A slot invoked inside a server <Loading> ships its `sc:slot:` args
record with the deferred fragment — ~the async's own delay after the
boundary adopted, long after the adopt-time drain ran. Nothing picked it
up: the solidjs#2968 classification gate's only other re-drain arms on
`_$HY.fr.pending()`, and the very reveal that DELIVERS the record is
what flips that false (post-solidjs#2978 a revealed fragment is no longer
pending). The event that makes the record available is the event that
disarms the mechanism that would fetch it.

So the record stranded in hydration data, and the next full sync — a
refetch's stream apply — found a recordless occurrence, classified the
region's render prop as a direct-insert VALUE, and handed it to insert
as a zero-arg accessor: a props read off `undefined` that halts the
reactive system.

The reveal callback already exists for the nested-placeholder cascade;
it now drains there too (re-drainable by design, so it is a cheap no-op
once caught up), and the subscription is no longer gated on `fr.claim`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: db8cdd3

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

This PR includes changesets to release 10 packages
Name Type
@solidjs/web Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
chat-example 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

@ryansolid
ryansolid merged commit 80970b7 into solidjs:next Aug 7, 2026
1 of 4 checks passed
ryansolid added a commit that referenced this pull request Aug 7, 2026
The brenelz round exposed two lifecycle shapes the matrix never pinned:

- Byte-identical shell across a switch (dom-expressions#564's policy):
  slot-driven content ships its differences as records, so the new
  address's html can be the same bytes — root affinity is per stream,
  and the identical shell still answers the gate while the record-driven
  difference lands.
- Double-switch mid-flight: the gate re-arms per switch, only the LIVE
  call's answer settles it, the superseded call's late stream warms its
  own store without touching the boundary — and switching back
  re-materializes from that warm synchronously.

Plus the reveal-then-refetch cell over an adopted region (#2979's
follow-through): the reveal-driven record drain is a one-time repair,
and the region stays fully live for the refetch that follows.

MATRIX.md gains the address-switch section (the #2977 trio was never
tabled) and drops its two GAP rows — both were closed on this branch
(shell gate, occurrence-unmount cleanup); the stale test.fails comments
go with them.

Co-authored-by: Cursor <cursoragent@cursor.com>
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