fix(signals): a Loading on reset collects what its forwarded readers still wait on (#3459) - #3467
Merged
ryansolid merged 5 commits intoSep 15, 2026
Conversation
…s still wait on (solidjs#3459) The `on` reset cleared the boundary's sources and rebuilt them from the pending notifications that followed. A reader already pending from an earlier write never re-notifies (status propagation dedupes on its `_pendingSources`), so with `fast` and `slow` read from sibling effects only the fresh flight was collected; the wake committed the held write (solidjs#3375: a reader behind a collecting boundary holds nothing), `fast` landed a microtask later, and the boundary revealed `B: 1 | Fast: 1 | Slow: 0` with `slow` still in the air. The reset now harvests, from every live transaction's `_asyncReporters` (INV-3, the one record of a forwarded reader), the sources of each reporter it routes (`_holds`: under this queue with no collecting pending-type boundary between, the `reporterBlocksSource` test) plus that reporter's `_pendingSources`, and flips to the fallback when it found any. The hold the ruling takes off the lane lands on the boundary: `B: 1 | Loading`, then `Fast: 1 | Slow: 1` together. `scheduler.ts` exports `transitions` for the walk; no scheduling logic changes. Core floor unchanged (23,752). Docs bullet, rules index, changeset. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 6308a0a 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 degrade performance by 5.8%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing |
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # packages/signals/docs/RULES-INDEX.md
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # packages/signals/docs/INTERNALS-ASYNC-STATE.md # packages/signals/docs/RULES-INDEX.md
…reset walk on top of solidjs#3464/solidjs#3465/solidjs#3466 Measured at 28861 B against next's 28815 (+46). The PR alone sat 2 B under against the pre-solidjs#3464 next; the three fixes that landed meanwhile used the room. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…ution Co-authored-by: Cursor <cursoragent@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.
Fixes #3459.
The gap
setB(1); await delay(500); setA(1). The first write is held byslow's flight (B stays0, as it should). The second write changeson, the boundary resets, and content came straight back withB: 1 | Fast: 1 | Slow: 0for the remaining 1.5 s, thenSlow: 1. Expected:B: 1 | Loadinguntil the b=1 answer lands, thenFast: 1 | Slow: 1together.Traced: the
onreset (CollectionQueue.notify) clears_initializedand_sources, wakes the parked transaction, and then rebuilds_sourcesfrom the pending notifications that follow. Only the Fast effect notifies: it has a fresh flight for a=1. The Slow effect was already pending onslowfrom the earlier write, and status propagation dedupes on the reader's_pendingSources, so it never re-notifies._sourcesends up{fast}. The wake re-judges the b=1 transaction; by the #3375 ruling a reader behind a collecting boundary no longer blocks (reporterBlocksSource), so it commits andB: 1shows. Thenfastlands in a microtask,_checkSourcesempties,_disabledflips, and the tree reveals withslowstill in the air. The hold left the lane, as ruled, but never arrived at the boundary. The #3375 fix collected the notifying effect's own_pendingSources, which is why one effect reading bothfast()andslow()is fine; two sibling effects, the JSX shape, are not.Fix
A reset moves the hold onto the boundary. The one place a forwarded reader is recorded is the live transactions'
_asyncReporters(INV-3), so the reset walks them: for every reporter this boundary routes (_holds: its_queuechain reaches this queue with no collecting pending-type boundary in between, the same testreporterBlocksSourceapplies, plus the zombie/disposed filter), it adds the registered source and that reporter's_pendingSources, and flips to the fallback if it found any.wakeParked()still follows, so the transaction commits as before; what changes is thatslowis now in_sources, and_checkSourceskeeps the fallback until its status clears.scheduler.tsexports thetransitionsset for this; no scheduling logic changes. The scan is O(registered reporters) once peronchange.Trade-offs, stated plainly: the boundary now shows the fallback at the reset instant when it found a still-pending forwarded reader, before the new flight's own notification arrives. That is the ruled behaviour for a reset (new question, fallback until the answer). A reader pending only on a transaction-less re-ask (
refresh()) has no reporter entry and is not collected; consistent with A9 (a re-ask is silent) and not the reported shape.Verification
@solidjs/signals: 180 files / 2037 tests green (1 skipped, pre-existing), including the newtests/loading-reset-collects-forwarded-3459.test.ts(the sibling-effect shape and the one-effect control, both pinned toB: 1 | Loadingat the reset andFast: 1 | Slow: 1together).@solidjs/web: 88 files / 792 tests green, including the newtest/loading-reset-collects-forwarded-3459.spec.tsx, a jsdom port of the issue's playground (real click, DOM sampled every 25 ms). Fails onnextatt=50: B: 1 | Fast: 1 | Slow: 0, passes here.nextand 23,752 here, +0 B (boundaries.tsis outside the core-reachable set; the scheduler change is anexportkeyword).nextbuild: one cap ratcheted,app: CSR with Show/For/Loading/Errored/lazy14.45 -> 14.5 KB, measured 14,467 against 14,429 (+38 B: the reset walk inboundaries.ts, retained whereverLoadingis; the note in.size-limit.jscites it). The other Loading-carrying app scenarios grow 57 to 80 B and stay under their caps (hydrating + every store family lands at 28,848 against its 28.85 KB cap, 2 B of room).signals: + createStoreis pre-existing red onnextby 9 B (15,809 vs 15,800, from perf(signals,web,universal,html): merge/omit are always lazy views; consumers read the leaves #3454/test(signals): store oracle + four signal states; latest() seed leak and body-end visibility fixes #3455); here it measures 15,747, under the cap by brotli layout drift from the new scheduler export, not a real shrink, and its cap is left alone. Baseline is a forced (turbo --force) build of a8a8949 in a clean worktree; it matched the shared baseline byte for byte.INTERNALS-ASYNC-STATE.mdextends the 2.0.0-rc Loading reveals stale content before downstream async work finishes #3375 bullet with the reset's collection rule; rules index regenerated (--checkclean; the one unresolved test id is pre-existing onnext). Changeset included.🤖 Generated with Claude Code