Skip to content

fix(signals): a Loading on reset collects what its forwarded readers still wait on (#3459) - #3467

Merged
ryansolid merged 5 commits into
solidjs:nextfrom
brenelz:fix/loading-reset-collects-forwarded-3459
Sep 15, 2026
Merged

ryansolid merged 5 commits into
solidjs:nextfrom
brenelz:fix/loading-reset-collects-forwarded-3459

Conversation

@brenelz

@brenelz brenelz commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #3459.

The gap

const fast = createMemo(async () => a());
const slow = createMemo(() => delay(2000, b()));
<p>B: {b()}</p>
<Loading on={a()} fallback={<p>Loading</p>}>
  <p>Fast: {fast()}</p>
  <p>Slow: {slow()}</p>
</Loading>

setB(1); await delay(500); setA(1). The first write is held by slow's flight (B stays 0, as it should). The second write changes on, the boundary resets, and content came straight back with B: 1 | Fast: 1 | Slow: 0 for the remaining 1.5 s, then Slow: 1. Expected: B: 1 | Loading until the b=1 answer lands, then Fast: 1 | Slow: 1 together.

Traced: the on reset (CollectionQueue.notify) clears _initialized and _sources, wakes the parked transaction, and then rebuilds _sources from the pending notifications that follow. Only the Fast effect notifies: it has a fresh flight for a=1. The Slow effect was already pending on slow from the earlier write, and status propagation dedupes on the reader's _pendingSources, so it never re-notifies. _sources ends 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 and B: 1 shows. Then fast lands in a microtask, _checkSources empties, _disabled flips, and the tree reveals with slow still 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 both fast() and slow() 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 _queue chain reaches this queue with no collecting pending-type boundary in between, the same test reporterBlocksSource applies, 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 that slow is now in _sources, and _checkSources keeps the fallback until its status clears.

scheduler.ts exports the transitions set for this; no scheduling logic changes. The scan is O(registered reporters) once per on change.

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 new tests/loading-reset-collects-forwarded-3459.test.ts (the sibling-effect shape and the one-effect control, both pinned to B: 1 | Loading at the reset and Fast: 1 | Slow: 1 together).
  • @solidjs/web: 88 files / 792 tests green, including the new test/loading-reset-collects-forwarded-3459.spec.tsx, a jsdom port of the issue's playground (real click, DOM sampled every 25 ms). Fails on next at t=50: B: 1 | Fast: 1 | Slow: 0, passes here.
  • Core floor: 23,752 on next and 23,752 here, +0 B (boundaries.ts is outside the core-reachable set; the scheduler change is an export keyword).
  • Brotli caps against a pristine next build: one cap ratcheted, app: CSR with Show/For/Loading/Errored/lazy 14.45 -> 14.5 KB, measured 14,467 against 14,429 (+38 B: the reset walk in boundaries.ts, retained wherever Loading is; the note in .size-limit.js cites 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: + createStore is pre-existing red on next by 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.
  • Docs: INTERNALS-ASYNC-STATE.md extends 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 (--check clean; the one unresolved test id is pre-existing on next). Changeset included.

🤖 Generated with Claude Code

…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-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6308a0a

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 15, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 5.8%

❌ 1 regressed benchmark
✅ 171 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
createStore setter: delete + set one root key (#3044 overlay) 549.4 µs 583.2 µs -5.8%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing brenelz:fix/loading-reset-collects-forwarded-3459 (6308a0a) with next (347a5ca)

Open in CodSpeed

ryansolid and others added 4 commits September 15, 2026 08:31
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>
@ryansolid
ryansolid merged commit 5c1f01f into solidjs:next Sep 15, 2026
6 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