Skip to content

fix(signals): a fresh Loading over a held value shows its fallback in a flush (L2 regression of #3540) - #3824

Open
ryansolid wants to merge 7 commits into
nextfrom
fix/l2-3540-fresh-loading
Open

ryansolid wants to merge 7 commits into
nextfrom
fix/l2-3540-fresh-loading

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Refs #3540

L2 regressed A29's boundary exemption (a Loading that has not shown content, mounted while a transaction holds what it reads, shows its fallback now and its content at the commit) in two ways. This is the #3540 piece of fix/create-time-holds (ca71e5d9d) on its own — #3802/#3814 and #3800 (_bornIn) are not in it — reworked to reuse STATUS_UNINITIALIZED, _initialized and the boundaries' existing catch (GlobalQueue._catch) instead of a new hook.

The two defects

  1. In a flush, the mount waits for the hold. A first pass under a fresh boundary joined the whole flush to the hold it read (flushTransaction), so the tick — the Show that mounted the boundary included — was held until the release: nothing showed, not even the fallback.
  2. Bound content reveals empty. A render effect born held under a fresh boundary (content bound in JSX — <p>{m()}</p> — so the boundary's tree never reads the held value) was never caught, so the boundary revealed its (empty) content instead of its fallback. In a flush and out of one.

The fix

  • joinPass (core.ts): a pass that reads a hold asks its boundaries through the existing GlobalQueue._catch, with STATUS_UNINITIALIZED in the flags; a render effect reading a node born into the future (no committed value) joins it like any reader. catchStatus catches only a first pass (STATUS_UNINITIALIZED), and only at the nearest loading boundary, if it has not shown content (!_initialized). A revealed boundary — re-armed by on or not — neither catches nor records it, no boundary past the nearest is asked, and a committed pass is never caught. So which boundaries catch what is unchanged beyond A29's exemption.
  • When the boundary catches it, joinPassTx(t, own) keeps the pass pass-scoped (passTx) inside a flush too, as a mount outside a flush is: the boundary's, not the tick's. Nothing caught it → it joins the flush as before, so a derivation outside the boundary in the same flush still holds the tick (membership is the tick's).
  • The boundary's output (boundaries.ts): its first pass shows the fallback whenever it has collected readers (b._readers.size !== 0), not only when its own tree is born held — this covers the bound-content case; the previous collecting.add(b) is redundant now (the catch adds it).
  • flush() clears passTx at its end, so a boundary's pass-scoped hold set in a flush does not merge with a later tick's.
  • The slow path of read() routes the uninitialized-owner case through joinPass (it was a duplicate of it minus the render-effect test).
// core.ts
function joinPass(c: Computed<any>, el: Signal<any> | Computed<any>): void {
  c._flags |= REACTIVE_JOINED;
  if (
    (el as Computed<any>)._statusFlags & STATUS_UNINITIALIZED ||
    (c as any)._type !== EFFECT_RENDER
  )
    joinPassTx(txOf(el), GlobalQueue._catch?.(c, STATUS_PENDING | STATUS_UNINITIALIZED));
}
// boundaries.ts, catchStatus's walk (nearest boundary first)
for (let b = boundaryOf(node); b !== undefined; b = b._parent ?? undefined) {
  if (!(b._type & flags)) continue;
  // A pass that read a hold: the nearest loading boundary only, and only
  // a first pass before it has shown content (then it is collecting).
  if (
    flags & STATUS_UNINITIALIZED &&
    (b._initialized || !(node._statusFlags & STATUS_UNINITIALIZED))
  )
    break;
  if (isCollecting(b)) {
    caught(b, node, error);
    return true;
  }
  b._readers.add(node);
  collecting.add(b);
}

SPEC-ASYNC-SEMANTICS.md: a dated amendment paragraph after the direction rule's bullets (the ruling and its bullets are unchanged); RULES-INDEX.md regenerated.

Public API changes

None. catchStatus's (internal) error parameter became optional; GlobalQueue._catch (internal) also accepts STATUS_UNINITIALIZED in its flags.

Review follow-up: what the first-pass catch records (open questions 1 and 2)

The first revision of this PR routed the first-pass catch through catchStatus's ordinary walk, which records a pending reader on every revealed loading boundary it passes and catches at the first one still collecting. For a never-committed first pass that meant:

  • No boundary that has not shown content on the chain (a fresh computation directly under a revealed Loading): the revealed boundary recorded it as a reader. next records only the pending memo the frame forwards; the first revision also recorded the born-held binding — 2 readers vs next's 1 (pinned now: exactly next's 1, and the mount joins the transaction as on next).
  • Revealed boundaries nearer than one that has not shown content (a revealed Loading inside one still on its fallback): the revealed inner one recorded it, and the outer one caught it — on next neither does (pinned now: as on next).
  • Revealed ancestors above the catching boundary: never — the walk stops at the boundary that catches (pinned: an outer revealed Loading over an inner fresh one records nothing, is not pending, and the transaction commits without waiting for the fresh computation's own first load).
  • on: the walk's isCollecting counts an armed (on changed this flush) revealed boundary as collecting. With !_initialized instead, an armed boundary that has shown content is treated as revealed. Every on shape tried — revealed, re-armed or not by the mounting flush, mounted by a render effect or by the boundary's own tree — behaves exactly as on next; an on boundary that has not revealed behaves as a Loading without on (from mainline as on next; in a flush the Loading on should behave as a keyed <Show> around the boundary; a boundary mounted under a hold must not be born held (A29) #3540 restore).

Now: only the nearest loading boundary is asked, it catches only if it has not shown content, and nothing is ever recorded on a revealed boundary for a first pass.

Tests

  • packages/signals/tests/loading-fallback-in-flush-3540.test.ts (18; 8 fail on next, all pass here):
    • in a flush — content through a memo, directly, bound by a render effect; nested under an outer Loading that has shown content; a derivation outside the boundary still holds the tick; the boundary's hold stays its own (pins the flush-end passTx clear — fails without it);
    • on a hold, mounted from mainline — the same three content shapes;
    • the catch belongs to the boundary that has not shown content and to no other — outer revealed / inner fresh / transaction in flight: the outer one's isPending is false and it records no reader before, during or after; the transaction commits while the fresh computation's own first load is still out; nothing stays registered after the inner one resolves. Directly under a revealed Loading, and under a revealed one inside one that has not shown content: exactly next's trace (pass on next);
    • on (fix(signals): Loading on follows the frame; DEV LOADING_ON_OUTSIDE_HOLD (#3540) #3575: a dependency list): revealed, re-armed or not, render-effect mount or tree mount — exactly next's trace (pass on next); not yet revealed, from mainline (passes on next) and in a flush (the restore).
  • packages/web/test/loading-fallback-in-flush-3540.spec.tsx (3, from ca71e5d9d): 2 fail on next.
  • direction-rule-probe.test.ts: pins unchanged (3 it.fails still fail; the in-flush-boundary row still passes).
  • signals 4972 passed / 10 expected fail; solid 821/821; web 1143 passed, 2 failed in lowercase-on-attribute.spec.tsx — the same 2 fail on next in this environment (native compiler binary); signals tsc ✓, pnpm types ✓; rules-index --check ✓.

Semantic fuzzer (fuzz/semantic-fuzzer-l2 @ 08921755e, oracle revision 19)

Seeds 3289 and 91501, all 16 cohorts × 2000 cases, the three equivalence modes × 500, and the corpus (2 jobs at a time), against next (7acc0390e, #3814 included) and this branch's final code: no delta — 330 findings on each, identical signatures and case indices in every cohort (all pre-existing on next).

Size

Size-Exception: approved by the maintainer for #3824 — app: hydrating + every store primitive family, app: compiled CSR, app: compiled hydrating, page: base server components, page: live server components. Restoring A29's boundary exemption (#3540) under L2 costs +27–28 B minified on every scenario with a boundary (+17 B core: the first-pass catch in joinPass, the pass-scoped join, the flush-end passTx clear; +10–11 B boundaries: the nearest-boundary stop that keeps revealed boundaries from catching or recording a first pass), 7–8 B past the 20 B allowance where these five are over their brotli caps.

The five caps are reset to this PR's CI-measured brotli (run on 2d2c28259, against next @ 7acc0390e) + 10 B, rounded up to 0.01 KB, with dated ledger notes in scripts/size/scenarios.js (the two pages' caps in floor-caps.json). No other cap changes. The core-only scenarios (+16–17 B minified) pass within the allowance.

Scenario minified (next → head) Δ min brotli (next → head) Δ br brotli cap gate
signals: core floor 20113 → 20130 +17 7347 → 7353 +6 7.35 KB warn (over cap, ≤20 B minified)
signals: + createStore 44376 → 44393 +17 14534 → 14587 +53 14.56 KB warn (over cap, ≤20 B minified)
signals: + isPending/latest 26808 → 26824 +16 9499 → 9505 +6 9.49 KB warn (over cap, ≤20 B minified)
app: render + one signal 27667 → 27684 +17 9852 → 9851 −1 9.86 KB ok
app: hydrating 52547 → 52575 +28 17673 → 17700 +27 17.71 KB ok
app: hydrating + every store primitive family 91605 → 91632 +27 28903 → 28881 −22 28.87 → 28.90 KB (Size-Exception) ok
app: CSR with Show/For/Loading/Errored/lazy 36548 → 36577 +29 12848 → 12858 +10 12.86 KB ok
app: CSR, observe tier 41071 → 41099 +28 14434 → 14428 −6 14.46 KB ok
app: CSR, observe tier + attribution engine enabled 86399 → 86427 +28 28606 → 28640 +34 28.66 KB ok
app: compiled floor 28198 → 28215 +17 10034 → 10027 −7 10.05 KB ok
app: compiled CSR 78878 → 78906 +28 25073 → 25204 +131 25.13 → 25.22 KB (Size-Exception) ok
app: compiled hydrating 99178 → 99206 +28 30962 → 30956 −6 30.93 → 30.97 KB (Size-Exception) ok
frames: eager client consumer 43310 → 43310 0 13770 → 13770 0 13.78 KB ok
page: base server components 145579 → 145607 +28 44820 → 44883 +63 44.84 → 44.90 KB (Size-Exception) ok
page: live server components 157542 → 157569 +27 48542 → 48513 −29 48.51 → 48.53 KB (Size-Exception) ok
server: floor 3324 → 3324 0 1331 → 1331 0 1.34 KB ok
server: renderToString 71813 → 71813 0 20412 → 20412 0 20.42 KB ok

… a flush (#3540, L2 regression)

L2 regressed A29's boundary exemption in two ways:

- In a flush, a first pass under a loading boundary that has not shown
  content joined the flush to the hold it read, so the whole tick (the
  Show that mounted the boundary included) waited for the hold. A first
  pass that reads a hold now asks the boundaries up its chain
  (GlobalQueue._fresh); one such boundary catches it, and the pass joins
  pass-scoped (passTx), as a mount outside a flush does: the boundary's,
  not the tick's. A derivation outside the boundary in the same flush
  still holds the tick. A flush that set passTx clears it at its end.
- A render effect born held under a fresh loading boundary (content bound
  in JSX: the tree never reads the held value) was not caught, so the
  boundary revealed empty content, in a flush and out of one. It is now
  collected as a reader, and the output's first pass shows the fallback
  whenever the boundary has collected readers.

Refs #3540

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3f05444

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

This PR includes changesets to release 12 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
todos-server-example 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

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.35 KB +6 B (+0.1%) +17 B 7.35 KB ⚠️ over by 3 B, noise
signals: + createStore 14.59 KB +53 B (+0.4%) +17 B 14.56 KB ⚠️ over by 27 B, noise
signals: + isPending/latest 9.51 KB +6 B (+0.1%) +16 B 9.49 KB ⚠️ over by 15 B, noise
app: render + one signal (the simple-app floor) 9.85 KB −1 B (−0.0%) +17 B 9.86 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.70 KB +27 B (+0.2%) +28 B 17.71 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 28.88 KB −22 B (−0.1%) +27 B 28.90 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.86 KB +10 B (+0.1%) +29 B 12.86 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.43 KB −6 B (−0.0%) +28 B 14.46 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.64 KB +34 B (+0.1%) +28 B 28.66 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.03 KB −7 B (−0.1%) +17 B 10.05 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.20 KB +131 B (+0.5%) +28 B 25.22 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 30.96 KB −6 B (−0.0%) +28 B 30.97 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 13.77 KB 0 B 0 B 13.78 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 44.88 KB +63 B (+0.1%) +28 B 44.90 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 48.51 KB −29 B (−0.1%) +27 B 48.53 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.41 KB 0 B 0 B 20.42 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • signals: core floor (createSignal/Memo/Effect/Root/flush): over brotli cap by 3 B, minified +17 B — layout noise; cap will be re-based at the next ratchet
  • signals: + createStore: over brotli cap by 27 B, minified +17 B — layout noise; cap will be re-based at the next ratchet
  • signals: + isPending/latest: over brotli cap by 15 B, minified +16 B — layout noise; cap will be re-based at the next ratchet

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and grew more than 20 B minified over the base; over the cap within that allowance is brotli layout noise and passes with a warning. Caps in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). Caps are re-based downward by npm run ratchet (scripts/size/README.md).

@coveralls

coveralls commented Oct 6, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37441526431

Coverage remained the same at 76.058%

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: 1196
Covered Lines: 963
Line Coverage: 80.52%
Relevant Branches: 930
Covered Branches: 654
Branch Coverage: 70.32%
Branches in Coverage %: Yes
Coverage Strength: 28.2 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 12.41%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
✅ 186 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ memo + sync render effect only (reference) 32 ms 27 ms +18.54%
⚡ build + consume 742 µs 696 µs +6.61%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/l2-3540-fresh-loading (a661e91) with next (7acc039)

Open in CodSpeed

ryansolid and others added 6 commits October 6, 2026 01:35
…dary, and only one that has not shown content (#3540)

A revealed boundary (re-armed by `on` or not) neither catches nor records
a never-committed first pass, and no boundary past the nearest is asked:
which boundaries catch what is unchanged beyond A29's boundary exemption.
catchStatus is back to next's.

Refs #3540

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

A pass that read a hold asks GlobalQueue._catch with STATUS_UNINITIALIZED
in its flags. Only a first pass is caught, and only by the nearest loading
boundary if it has not shown content (`_initialized`, not `_armed`): a
revealed boundary, re-armed by `on` or not, neither catches nor records
it, and no boundary past the nearest is asked. Pins the revealed-boundary,
nested and `on` shapes against next.

Refs #3540

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… B (A29 restore, #3540)

app: hydrating + every store primitive family 28.87 -> 28.90 KB,
app: compiled CSR 25.13 -> 25.22 KB, app: compiled hydrating 30.93 ->
30.97 KB, page: base server components 44.84 -> 44.90 KB, page: live
server components 48.51 -> 48.53 KB. Approved by the maintainer.

Refs #3540

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ding

# Conflicts:
#	packages/signals/docs/RULES-INDEX.md
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