Conversation
…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 detectedLatest commit: 55779c0 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 |
on is a key; boundaries are not born held (#3540)
Merging this PR will not alter performance
Comparing Footnotes
|
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.
Summary
Loading'sonprop 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: aLoadingmounted 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
onas a key (withon={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>withoutonis such a reader: once it has content, a refetch keeps that content visible and holds the write until the data lands.onis a key —keyon React'sSuspense, 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 oncount, 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:
onshould be equivalent to a keyed<Show>wrapping the boundary, minus the remount — whatever the key expression reads falls out of that. If the key readslatest(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 oflatest; 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
Loadingmounted 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 inconsistencyThe issue's table showed
<Show keyed when={latest(count)}>revealing early with static children but holding with{() => <Loading>…</Loading>}. The difference is notuntrack: a zero-arg function child is an accessor —Showevaluates it once and does not remount it per key, so at the key change itsLoadingalready 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 howShowtreats 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
Loadingover a held value inside a flush made the content pass enter the transaction (enterStagedRead→initTransition), adopting the whole flush into the hold — so theShowthat 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 boundaryunderFreshLoadingBoundary(el): walksel._queueup the_parentchain to the nearest pending-collectingCollectionQueue; true when that boundary is not yet_initialized. (Kept inline: relocating behind aGlobalQueueslot measured −8 B on the core floor and +50–70 B per boundary-using app.)enterStagedReadnow takes the staging path (stagedEntry = t, noinitTransition) 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. Aspectateread enters nothing.recompute's born-held arm restages a re-pass that is already the transaction's instead of re-queuing it (_transition !== bornHeldguard), 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.spectatingwidened: it now also suppresses the transaction entry inenterStagedReadand, inserve, a node with a staged value and no committed one throwsNotReadyErrorto 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 passkeyComputed(owner, onFn):onbecomes its own lazycomputed, 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: aNotReadyErrorfrom the key answersON_INIT(counts as a change; this boundary, not an outer one, shows its fallback — a custom_notifyStatusscrubs 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).computedreads the key first. A change under an initialized boundary callsqueue._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._disabledis still read regardless, since its clear is what re-runs the pass._reset()is the formernotify-time reset body moved out ofnotify: clears_initialized/_sources, collects forwarded readers' sources fromtransitions[*]._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 theboundaryFallbackattribution,wakeParked()._checkSourceskeeps 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.spectateinstead ofuntrack, so the mounting pass is neither linked to the tree nor pulled into a hold the tree was staged into._onFn,_readOn, and the_prevOnre-sync in_checkSourcesare removed;_prevOnis maintained by the output pass.packages/solid/src/client/flow.tsLoadingdocblock only:ondescribed as a key,on={route}example fixed toon={route()}, and anon={latest(route)}example added.Changed expectations
packages/signals/tests/posture-store-parity.test.tsS2 — 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.ts—onis 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 wayon={latest(x)}does:["A=Loading", "count=1", "A=2 0"](was["count=1", "A=Loading", "A=2 0"]); with two boundariesA=Loadinglands 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 withouton:["A=2 0", "count=1"].Tests
packages/signals/tests/boundary-not-born-held-3540.test.ts: aLoadingmounted 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≡ keyedShowmatrix overcountvslatest(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.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 overlatest(count)(reveal early, remounted) andcount()(held, remounted),on={count()}(held, not remounted),on={latest(count)}(reveal early, not remounted) — with mount counts pinned.Docs
packages/signals/docs/SPEC-ASYNC-SEMANTICS.md: A29 amended (status, pinned-by, mechanism index, and a "Boundaries are exempt" paragraph, plusonas the same rule seen from outside).packages/signals/docs/RULES-INDEX.md: A29 / A33 rows.packages/solid/src/client/flow.tsLoadingdocblock;createLoadingBoundaryonparam docblock inboundaries.ts.packages/solid/CHEATSHEET.mdLoadingline.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.jswith#3540notes (brotli, againstnext):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 forspectatingatservebeside its_verdictPullgate).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