Skip to content

fix(signals,solid): Loading on is a key; boundaries are not born held (#3540) - #3556

Open
ryansolid wants to merge 1 commit into
nextfrom
fix/loading-on-keyed-boundary
Open

ryansolid wants to merge 1 commit into
nextfrom
fix/loading-on-keyed-boundary

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 20, 2026

Copy link
Copy Markdown
Member

Summary

Loading's on prop is a key — semantically a keyed <Show> wrapping the boundary, minus the remount. It is now a tracked expression: a change resets the boundary and lands wherever the key's change lands (on={count()} with the write's commit, on={latest(count)} ahead of it). And boundaries are exempt from A29 born-held: a Loading mounted while a transaction holds what it reads shows its fallback now and reveals at the commit, instead of being born held with the transaction.

Docs updated to describe on as a key (with on={latest(x)} as the spelling for a placeholder ahead of the commit), A29 amended in the spec, two existing expectations changed, size caps ratcheted with notes.

Model & ruling (from #3540)

Every reader of a pending value holds the write that made it pending. A <Loading> without on is such a reader: once it has content, a refetch keeps that content visible and holds the write until the data lands. on is a key — key on React's Suspense, not a reset trigger. While the key is unchanged the boundary holds like any reader; when a write changes the key, the boundary stops holding that write and lands in its fallback as part of that write's commit. The fallback belongs to the committed frame ("Loading comments for product B" must not render beside product A's info). So #3529 — A keyed on count, B a plain <Loading> reading the same async memo — is by design: B holds the count write, A's key change is part of that write, and the commit waits for B.

Gabriel's argument, adopted: on should be equivalent to a keyed <Show> wrapping the boundary, minus the remount — whatever the key expression reads falls out of that. If the key reads latest(count), it changes ahead of the commit, the boundary swaps, and a fresh boundary has nothing to preserve, so it shows its fallback now. No detection of latest; the key is an ordinary reactive expression.

Maintainer ruling: A29 ("born held") is wrong for boundaries. Born held is right for a plain memo/effect created mid-hold that reads a held value — it would otherwise tear. A boundary is the exception by definition: its job is to catch pending reads under it rather than let them hold. A Loading mounted while a write is held shows its fallback; the hold stays with readers that have content to keep.

Root cause of the <Show keyed> static-vs-function inconsistency

The issue's table showed <Show keyed when={latest(count)}> revealing early with static children but holding with {() => <Loading>…</Loading>}. The difference is not untrack: a zero-arg function child is an accessorShow evaluates it once and does not remount it per key, so at the key change its Loading already had content and held like any initialized boundary. Static children and a 1-arg callback ({(c) => <L />}) are re-evaluated per key and mount a fresh boundary, which is the case the ruling covers. Changing how Show treats zero-arg children is out of scope per the maintainer; the web matrix pins static + 1-arg callback children (with mount counts) and deliberately does not pin the zero-arg form.

The real bug under the ruling was on the fresh-boundary path: mounting a Loading over a held value inside a flush made the content pass enter the transaction (enterStagedReadinitTransition), adopting the whole flush into the hold — so the Show that flipped to mount it waited for the hold too.

Mechanism

packages/signals/src/core/core.ts — born-held exemption for creation under a fresh loading boundary

  • underFreshLoadingBoundary(el): walks el._queue up the _parent chain to the nearest pending-collecting CollectionQueue; true when that boundary is not yet _initialized. (Kept inline: relocating behind a GlobalQueue slot measured −8 B on the core floor and +50–70 B per boundary-using app.)
  • enterStagedRead now takes the staging path (stagedEntry = t, no initTransition) inside a flush too when the recomputing pass is under a fresh boundary. The mainline and in-flush arms are folded into one predicate; verdict pulls (GlobalQueue._verdictPull) still never enter from mainline and never take the in-flush staging arm. A spectate read enters nothing.
  • recompute's born-held arm restages a re-pass that is already the transaction's instead of re-queuing it (_transition !== bornHeld guard), and when the node is under a fresh boundary, notifies that boundary (el._queue.notify(el, STATUS_PENDING, STATUS_PENDING, new NotReadyError(el))) so it collects the node and shows its fallback. A boundary already showing content is not told — it holds like any reader.
  • spectating widened: it now also suppresses the transaction entry in enterStagedRead and, in serve, a node with a staged value and no committed one throws NotReadyError to a spectating reader (it derives nothing, so it cannot enter; a held-only value is simply not ready to it).

packages/signals/src/boundaries.ts — tracked key + reset in the output pass

  • keyComputed(owner, onFn): on becomes its own lazy computed, created under the boundary's owner before the owner's queue becomes this boundary's — so it belongs to the parent boundary, exactly as the condition memo of a <Show keyed when={key}> wrapping the boundary would. It is a plain reader (born held over a held value per A29; staged with a transaction it reads). Two status rules differ from a plain memo: a NotReadyError from the key answers ON_INIT (counts as a change; this boundary, not an outer one, shows its fallback — a custom _notifyStatus scrubs the pending mark, re-enqueues and re-derives), and a real error is forwarded up the queue chain like a render effect's, halting if uncaught (2.0.0 beta.18 error causing REACTIVITY_HALTED is not logged to the console #2884).
  • The boundary's output computed reads the key first. A change under an initialized boundary calls queue._reset() and answers the fallback directly from the returned flag, so the fallback lands as the pass's value does — with the key's change. _disabled is still read regardless, since its clear is what re-runs the pass.
  • _reset() is the former notify-time reset body moved out of notify: clears _initialized/_sources, collects forwarded readers' sources from transitions[*]._asyncReporters (A33, 2.0.0-rc Loading reveals stale content before downstream async work finishes #3375/Resetting Loading reveals stale content while async work is still pending #3459), sets _disabled, fires the boundaryFallback attribution, wakeParked().
  • _checkSources keeps a source collected while it is born held (staged _pendingValue, STATUS_UNINITIALIZED) — it carries no status of its own — and releases it at the commit that initializes it.
  • The priming read of the tree at creation is spectate instead of untrack, so the mounting pass is neither linked to the tree nor pulled into a hold the tree was staged into.
  • _onFn, _readOn, and the _prevOn re-sync in _checkSources are removed; _prevOn is maintained by the output pass.

packages/solid/src/client/flow.ts

Loading docblock only: on described as a key, on={route} example fixed to on={route()}, and an on={latest(route)} example added.

Changed expectations

  • packages/signals/tests/posture-store-parity.test.ts S2 — previously OBSERVED: a memo + render effect created inside loading-boundary content over a held value published the held value and ran its effect behind the fallback (the in-flush escape of A29's creation-time form — the content pass entered the hold, the creation direct-committed). Now creation under a boundary that has not revealed is born held into the transaction and collected by the boundary as not ready: nothing publishes until the hold commits, and the effect waits for the reveal like every effect behind a fallback. Both signal and store sides agree.
  • packages/signals/tests/ispending-in-boundary-on-3528.test.tson is a tracked key, not a trigger evaluated per pending notification. isPending(dep) in the key is a verdict channel that flips ahead of the write's commit, so the key changes — and the fallback shows — ahead of it, the way on={latest(x)} does: ["A=Loading", "count=1", "A=2 0"] (was ["count=1", "A=Loading", "A=2 0"]); with two boundaries A=Loading lands first, then one reveal. on=always (a fresh token per evaluation that reads nothing reactive) is evaluated once and never changes, so the boundary is never reset — it forwards the pending and holds like a boundary without on: ["A=2 0", "count=1"].

Tests

  • New packages/signals/tests/boundary-not-born-held-3540.test.ts: a Loading mounted mainline over a live action / an async refetch shows its fallback now and reveals at the commit while a plain effect beside it stays born held; content created behind a fallback over a held value is collected, not published; on ≡ keyed Show matrix over count vs latest(count); a key change with nothing pending is a no-op; a non-reactive token key never changes; a not-ready key counts as a change and does not suspend the parent.
  • New packages/web/test/loading-on-keyed-boundary-3540.spec.tsx: the issue's jsdom repro as a matrix — <Show keyed> with static and 1-arg callback children over latest(count) (reveal early, remounted) and count() (held, remounted), on={count()} (held, not remounted), on={latest(count)} (reveal early, not remounted) — with mount counts pinned.
  • Full runs: signals 3476 passed, solid-js 654, web 840 / 1032 / 190, test-types green, treeshake 10/10, size-limit all under caps.

Docs

  • packages/signals/docs/SPEC-ASYNC-SEMANTICS.md: A29 amended (status, pinned-by, mechanism index, and a "Boundaries are exempt" paragraph, plus on as the same rule seen from outside).
  • packages/signals/docs/RULES-INDEX.md: A29 / A33 rows.
  • packages/solid/src/client/flow.ts Loading docblock; createLoadingBoundary on param docblock in boundaries.ts.
  • packages/solid/CHEATSHEET.md Loading line.
  • documentation/solid-2.0/03-control-flow.md, 05-async-data.md (section renamed "keying the boundary", latest(id) spelling), MIGRATION.md.

Size

Caps ratcheted in scripts/size/.size-limit.js with #3540 notes (brotli, against next):

  • signals: core floor — 9,613 B vs 9,567 (+46 B) → 9.65 KB. Treeshake in-package floor 25,660 → 25,829 B minified (+169 B), cap 25,900.
  • signals: + createStore — 16,759 B vs 16,707 (+52 B) → 16.80 KB (0 B in the store).
  • signals: + isPending/latest — 12,367 B vs 12,253 (+114 B) → 12.40 KB (verdict layer pays for spectating at serve beside its _verdictPull gate).
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy — 20,521 B vs 20,431 (+90 B) → 20.55 KB.
  • app: hydrating + every store primitive family — 30,695 B vs 30,545 (+150 B) → 30.75 KB (0 B in the store engine).
  • app: CSR with Show/For/Loading/Errored/lazy — 15,617 B vs 15,492 (+125 B) → 15.65 KB.
  • app: CSR, observe tier — 17,090 B vs 16,965 (+125 B) → 17.15 KB.
  • app: CSR, observe tier + attribution engine enabled — 27,579 B vs 27,448 (+131 B) → 27.60 KB.

Fixes #3540

…ld (#3540)

Model (maintainer ruling on #3540): every reader of a pending value holds
the write that made it pending, and a `Loading` that already shows content
is such a reader. `on` is a KEY — `key` on React's Suspense, not a reset
trigger — semantically a keyed `<Show>` wrapping the boundary minus the
remount. While the key is unchanged the boundary holds like any reader;
when a write changes it, the boundary is fresh again and shows its fallback
wherever the key's change lands: `on={count()}` with the write's commit,
`on={latest(count)}` ahead of it. And A29 born-held is wrong for
boundaries: a boundary that has not revealed is the exception by
definition — its job is to catch what is not ready under it rather than let
it hold — so a `Loading` mounted while a transaction holds what it reads
shows its fallback now and reveals the staged content at the commit. The
hold stays with readers that have content to keep.

Mechanism

core.ts — born-held exemption for creation under a fresh loading boundary:
- `underFreshLoadingBoundary(el)`: walk the queue chain to the nearest
  pending-collecting boundary; true when it is uninitialized.
- `enterStagedRead` takes the staging (`stagedEntry`) path inside a flush
  too when the pass is under a fresh boundary (the mainline and in-flush
  arms folded into one predicate; verdict pulls still never enter from
  mainline). Entering instead adopted the whole flush into the hold — a
  `Show` that flipped mainline to mount a `Loading` over a held value
  waited for the hold with it.
- `recompute`'s born-held arm restages a re-pass instead of re-queuing it,
  and tells a fresh boundary (`queue.notify` with a `NotReadyError` sourced
  at the node) so it collects the node and shows its fallback.
- `spectating` now also refuses the transaction entry (`enterStagedRead`)
  and the staged-only value (`serve` throws `NotReadyError`): the
  boundary's priming read of its tree is a `spectate`, so a born-held tree
  neither links the mounting pass nor pulls it into the hold.

boundaries.ts — tracked key + reset in the output pass:
- `keyComputed`: `on` becomes its own lazy computed created under the
  owner BEFORE the owner's queue becomes this boundary's, so it belongs to
  the parent boundary like a `<Show keyed when={key}>` condition would. A
  `NotReadyError` from the key answers `ON_INIT` (counts as a change, and
  the boundary — not an outer one — shows its fallback; a custom
  `_notifyStatus` scrubs the pending mark and re-derives); a real error is
  forwarded up the queue chain like a render effect's, halting if uncaught.
- The boundary's output pass reads the key first; a change under an
  initialized boundary calls `_reset()` (moved out of `notify`; the A33
  forwarded-reader collection and `boundaryFallback` attribution move with
  it) and answers the fallback directly, so it lands as the pass's value
  does — with the key's change.
- `_checkSources` keeps a born-held source collected (staged value,
  `STATUS_UNINITIALIZED`) until the commit that initializes it releases it.
- The priming read of the tree is `spectate` rather than `untrack`.
- `_onFn` / `_readOn` are gone.

Docs: A29 amended in SPEC-ASYNC-SEMANTICS (status, pinned-by, mechanism,
boundary exemption paragraph); RULES-INDEX A29/A33 rows; `Loading`
docblock in client/flow.ts (`on` is a key, `on={route()}` example fixed,
`on={latest(route)}` spelling); `createLoadingBoundary` `on` param
docblock; CHEATSHEET line; solid-2.0 docs 03-control-flow, 05-async-data,
MIGRATION.

Changed expectations:
- posture-store-parity S2: a memo + render effect created inside boundary
  content over a held value used to publish the held value and run its
  effect behind the fallback (the in-flush escape of the creation-time
  form). Now born held into the transaction and collected by the boundary:
  nothing publishes until the commit.
- ispending-in-boundary-on-3528: an `isPending`-driven key is a verdict
  channel that flips ahead of the commit, so the fallback lands first
  (`A=Loading`, then `count=1`, `A=2 0`). `on=always` (a fresh token per
  evaluation that reads nothing reactive) is evaluated once and never
  changes, so it no longer resets — the boundary holds like one without
  `on`.

Tests: signals/tests/boundary-not-born-held-3540.test.ts (mainline mount
over a live action / async refetch; in-flush creation behind a fallback;
`on` ≡ keyed Show matrix over `count` vs `latest(count)`; no-op key
change; non-reactive token key; not-ready key), and
web/test/loading-on-keyed-boundary-3540.spec.tsx (the issue's matrix:
`<Show keyed>` static + 1-arg callback children over `latest(count)` /
`count()`, `on={count()}`, `on={latest(count)}`, with mount counts).

Size: core floor +46 B (9.60 -> 9.65 KB; +169 B minified in the in-package
floor, treeshake cap 25,750 -> 25,900), + createStore 16.75 -> 16.80 KB,
+ isPending/latest 12.35 -> 12.40 KB, app CSR 15.50 -> 15.65 KB, app
hydrating (no stores) 20.5 -> 20.55 KB, hydrating + every store family
30.6 -> 30.75 KB, CSR observe 17.00 -> 17.15 KB, CSR observe + attribution
27.50 -> 27.60 KB. Relocating the boundary walk behind a GlobalQueue slot
measured no-win (-8 B floor, +50-70 B per boundary-using app).

Fixes #3540

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

changeset-bot Bot commented Sep 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 55779c0

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
solid-js Patch
test-integration Patch
@solidjs/web Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics 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

@ryansolid ryansolid changed the title fix(signals,solid): Loading on is a key; boundaries are not born held (#3540) fix(signals,solid): Loading on is a key; boundaries are not born held (#3540) Sep 20, 2026
@codspeed

codspeed Bot commented Sep 20, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing fix/loading-on-keyed-boundary (55779c0) with next (be46a04)

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.

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.

1 participant