fix(signals): Loading on follows the frame; DEV LOADING_ON_OUTSIDE_HOLD (#3540) - #3575
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
…ads stay display-ahead; DEV LOADING_ON_OUTSIDE_HOLD (#3540) Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…r +46 B (#3540) Co-authored-by: Cursor <cursoragent@cursor.com>
…ction outlasts the data (#3540) A frame-following swap staged by an `on` re-arm can be cleared before any effect phase runs when the transaction parks past the content's landing on nothing but the write's own action: no fallback is ever displayed, and the source rule in `_rearm` cannot see it (nothing outside the boundary reads the source). A DEV-only read-only boundary sweep at the parked finalize (`_devHeldSweep`) reports it once per re-arm; a frame held by other async data is a race the fallback may still win and stays silent. Co-authored-by: Cursor <cursoragent@cursor.com>
Re-derive each expectation from frame-following: a write inside a held action holds the old DOM and lands the fallback WITH the commit; the action outlasting the data shows no fallback and warns LOADING_ON_OUTSIDE_HOLD; `latest()` in `on` shows the fallback now beside the held DOM (new variant); a mainline write with nothing else holding lands the fallback now (unchanged); the matrix's `on-committed` row is held mid-flight because sibling B reads the same data (source rule warns); Errored `on` unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
…ation, cheatsheet, JSDoc, LOADING_ON_OUTSIDE_HOLD (#3540) Co-authored-by: Cursor <cursoragent@cursor.com>
…le out of prod bytes (#3540) The DEV sweep is its own scheduler function (shaken in prod; the settling sweep is unchanged), the emit and the rule are module-level functions called only under __DEV__, and the per-re-arm flag is a bare optional field. Prod's scheduler is byte-identical to before the rule. Co-authored-by: Cursor <cursoragent@cursor.com>
Eight entries re-measured against next on this machine: core floor 9,640 → 9,663; +createStore 16,811 → 16,851; +isPending/latest 12,391 → 12,415; simple app 12,472 → 12,484 (within cap); hydrating 20,542 → 20,666; hydrating+store 30,739 → 30,824; CSR 15,637 → 15,752; CSR observe 17,127 → 17,272; CSR observe+attribution 27,614 → 27,698; frames unchanged. The second re-arm drain in the scheduler and `_rearm(mainline)` / `_swap` / the lane-aware on-node in boundaries; the DEV sweep is shaken. Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: 07e2a10 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 not alter performance
Comparing Footnotes
|
…swap on the lane channel; drop Errored `on` (#3540) The re-arm drains once, in flush after the heap and before the verdict. A boundary whose on-node ran under an optimistic lane swaps by writing _disabled committed and notifying its subscribers on that lane (notifyOnLane), so the fallback shows now beside the held frame with no second finalize drain, _rearmAhead/_swapOwed flags, or re-queue. Errored's `on` prop / createErrorBoundary's `on` option are removed: a retry recompute under the frame stages the recovered content with the frame instead of retrying now, and keeping it would need a second drain point nothing else uses. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Coverage Report for CI Build 35697053607Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage increased (+0.009%) to 72.775%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
… mention; changeset notes the removal (#3540) Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…wing (#3540, #3575) The two reveal-timing pins were written against #3556's mainline swap. Under #3575 the swap is staged into the notifying write's transaction, and the boundary's output is that frame's from then on: - staged source: the action outlasts the data, so old content holds through the flight, `data 2` lands at the commit, the fallback is never shown, and LOADING_ON_OUTSIDE_HOLD reports once after the fact. - independent source: the content's own hold (the pending write it derives from) is joined to the action's frame when the staged output pass reads its landing — reveal at the commit, no fallback, same diagnostic. The pre-#3575 sequence is the display-ahead read's (`latest()`), pinned beside it. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…wing (#3540, #3575) The two reveal-timing pins were written against #3556's mainline swap. Under #3575 the swap is staged into the notifying write's transaction, and the boundary's output is that frame's from then on: - staged source: the action outlasts the data, so old content holds through the flight, `data 2` lands at the commit, the fallback is never shown, and LOADING_ON_OUTSIDE_HOLD reports once after the fact. - independent source: the content's own hold (the pending write it derives from) is joined to the action's frame when the staged output pass reads its landing — reveal at the commit, no fallback, same diagnostic. The pre-#3575 sequence is the display-ahead read's (`latest()`), pinned beside it. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…tent without waiting for the fallback's flight (#3540) (#3581) * test(signals): pin what an on= re-arm does not license (#3540) Reveal-timing and fallback-pending pins for re-armed Loading boundaries. One pins a live defect: a re-armed boundary whose fallback is itself not ready does not reveal landed content until the fallback's read lands. Co-authored-by: Cursor <cursoragent@cursor.com> * test(signals): re-derive the on= reveal-timing pins under frame-following (#3540, #3575) The two reveal-timing pins were written against #3556's mainline swap. Under #3575 the swap is staged into the notifying write's transaction, and the boundary's output is that frame's from then on: - staged source: the action outlasts the data, so old content holds through the flight, `data 2` lands at the commit, the fallback is never shown, and LOADING_ON_OUTSIDE_HOLD reports once after the fact. - independent source: the content's own hold (the pending write it derives from) is joined to the action's frame when the staged output pass reads its landing — reveal at the commit, no fallback, same diagnostic. The pre-#3575 sequence is the display-ahead read's (`latest()`), pinned beside it. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(signals): judge a boundary whose fallback is pending before the verdict (#3540) A collecting Loading boundary whose FALLBACK read something not ready left its output pass pending on that read; an initialized parent held the frame on it. When the content landed, the boundary was ready — but its output pass derives from `_disabled`, not the tree, so the tree settling never re-ran it; only the boundary sweep does, and that sweep ran at the commit (finalizePureQueue), after the verdict the output's own read kept parking. The content waited for the fallback's flight. Judge that one shape before the verdict, under the transaction (`CollectionQueue._judgeHeld`, walked by `checkBoundaryChildren(this, true)` after the re-arm drain): a collecting boundary whose output is pending stages `_disabled` false with the frame and its output re-runs in the same heap, reads the tree, and drops the fallback's read through recompute's ordinary settle of a pass's outgoing pending sources — the verdict sees the release. Boundaries with a ready fallback park nothing and keep the commit sweep's reveal unchanged. The DEV after-the-fact LOADING_ON_OUTSIDE_HOLD rule (devHeldSweep) now also recognizes a swap the pre-verdict sweep cleared while still staged (`_disabled` staged false over a never-committed true) as never displayed; `_checkSources` drops `_swapUnseen` only for a swap that committed. Pins: the on= case (loading-on-rearm-reveal-3540 'content lands before the fallback') and its no-on analogue (loading-fallback-release-3540, whose second test shows the general computed case always released — the bug was boundary-specific). Core floor +63 B, documented in treeshake.test.ts. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * chore(size): re-set four caps for the pre-verdict boundary judgment (#3540) +38..+81 B brotli per scenario, re-measured rebased over #3577 (origin/next 709c02b, same solid/web dist): the flush's pre-verdict boundary walk in core, and `_judgeHeld` / `_output` in boundaries. Re-set: isPending/latest (12.40 -> 12.45), hydrating no-stores (20.70 -> 20.75), CSR (15.75 -> 15.80, over #3577's own bump) and the CSR observe tier (17.30 -> 17.35, no observe-gated bytes; brotli layout). Each carries its measurement note. The simple-app floor (12,493 / 12.50) and observe + attribution (27,743 / 27.75) now land within `next`'s caps and keep them; the other four scenarios are within their caps too. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude via Cursor <noreply@cursor.com>
A <Loading> swap is a staged write that lands with its frame (#3575); the engine held the fallback open from the swap and so timed — and, for a swap the content outran or the commit's sweep cleared before any effect ran, recorded — a fallback that was never on screen. The show now carries the transaction the swap is staged in; the engine moves the open to the drain at transitionSettled, follows transitionMerged, stamps `at` at flushEnd, and drops an open hidden before that. The folds hear show/hide from the same gate. Observe caps ratcheted (+72 B tier, +253 B engine); prod unchanged. Co-authored-by: Claude via Cursor <noreply@cursor.com>
A <Loading> swap is a staged write that lands with its frame (#3575); the engine held the fallback open from the swap and so timed — and, for a swap the content outran or the commit's sweep cleared before any effect ran, recorded — a fallback that was never on screen. The show now carries the transaction the swap is staged in; the engine moves the open to the drain at transitionSettled, follows transitionMerged, stamps `at` at flushEnd, and drops an open hidden before that. The folds hear show/hide from the same gate. Observe caps ratcheted (+72 B tier, +253 B engine); prod unchanged. Co-authored-by: Claude via Cursor <noreply@cursor.com>
A <Loading> swap is a staged write that lands with its frame (#3575); the engine held the fallback open from the swap and so timed — and, for a swap the content outran or the commit's sweep cleared before any effect ran, recorded — a fallback that was never on screen. The show now carries the transaction the swap is staged in; the engine moves the open to the drain at transitionSettled, follows transitionMerged, stamps `at` at flushEnd, and drops an open hidden before that. The folds hear show/hide from the same gate. Observe caps ratcheted (+72 B tier, +253 B engine); prod unchanged. Co-authored-by: Claude via Cursor <noreply@cursor.com>
A <Loading> swap is a staged write that lands with its frame (#3575); the engine held the fallback open from the swap and so timed — and, for a swap the content outran or the commit's sweep cleared before any effect ran, recorded — a fallback that was never on screen. The show now carries the transaction the swap is staged in; the engine moves the open to the drain at transitionSettled, follows transitionMerged, stamps `at` at flushEnd, and drops an open hidden before that. The folds hear show/hide from the same gate. Observe caps ratcheted (+72 B tier, +253 B engine); prod unchanged. Co-authored-by: Claude via Cursor <noreply@cursor.com>
…ance-tracks) (#3580) * feat(web): Chrome Performance panel tracks for Solid — engine foundation, adapter, timeline records Stage 0 — shared engine foundation (@solidjs/signals, @solidjs/web): - attribution.enable()/disable() are ref-counted: the engine is shared by every consumer on the page (a profiler track, an APM adapter, a diagnostics capture); the first hold installs, each enable() opens a fresh window over the ring buffers and folds without disturbing live tracking or others' subscriptions, the last disable() uninstalls. - `checks` option: one switch for the five thresholded cost checks, for records-only consumers. - isSilentHold / isLongHold exported, so adapters paint the engine's own verdicts rather than re-deriving thresholds. - dispatchAsInteraction passes at: e.timeStamp; InteractionEvent gains inputDelayMs (event creation → handler entry, the INP input-delay term) and handlerMs measures from the handler's actual start. - ownerPath re-exported from solid-js (client from @solidjs/signals, server via the ownerLabels twin). Stage 1 — @solidjs/web/performance-tracks: - enablePerformanceTracks() paints rerun / interaction / hold / navigation records and the web runtime's call / frame records as tracks in the `Solid` group, retroactively from the records' own performance.now() stamps: performance.measure with detail.devtools (rich: tooltips, properties; dev default) or the six-argument console.timeStamp (observe default). Track seeding for stable display order, minMs floor, scrub mode for shared traces, ref-counted disable. Prod artifact folds to a no-op. - rollup dev/observe/prod entries, ./performance-tracks export conditions, types script. Stage 2 — timeline records (@solidjs/signals, solid-js, @solidjs/web): - Five listener-gated records on attribution.subscribe: create (a computation's creation run), effect (an effect callback, timed and joined to its compute run), flush (one scheduler drain), flight (an async flight to landing or abandonment), fallback (a loading boundary's fallback from show to hide). None is built, logged or folded unless a listener for its type exists; OBSERVE.subjectOf answers for the node-bearing ones. - Core: flushStart hook beside flushEnd (one drain, never nested); effectRunStart now fires in observe like its effectRunEnd twin, so writes inside effect callbacks carry their effect origin in observe too. Observe core +67 B minified; prod byte-identical. - The effect-frame → node map is filled by the first write inside a callback rather than by every callback (every reader resolves it through a write's origin; most callbacks never write). Enabled-engine cost per effect callback is at or below `next` on every configuration measured. - Adapter: creation runs and callbacks on Effects/Memos, drains on a new Scheduler track, flights and fallbacks on a new Async track. Tests: ref-counting, checks, inputDelayMs, the adapter spec (18), the timeline records (12), dist-artifact coverage of both new hooks per tier. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): Propagation track — each drain as a wave of its root writes, runs labelled by cause, flow internals folded Replaces the Scheduler track. Nothing re-renders in Solid, so React's component flame has no counterpart; the picture is the graph a write travelled. Each drain paints as a wave named by its root writes and the interaction (`count 0 → 1 — click on button#next · 5 runs, 1 unchanged`); compute runs, creations and effect callbacks paint inside it at their own time labelled `node ← cause`, and the panel stacks them beneath the wave. Labels fold framework structure into what the developer wrote: flow-control internals (Show/Switch/Loading/Errored/Reveal) present as the tag, `primitive.local` names as the primitive, the runtime name kept in a `Node` property. The boundary nodes and Switch's condition builder are now named in observe builds so the fold has names to key on. Engine: ChangeRecord.nodeId on writes and derived changes, same id space as RerunEvent.nodeId, so a derived cause joins the run that produced it. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * compiler: rename componentNames → sourceNames (boolean | { components }) The option becomes the home for every kind of source name the compilers carry into output for the dev/observe runtimes to label the graph with; `components` is the existing behavior, further kinds follow. Object form picks kinds; `true` turns on every kind. Both compilers, the node adapter's validation (rejects the old name and malformed shapes), typings, READMEs, tests, and the web server test config. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * compiler: sourceNames.bindings — name compiled binding effects by their target Attribute effects get `<tag>.<attribute>` as written (`span.textContent`, `div.class:active`; a merged effect lists all its bindings), holes `<tag>.children` through insert's options, spreads pass the tag and the runtime labels `<tag>.spread` / `<tag>.children`. Static holes (component calls, literals) create no effect and get no name. `prop:` the locked DOM property pre-pass added is undone in the label (`input.value`). @solidjs/web: `effect`/`insert` take `{ name }`, `spread` a trailing tag; names land on the render effect nodes for the dev/observe tiers. Both compilers, parity fixtures (dom-source-names suite replaces dom-component-names, run with `sourceNames: true`), option validation, runtime spec, READMEs. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * compiler: transformSourceNames pass names primitives from their declared identifier The sourceNames.primitives half of Stage 3.5, as its own native pass since it is plain JavaScript in and out and applies to .ts/.js modules: calls resolving to solid-js / @solidjs/signals imports (createSignal, createMemo, createOptimistic, createStore, createOptimisticStore, createProjection) gain { name } from the array pattern, binding, property key, or class field they initialise, prefixed with the enclosing non-component function so a composed primitive's nodes fold under it. Never overrides an explicit name; skips spread/opaque options and the ambiguous two-argument createStore(x, y). Stores now honour options.name in the observe tiers: property nodes read todos.title instead of store.title, derived/optimistic stores included. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * feat(performance-tracks): findings as markers, JSX-site stacks, richer node properties Stage 4 of the Chrome performance tracks plan. - Every DiagnosticEvent delivered while the tracks are enabled is a marker on the panel's Timings track (`SILENT_HOLD — <App> › <Search>`), coloured by severity; at warn or worse it carries detail.devtools.performanceIssue for the Insights sidebar, with the repair guide's section as learnMoreUrl. info stays a plain marker. Under the scrub only code, kind and owner travel. Plain mode uses the one-argument console.timeStamp. - Dev: observedComponent stores console.createTask(label) on _component.task; the adapter emits every span and marker inside the nearest component's task so the entry's stack in the panel is the JSX site (React's _debugTask). - Rich mode adds Owner path (unfolded), Node id and the root write's Origin to node spans. - solid-js exports diagnosticGuideUrl(code), shared with the console footer. - Docs: 08-dev-diagnostics gains the timeline records, the ref-counted enable, the INP join recipe and a performance-tracks section; the plan lands as documentation/plans/chrome-performance-tracks-plan.md. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * chore(size): fold source-name plumbing out of prod; ratchet observe caps for the tracks records Measured against the branch base (49887e1) with the size scenarios: prod core floor, + createStore, + isPending and frames byte-identical; simple app -26 B, CSR -7 B, hydrating + stores -38 B, hydrating +40 B (two `computed(fn, void 0)` trailing arguments the boundary names fold to; the restructured alternatives measured larger under brotli). - createStoreNext loses its `name` parameter - a parameter survives into the prod artifact; the public createStore records the name through nameStore (same attrHooks gate) under __OBSERVE__ instead. - web spread's `.spread`/`.children` labels sit behind "_SOLID_OBSERVE_" so prod folds them to undefined rather than testing the argument at runtime. - Observe tier 17.00 -> 17.15 KB (17,063 measured, +92 B: flushStart site, effectRun hooks under __OBSERVE__, flow-internal and binding names, store names). Observe + attribution 27.50 -> 28.30 KB (28,200 measured, +738 B: ref-counted enable, checks, hold verdicts, at/inputDelayMs, the flush / create / effect / flight / fallback records, nodeId on derived causes). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(signals): fallback records time the display, not the swap A <Loading> swap is a staged write that lands with its frame (#3575); the engine held the fallback open from the swap and so timed — and, for a swap the content outran or the commit's sweep cleared before any effect ran, recorded — a fallback that was never on screen. The show now carries the transaction the swap is staged in; the engine moves the open to the drain at transitionSettled, follows transitionMerged, stamps `at` at flushEnd, and drops an open hidden before that. The folds hear show/hide from the same gate. Observe caps ratcheted (+72 B tier, +253 B engine); prod unchanged. Co-authored-by: Claude via Cursor <noreply@cursor.com> * fix(web): performance-tracks review fixes — single instance, server-inert export, guarded host calls, owned clearing - enablePerformanceTracks: one instance per page; a call while enabled joins it and returns its own release; torn down at the last release - ./performance-tracks: node/worker/deno conditions resolve to the inert artifact under every posture (the tracks are a browser view) - Emitter guards performance/console calls; a throw drops the entry and never reaches the engine's record loop (dev warns once) - rich:false falls back to performance.measure when console.timeStamp is missing (the mirror of the existing fallback) - rich mode clears a User Timing name only while the timeline holds no more of it than the adapter made; an app measure sharing a label is left alone - signals: stagedFallbacks is a WeakMap keyed by transaction (DRAIN is an object key) so a dropped transaction releases its staged opens - changeset: stale 'Scheduler track' wording -> Propagation - docs: transformSourceNames is the compiler pass, sourceNames.primitives the Vite plugin option Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * feat(signals): attribution.enable returns its release; options layer per hold; dev component tasks only under an installed engine Review items 5 and 6 on #3580. - attribution.enable(opts) returns an idempotent release for the hold it takes. Options in effect = defaults with each live hold's opts layered in hold order, recomputed on release — a track's log:false beside a console session gives the log back when it leaves. disable() is the full teardown whatever holds are outstanding, so enable();enable();disable() cannot strand a hold. Prod twin: enable returns noop. - performance-tracks releases through the token and layers log:false only when it is the consumer installing the engine (OBSERVE.attribution.installed === null); joining leaves a co-holder's options alone. - solid-js dev: the component wrapper creates the console.createTask task only while an attribution engine is installed. The per-call stack capture roughly doubled dev mount (10k components 1.9 -> 4.1 ms) for sessions with nothing enabled; observe and prod builds never contained the call. - Tests: layered options + release, disable teardown, adapter join vs install log default, task gating; docs and changesets updated. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * test: CI fixes — tuple typing in the tracks harness, fallback timing asserted against the commit stamp - web test-types: the stubbed-API list is typed [object, string][] (the inferred (string | Console | Performance)[] failed tsc); getEntriesByName is restored with the rest - signals: the shell-landed-first fallback test asserts at >= the landing stamp and shownMs >= the wait instead of a wall-clock upper bound, which a slow runner exceeded Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(diagnostics): captures hold the engine through enable()'s release token capture.ts and the browser bridge release their own hold instead of calling disable(), which under the token contract is the full teardown — a track or APM adapter enabled beside a capture now survives it. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * feat(signals): attribution options combine across holds by the most demanding request Replaces last-wins layering. Each hold's request is resolved against the defaults (an explicit undefined is unsaid; checks:false folds its five checks first), then combined per key: booleans OR, historyLimit max, a threshold config over false, and between configs the bound that fires sooner (lower count/budget/ms, longer windowMs). A hold adds to what the engine does and never takes away what another asked for, so the result is independent of the order holds were taken — a track enabled with log:false beside a console session leaves its log alone in either order; a capture with tight thresholds beside a records-only adapter runs the checks for its own duration. Prior art: Node trace_events (union of every enabled Tracing's categories), CDP per-session domain enablement. - performance-tracks: always asks {log:false, ...options.attribution}; the install-time heuristic is gone - tests: order independence, checks while any holder wants them, explicit undefined; adapter beside a console session - size: engine scenario 28.85 -> 29.00 KB (+194 B, engine-only), noted Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> * chore(size): CSR prod cap 15.80 -> 15.85 KB after rebasing over #3586/#3555 The merge with the new next measured 15,815 B against next's 15,764: the same +51 B brotli name-assignment noise this branch has carried (minified bundles 44,649 B on both sides), now on top of upstream bytes those PRs left 36 B under the cap. No prod source changed. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: name the Vite plugin as the owner of sourceNames.primitives Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Follow-up to #3556, from the discussion on #3540 with @GabbeV and @mizulu.
Public API changes
Loadingon— behavior change. The fallback swap now lands with the frame the notifying write belongs to (see below). Still a dependency list; still releases the boundary's hold immediately.latest()insideon— documented contract. Reading display-ahead state inon(latest(),isPending(), an optimistic signal) shows the fallback now, beside the still-held frame. This is the spelling for the fix(signals,solid): Loading/Erroredonis a dependency list that re-arms the boundary in the current frame; boundaries are not born held (#3540) #3556 behavior.Erroredonprop — removed. Added in fix(signals,solid): Loading/Erroredonis a dependency list that re-arms the boundary in the current frame; boundaries are not born held (#3540) #3556 without being asked for; never in a stable release.createErrorBoundary(fn, fallback)no longer takes an options parameter. Use the fallback'sreset()or a keyed re-mount.LOADING_ON_OUTSIDE_HOLD(see below).What changes
ononLoadingis still a dependency list and still releases the boundary's hold the moment a dependency notifies. What moves is where the fallback swap lands: with the frame the notifying write belongs to, instead of in the current frame beside content that write is still holding.Product A → B, shell reads
product(id)outside a<Loading on={id()}>whose content readscomments(id), written inside an action (or any write whose async is in flight):on[A] → [B + comments]— the initialized boundary holds; the frame waits for bothonin #3556 (trigger)[A] → [A + spinner] → [B + spinner] → [B + comments]onhere (frame-following)[A] → [B + spinner] → [B + comments]The
[A + spinner]frame was B's loading state inside A's page — a fallback for a change nothing on screen reflected yet. That's the frame this removes. If comments land before the shell, the sweep clears the swap before the commit and no fallback is ever shown. Both semantics release the hold and commit at the same time; the only difference is that frame.setIdwith the boundary's content as the only async reader): the release ends the hold, the frame commits in the same pass, fallback and the committedidland together — now. Unchanged; pinned.on={latest(id)}: fallback now, beside the still-held frame.latestmeans here what it means everywhere else.<Loading>fallback reset does not detect changes when a signal source is pending , but does when it is from a memo #3524's shape): the frame waits on it and the fallback is never seen. That's the trade, now by design. DEV warnsLOADING_ON_OUTSIDE_HOLD— at re-arm when an outside reporter holds the same source, and after the fact at a parked finalize when a staged swap's content settled with nothing but the action holding the frame — with the fix (latest()inon, or move the outside read under the boundary).Mechanism
One drain.
onNoderuns theonthunk tracked; every run after the first queues the boundary (pendingRearms) and records the optimistic lane the pass ran under, if any.drainRearms()runs inGlobalQueue.runafter the heap and before the verdict, under the notifying write's transaction._rearm()collects the boundary's held readers from the live transactions' reporter registrations, releases (_initialized = false,wakeParked), and swaps: with no lane, a plainsetSignal(_disabled, true)staged into the frame; with a lane, the value is committed outright andnotifyOnLane(new,core.ts) walks the subscribers on the lane's channel so the output pass and its effects run from the lane queue at the park.finalizePureQueueno longer drains anything. Net −227 lines versus the two-drain version this PR started with.Fallout
loading-on-rearm-3540.test.ts: held-action pin flips to fallback-at-commit;latestvariant keeps the old expectation;Errored oncases removed.packages/web/test/loading-on-keyed-boundary-3540.spec.tsx: matrixon-committedEARLY → HELD(a sibling reads the samedata()and holds the frame; the source rule warns); held-action case flips; new rows for action-outlasts-data,latest, mainline; rows assert diagnostics;Errored oncase removed.ispending-in-boundary-on-3528.test.ts: intra-pass effect order for theisPending-drivenonrows changed fromcount=1, A=LoadingtoA=Loading, count=1. Same pass, same end state; a lane-shown swap's effects run from the lane queue ahead of the regular queue.loading-on-frame-following-3540.test.ts(all four cases, both orderings, diagnostics).05-async-data.md(rewrittenonsection with the three-frame example),03-control-flow.md,MIGRATION.md,08-dev-diagnostics.md,CHEATSHEET.md, JSDoc.next(brotli): core +13 B, +createStore −18, +isPending/latest −21, hydrating +121, hydrating+store +105, CSR +49, CSR observe +120, CSR observe+attribution +87. Caps re-set with notes.Known follow-up (not in this PR)
Reveal-hold bug, found while pinning this: a boundary whose fallback reads something not ready doesn't reveal landed content until the fallback's flight lands. Reproduces without
on(general boundary bug). Pins ontest/on-rearm-reveal-pins; rebases onto this.Suites: signals 208/3500, solid 33/654, web 97/844;
tscandtest-typesgreen.Refs #3540, #3524, #3529. Supersedes the trigger semantics of #3556.