fix(signals): a boundary whose fallback is pending reveals landed content without waiting for the fallback's flight (#3540) - #3581
Merged
Conversation
🦋 Changeset detectedLatest commit: 880daeb The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
Merging this PR will not alter performance
Comparing Footnotes
|
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
force-pushed
the
test/on-rearm-reveal-pins
branch
from
September 22, 2026 09:16
3dd9e08 to
880daeb
Compare
Coverage Report for CI Build 35709477758Coverage remained the same at 72.775%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
This was referenced Sep 22, 2026
Merged
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
A collecting
Loadingboundary 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-onanalogueloading-fallback-release-3540): an initialized outer boundary around an inner boundary whose fallback readsfallbackData(3000 ms) and whose content readsdata(1000 ms).Expected frames:
Actual frames before this PR:
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 onorigin/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
btoa) passes onnext— recompute settles a pass's outgoing pending sources (core.ts~L754) andwakeParked()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 infinalizePureQueueafter the verdict, on a non-parked finalize only (scheduler.tsif (!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
onre-arm drain already runs (scheduler.tsrun(), afterpendingRearms):checkBoundaryChildren(this, true)walks the boundaries and callsCollectionQueue._judgeHeld(), which gates_checkSources()on "collecting and the output pass is pending". A ready boundary stages_disabledfalse 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 twocreateErrorBoundarycompute-phase pins; the narrow gate is the root-cause shape).The DEV after-the-fact
LOADING_ON_OUTSIDE_HOLDrule (devHeldSweep) keyed on "swap still stagedtrueat the park"; it now also recognizes a swap the pre-verdict sweep cleared while still staged (_disabledstagedfalseover a never-committedtrue) as never displayed._checkSourcesdrops_swapUnseenonly 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:settledBoundarieslist +noteSettledBoundaryhook insettlePendingSource,retirePendingSourcesin async.ts,_pruneSourcessplit,_settled()retiring the output's pending sources by hand while leaving_disabledtrue): 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_outputback-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:
on: count, action writescount, stays open past the landing): old content held through the flight,data 2at the release, fallback never logged,LOADING_ON_OUTSIDE_HOLDonce after the fact. Matches the derivation.on: dep;datare-asked throughasked, which the action never writes): the derivation in the brief expecteddata 1at +1000 while dep/other were still held. The engine does not do that with a plainon: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 whendatalands the staged output pass reads it and the two holds merge — reveal at the release, fallback never shown,LOADING_ON_OUTSIDE_HOLDonce. That is frame-following rule 5 with a different source topology, so the pin records it. The brief's sequence (fallback now,data 1at +1000 beside the held dep/other, no diagnostic) is exactly whaton: () => 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 newloading-fallback-release-3540.test.ts).treeshakecore 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 vsorigin/next; five caps re-set with measurement notes, five unchanged.