Skip to content

fix(signals): a boundary whose fallback is pending reveals landed content without waiting for the fallback's flight (#3540) - #3581

Merged
ryansolid merged 4 commits into
nextfrom
test/on-rearm-reveal-pins
Sep 22, 2026
Merged

ryansolid merged 4 commits into
nextfrom
test/on-rearm-reveal-pins

Conversation

@ryansolid

Copy link
Copy Markdown
Member

The bug

A collecting Loading boundary whose fallback reads a pending source revealed its landed content only when the fallback's flight landed — not when the content did. Shape (loading-on-rearm-reveal-3540 "content lands before the fallback", and its no-on analogue loading-fallback-release-3540): an initialized outer boundary around an inner boundary whose fallback reads fallbackData (3000 ms) and whose content reads data (1000 ms).

Expected frames:

[data 1] → (+1000: data lands) [data 2]

Actual frames before this PR:

[data 1] → (+1000: data lands) [data 1] → (+3000: fallbackData lands) [data 2]

The bug predates #3575 and is independent of on — the analogue mounts a fresh inner boundary with a pending fallback under an initialized outer, mainline, and fails the same way on origin/next.

Root cause

Boundary-specific, not a general "node stopped deriving from X" bug: the second analogue test (a plain computed switching from a pending source b to a) passes on next — recompute settles a pass's outgoing pending sources (core.ts ~L754) and wakeParked() re-judges.

The boundary's output pass is the exception. It derives from _disabled, not from the tree (while disabled it never reads the tree), so the tree settling never re-runs it. Only the boundary sweep (_checkSources, which flips _disabled) re-runs it — and that sweep runs in finalizePureQueue after the verdict, on a non-parked finalize only (scheduler.ts if (!incomplete …) checkBoundaryChildren). Meanwhile the output pass is pending on the fallback's read, forwarded through the initialized parent, which holds the frame on it (reporterBlocksSource). The verdict parks on exactly the pending the sweep would clear: a loop that only the fallback's own landing breaks.

The fix

Judge that one shape before the verdict, under the transaction — the same point where the on re-arm drain already runs (scheduler.ts run(), after pendingRearms): checkBoundaryChildren(this, true) walks the boundaries and calls CollectionQueue._judgeHeld(), which gates _checkSources() on "collecting and the output pass is pending". A ready boundary stages _disabled false with the frame; the heap re-runs so the output pass reads the tree and drops the fallback's read through the ordinary recompute settle path; the verdict sees the release.

Boundaries whose fallback is ready park nothing and keep the commit-sweep reveal exactly as before (a broader "sweep everything pre-verdict" variant was tried and regressed lane-outside-view #3479 and two createErrorBoundary compute-phase pins; the narrow gate is the root-cause shape).

The DEV after-the-fact LOADING_ON_OUTSIDE_HOLD rule (devHeldSweep) keyed on "swap still staged true at the park"; it now also recognizes a swap the pre-verdict sweep cleared while still staged (_disabled staged false over a never-committed true) as never displayed. _checkSources drops _swapUnseen only for a swap that committed (was displayed). All frame-following diagnostic pins hold.

Compared with the earlier attempt on wip/rearm-reveal-hold-fix (~145 lines: settledBoundaries list + noteSettledBoundary hook in settlePendingSource, retirePendingSources in async.ts, _pruneSources split, _settled() retiring the output's pending sources by hand while leaving _disabled true): this is ~30 lines, no new async.ts surface, no manual pending-source retirement — the output pass drops the read itself by re-running. The _output back-reference is the one piece kept.

Public API changes

None.

Re-derived pins (loading-on-rearm-reveal-3540.test.ts)

Two reveal-timing pins were written against #3556's mainline swap and re-derived under #3575 (frame-following); expectations verified against the engine, not bent:

  • staged source (on: count, action writes count, stays open past the landing): old content held through the flight, data 2 at the release, fallback never logged, LOADING_ON_OUTSIDE_HOLD once after the fact. Matches the derivation.
  • independent source (on: dep; data re-asked through asked, which the action never writes): the derivation in the brief expected data 1 at +1000 while dep/other were still held. The engine does not do that with a plain on: setAsked(1) opens its own hold (an initialized boundary forwards the pending; scheduler.ts ~L986 opens a transition when the batch has staged writes), the re-arm stages the swap into the action's transaction, and when data lands the staged output pass reads it and the two holds merge — reveal at the release, fallback never shown, LOADING_ON_OUTSIDE_HOLD once. That is frame-following rule 5 with a different source topology, so the pin records it. The brief's sequence (fallback now, data 1 at +1000 beside the held dep/other, no diagnostic) is exactly what on: () => latest(dep) produces, pinned beside it as the display-ahead contrast.

The other four tests in that file were unaffected.

Tests

  • @solidjs/signals: 210 files, 3509 passed / 1 skipped (includes the new loading-fallback-release-3540.test.ts). treeshake core floor +63 B minified (25,984 → 26,047), cap re-set with its note.
  • solid-js: 33 files / 654 passed. @solidjs/web: 97 files / 844 passed.
  • scripts/size: +26..+69 B gzip per scenario vs origin/next; five caps re-set with measurement notes, five unchanged.

@changeset-bot

changeset-bot Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 880daeb

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

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal 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

codspeed Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing test/on-rearm-reveal-pins (880daeb) with next (709c02b)

Open in CodSpeed

Footnotes

  1. 3 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 and others added 4 commits September 22, 2026 02:08
Reveal-timing and fallback-pending pins for re-armed Loading boundaries.
One pins a live defect: a re-armed boundary whose fallback is itself not
ready does not reveal landed content until the fallback's read lands.

Co-authored-by: Cursor <cursoragent@cursor.com>
…wing (#3540, #3575)

The two reveal-timing pins were written against #3556's mainline swap. Under
#3575 the swap is staged into the notifying write's transaction, and the
boundary's output is that frame's from then on:

- staged source: the action outlasts the data, so old content holds through
  the flight, `data 2` lands at the commit, the fallback is never shown, and
  LOADING_ON_OUTSIDE_HOLD reports once after the fact.
- independent source: the content's own hold (the pending write it derives
  from) is joined to the action's frame when the staged output pass reads
  its landing — reveal at the commit, no fallback, same diagnostic. The
  pre-#3575 sequence is the display-ahead read's (`latest()`), pinned
  beside it.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…erdict (#3540)

A collecting Loading boundary whose FALLBACK read something not ready left
its output pass pending on that read; an initialized parent held the frame
on it. When the content landed, the boundary was ready — but its output pass
derives from `_disabled`, not the tree, so the tree settling never re-ran
it; only the boundary sweep does, and that sweep ran at the commit
(finalizePureQueue), after the verdict the output's own read kept parking.
The content waited for the fallback's flight.

Judge that one shape before the verdict, under the transaction
(`CollectionQueue._judgeHeld`, walked by `checkBoundaryChildren(this, true)`
after the re-arm drain): a collecting boundary whose output is pending
stages `_disabled` false with the frame and its output re-runs in the same
heap, reads the tree, and drops the fallback's read through recompute's
ordinary settle of a pass's outgoing pending sources — the verdict sees the
release. Boundaries with a ready fallback park nothing and keep the commit
sweep's reveal unchanged.

The DEV after-the-fact LOADING_ON_OUTSIDE_HOLD rule (devHeldSweep) now also
recognizes a swap the pre-verdict sweep cleared while still staged
(`_disabled` staged false over a never-committed true) as never displayed;
`_checkSources` drops `_swapUnseen` only for a swap that committed.

Pins: the on= case (loading-on-rearm-reveal-3540 'content lands before the
fallback') and its no-on analogue (loading-fallback-release-3540, whose
second test shows the general computed case always released — the bug was
boundary-specific). Core floor +63 B, documented in treeshake.test.ts.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…3540)

+38..+81 B brotli per scenario, re-measured rebased over #3577 (origin/next
709c02b, same solid/web dist): the flush's pre-verdict boundary walk in
core, and `_judgeHeld` / `_output` in boundaries. Re-set: isPending/latest
(12.40 -> 12.45), hydrating no-stores (20.70 -> 20.75), CSR (15.75 ->
15.80, over #3577's own bump) and the CSR observe tier (17.30 -> 17.35,
no observe-gated bytes; brotli layout). Each carries its measurement
note. The simple-app floor (12,493 / 12.50) and observe + attribution
(27,743 / 27.75) now land within `next`'s caps and keep them; the other
four scenarios are within their caps too.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the test/on-rearm-reveal-pins branch from 3dd9e08 to 880daeb Compare September 22, 2026 09:16
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 35709477758

Coverage remained the same at 72.775%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1070
Covered Lines: 824
Line Coverage: 77.01%
Relevant Branches: 818
Covered Branches: 550
Branch Coverage: 67.24%
Branches in Coverage %: Yes
Coverage Strength: 17.45 hits per line

💛 - Coveralls

@ryansolid
ryansolid merged commit a124577 into next Sep 22, 2026
7 checks passed
ryansolid added a commit that referenced this pull request Sep 22, 2026
The `app: CSR, observe tier + attribution engine enabled` scenario measures
27,756 B brotli on `next` (4d7638c) against 5e46732's 27,704 (+52 B),
6 B over the 27.75 KB cap. #3581 re-set the isPending/hydrating/CSR/CSR-observe
caps but not this one — its own measurement landed at 27,743, 7 B under, and
the compressor layout on the merged tree does not hold that. Re-set to
27.80 KB with a dated note. The bytes are #3581's pre-verdict boundary
judgment in boundaries.ts and the scheduler's checkBoundaryChildren walk;
no attribution-engine change. Tooling only, no changeset.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
ryansolid added a commit that referenced this pull request Sep 22, 2026
…tside read (#3540)

The DEV diagnostic fired in two shapes. Only the first is sound:

- At the change, same source (kept): `on` re-arms the boundary while the
  very async source it is waiting on is also read by a live reader outside
  it, so the frame is held on that source and the fallback can never be
  seen. Deterministic and structural; `data.source` names the source and
  the fix is to move the outside read under the boundary. The message now
  states that claim; `latest()` in `on` stays a parenthetical capability
  (#3578 ruling).

- After the fact (removed): the frame was held — by the write's action, or
  by other pending data — past the content's landing, so the staged swap
  was cleared before display. That is a race the developer does not
  control (the nested boundary's data may simply be faster than the frame's
  hold) and a fallback that loses it is a legitimate outcome, not a defect.
  The engine cannot distinguish "the action awaited exactly this data" from
  "the action awaited something else that happened to be slower", so the
  rule is unsound.

Removed with it: `devHeldSweep`, `CollectionQueue._swapUnseen` and
`_devHeldSweep`, the `_checkSources` DEV drop, and the scheduler's DEV-only
parked-finalize walk (`devSweepBoundaryChildren`). `_settled`, `_judgeHeld`
and `_output` stay — they carry the pre-verdict boundary judgment (#3581).
Prod core floor unchanged (boundaries.ts is shed from that fixture; the
scheduler branch was DEV-folded); core+Loading −9 B prod, −647 B dev.

Tests: the after-the-fact pins flip to "not reported" (frame-following 5.,
rearm-reveal held-action cases, the web held-action spec); the same-source
pins tighten on the new message; one new pin for a frame held by OTHER
pending data that outlasts the content with no action — no report, the
content reveals at the commit. Docs, CHEATSHEET, `Loading` JSDoc and the
two pending changesets that announced the removed rule updated to match.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
ryansolid added a commit that referenced this pull request Sep 22, 2026
Measured against next (4d7638c), brotli:

- signals: + createStore: 16,866 B vs 16,805 (+61 B); cap 16.85 -> 16.90 KB.
  Minified bundle length unchanged; the delta is the prop mangler's
  short-name reshuffle after two _ props left CollectionQueue.
- app: CSR, observe tier + attribution engine enabled: 27,767 B vs 27,756
  (+11 B; next already 6 B over the old cap since #3581); cap 27.75 -> 27.80 KB.
- boundaries.ts is -9 B minified on both tiers; scheduler.ts 0 B.
- All other scenarios under cap: core floor 9,675 (+4), isPending/latest
  12,411 (-20), simple-app 12,489 (+16), hydrating 20,655 (-49), hydrating +
  stores 30,825 (-55), CSR 15,779 (+36), CSR observe 17,293 (+18), frames
  11,384 (0).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
ryansolid added a commit that referenced this pull request Sep 22, 2026
signals: + createStore: 16.85 -> 16.90 KB, measured at 16,854 B against next's 16,805 (+49 B) — merge()'s presized source arrays and the $RECORD classification in store/utils.ts.

app: CSR, observe tier + attribution engine enabled: 27.75 -> 27.80 KB, measured at 27,756 B on both this branch and next (+0 B); next has been 6 B over this cap since #3581 (a124577). Set so the gate on this PR measures this PR.

Every other scenario is within its cap (core floor +12 B, every-store-family app -36 B, the rest 0 B).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
ryansolid added a commit that referenced this pull request Sep 22, 2026
…tside read (#3540)

The DEV diagnostic fired in two shapes. Only the first is sound:

- At the change, same source (kept): `on` re-arms the boundary while the
  very async source it is waiting on is also read by a live reader outside
  it, so the frame is held on that source and the fallback can never be
  seen. Deterministic and structural; `data.source` names the source and
  the fix is to move the outside read under the boundary. The message now
  states that claim; `latest()` in `on` stays a parenthetical capability
  (#3578 ruling).

- After the fact (removed): the frame was held — by the write's action, or
  by other pending data — past the content's landing, so the staged swap
  was cleared before display. That is a race the developer does not
  control (the nested boundary's data may simply be faster than the frame's
  hold) and a fallback that loses it is a legitimate outcome, not a defect.
  The engine cannot distinguish "the action awaited exactly this data" from
  "the action awaited something else that happened to be slower", so the
  rule is unsound.

Removed with it: `devHeldSweep`, `CollectionQueue._swapUnseen` and
`_devHeldSweep`, the `_checkSources` DEV drop, and the scheduler's DEV-only
parked-finalize walk (`devSweepBoundaryChildren`). `_settled`, `_judgeHeld`
and `_output` stay — they carry the pre-verdict boundary judgment (#3581).
Prod core floor unchanged (boundaries.ts is shed from that fixture; the
scheduler branch was DEV-folded); core+Loading −9 B prod, −647 B dev.

Tests: the after-the-fact pins flip to "not reported" (frame-following 5.,
rearm-reveal held-action cases, the web held-action spec); the same-source
pins tighten on the new message; one new pin for a frame held by OTHER
pending data that outlasts the content with no action — no report, the
content reveals at the commit. Docs, CHEATSHEET, `Loading` JSDoc and the
two pending changesets that announced the removed rule updated to match.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
ryansolid added a commit that referenced this pull request Sep 22, 2026
…tside read (#3540) (#3584)

* fix(signals): LOADING_ON_OUTSIDE_HOLD reports only the same-source outside read (#3540)

The DEV diagnostic fired in two shapes. Only the first is sound:

- At the change, same source (kept): `on` re-arms the boundary while the
  very async source it is waiting on is also read by a live reader outside
  it, so the frame is held on that source and the fallback can never be
  seen. Deterministic and structural; `data.source` names the source and
  the fix is to move the outside read under the boundary. The message now
  states that claim; `latest()` in `on` stays a parenthetical capability
  (#3578 ruling).

- After the fact (removed): the frame was held — by the write's action, or
  by other pending data — past the content's landing, so the staged swap
  was cleared before display. That is a race the developer does not
  control (the nested boundary's data may simply be faster than the frame's
  hold) and a fallback that loses it is a legitimate outcome, not a defect.
  The engine cannot distinguish "the action awaited exactly this data" from
  "the action awaited something else that happened to be slower", so the
  rule is unsound.

Removed with it: `devHeldSweep`, `CollectionQueue._swapUnseen` and
`_devHeldSweep`, the `_checkSources` DEV drop, and the scheduler's DEV-only
parked-finalize walk (`devSweepBoundaryChildren`). `_settled`, `_judgeHeld`
and `_output` stay — they carry the pre-verdict boundary judgment (#3581).
Prod core floor unchanged (boundaries.ts is shed from that fixture; the
scheduler branch was DEV-folded); core+Loading −9 B prod, −647 B dev.

Tests: the after-the-fact pins flip to "not reported" (frame-following 5.,
rearm-reveal held-action cases, the web held-action spec); the same-source
pins tighten on the new message; one new pin for a frame held by OTHER
pending data that outlasts the content with no action — no report, the
content reveals at the commit. Docs, CHEATSHEET, `Loading` JSDoc and the
two pending changesets that announced the removed rule updated to match.

Co-authored-by: Claude via Cursor <noreply@cursor.com>

* chore(size): re-set the createStore cap after the CollectionQueue field removal (#3584)

Measured against next (4d7638c), brotli:

- signals: + createStore: 16,866 B vs 16,805 (+61 B); cap 16.85 -> 16.90 KB.
  Minified bundle length unchanged; the delta is the prop mangler's
  short-name reshuffle after two _ props left CollectionQueue.
- app: CSR, observe tier + attribution engine enabled: 27,767 B, under the
  27.80 KB cap `next` already re-set in #3587 — no change here.
- boundaries.ts is -9 B minified on both tiers; scheduler.ts 0 B.
- All other scenarios under cap: core floor 9,675 (+4), isPending/latest
  12,411 (-20), simple-app 12,489 (+16), hydrating 20,655 (-49), hydrating +
  stores 30,825 (-55), CSR 15,779 (+36), CSR observe 17,293 (+18), frames
  11,384 (0).

Co-authored-by: Claude via Cursor <noreply@cursor.com>

---------

Co-authored-by: Claude via Cursor <noreply@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