From 04790534148b8c562879dd4b3d9e74b0b06bbeca Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 00:42:27 -0700 Subject: [PATCH 01/10] fix(signals): a fresh Loading over a held value shows its fallback in 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 Co-authored-by: Cursor --- .changeset/fresh-loading-fallback-in-flush.md | 5 + packages/signals/docs/RULES-INDEX.md | 96 ++++---- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 2 + packages/signals/src/boundaries.ts | 30 +-- packages/signals/src/core/core.ts | 20 +- packages/signals/src/core/scheduler.ts | 23 +- .../loading-fallback-in-flush-3540.test.ts | 228 ++++++++++++++++++ .../loading-fallback-in-flush-3540.spec.tsx | 119 +++++++++ 8 files changed, 445 insertions(+), 78 deletions(-) create mode 100644 .changeset/fresh-loading-fallback-in-flush.md create mode 100644 packages/signals/tests/loading-fallback-in-flush-3540.test.ts create mode 100644 packages/web/test/loading-fallback-in-flush-3540.spec.tsx diff --git a/.changeset/fresh-loading-fallback-in-flush.md b/.changeset/fresh-loading-fallback-in-flush.md new file mode 100644 index 000000000..ef486c3c1 --- /dev/null +++ b/.changeset/fresh-loading-fallback-in-flush.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A fresh `Loading` mounted over a held value shows its fallback in a flush too (#3540 under L2): a first pass a loading boundary that has not shown content catches is the boundary's, not the tick's, so the mount publishes and the content reveals at the commit. Content bound by a render effect under the boundary is collected, so the boundary no longer reveals empty content. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 2b199c54f..5c10bf7a7 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -45,66 +45,66 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | |---|---|---|---|---|---| -| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:307` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | -| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:315` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | -| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:323` | — | derived-write-then-derivation-3733.test.ts×5 equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | -| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:331` | — | derived-write-then-derivation-3733.test.ts×1 equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | -| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:339` | — | derived-write-then-derivation-3733.test.ts×1 errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | -| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:347` | boundaries.ts×2 | derived-write-then-derivation-3733.test.ts×1 enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | -| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:153` | — | fuzz-findings-l2.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… | -| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:161` | — | createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | -| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:169` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | -| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:177` | core.ts×3 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | -| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:95` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | -| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:185` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | -| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:193` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | -| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:201` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:237` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | -| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:209` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | -| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:67` | async.ts×3 constants.ts×1 lanes.ts×6 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | -| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:77` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | -| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:137` | async.ts×1 constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×1 | fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… | -| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:396` | — | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | -| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:403` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | -| A22 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:217` | store.ts×1 | createProjection.draft-lifetime-3585.test.ts×1 spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled, amended in place 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — **Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) … | -| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:227` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | -| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:145` | affects.ts×2 constants.ts×1 lanes.ts×2 types.ts×1 verdict.ts×3 affects.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×3 kanban-a17-fixture.test.ts×1 signal-store-twins-qd.test.ts×1 visibility-oracle-store.states.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | -| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:297` | projection.ts×1 | derived-presence-async-3726.test.ts×2 uninitialized-visibility.test.ts×3 visibility-oracle-store.states.ts×7 visibility-oracle-store.test.ts×1 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | -| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:103` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | -| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:289` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | -| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:87` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:111` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×9 effect.ts×1 scheduler.ts×3 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | -| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:245` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | -| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:119` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | -| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:127` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | -| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:261` | boundaries.ts×1 scheduler.ts×1 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 fuzz-findings-l2.test.ts×2 ispending-in-boundary-on-3528.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | -| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:271` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×4 | a34-writes-then-derivations.test.ts×2 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 fuzz-findings-l2.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | +| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:309` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | +| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:317` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | +| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:325` | — | derived-write-then-derivation-3733.test.ts×5 equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | +| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:333` | — | derived-write-then-derivation-3733.test.ts×1 equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | +| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:341` | — | derived-write-then-derivation-3733.test.ts×1 errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | +| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:349` | boundaries.ts×2 | derived-write-then-derivation-3733.test.ts×1 enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | +| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:155` | — | fuzz-findings-l2.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… | +| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:163` | — | createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | +| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:171` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:179` | core.ts×3 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | +| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:97` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | +| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:187` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | +| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:195` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | +| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:203` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:239` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:211` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:69` | async.ts×3 constants.ts×1 lanes.ts×6 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | +| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:79` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | +| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:139` | async.ts×1 constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×1 | fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… | +| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:398` | — | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | +| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:405` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | +| A22 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:219` | store.ts×1 | createProjection.draft-lifetime-3585.test.ts×1 spec-async-semantics.test.ts×1 visibility-oracle-store.states.ts×1 | [ruled, amended in place 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — **Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) … | +| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:229` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | +| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:147` | affects.ts×2 constants.ts×1 lanes.ts×2 types.ts×1 verdict.ts×3 affects.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×3 kanban-a17-fixture.test.ts×1 signal-store-twins-qd.test.ts×1 visibility-oracle-store.states.ts×4 visibility-oracle.states.ts×5 visibility-oracle.test.ts×1 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | +| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:299` | projection.ts×1 | derived-presence-async-3726.test.ts×2 uninitialized-visibility.test.ts×3 visibility-oracle-store.states.ts×7 visibility-oracle-store.test.ts×1 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | +| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:105` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | +| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:291` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | +| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:89` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:113` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×10 effect.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:247` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | +| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:121` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | +| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:129` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | +| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:263` | boundaries.ts×1 scheduler.ts×1 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 fuzz-findings-l2.test.ts×2 ispending-in-boundary-on-3528.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | +| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:273` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×4 | a34-writes-then-derivations.test.ts×2 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 fuzz-findings-l2.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | ## V — fixed violations | id | status | defined | cited in src | cited in tests | statement (at definition) | |---|---|---|---|---|---| -| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:466` | — | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | -| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:476` | — | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | -| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:482` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:489` | — | spec-async-semantics.test.ts×5 | - **V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | -| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:501` | scheduler.ts×1 | spec-async-semantics.test.ts×3 | - **V5 (A17 corollary — found and fixed with the revert-target elimination, | +| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:468` | — | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | +| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:478` | — | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | +| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:484` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:491` | — | spec-async-semantics.test.ts×5 | - **V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | +| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:503` | scheduler.ts×1 | spec-async-semantics.test.ts×3 | - **V5 (A17 corollary — found and fixed with the revert-target elimination, | ## B — tier B | id | status | defined | cited in src | cited in tests | statement (at definition) | |---|---|---|---|---|---| -| B1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:193` | — | createRevealOrder.test.ts×16 derived-write-then-derivation-3733.test.ts×1 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A13 (A13's section carries the ruling). | -| B2 | live | `docs/SPEC-ASYNC-SEMANTICS.md:201` | — | createRevealOrder.test.ts×16 derived-write-then-derivation-3733.test.ts×1 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A14 (A14's section carries the ruling). | -| B3 | live | `docs/SPEC-ASYNC-SEMANTICS.md:237` | — | spec-async-semantics.test.ts×2 | PROMOTED → A15 (A15's section carries the ruling). | -| B4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:77` | — | derived-write-then-derivation-3733.test.ts×1 spec-async-semantics.test.ts×2 | PROMOTED → A18 (A18's section carries the ruling). | -| B5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:209` | boundaries.ts×1 | derived-write-then-derivation-3733.test.ts×1 fuzz-findings-l2.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A16 (A16's section carries the ruling). | +| B1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:195` | — | createRevealOrder.test.ts×16 derived-write-then-derivation-3733.test.ts×1 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A13 (A13's section carries the ruling). | +| B2 | live | `docs/SPEC-ASYNC-SEMANTICS.md:203` | — | createRevealOrder.test.ts×16 derived-write-then-derivation-3733.test.ts×1 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A14 (A14's section carries the ruling). | +| B3 | live | `docs/SPEC-ASYNC-SEMANTICS.md:239` | — | spec-async-semantics.test.ts×2 | PROMOTED → A15 (A15's section carries the ruling). | +| B4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:79` | — | derived-write-then-derivation-3733.test.ts×1 spec-async-semantics.test.ts×2 | PROMOTED → A18 (A18's section carries the ruling). | +| B5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:211` | boundaries.ts×1 | derived-write-then-derivation-3733.test.ts×1 fuzz-findings-l2.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A16 (A16's section carries the ruling). | ## C — tier C | id | status | defined | cited in src | cited in tests | statement (at definition) | |---|---|---|---|---|---| -| C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:137` | — | onCleanup.test.ts×2 spec-async-semantics.test.ts×1 | PROMOTED → A19 (A19's section carries the ruling). | -| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:432` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | -| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:442` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | -| C4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:67` | — | spec-async-semantics.test.ts×1 | PROMOTED → A17 (A17's section carries the ruling). | +| C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:139` | — | onCleanup.test.ts×2 spec-async-semantics.test.ts×1 | PROMOTED → A19 (A19's section carries the ruling). | +| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:434` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | +| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:444` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | +| C4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:69` | — | spec-async-semantics.test.ts×1 | PROMOTED → A17 (A17's section carries the ruling). | ## INV — invariants | id | status | defined | cited in src | cited in tests | statement (at definition) | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 66ea0e230..ad1a31871 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -55,6 +55,8 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t - **A first load derived from a hold lands into it** (#3800): a first load born pending in a frame that read a hold lands into that hold if the hold is still live, else as its own commit; the hold commits without it. **Pending** — `fix/create-time-holds` (`91e474506`); [#3800](https://github.com/solidjs/solid/issues/3800) is open. - **A first pass under a loading boundary that has not shown content is the boundary's, not the tick's** — in a flush as outside one (A29's boundary exemption, #3540). The out-of-flush form is on `next`; the in-flush form (a fresh `Loading` over a held value shows its fallback in a flush) is **pending** — `fix/create-time-holds` (`ca71e5d9d`). +**The boundary scope, in a flush (2026-10-06, amendment — #3540's L2 regression; the bullet above is unchanged).** The in-flush form lands with `fix/l2-3540-fresh-loading`, standalone of `fix/create-time-holds`. A first pass (`STATUS_UNINITIALIZED`) that reads a hold asks the boundaries up its chain (`GlobalQueue._fresh`: caught as a pending status). When a loading boundary that has not shown content catches it, the pass joins pass-scoped (`passTx`), as a mount outside a flush does, instead of joining the flush: the Show that mounted the boundary publishes, and the boundary shows its fallback. A pass nothing catches joins the flush as before, so a derivation outside the boundary in the same flush still holds the tick. A render effect born held under the boundary (content bound in JSX — the tree never reads the held value) is caught the same way, so the boundary does not reveal empty content, in a flush or out of one; its output's first pass shows the fallback whenever it has collected readers. A flush that set `passTx` clears it at its end, so the boundary's hold does not merge with a later tick's. The `direction-rule-probe` pins are unchanged. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (in a flush: through a memo, directly, bound, nested under a Loading that has shown content; a derivation outside the boundary still holds the tick; the boundary's hold stays its own; on a hold, from mainline: the same three content shapes); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. + **Not yet one-way (recorded, not ruled).** The frame of a tick is still one transaction (the tick ruling above stands), so two shapes keep the hold waiting on first loads it never needed: - **Same tick:** mounts created outside a flush in one tick — two mounts over the held value, or a mount and a sync reader — share the tick's frame. The frame joins the hold, and the hold waits on the mounts' first loads. diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 97ca08c6f..631f60627 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -203,7 +203,7 @@ export function redraw(b: Boundary): void { /** GlobalQueue._catch: status from a frame reader, nearest boundary first. * A loading boundary on the way records a pending reader whether or not it * catches it (its `on` may collect it later). */ -function catchStatus(node: Computed, flags: number, error: unknown): boolean { +function catchStatus(node: Computed, flags: number, error?: unknown): boolean { if (flags === 0) { // A status cleared. Judged by the node's status now, against each // boundary's own rule (`unsettled`) — the error path clears pending @@ -596,25 +596,23 @@ function createBoundary( } if (isCollecting(b)) { // A29's boundary exemption (#3540): a boundary MOUNTED over a held - // value (its first pass; the tree born held) shows its fallback now - // and the content at the commit — entering the transaction would - // make the output itself born held, and nothing would show until - // the commit. (The seam keeps a held reader until it is committed.) - // A boundary with a committed value reads a held tree and enters: - // the outside sees its committed value until the landing, which - // reveals the content — a fallback staged earlier is replaced ahead - // of the commit and never shown. + // value (its first pass; a first pass under it that read a hold was + // collected, `joinPass` — the tree's own, or a render effect's that + // binds the content) shows its fallback now and the content at the + // commit — entering the transaction would make the output itself + // born held, and nothing would show until the commit. The seam + // keeps a held reader until it is committed, then re-derives this + // pass. A boundary with a committed value reads a held tree and + // enters: the outside sees its committed value until the landing, + // which reveals the content — a fallback staged earlier is replaced + // ahead of the commit and never shown. const self = getOwner() as Computed; if ( - tree._config & CONFIG_HELD && + b._readers.size !== 0 && self._statusFlags & STATUS_UNINITIALIZED && !(self._config & CONFIG_HELD) - ) { - // The seam re-derives this pass once; by then the fallback is - // the committed value, and the next pass enters. - collecting.add(b); + ) return fallback(b); - } // Readers under it still unready: the fallback, the tree untouched. // The seam re-derives this pass when they settle. if (prune(b, true) !== 0) return fallback(b); @@ -776,6 +774,8 @@ export function createErrorBoundary( // Installed at module evaluation — present exactly when something imports a // boundary. An app without one pays the three null checks and nothing else. GlobalQueue._catch = catchStatus; +GlobalQueue._fresh = node => + node._statusFlags & STATUS_UNINITIALIZED && catchStatus(node, STATUS_PENDING); GlobalQueue._hidden = hidden; GlobalQueue._boundarySeam = boundarySeam; GlobalQueue._heldRun = heldRun; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 80f163985..b6b77681c 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -941,10 +941,18 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension { * reader is a render effect. A render effect is the frame, not a derivation * (rule 3): in that transaction's own flush, or born into it (uninitialized, * A29), it reads the staged value and holds nothing of its own; otherwise it - * reads the committed value instead (`frameRead`). */ + * reads the committed value instead (`frameRead`). A node born into the + * future has no committed value: every reader joins it. A first pass is + * something not ready under a loading boundary that has not shown content: + * the boundary collects it and shows its fallback, and the pass is the + * boundary's, not the tick's (A29's boundary exemption, #3540). */ function joinPass(c: Computed, el: Signal | Computed): void { c._flags |= REACTIVE_JOINED; - if ((c as any)._type !== EFFECT_RENDER) joinPassTx(txOf(el)); + if ( + (el as Computed)._statusFlags & STATUS_UNINITIALIZED || + (c as any)._type !== EFFECT_RENDER + ) + joinPassTx(txOf(el), GlobalQueue._fresh?.(c)); } /** A15's stale reader (shared-hole and reveal corollaries): a render effect @@ -1651,11 +1659,9 @@ export function read(el: Signal | Computed): T { !(el._config & CONFIG_OVERRIDE) && !(c._config & CONFIG_CHILDREN_FORBIDDEN) ) { - if (owner._statusFlags & STATUS_UNINITIALIZED) { - (c as Computed)._flags |= REACTIVE_JOINED; - joinPassTx(txOf(el)); - } else if (frameRead(c as Computed, el)) committed = true; - else joinPass(c as Computed, el); + if (owner._statusFlags & STATUS_UNINITIALIZED || !frameRead(c as Computed, el)) + joinPass(c as Computed, el); + else committed = true; } } // Lanes: a lane's node (after the pull — the node is current). NOT_PENDING diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index caa27c767..7db835628 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -172,11 +172,15 @@ export let flushTransaction: Transaction | null = null; * into it, and nothing else in the tick is — a write made after the mount * is a mainline write, a render effect mounted beside it a stale reader. * Inside a flush, or in a tick that already has its transaction (an - * action's body), the frame joins instead (`flushTransaction`). Cleared by - * the flush the join schedules. */ + * action's body), the frame joins instead (`flushTransaction`) — except a + * first pass a loading boundary that has not shown content catches (`own`, + * A29's boundary exemption, #3540): it is the boundary's, not the tick's, + * and a flush that has joined nothing keeps it pass-scoped as outside one. + * Cleared by the flush the join schedules, and at the end of a flush that + * set it. */ export let passTx: Transaction | null = null; -export function joinPassTx(t: Transaction): void { - if (globalQueue._running || flushTransaction !== null) return joinFuture(t); +export function joinPassTx(t: Transaction, own?: unknown): void { + if ((globalQueue._running && !own) || flushTransaction !== null) return joinFuture(t); if (passTx === null) passTx = resolveTx(t); else merge(resolveTx(t), passTx); schedule(); @@ -650,13 +654,15 @@ export class GlobalQueue implements IQueue { // Boundaries (boundaries.ts): the display consumers between an observer // and the root. `_catch` — status from a frame reader, nearest boundary // first (true: caught, the root never hears of it; a clear — flags 0 — - // settles the reader there); `_hidden` — a frame reader behind a fallback - // is not on screen and holds nothing; `_boundarySeam` — the seam's sweep - // (readers gone or settled without a pass reveal; an `on` re-arm - // resolves). + // settles the reader there); `_fresh` — a pass that read a hold (true: a + // first pass a loading boundary that has not shown content caught, + // `joinPass`); `_hidden` — a frame reader behind a fallback is not on + // screen and holds nothing; `_boundarySeam` — the seam's sweep (readers + // gone or settled without a pass reveal; an `on` re-arm resolves). declare static _catch: | ((node: Computed, flags: number, error: unknown) => boolean) | undefined; + declare static _fresh: ((node: Computed) => unknown) | undefined; declare static _hidden: ((r: Computed) => boolean) | undefined; declare static _boundarySeam: (() => void) | undefined; // `_heldRun` — a queued run under a fallback-showing boundary waits for @@ -702,6 +708,7 @@ export class GlobalQueue implements IQueue { if (__DEV__) DEV.hooks.onUpdate?.(); } finally { this._running = false; + passTx = null; } } /** L2 — the seam: end of the pure phase. Commit this flush's staged nodes, diff --git a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts new file mode 100644 index 000000000..a0a0bbf32 --- /dev/null +++ b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts @@ -0,0 +1,228 @@ +/** + * #3540 under L2 — A29's boundary exemption, in a flush and on a hold. + * + * A loading boundary that has not shown content mounted while a transaction + * holds what it reads shows its fallback now and its content at the commit: + * the first pass under it is the boundary's, not the tick's. A mount inside + * a flush (a Show opening) publishes with it; a derivation outside the + * boundary in the same flush still holds the tick. Content bound by a render + * effect (the tree never reads the held value) is collected too, so the + * boundary never reveals empty content. + */ +import { describe, expect, it } from "vitest"; +import { + action, + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + untrack +} from "../src/index.js"; + +const tick = async () => { + for (let i = 0; i < 6; i++) await Promise.resolve(); + flush(); +}; + +function hold(write: () => void) { + let release!: () => void; + action(function* () { + write(); + yield new Promise(resolve => (release = resolve)); + })(); + flush(); + return () => release(); +} + +/** `{fn()}`, created untracked as + * createComponent does. */ +const Loading = (fn: () => T, fallback: string) => + untrack(() => createLoadingBoundary(fn, () => fallback)); + +type Content = "memo" | "direct" | "bound"; + +/** The boundary's content over `x`, logging into `log`. */ +function content(kind: Content, x: () => number, log: string[]) { + return () => { + if (kind === "direct") return `content ${x()}`; + const m = createMemo(() => `content ${x()}`); + if (kind === "memo") return m(); + //

{m()}

: the content's binding reads the memo, the tree does not. + createRenderEffect(m, v => { + log.push(`text ${v}`); + }); + return "p"; + }; +} + +function mount(kind: Content, x: () => number, log: string[]) { + const view = Loading(content(kind, x, log), "fallback"); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); +} + +const shapes = [ + ["memo", "through a memo"], + ["direct", "directly"], + ["bound", "bound by a render effect under the boundary"] +] as const; + +/** The commit's reveal: the content's own entries, in any order. */ +const revealed = (kind: Content) => + kind === "bound" ? ["text content 1", "view p"] : ["view content 1"]; + +describe("#3540 in a flush: a fresh Loading mounted over a held value shows its fallback now", () => { + function setup(kind: Content, nested = false) { + const [x, setX] = createSignal(0); + const [open, setOpen] = createSignal(false); + const log: string[] = []; + createRoot(() => { + createRenderEffect(x, v => { + log.push(`x ${v}`); + }); + // + const show = () => + createRenderEffect( + () => { + if (!open()) return false; + mount(kind, x, log); + return true; + }, + v => { + log.push(`open ${v}`); + } + ); + if (!nested) return show(); + // Under an outer Loading that has shown content. + const outer = Loading(() => (show(), "outer"), "outer fallback"); + createRenderEffect(outer, v => { + log.push(`outer ${v}`); + }); + }); + flush(); + if (nested) expect(log).toContain("outer outer"); + const release = hold(() => setX(1)); + log.length = 0; + return { setOpen, log, release }; + } + + for (const [kind, how] of shapes) + it(`content reads the held value ${how}: the mount publishes, the content reveals at the commit`, async () => { + const s = setup(kind); + s.setOpen(true); + flush(); + expect(s.log).toEqual(["view fallback", "open true"]); + + s.release(); + await tick(); + expect(s.log.slice(2).sort()).toEqual(["x 1", ...revealed(kind)].sort()); + }); + + it("nested under an outer Loading that has shown content: the inner fallback now, the outer keeps its content", async () => { + const s = setup("memo", true); + s.setOpen(true); + flush(); + expect(s.log).toEqual(["view fallback", "open true"]); + + s.release(); + await tick(); + expect(s.log.slice(2).sort()).toEqual(["view content 1", "x 1"]); + }); + + it("a derivation outside the boundary in the same flush still holds the tick (membership is the tick's)", async () => { + const [x, setX] = createSignal(0); + const [open, setOpen] = createSignal(false); + const log: string[] = []; + createRoot(() => { + createRenderEffect( + () => { + if (!open()) return false; + const label = createMemo(() => `label ${x()}`); + createRenderEffect(label, v => { + log.push(v); + }); + mount("direct", x, log); + return true; + }, + v => { + log.push(`open ${v}`); + } + ); + }); + flush(); + const release = hold(() => setX(1)); + log.length = 0; + + setOpen(true); + flush(); + expect(log).not.toContain("open true"); + expect(log.filter(l => l.startsWith("label"))).toEqual([]); + + release(); + await tick(); + expect(log).toContain("open true"); + expect(log).toContain("label 1"); + expect(log.at(-1)).toBe("view content 1"); + }); + + it("the boundary's hold stays its own: a mount over another hold after the flush reveals at that hold's release", async () => { + const [x, setX] = createSignal(0); + const [y, setY] = createSignal(0); + const [open, setOpen] = createSignal(false); + const log: string[] = []; + createRoot(() => { + createRenderEffect( + () => { + if (!open()) return false; + mount("direct", x, log); + return true; + }, + v => { + log.push(`open ${v}`); + } + ); + }); + flush(); + const releaseX = hold(() => setX(1)); + const releaseY = hold(() => setY(1)); + log.length = 0; + + setOpen(true); + flush(); + expect(log).toEqual(["view fallback", "open true"]); + + // Mounted from mainline, outside a flush, over y's hold only. + const other: string[] = []; + createRoot(() => mount("direct", y, other)); + flush(); + expect(other).toEqual(["view fallback"]); + + releaseY(); + await tick(); + expect(other).toEqual(["view fallback", "view content 1"]); + expect(log).toEqual(["view fallback", "open true"]); + + releaseX(); + await tick(); + expect(log.at(-1)).toBe("view content 1"); + }); +}); + +describe("#3540 on a hold: a fresh Loading mounted from mainline over a held value", () => { + for (const [kind, how] of shapes) + it(`content reads the held value ${how}: the fallback now, the content at the commit`, async () => { + const [x, setX] = createSignal(0); + const release = hold(() => setX(1)); + const log: string[] = []; + createRoot(() => mount(kind, x, log)); + flush(); + expect(log).toEqual(["view fallback"]); + + release(); + await tick(); + expect(log.slice(1).sort()).toEqual(revealed(kind).sort()); + }); +}); diff --git a/packages/web/test/loading-fallback-in-flush-3540.spec.tsx b/packages/web/test/loading-fallback-in-flush-3540.spec.tsx new file mode 100644 index 000000000..21e199a71 --- /dev/null +++ b/packages/web/test/loading-fallback-in-flush-3540.spec.tsx @@ -0,0 +1,119 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { describe, expect, test } from "vitest"; +import { Loading, Show, action, createMemo, createSignal, flush } from "solid-js"; +import { render } from "../src/index.js"; + +const tick = async () => { + for (let i = 0; i < 6; i++) await Promise.resolve(); + flush(); +}; + +/** + * #3540, in a flush: a `` that flips mainline to mount a fresh + * `` over a held value shows `open` and the fallback now; the + * content reveals with the hold's commit. A derivation outside the boundary + * in the same mount still makes the tick join the hold (membership is the + * tick's). + */ +describe("a fresh Loading mounted by a flush over a held value (#3540)", () => { + function setup(outside: boolean) { + const container = document.createElement("div"); + const [x, setX] = createSignal(0); + const [open, setOpen] = createSignal(false); + function Content() { + const m = createMemo(() => `content ${x()}`); + return

{m()}

; + } + function Label() { + const m = createMemo(() => `label ${x()}`); + return {m()}; + } + const dispose = render( + () => ( +
+

{x()}

+ {String(open())} + + {outside && +
+ ), + container + ); + flush(); + let release!: () => void; + const done = action(function* () { + setX(1); + yield new Promise(r => (release = r)); + })(); + flush(); + return { container, setOpen, release: () => release(), done, dispose }; + } + + test("the mount and the fallback show now; the content reveals at the commit", async () => { + const s = setup(false); + expect(s.container.innerHTML).toBe("

0

false
"); + + s.setOpen(true); + flush(); + expect(s.container.innerHTML).toBe("

0

true

fallback

"); + + s.release(); + await s.done; + await tick(); + expect(s.container.innerHTML).toBe("

1

true

content 1

"); + s.dispose(); + }); + + test("mounted outside a flush, content bound in JSX: the fallback now, the content at the commit", async () => { + const [x, setX] = createSignal(0); + let release!: () => void; + const done = action(function* () { + setX(1); + yield new Promise(r => (release = r)); + })(); + flush(); + function Content() { + const m = createMemo(() => `content ${x()}`); + return

{m()}

; + } + const container = document.createElement("div"); + const dispose = render( + () => ( + fallback

}> + +
+ ), + container + ); + flush(); + expect(container.innerHTML).toBe("

fallback

"); + + release(); + await done; + await tick(); + expect(container.innerHTML).toBe("

content 1

"); + dispose(); + }); + + test("a derivation outside the boundary holds the mount with the tick", async () => { + const s = setup(true); + s.setOpen(true); + flush(); + expect(s.container.innerHTML).toBe("

0

false
"); + + s.release(); + await s.done; + await tick(); + expect(s.container.innerHTML).toBe( + "

1

truelabel 1

content 1

" + ); + s.dispose(); + }); +}); From 4c6d570f7c6e45e03b170fff0b4abd7c813871a1 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 01:35:19 -0700 Subject: [PATCH 02/10] fix(signals): the first-pass catch asks only the nearest loading boundary, 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 Co-authored-by: Cursor --- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 2 +- packages/signals/src/boundaries.ts | 19 +- packages/signals/src/core/scheduler.ts | 6 +- .../loading-fallback-in-flush-3540.test.ts | 345 ++++++++++++++++++ 5 files changed, 366 insertions(+), 8 deletions(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 5c10bf7a7..cabf38466 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:105` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:291` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:89` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:113` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×10 effect.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:113` | boundaries.ts×2 action.ts×1 constants.ts×1 core.ts×10 effect.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:247` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:121` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:129` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index ad1a31871..74faba282 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -55,7 +55,7 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t - **A first load derived from a hold lands into it** (#3800): a first load born pending in a frame that read a hold lands into that hold if the hold is still live, else as its own commit; the hold commits without it. **Pending** — `fix/create-time-holds` (`91e474506`); [#3800](https://github.com/solidjs/solid/issues/3800) is open. - **A first pass under a loading boundary that has not shown content is the boundary's, not the tick's** — in a flush as outside one (A29's boundary exemption, #3540). The out-of-flush form is on `next`; the in-flush form (a fresh `Loading` over a held value shows its fallback in a flush) is **pending** — `fix/create-time-holds` (`ca71e5d9d`). -**The boundary scope, in a flush (2026-10-06, amendment — #3540's L2 regression; the bullet above is unchanged).** The in-flush form lands with `fix/l2-3540-fresh-loading`, standalone of `fix/create-time-holds`. A first pass (`STATUS_UNINITIALIZED`) that reads a hold asks the boundaries up its chain (`GlobalQueue._fresh`: caught as a pending status). When a loading boundary that has not shown content catches it, the pass joins pass-scoped (`passTx`), as a mount outside a flush does, instead of joining the flush: the Show that mounted the boundary publishes, and the boundary shows its fallback. A pass nothing catches joins the flush as before, so a derivation outside the boundary in the same flush still holds the tick. A render effect born held under the boundary (content bound in JSX — the tree never reads the held value) is caught the same way, so the boundary does not reveal empty content, in a flush or out of one; its output's first pass shows the fallback whenever it has collected readers. A flush that set `passTx` clears it at its end, so the boundary's hold does not merge with a later tick's. The `direction-rule-probe` pins are unchanged. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (in a flush: through a memo, directly, bound, nested under a Loading that has shown content; a derivation outside the boundary still holds the tick; the boundary's hold stays its own; on a hold, from mainline: the same three content shapes); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. +**The boundary scope, in a flush (2026-10-06, amendment — #3540's L2 regression; the bullet above is unchanged).** The in-flush form lands with `fix/l2-3540-fresh-loading`, standalone of `fix/create-time-holds`. A first pass (`STATUS_UNINITIALIZED`) that reads a hold asks its nearest loading boundary (`GlobalQueue._fresh`). Only a boundary that has not shown content catches it; a boundary that has (re-armed by `on` or not) neither catches nor records it, and no boundary past the nearest is asked, so which boundaries catch what is otherwise unchanged. When the boundary catches it, the pass joins pass-scoped (`passTx`), as a mount outside a flush does, instead of joining the flush: the Show that mounted the boundary publishes, and the boundary shows its fallback. A pass nothing catches joins the flush as before, so a derivation outside the boundary in the same flush still holds the tick. A render effect born held under the boundary (content bound in JSX — the tree never reads the held value) is caught the same way, so the boundary does not reveal empty content, in a flush or out of one; its output's first pass shows the fallback whenever it has collected readers. A flush that set `passTx` clears it at its end, so the boundary's hold does not merge with a later tick's. The `direction-rule-probe` pins are unchanged. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (in a flush: through a memo, directly, bound, nested under a Loading that has shown content; a derivation outside the boundary still holds the tick; the boundary's hold stays its own; on a hold, from mainline: the same three content shapes; under an inner Loading that has not shown content, the outer revealed one records nothing, is not pending, and the commit does not wait for the first load; directly under a revealed Loading, or under a revealed one inside one that has not shown content, not caught, as before; `on` boundaries revealed (re-armed or not) as before, not yet revealed as a Loading without `on`); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. **Not yet one-way (recorded, not ruled).** The frame of a tick is still one transaction (the tick ruling above stands), so two shapes keep the hold waiting on first loads it never needed: diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 631f60627..06f79b3e7 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -203,7 +203,7 @@ export function redraw(b: Boundary): void { /** GlobalQueue._catch: status from a frame reader, nearest boundary first. * A loading boundary on the way records a pending reader whether or not it * catches it (its `on` may collect it later). */ -function catchStatus(node: Computed, flags: number, error?: unknown): boolean { +function catchStatus(node: Computed, flags: number, error: unknown): boolean { if (flags === 0) { // A status cleared. Judged by the node's status now, against each // boundary's own rule (`unsettled`) — the error path clears pending @@ -236,6 +236,20 @@ function catchStatus(node: Computed, flags: number, error?: unknown): boole return false; } +/** GlobalQueue._fresh: a pass that read a hold. A first pass is not pending, + * and only A29's boundary exemption catches it (#3540): the nearest loading + * boundary, if it has not shown content. One that has (re-armed or not) + * neither catches nor records it — it joins the hold like any reader of + * committed content — and no boundary past the nearest is asked. */ +function fresh(node: Computed): boolean { + if (!(node._statusFlags & STATUS_UNINITIALIZED)) return false; + let b = boundaryOf(node); + while (b !== undefined && b._type !== STATUS_PENDING) b = b._parent ?? undefined; + if (b === undefined || b._initialized) return false; + caught(b, node, undefined); + return true; +} + /** The nearest boundary on `r`'s chain showing its fallback, if any. */ function hiddenBy(r: Computed): Boundary | undefined { for (let b = boundaryOf(r); b !== undefined; b = b._parent ?? undefined) @@ -774,8 +788,7 @@ export function createErrorBoundary( // Installed at module evaluation — present exactly when something imports a // boundary. An app without one pays the three null checks and nothing else. GlobalQueue._catch = catchStatus; -GlobalQueue._fresh = node => - node._statusFlags & STATUS_UNINITIALIZED && catchStatus(node, STATUS_PENDING); +GlobalQueue._fresh = fresh; GlobalQueue._hidden = hidden; GlobalQueue._boundarySeam = boundarySeam; GlobalQueue._heldRun = heldRun; diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 7db835628..77711bc18 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -655,14 +655,14 @@ export class GlobalQueue implements IQueue { // and the root. `_catch` — status from a frame reader, nearest boundary // first (true: caught, the root never hears of it; a clear — flags 0 — // settles the reader there); `_fresh` — a pass that read a hold (true: a - // first pass a loading boundary that has not shown content caught, - // `joinPass`); `_hidden` — a frame reader behind a fallback is not on + // first pass the nearest loading boundary, not having shown content, + // caught — `joinPass`); `_hidden` — a frame reader behind a fallback is not on // screen and holds nothing; `_boundarySeam` — the seam's sweep (readers // gone or settled without a pass reveal; an `on` re-arm resolves). declare static _catch: | ((node: Computed, flags: number, error: unknown) => boolean) | undefined; - declare static _fresh: ((node: Computed) => unknown) | undefined; + declare static _fresh: ((node: Computed) => boolean) | undefined; declare static _hidden: ((r: Computed) => boolean) | undefined; declare static _boundarySeam: (() => void) | undefined; // `_heldRun` — a queued run under a fallback-showing boundary waits for diff --git a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts index a0a0bbf32..b6d1c9aad 100644 --- a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts +++ b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts @@ -18,6 +18,8 @@ import { createRoot, createSignal, flush, + getOwner, + isPending, untrack } from "../src/index.js"; @@ -226,3 +228,346 @@ describe("#3540 on a hold: a fresh Loading mounted from mainline over a held val expect(log.slice(1).sort()).toEqual(revealed(kind).sort()); }); }); + +/** The readers recorded on the boundary `owner` is under (internal: the + * catch's registrations). */ +function readersOf(owner: object): number { + const context = (owner as { _context: Record })._context; + for (const key of Object.getOwnPropertySymbols(context)) { + const b = context[key] as { _readers?: Set } | null; + if (b?._readers) return b._readers.size; + } + throw new Error("no boundary"); +} + +describe("#3540: the first-pass catch belongs to the boundary that has not shown content, and to no other", () => { + /** + * A revealed outer `Loading`; a transaction holding `x`; a Show under the + * outer one mounts, in a flush, a fresh computation over `x` with a first + * load of its own (`slow`) — under an inner `Loading` that has not shown + * content (`inner`), or directly under the revealed outer one. + */ + function setup(inner: boolean) { + const [x, setX] = createSignal(0); + const [open, setOpen] = createSignal(false); + const log: string[] = []; + let resolveSlow!: () => void; + let outerOwner!: object; + let freshOwner!: object; + let outer!: () => unknown; + createRoot(() => { + createRenderEffect(x, v => { + log.push(`x ${v}`); + }); + outer = Loading(() => { + outerOwner = getOwner()!; + createRenderEffect( + () => { + if (!open()) return false; + const body = () => { + freshOwner = getOwner()!; + const m = createMemo(async () => { + const v = x(); + await new Promise(r => (resolveSlow = r)); + return `fresh ${v}`; + }); + createRenderEffect(m, v => { + log.push(`text ${v}`); + }); + return "p"; + }; + if (!inner) return body(); + const view = Loading(body, "inner fallback"); + createRenderEffect(view, v => { + log.push(`inner ${v}`); + }); + return true; + }, + v => { + log.push(`open ${v}`); + } + ); + return "outer"; + }, "outer fallback"); + createRenderEffect(outer, v => { + log.push(`outer ${v}`); + }); + }); + flush(); + expect(log).toEqual(["x 0", "open false", "outer outer"]); + const release = hold(() => setX(1)); + log.length = 0; + setOpen(true); + flush(); + const state = () => ({ + log: [...log], + outerPending: isPending(() => outer()), + outerReaders: readersOf(outerOwner), + freshReaders: readersOf(freshOwner) + }); + return { release, resolve: () => resolveSlow(), state }; + } + + it("under an inner Loading that has not shown content: the inner one catches it; the outer one records nothing, is not pending, and the commit does not wait for it", async () => { + const s = setup(true); + expect(s.state()).toEqual({ + log: ["inner inner fallback", "open true"], + outerPending: false, + outerReaders: 0, + freshReaders: 2 + }); + + s.release(); + await tick(); + // The transaction commits without the fresh computation's first load. + expect(s.state()).toMatchObject({ + log: ["inner inner fallback", "open true", "x 1"], + outerPending: false, + outerReaders: 0 + }); + + s.resolve(); + await tick(); + expect(s.state()).toEqual({ + log: ["inner inner fallback", "open true", "x 1", "text fresh 1", "inner p"], + outerPending: false, + outerReaders: 0, + freshReaders: 0 + }); + }); + + it("directly under the revealed Loading (none has not shown content): not caught — it joins the transaction, as on next", async () => { + const s = setup(false); + const trace: unknown[] = [s.state()]; + s.release(); + await tick(); + trace.push(s.state()); + s.resolve(); + await tick(); + trace.push(s.state()); + // The Show's mount waits with the transaction, the outer content stays, + // and the outer boundary holds the one pending reader the frame + // forwarded (the memo) — never the born-held binding. + const held = { log: [], outerPending: false, outerReaders: 1, freshReaders: 1 }; + expect(trace).toEqual([ + held, + held, + { + log: ["text fresh 1", "x 1", "open p"], + outerPending: false, + outerReaders: 0, + freshReaders: 0 + } + ]); + }); + + it("under a revealed Loading inside one that has not shown content: the nearest one is asked only — not caught, as on next", async () => { + const [x, setX] = createSignal(0); + const [open, setOpen] = createSignal(false); + let resolveGate!: () => void; + const log: string[] = []; + let outerOwner!: object; + let innerOwner!: object; + createRoot(() => { + const outer = Loading(() => { + outerOwner = getOwner()!; + // A revealed inner Loading whose tree mounts the fresh computation. + const inner = Loading(() => { + innerOwner = getOwner()!; + if (!open()) return "closed"; + const m = createMemo(() => `fresh ${x()}`); + return m(); + }, "inner fallback"); + createRenderEffect(inner, v => { + log.push(`inner ${v}`); + }); + // A sibling still loading: the outer one has not shown content. + const gate = createMemo(() => new Promise(r => (resolveGate = () => r("gate")))); + createRenderEffect(gate, v => { + log.push(`gate ${v}`); + }); + return "outer"; + }, "outer fallback"); + createRenderEffect(outer, v => { + log.push(`outer ${v}`); + }); + }); + flush(); + const release = hold(() => setX(1)); + const state = () => ({ + log: [...log], + outerReaders: readersOf(outerOwner), + innerReaders: readersOf(innerOwner) + }); + const trace: unknown[] = [state()]; + setOpen(true); + flush(); + trace.push(state()); + release(); + await tick(); + trace.push(state()); + resolveGate(); + await tick(); + trace.push(state()); + // The outer boundary only ever waits on the gate; the revealed inner one + // records nothing. + const waiting = { + log: ["inner closed", "outer outer fallback"], + outerReaders: 1, + innerReaders: 0 + }; + expect(trace).toEqual([ + waiting, + waiting, + waiting, + { + log: ["inner closed", "outer outer fallback", "gate gate", "inner fresh 1", "outer outer"], + outerReaders: 0, + innerReaders: 0 + } + ]); + }); +}); + +describe("#3540 and `on` (a dependency list, #3575): the first-pass catch adds nothing to what `on` does", () => { + /** A revealed ``; a transaction holding `x`; a Show in + * its content mounts, in a flush, a fresh computation over `x`. `rearm`: + * the Show's own signal is in `on`, so the mounting flush re-arms it. */ + function trace(rearm: boolean) { + const [x, setX] = createSignal(0); + const [open, setOpen] = createSignal(false); + const log: string[] = []; + let owner!: object; + createRoot(() => { + createRenderEffect(x, v => { + log.push(`x ${v}`); + }); + const view = untrack(() => + createLoadingBoundary( + () => { + owner = getOwner()!; + createRenderEffect( + () => { + if (!open()) return false; + const m = createMemo(() => `fresh ${x()}`); + createRenderEffect(m, v => { + log.push(`text ${v}`); + }); + return true; + }, + v => { + log.push(`open ${v}`); + } + ); + return "content"; + }, + () => "fallback", + { on: () => (rearm ? open() : undefined) } + ) + ); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + }); + flush(); + const release = hold(() => setX(1)); + log.length = 0; + const state = () => ({ log: [...log], readers: readersOf(owner) }); + return { release, setOpen, state }; + } + + for (const rearm of [false, true]) + it(`revealed, ${rearm ? "re-armed by" : "not re-armed by"} the mounting flush: not caught — the mount joins the transaction, as on next`, async () => { + const s = trace(rearm); + s.setOpen(true); + flush(); + const steps: unknown[] = [s.state()]; + s.release(); + await tick(); + steps.push(s.state()); + expect(steps).toEqual([ + { log: [], readers: 0 }, + { log: ["text fresh 1", "x 1", "open true"], readers: 0 } + ]); + }); + + for (const rearm of [false, true]) + it(`revealed, its tree mounting in the flush that ${rearm ? "re-arms" : "does not re-arm"} it: not caught, as on next`, async () => { + const [x, setX] = createSignal(0); + const [open, setOpen] = createSignal(false); + const log: string[] = []; + let owner!: object; + createRoot(() => { + createRenderEffect(x, v => { + log.push(`x ${v}`); + }); + const view = untrack(() => + createLoadingBoundary( + () => { + owner = getOwner()!; + if (!open()) return "closed"; + return createMemo(() => `fresh ${x()}`)(); + }, + () => "fallback", + { on: () => (rearm ? open() : undefined) } + ) + ); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + }); + flush(); + const release = hold(() => setX(1)); + log.length = 0; + setOpen(true); + flush(); + const steps: unknown[] = [{ log: [...log], readers: readersOf(owner) }]; + release(); + await tick(); + steps.push({ log: [...log], readers: readersOf(owner) }); + expect(steps).toEqual([ + { log: [], readers: 0 }, + { log: ["x 1", "view fresh 1"], readers: 0 } + ]); + }); + + for (const inFlush of [true, false]) + it(`not yet revealed, mounted ${inFlush ? "in a flush" : "from mainline"} over the held value: the fallback now, the content at the commit, as a Loading without \`on\``, async () => { + const [x, setX] = createSignal(0); + const [open, setOpen] = createSignal(!inFlush); + const [key] = createSignal(0); + const release = hold(() => setX(1)); + const log: string[] = []; + createRoot(() => + createRenderEffect( + () => { + if (!open()) return false; + const view = untrack(() => + createLoadingBoundary( + () => `content ${x()}`, + () => "fallback", + { on: key } + ) + ); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + return true; + }, + v => { + log.push(`open ${v}`); + } + ) + ); + flush(); + if (inFlush) { + log.length = 0; + setOpen(true); + flush(); + } + expect(log).toEqual(["view fallback", "open true"]); + release(); + await tick(); + expect(log).toEqual(["view fallback", "open true", "view content 1"]); + }); +}); From 4a9551460974e2766dc2f423670c9a1fafb13523 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 01:44:17 -0700 Subject: [PATCH 03/10] fix(signals): ask the nearest loading boundary through _catch; no new 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 Co-authored-by: Cursor --- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 2 +- packages/signals/src/boundaries.ts | 32 +++++++++---------- packages/signals/src/core/core.ts | 2 +- packages/signals/src/core/scheduler.ts | 14 ++++---- 4 files changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 74faba282..5ce2c6ec8 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -55,7 +55,7 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t - **A first load derived from a hold lands into it** (#3800): a first load born pending in a frame that read a hold lands into that hold if the hold is still live, else as its own commit; the hold commits without it. **Pending** — `fix/create-time-holds` (`91e474506`); [#3800](https://github.com/solidjs/solid/issues/3800) is open. - **A first pass under a loading boundary that has not shown content is the boundary's, not the tick's** — in a flush as outside one (A29's boundary exemption, #3540). The out-of-flush form is on `next`; the in-flush form (a fresh `Loading` over a held value shows its fallback in a flush) is **pending** — `fix/create-time-holds` (`ca71e5d9d`). -**The boundary scope, in a flush (2026-10-06, amendment — #3540's L2 regression; the bullet above is unchanged).** The in-flush form lands with `fix/l2-3540-fresh-loading`, standalone of `fix/create-time-holds`. A first pass (`STATUS_UNINITIALIZED`) that reads a hold asks its nearest loading boundary (`GlobalQueue._fresh`). Only a boundary that has not shown content catches it; a boundary that has (re-armed by `on` or not) neither catches nor records it, and no boundary past the nearest is asked, so which boundaries catch what is otherwise unchanged. When the boundary catches it, the pass joins pass-scoped (`passTx`), as a mount outside a flush does, instead of joining the flush: the Show that mounted the boundary publishes, and the boundary shows its fallback. A pass nothing catches joins the flush as before, so a derivation outside the boundary in the same flush still holds the tick. A render effect born held under the boundary (content bound in JSX — the tree never reads the held value) is caught the same way, so the boundary does not reveal empty content, in a flush or out of one; its output's first pass shows the fallback whenever it has collected readers. A flush that set `passTx` clears it at its end, so the boundary's hold does not merge with a later tick's. The `direction-rule-probe` pins are unchanged. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (in a flush: through a memo, directly, bound, nested under a Loading that has shown content; a derivation outside the boundary still holds the tick; the boundary's hold stays its own; on a hold, from mainline: the same three content shapes; under an inner Loading that has not shown content, the outer revealed one records nothing, is not pending, and the commit does not wait for the first load; directly under a revealed Loading, or under a revealed one inside one that has not shown content, not caught, as before; `on` boundaries revealed (re-armed or not) as before, not yet revealed as a Loading without `on`); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. +**The boundary scope, in a flush (2026-10-06, amendment — #3540's L2 regression; the bullet above is unchanged).** The in-flush form lands with `fix/l2-3540-fresh-loading`, standalone of `fix/create-time-holds`. A first pass (`STATUS_UNINITIALIZED`) that reads a hold asks its nearest loading boundary (`GlobalQueue._catch`, with `STATUS_UNINITIALIZED` in its flags; no new hook). Only a boundary that has not shown content catches it; a boundary that has (re-armed by `on` or not) neither catches nor records it, and no boundary past the nearest is asked, so which boundaries catch what is otherwise unchanged. When the boundary catches it, the pass joins pass-scoped (`passTx`), as a mount outside a flush does, instead of joining the flush: the Show that mounted the boundary publishes, and the boundary shows its fallback. A pass nothing catches joins the flush as before, so a derivation outside the boundary in the same flush still holds the tick. A render effect born held under the boundary (content bound in JSX — the tree never reads the held value) is caught the same way, so the boundary does not reveal empty content, in a flush or out of one; its output's first pass shows the fallback whenever it has collected readers. A flush that set `passTx` clears it at its end, so the boundary's hold does not merge with a later tick's. The `direction-rule-probe` pins are unchanged. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (in a flush: through a memo, directly, bound, nested under a Loading that has shown content; a derivation outside the boundary still holds the tick; the boundary's hold stays its own; on a hold, from mainline: the same three content shapes; under an inner Loading that has not shown content, the outer revealed one records nothing, is not pending, and the commit does not wait for the first load; directly under a revealed Loading, or under a revealed one inside one that has not shown content, not caught, as before; `on` boundaries revealed (re-armed or not) as before, not yet revealed as a Loading without `on`); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. **Not yet one-way (recorded, not ruled).** The frame of a tick is still one transaction (the tick ruling above stands), so two shapes keep the hold waiting on first loads it never needed: diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 06f79b3e7..994890a7f 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -202,8 +202,14 @@ export function redraw(b: Boundary): void { /** GlobalQueue._catch: status from a frame reader, nearest boundary first. * A loading boundary on the way records a pending reader whether or not it - * catches it (its `on` may collect it later). */ -function catchStatus(node: Computed, flags: number, error: unknown): boolean { + * catches it (its `on` may collect it later). A pass that read a hold + * (`joinPass`: STATUS_UNINITIALIZED in `flags`) is asked too. A first pass + * is not pending — it has never committed — and only A29's boundary + * exemption catches it (#3540): the nearest loading boundary, if it has not + * shown content. One that has, re-armed by `on` or not, neither catches nor + * records it (it joins the hold like any reader of committed content), no + * boundary past the nearest is asked, and a committed pass is not caught. */ +function catchStatus(node: Computed, flags: number, error?: unknown): boolean { if (flags === 0) { // A status cleared. Judged by the node's status now, against each // boundary's own rule (`unsettled`) — the error path clears pending @@ -226,6 +232,13 @@ function catchStatus(node: Computed, flags: number, error: unknown): boolea } 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; @@ -236,20 +249,6 @@ function catchStatus(node: Computed, flags: number, error: unknown): boolea return false; } -/** GlobalQueue._fresh: a pass that read a hold. A first pass is not pending, - * and only A29's boundary exemption catches it (#3540): the nearest loading - * boundary, if it has not shown content. One that has (re-armed or not) - * neither catches nor records it — it joins the hold like any reader of - * committed content — and no boundary past the nearest is asked. */ -function fresh(node: Computed): boolean { - if (!(node._statusFlags & STATUS_UNINITIALIZED)) return false; - let b = boundaryOf(node); - while (b !== undefined && b._type !== STATUS_PENDING) b = b._parent ?? undefined; - if (b === undefined || b._initialized) return false; - caught(b, node, undefined); - return true; -} - /** The nearest boundary on `r`'s chain showing its fallback, if any. */ function hiddenBy(r: Computed): Boundary | undefined { for (let b = boundaryOf(r); b !== undefined; b = b._parent ?? undefined) @@ -788,7 +787,6 @@ export function createErrorBoundary( // Installed at module evaluation — present exactly when something imports a // boundary. An app without one pays the three null checks and nothing else. GlobalQueue._catch = catchStatus; -GlobalQueue._fresh = fresh; GlobalQueue._hidden = hidden; GlobalQueue._boundarySeam = boundarySeam; GlobalQueue._heldRun = heldRun; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index b6b77681c..2b6a0bcbf 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -952,7 +952,7 @@ function joinPass(c: Computed, el: Signal | Computed): void { (el as Computed)._statusFlags & STATUS_UNINITIALIZED || (c as any)._type !== EFFECT_RENDER ) - joinPassTx(txOf(el), GlobalQueue._fresh?.(c)); + joinPassTx(txOf(el), GlobalQueue._catch?.(c, STATUS_PENDING | STATUS_UNINITIALIZED)); } /** A15's stale reader (shared-hole and reveal corollaries): a render effect diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 77711bc18..ecb225cb9 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -654,15 +654,15 @@ export class GlobalQueue implements IQueue { // Boundaries (boundaries.ts): the display consumers between an observer // and the root. `_catch` — status from a frame reader, nearest boundary // first (true: caught, the root never hears of it; a clear — flags 0 — - // settles the reader there); `_fresh` — a pass that read a hold (true: a - // first pass the nearest loading boundary, not having shown content, - // caught — `joinPass`); `_hidden` — a frame reader behind a fallback is not on - // screen and holds nothing; `_boundarySeam` — the seam's sweep (readers - // gone or settled without a pass reveal; an `on` re-arm resolves). + // settles the reader there; a pass that read a hold — STATUS_UNINITIALIZED + // in flags, `joinPass` — is caught only as a first pass, by the nearest + // loading boundary if it has not shown content); `_hidden` — a frame + // reader behind a fallback is not on screen and holds nothing; + // `_boundarySeam` — the seam's sweep (readers gone or settled without a + // pass reveal; an `on` re-arm resolves). declare static _catch: - | ((node: Computed, flags: number, error: unknown) => boolean) + | ((node: Computed, flags: number, error?: unknown) => boolean) | undefined; - declare static _fresh: ((node: Computed) => boolean) | undefined; declare static _hidden: ((r: Computed) => boolean) | undefined; declare static _boundarySeam: (() => void) | undefined; // `_heldRun` — a queued run under a fallback-showing boundary waits for From a661e91f04b814eb91429ea1f39ba06923d0e388 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 02:13:46 -0700 Subject: [PATCH 04/10] =?UTF-8?q?chore(size):=20Size-Exception=20for=20#38?= =?UTF-8?q?24=20=E2=80=94=20five=20caps=20at=20CI-measured=20+=2010=20B=20?= =?UTF-8?q?(A29=20restore,=20#3540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Cursor --- scripts/size/floor-caps.json | 4 +-- scripts/size/scenarios.js | 56 ++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 0fb36068d..248d126c0 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -2,8 +2,8 @@ "signals: core floor (createSignal/Memo/Effect/Root/flush)": "7.35 KB", "app: render + one signal (the simple-app floor)": "9.86 KB", "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": "17.71 KB", - "page: base server components (hydrating + dynamic + frames + sf reference)": "44.84 KB", - "page: live server components (base + live/GET + action + isPending/latest)": "48.51 KB", + "page: base server components (hydrating + dynamic + frames + sf reference)": "44.90 KB", + "page: live server components (base + live/GET + action + isPending/latest)": "48.53 KB", "server: floor (getRequestEvent + isServer)": "1.34 KB", "server: renderToString (the server-render floor)": "20.42 KB" } diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 45fab4d25..1222f3164 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -2070,7 +2070,17 @@ module.exports = [ // render-effect mark in laneDirty (+4). Cap set at measured + 10 B // rounded up to 0.01 KB. Accepted by the maintainer (2026-10-05). The // cap is frozen again at 28.87 KB. - limit: "28.87 KB", + // Size-Exception (#3824, 2026-10-06): 28.87 -> 28.90 KB, + // measured at 28,881 B by CI against `next` @ 7acc0390e's 28,903 + // (-22 B; 11 B over the cap; +27 B minified) — A29's boundary + // exemption restored under L2 (#3540): a fresh `Loading` over a held + // value shows its fallback in a flush, and catches content bound under it; only the nearest loading boundary that has not shown + // content catches a first pass (+17 B minified in the core: joinPass, + // the pass-scoped join, the flush-end passTx clear; +10-11 B in + // boundaries: the catch's nearest-boundary stop); brotli layout. + // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the + // maintainer (2026-10-06). The cap is frozen again at 28.90 KB. + limit: "28.90 KB", alias }, { @@ -3024,7 +3034,17 @@ module.exports = [ // Cap at CI-measured + 10 B rounded up to 0.01 KB. path: "fixtures/compiled/csr.jsx", compile: { hydratable: false }, - limit: "25.13 KB", + // Size-Exception (#3824, 2026-10-06): 25.13 -> 25.22 KB, + // measured at 25,204 B by CI against `next` @ 7acc0390e's 25,073 + // (+131 B; 74 B over the cap; +28 B minified) — A29's boundary + // exemption restored under L2 (#3540): a fresh `Loading` over a held + // value shows its fallback in a flush, and catches content bound under it; only the nearest loading boundary that has not shown + // content catches a first pass (+17 B minified in the core: joinPass, + // the pass-scoped join, the flush-end passTx clear; +10-11 B in + // boundaries: the catch's nearest-boundary stop); brotli layout. + // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the + // maintainer (2026-10-06). The cap is frozen again at 25.22 KB. + limit: "25.22 KB", alias }, { @@ -3055,7 +3075,17 @@ module.exports = [ // web=16,591 app=3,058). Cap at CI-measured + 10 B rounded up to 0.01 KB. path: "fixtures/compiled/hydrating.jsx", compile: { hydratable: true }, - limit: "30.93 KB", + // Size-Exception (#3824, 2026-10-06): 30.93 -> 30.97 KB, + // measured at 30,956 B by CI against `next` @ 7acc0390e's 30,962 + // (-6 B; 26 B over the cap; +28 B minified) — A29's boundary + // exemption restored under L2 (#3540): a fresh `Loading` over a held + // value shows its fallback in a flush, and catches content bound under it; only the nearest loading boundary that has not shown + // content catches a first pass (+17 B minified in the core: joinPass, + // the pass-scoped join, the flush-end passTx clear; +10-11 B in + // boundaries: the catch's nearest-boundary stop); brotli layout. + // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the + // maintainer (2026-10-06). The cap is frozen again at 30.97 KB. + limit: "30.97 KB", alias }, { @@ -3422,6 +3452,16 @@ module.exports = [ // +16, observeFlight first-observer hold +17); brotli layout. Cap set at // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer // (2026-10-05). The cap is frozen again at 44.84 KB. + // Size-Exception (#3824, 2026-10-06): 44.84 -> 44.90 KB (floor-caps.json), + // measured at 44,883 B by CI against `next` @ 7acc0390e's 44,820 + // (+63 B; 43 B over the cap; +28 B minified) — A29's boundary + // exemption restored under L2 (#3540): a fresh `Loading` over a held + // value shows its fallback in a flush, and catches content bound under it; only the nearest loading boundary that has not shown + // content catches a first pass (+17 B minified in the core: joinPass, + // the pass-scoped join, the flush-end passTx clear; +10-11 B in + // boundaries: the catch's nearest-boundary stop); brotli layout. + // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the + // maintainer (2026-10-06). The cap is frozen again at 44.90 KB. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], alias: pageAlias }, @@ -3529,6 +3569,16 @@ module.exports = [ // lane re-stage +13, verdict pull +1). Cap set at measured + 10 B rounded // up to 0.01 KB. Accepted by the maintainer (2026-10-05). The cap is // frozen again at 48.51 KB. + // Size-Exception (#3824, 2026-10-06): 48.51 -> 48.53 KB (floor-caps.json), + // measured at 48,513 B by CI against `next` @ 7acc0390e's 48,542 + // (-29 B; 3 B over the cap; +27 B minified) — A29's boundary + // exemption restored under L2 (#3540): a fresh `Loading` over a held + // value shows its fallback in a flush, and catches content bound under it; only the nearest loading boundary that has not shown + // content catches a first pass (+17 B minified in the core: joinPass, + // the pass-scoped join, the flush-end passTx clear; +10-11 B in + // boundaries: the catch's nearest-boundary stop); brotli layout. + // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the + // maintainer (2026-10-06). The cap is frozen again at 48.53 KB. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], alias: pageAlias }, From 3ab71b0dc559ece8467322fb770be1a0278951be Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 03:34:13 -0700 Subject: [PATCH 05/10] chore(size): reset #3824's caps from CI with the recorded minified (#3822 format) Size-Exception (approved): hydrating + stores 28.85 KB, CSR observe 14.48 KB, compiled CSR 25.23 KB, compiled hydrating 30.95 KB, base page 44.86 KB, live page 48.56 KB, each CI brotli (Size run 37450070886) + 10 B with the minified from the same run. Co-authored-by: Cursor --- scripts/size/floor-caps.json | 8 +- scripts/size/scenarios.js | 138 ++++++++++++++++++++--------------- 2 files changed, 84 insertions(+), 62 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 0643fb10a..0baa29822 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -12,12 +12,12 @@ "minified": 52567 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "44.90 KB", - "minified": 145607 + "cap": "44.86 KB", + "minified": 145627 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "48.53 KB", - "minified": 157569 + "cap": "48.56 KB", + "minified": 157589 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index a26349e30..31b19af85 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -2115,18 +2115,20 @@ module.exports = [ // unchanged at 28.87 KB, recorded minified 91,625 B (first record); // CI-measured at 28,888 B (91,625 B minified). Lower only: cap at measured // + 10 B rounded up to 0.01 KB; recorded minified never raised. - // Size-Exception (#3824, 2026-10-06): 28.87 -> 28.90 KB, - // measured at 28,881 B by CI against `next` @ 7acc0390e's 28,903 - // (-22 B; 11 B over the cap; +27 B minified) — A29's boundary - // exemption restored under L2 (#3540): a fresh `Loading` over a held - // value shows its fallback in a flush, and catches content bound under it; only the nearest loading boundary that has not shown - // content catches a first pass (+17 B minified in the core: joinPass, - // the pass-scoped join, the flush-end passTx clear; +10-11 B in - // boundaries: the catch's nearest-boundary stop); brotli layout. - // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the - // maintainer (2026-10-06). The cap is frozen again at 28.90 KB. - limit: "28.90 KB", - capMinified: 91632, + // Size-Exception (#3824, 2026-10-06): 28.87 -> 28.85 KB, recorded minified + // 91,625 -> 91,652 B; measured at 28,836 B (91,652 B minified) by CI (Size + // run 37450070886) against `next` @ b987c41f4's 28,888 (-52 B; 34 B under + // the cap; +27 B minified) — A29's boundary exemption restored under L2 + // (#3540): a fresh `Loading` over a held value shows its fallback in a + // flush, and catches content bound under it; only the nearest loading + // boundary that has not shown content catches a first pass (+17 B minified + // in the core: joinPass, the pass-scoped join, the flush-end passTx clear; + // +10-11 B in boundaries: the catch's nearest-boundary stop); brotli + // layout. Cap set at measured + 10 B rounded up to 0.01 KB, with the + // minified measured in the same run. Accepted by the maintainer + // (2026-10-06). The cap is frozen again at 28.85 KB. + limit: "28.85 KB", + capMinified: 91652, alias }, { @@ -2629,8 +2631,20 @@ module.exports = [ // unchanged at 14.46 KB, recorded minified 41,091 B (first record); // CI-measured at 14,449 B (41,091 B minified). Lower only: cap at measured // + 10 B rounded up to 0.01 KB; recorded minified never raised. - limit: "14.46 KB", - capMinified: 41091, + // Size-Exception (#3824, 2026-10-06): 14.46 -> 14.48 KB, recorded minified + // 41,091 -> 41,119 B; measured at 14,468 B (41,119 B minified) by CI (Size + // run 37450070886) against `next` @ b987c41f4's 14,449 (+19 B; 8 B over the + // cap; +28 B minified) — A29's boundary exemption restored under L2 + // (#3540): a fresh `Loading` over a held value shows its fallback in a + // flush, and catches content bound under it; only the nearest loading + // boundary that has not shown content catches a first pass (+17 B minified + // in the core: joinPass, the pass-scoped join, the flush-end passTx clear; + // +10-11 B in boundaries: the catch's nearest-boundary stop); brotli + // layout. Cap set at measured + 10 B rounded up to 0.01 KB, with the + // minified measured in the same run. Accepted by the maintainer + // (2026-10-06). The cap is frozen again at 14.48 KB. + limit: "14.48 KB", + capMinified: 41119, alias: observeAlias }, { @@ -3104,18 +3118,20 @@ module.exports = [ // unchanged at 25.13 KB, recorded minified 78,898 B (first record); // CI-measured at 25,129 B (78,898 B minified). Lower only: cap at measured // + 10 B rounded up to 0.01 KB; recorded minified never raised. - // Size-Exception (#3824, 2026-10-06): 25.13 -> 25.22 KB, - // measured at 25,204 B by CI against `next` @ 7acc0390e's 25,073 - // (+131 B; 74 B over the cap; +28 B minified) — A29's boundary - // exemption restored under L2 (#3540): a fresh `Loading` over a held - // value shows its fallback in a flush, and catches content bound under it; only the nearest loading boundary that has not shown - // content catches a first pass (+17 B minified in the core: joinPass, - // the pass-scoped join, the flush-end passTx clear; +10-11 B in - // boundaries: the catch's nearest-boundary stop); brotli layout. - // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the - // maintainer (2026-10-06). The cap is frozen again at 25.22 KB. - limit: "25.22 KB", - capMinified: 78906, + // Size-Exception (#3824, 2026-10-06): 25.13 -> 25.23 KB, recorded minified + // 78,898 -> 78,926 B; measured at 25,218 B (78,926 B minified) by CI (Size + // run 37450070886) against `next` @ b987c41f4's 25,129 (+89 B; 88 B over + // the cap; +28 B minified) — A29's boundary exemption restored under L2 + // (#3540): a fresh `Loading` over a held value shows its fallback in a + // flush, and catches content bound under it; only the nearest loading + // boundary that has not shown content catches a first pass (+17 B minified + // in the core: joinPass, the pass-scoped join, the flush-end passTx clear; + // +10-11 B in boundaries: the catch's nearest-boundary stop); brotli + // layout. Cap set at measured + 10 B rounded up to 0.01 KB, with the + // minified measured in the same run. Accepted by the maintainer + // (2026-10-06). The cap is frozen again at 25.23 KB. + limit: "25.23 KB", + capMinified: 78926, alias }, { @@ -3150,18 +3166,20 @@ module.exports = [ // unchanged at 30.93 KB, recorded minified 99,198 B (first record); // CI-measured at 30,957 B (99,198 B minified). Lower only: cap at measured // + 10 B rounded up to 0.01 KB; recorded minified never raised. - // Size-Exception (#3824, 2026-10-06): 30.93 -> 30.97 KB, - // measured at 30,956 B by CI against `next` @ 7acc0390e's 30,962 - // (-6 B; 26 B over the cap; +28 B minified) — A29's boundary - // exemption restored under L2 (#3540): a fresh `Loading` over a held - // value shows its fallback in a flush, and catches content bound under it; only the nearest loading boundary that has not shown - // content catches a first pass (+17 B minified in the core: joinPass, - // the pass-scoped join, the flush-end passTx clear; +10-11 B in - // boundaries: the catch's nearest-boundary stop); brotli layout. - // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the - // maintainer (2026-10-06). The cap is frozen again at 30.97 KB. - limit: "30.97 KB", - capMinified: 99206, + // Size-Exception (#3824, 2026-10-06): 30.93 -> 30.95 KB, recorded minified + // 99,198 -> 99,226 B; measured at 30,940 B (99,226 B minified) by CI (Size + // run 37450070886) against `next` @ b987c41f4's 30,957 (-17 B; 10 B over + // the cap; +28 B minified) — A29's boundary exemption restored under L2 + // (#3540): a fresh `Loading` over a held value shows its fallback in a + // flush, and catches content bound under it; only the nearest loading + // boundary that has not shown content catches a first pass (+17 B minified + // in the core: joinPass, the pass-scoped join, the flush-end passTx clear; + // +10-11 B in boundaries: the catch's nearest-boundary stop); brotli + // layout. Cap set at measured + 10 B rounded up to 0.01 KB, with the + // minified measured in the same run. Accepted by the maintainer + // (2026-10-06). The cap is frozen again at 30.95 KB. + limit: "30.95 KB", + capMinified: 99226, alias }, { @@ -3537,16 +3555,18 @@ module.exports = [ // unchanged at 44.84 KB, recorded minified 145,599 B (first record); // CI-measured at 44,864 B (145,599 B minified). Lower only: cap at measured // + 10 B rounded up to 0.01 KB; recorded minified never raised. - // Size-Exception (#3824, 2026-10-06): 44.84 -> 44.90 KB (floor-caps.json), - // measured at 44,883 B by CI against `next` @ 7acc0390e's 44,820 - // (+63 B; 43 B over the cap; +28 B minified) — A29's boundary - // exemption restored under L2 (#3540): a fresh `Loading` over a held - // value shows its fallback in a flush, and catches content bound under it; only the nearest loading boundary that has not shown - // content catches a first pass (+17 B minified in the core: joinPass, - // the pass-scoped join, the flush-end passTx clear; +10-11 B in - // boundaries: the catch's nearest-boundary stop); brotli layout. - // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the - // maintainer (2026-10-06). The cap is frozen again at 44.90 KB. + // Size-Exception (#3824, 2026-10-06): 44.84 -> 44.86 KB (floor-caps.json), + // recorded minified 145,599 -> 145,627 B; measured at 44,842 B (145,627 B + // minified) by CI (Size run 37450070886) against `next` @ b987c41f4's + // 44,864 (-22 B; 2 B over the cap; +28 B minified) — A29's boundary + // exemption restored under L2 (#3540): a fresh `Loading` over a held value + // shows its fallback in a flush, and catches content bound under it; only + // the nearest loading boundary that has not shown content catches a first + // pass (+17 B minified in the core: joinPass, the pass-scoped join, the + // flush-end passTx clear; +10-11 B in boundaries: the catch's + // nearest-boundary stop); brotli layout. Cap set at measured + 10 B rounded + // up to 0.01 KB, with the minified measured in the same run. Accepted by + // the maintainer (2026-10-06). The cap is frozen again at 44.86 KB. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -3660,16 +3680,18 @@ module.exports = [ // unchanged at 48.51 KB, recorded minified 157,562 B (first record); // CI-measured at 48,527 B (157,562 B minified). Lower only: cap at measured // + 10 B rounded up to 0.01 KB; recorded minified never raised. - // Size-Exception (#3824, 2026-10-06): 48.51 -> 48.53 KB (floor-caps.json), - // measured at 48,513 B by CI against `next` @ 7acc0390e's 48,542 - // (-29 B; 3 B over the cap; +27 B minified) — A29's boundary - // exemption restored under L2 (#3540): a fresh `Loading` over a held - // value shows its fallback in a flush, and catches content bound under it; only the nearest loading boundary that has not shown - // content catches a first pass (+17 B minified in the core: joinPass, - // the pass-scoped join, the flush-end passTx clear; +10-11 B in - // boundaries: the catch's nearest-boundary stop); brotli layout. - // Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by the - // maintainer (2026-10-06). The cap is frozen again at 48.53 KB. + // Size-Exception (#3824, 2026-10-06): 48.51 -> 48.56 KB (floor-caps.json), + // recorded minified 157,562 -> 157,589 B; measured at 48,544 B (157,589 B + // minified) by CI (Size run 37450070886) against `next` @ b987c41f4's + // 48,527 (+17 B; 34 B over the cap; +27 B minified) — A29's boundary + // exemption restored under L2 (#3540): a fresh `Loading` over a held value + // shows its fallback in a flush, and catches content bound under it; only + // the nearest loading boundary that has not shown content catches a first + // pass (+17 B minified in the core: joinPass, the pass-scoped join, the + // flush-end passTx clear; +10-11 B in boundaries: the catch's + // nearest-boundary stop); brotli layout. Cap set at measured + 10 B rounded + // up to 0.01 KB, with the minified measured in the same run. Accepted by + // the maintainer (2026-10-06). The cap is frozen again at 48.56 KB. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], From 431c2dda9bfdd645dc3c934963d62c089dd5b953 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 11:54:42 -0700 Subject: [PATCH 06/10] =?UTF-8?q?proto(signals):=20A29's=20boundary=20scop?= =?UTF-8?q?e=20=E2=80=94=20a=20boundary=20that=20has=20not=20shown=20conte?= =?UTF-8?q?nt=20owns=20its=20subtree=20(#3540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prototype for the 2026-10-06 ruling: membership is decided by the boundary scope, not per pass. A read of a held node from under a loading boundary that has not shown content, or that `on` re-armed, joins nothing: the reader is the hold's stale reader (re-derived at its commit) and the read is pending, so the boundary shows its fallback. Exempt when the boundary's own display is the hold's (the flush joined it, the output is held by it, or the mounting pass joined it): the boundary then appears at the commit. Replaces the per-pass attempt (passTx exemption in joinPassTx, the first-pass marker, the creation-branch pending report, the prune uninitialized keep, and the output's A29 exemption). LOADING_ON_OUTSIDE_HOLD reports only a re-arm whose swap is held. Not for #3824 as-is; the maintainer decides from the report. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/fresh-loading-fallback-in-flush.md | 3 +- documentation/solid-2.0/05-async-data.md | 2 + packages/signals/docs/RULES-INDEX.md | 4 +- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 2 +- packages/signals/src/boundaries.ts | 111 +- packages/signals/src/core/async.ts | 10 +- packages/signals/src/core/core.ts | 51 +- packages/signals/src/core/scheduler.ts | 28 +- packages/signals/src/store/store.ts | 9 +- .../loading-fallback-in-flush-3540.test.ts | 1234 +++++++++++------ .../loading-on-frame-following-3540.test.ts | 71 + packages/solid/CHEATSHEET.md | 2 +- packages/solid/src/client/flow.ts | 12 +- 13 files changed, 1015 insertions(+), 524 deletions(-) diff --git a/.changeset/fresh-loading-fallback-in-flush.md b/.changeset/fresh-loading-fallback-in-flush.md index ef486c3c1..c12c435b0 100644 --- a/.changeset/fresh-loading-fallback-in-flush.md +++ b/.changeset/fresh-loading-fallback-in-flush.md @@ -1,5 +1,6 @@ --- "@solidjs/signals": patch +"solid-js": patch --- -A fresh `Loading` mounted over a held value shows its fallback in a flush too (#3540 under L2): a first pass a loading boundary that has not shown content catches is the boundary's, not the tick's, so the mount publishes and the content reveals at the commit. Content bound by a render effect under the boundary is collected, so the boundary no longer reveals empty content. +A loading boundary that has not shown content — or that an `on` change has re-armed — owns its subtree (#3540 under L2). Content under it that reads a held value or waits on its first loads is pending: the boundary shows its fallback, no hold waits for it, and it re-derives at the hold's commit. A fresh `Loading` mounted over a held value shows its fallback in a flush too, and content bound by a render effect under it no longer reveals empty. Committed content under an `on`-re-armed boundary that reads a value held by another change now waits behind the fallback instead of holding the change. A boundary mounted as part of a hold still appears at that hold's commit, and outside such a boundary nothing changes. DEV's `LOADING_ON_OUTSIDE_HOLD` reports only a re-arm whose fallback is actually held; the `Loading` `on` docs say which data holds the change. diff --git a/documentation/solid-2.0/05-async-data.md b/documentation/solid-2.0/05-async-data.md index 63c67488c..a3193823c 100644 --- a/documentation/solid-2.0/05-async-data.md +++ b/documentation/solid-2.0/05-async-data.md @@ -81,6 +81,8 @@ The shell keeps showing A until `product(2)` lands; the spinner arrives with B, One shape shows no fallback at all: when the data the boundary is waiting on is also read outside it (a sibling `` over the same `comments(id)`, an `isPending` on it in the header), or the write's `action` stays open until the data lands. The frame waits on that read, so by the time it commits the content is ready and the fallback was never needed. The first of these is structural — no ordering of the flights can show that fallback — and in development the `LOADING_ON_OUTSIDE_HOLD` diagnostic names the source; the fix is to move the outside read under the boundary so one hold owns the data. The second is a race the fallback may still win (an action that ends first shows it with the commit), and is not reported: during an `action`, show the wait with `isPending()` or an optimistic value, which is what a hold's stale content is for. The old content is on screen and valid the whole time; a `Loading` fallback says it is not. +Both shapes are about data the change itself starts loading. The re-armed boundary owns its content, so data still loading from an earlier change (a flight another write started, or an earlier `action`'s held write) does not hold this one: the fallback shows now, even where that data is also read outside the boundary, and nothing is reported. + It is possible to show the fallback beside the still-held frame anyway: a display-ahead read in `on` — `latest(id)`, `isPending()`, an optimistic signal — says the change is already on screen, so the fallback lands there too: ``` diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index adee7f3b1..800de63b3 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:191` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:207` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:243` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:243` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:215` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:69` | async.ts×3 constants.ts×1 lanes.ts×6 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:79` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:295` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:93` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | boundaries.ts×2 action.ts×1 constants.ts×1 core.ts×10 effect.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | boundaries.ts×2 action.ts×1 async.ts×1 constants.ts×1 core.ts×11 effect.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:251` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:125` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index d48dfdff0..fc9374b52 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -55,7 +55,7 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t - **A first load derived from a hold lands into it** (#3800): a first load born pending in a frame that read a hold lands into that hold if the hold is still live, else as its own commit; the hold commits without it. **Pending** — `fix/create-time-holds` (`91e474506`); [#3800](https://github.com/solidjs/solid/issues/3800) is open. - **A first pass under a loading boundary that has not shown content is the boundary's, not the tick's** — in a flush as outside one (A29's boundary exemption, #3540). The out-of-flush form is on `next`; the in-flush form (a fresh `Loading` over a held value shows its fallback in a flush) is **pending** — `fix/create-time-holds` (`ca71e5d9d`). -**The boundary scope, in a flush (2026-10-06, amendment — #3540's L2 regression; the bullet above is unchanged).** The in-flush form lands with `fix/l2-3540-fresh-loading`, standalone of `fix/create-time-holds`. A first pass (`STATUS_UNINITIALIZED`) that reads a hold asks its nearest loading boundary (`GlobalQueue._catch`, with `STATUS_UNINITIALIZED` in its flags; no new hook). Only a boundary that has not shown content catches it; a boundary that has (re-armed by `on` or not) neither catches nor records it, and no boundary past the nearest is asked, so which boundaries catch what is otherwise unchanged. When the boundary catches it, the pass joins pass-scoped (`passTx`), as a mount outside a flush does, instead of joining the flush: the Show that mounted the boundary publishes, and the boundary shows its fallback. A pass nothing catches joins the flush as before, so a derivation outside the boundary in the same flush still holds the tick. A render effect born held under the boundary (content bound in JSX — the tree never reads the held value) is caught the same way, so the boundary does not reveal empty content, in a flush or out of one; its output's first pass shows the fallback whenever it has collected readers. A flush that set `passTx` clears it at its end, so the boundary's hold does not merge with a later tick's. The `direction-rule-probe` pins are unchanged. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (in a flush: through a memo, directly, bound, nested under a Loading that has shown content; a derivation outside the boundary still holds the tick; the boundary's hold stays its own; on a hold, from mainline: the same three content shapes; under an inner Loading that has not shown content, the outer revealed one records nothing, is not pending, and the commit does not wait for the first load; directly under a revealed Loading, or under a revealed one inside one that has not shown content, not caught, as before; `on` boundaries revealed (re-armed or not) as before, not yet revealed as a Loading without `on`); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. +**A boundary that has not shown content owns its subtree — A29's boundary scope (2026-10-06, maintainer ruling; amendment — #3540's L2 regression; the bullet above and A15 are unchanged outside such a boundary).** The ruling, in substance: content under a loading boundary that has not shown content — or that an `on` change has re-armed — belongs to that boundary, not to any hold it reads, whichever computation does the reading (a creation pass, a re-running mount effect, the boundary's own render, or committed content under a re-armed boundary). While that content waits for a hold or its own first loads, it is pending: the boundary shows its fallback, and no hold waits for it. Whether the fallback is seen depends on whether the boundary's own mount is held: a boundary mounted as part of a hold (`` with `x` held, or nested in held content) appears at that hold's commit. Outside such a boundary, A15 and the direction rule are unchanged: committed work that reads a hold joins it, never-committed work waits on every hold it read, and if no boundary would catch it, it stays hidden with the hold. The `on` case is the one place the rule overrides A15 for committed content: committed content under a re-armed boundary that reads a hold waits behind the fallback rather than joining the hold. Membership is decided by the scope, not by the pass: the reading computation's nearest collecting loading boundary (the one `catchStatus` would catch at) owns the read, unless the boundary's own display is the hold's anyway — the flush has joined that hold, the boundary's output is held by it while not showing content, or the pass that is mounting the boundary has joined it. Readers that waited re-derive at the hold's commit as A15's stale readers do; no reader is registered after the commit. `isPending` of the boundary's value is false while its fallback shows (nothing stale is shown), and content that has not loaded under it has no value (`latest` throws, as for any first load). Pre-L2 served the committed value in these shapes (a fresh boundary in a flush, and committed content under a re-armed boundary over a held write — `content 0`); under A29 that tears, and the fallback is the ruled display. **Mechanism:** `GlobalQueue._owns(c, t)` (boundaries.ts, `owns`: `catcherOf` over `boundaryOf` — the output carries its boundary scope, `_scope`), asked by `read()` for a held source (`scopedRead`: the reader is a stale reader of `t` and the read throws `NotReadyError(source)`, so the boundary collects it), by `propagateStatus` before joining a pending memo into a hold, and by `recompute`'s head before joining a held node's pass (an owned held node leaves the transaction). Lanes and verdict readers are not scoped. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (fresh mounts in a flush and from mainline: memo, direct and bound content; nested under a boundary that has shown content; a mount that joined the hold appears at its commit; the boundary's hold stays its own; catchers at any depth — unrevealed, re-armed outer or nearest, revealed, plain; committed content under a re-armed or plain boundary; boundaries mounted by the hold, top-level or nested; no catcher; `isPending` and `latest` on each); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. **Not yet one-way (recorded, not ruled).** The frame of a tick is still one transaction (the tick ruling above stands), so two shapes keep the hold waiting on first loads it never needed: diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 994890a7f..e47698e9f 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -49,6 +49,7 @@ import { EFFECT_USER, NOT_PENDING, REACTIVE_DISPOSED, + REACTIVE_JOINED, REACTIVE_LANE_READ, REACTIVE_ZOMBIE, STATUS_ERROR, @@ -77,7 +78,7 @@ import { type Transaction } from "./core/scheduler.js"; import { attrHooks } from "./core/attribution-hooks.js"; -import type { Computed, Owner, Signal } from "./core/types.js"; +import type { Computed, Owner, Root, Signal } from "./core/types.js"; import { flatten } from "./flatten.js"; import { accessor, type Accessor } from "./signals.js"; @@ -122,6 +123,11 @@ interface Boundary { _show: (b: Boundary) => unknown; } +/** The output is created beside the boundary, not under it; it is the + * boundary's own render all the same (`owns`): `_scope` is the owner whose + * context names the boundary. */ +type BoundaryOutput = Computed & { _scope?: Owner }; + /** Context key: the nearest boundary of a node, inherited at creation. */ const BOUNDARY = Symbol(__DEV__ ? "boundary" : ""); /** Context key: the reveal controller a Loading boundary created here is a @@ -200,16 +206,45 @@ export function redraw(b: Boundary): void { } } +/** The boundary a status from `node` stops at — the nearest of its type that + * is collecting — or none (the root hears of it). Side-effect free; + * `catchStatus` catches there. */ +function catcherOf(node: Owner, flags: number): Boundary | undefined { + let b = boundaryOf(node); + while (b !== undefined && !(b._type & flags && isCollecting(b))) b = b._parent ?? undefined; + return b; +} + +/** GlobalQueue._owns — A29's boundary scope (2026-10-06): a loading boundary + * that has not shown content, or that `on` re-armed, owns its subtree. A + * pass under it reading a node `t` holds does not join `t`: the content is + * pending under that boundary — it is the boundary that would catch it — + * and no hold waits for it. Not when the boundary's display is `t`'s + * anyway — then its fallback is never seen, and the content enters `t` to + * appear with its commit: the flush is `t`'s (everything it stages lands + * with `t`, the swap included); the output is held by `t` showing its + * fallback or never committed (the boundary was mounted, or re-armed, as + * part of the hold); or, mid-mount, the pass creating the boundary read + * `t`. */ +function owns(c: Computed, t: Transaction): boolean { + const b = catcherOf((c as BoundaryOutput)._scope ?? c, STATUS_PENDING); + if (b === undefined) return false; + if (flushTransaction !== null && resolveTx(flushTransaction) === t) return false; + const o = b._output; + if (o !== null) + return !( + o._config & CONFIG_HELD && + (b._fallback || o._statusFlags & STATUS_UNINITIALIZED) && + txOf(o) === t + ); + const p = (b._owner as Root)._parentComputed as Computed | null; + return !(p !== null && p._flags & REACTIVE_JOINED && passTx !== null && resolveTx(passTx) === t); +} + /** GlobalQueue._catch: status from a frame reader, nearest boundary first. * A loading boundary on the way records a pending reader whether or not it - * catches it (its `on` may collect it later). A pass that read a hold - * (`joinPass`: STATUS_UNINITIALIZED in `flags`) is asked too. A first pass - * is not pending — it has never committed — and only A29's boundary - * exemption catches it (#3540): the nearest loading boundary, if it has not - * shown content. One that has, re-armed by `on` or not, neither catches nor - * records it (it joins the hold like any reader of committed content), no - * boundary past the nearest is asked, and a committed pass is not caught. */ -function catchStatus(node: Computed, flags: number, error?: unknown): boolean { + * catches it (its `on` may collect it later). */ +function catchStatus(node: Computed, flags: number, error: unknown): boolean { if (flags === 0) { // A status cleared. Judged by the node's status now, against each // boundary's own rule (`unsettled`) — the error path clears pending @@ -232,13 +267,6 @@ function catchStatus(node: Computed, flags: number, error?: unknown): boole } 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; @@ -352,7 +380,8 @@ function boundarySeam(): void { // A parked frame's boundary (zombie) keeps its state for its revival. if (flags & REACTIVE_ZOMBIE) continue; prune(b, false); - if (__DEV__ && b._armed && !b._initialized && !b._ahead) reportOutsideHold(b); + if (__DEV__ && b._armed && !b._initialized && !b._ahead && b._output!._config & CONFIG_HELD) + reportOutsideHold(b); b._armed = false; if (b._readers.size === 0) { collecting.delete(b); @@ -362,9 +391,11 @@ function boundarySeam(): void { } } -/** DEV, at the re-arm that flipped a boundary to its fallback: a source it - * now waits on is also read by a frame reader outside it — the frame waits - * on the very source, and the fallback can never be seen. Structural, so +/** DEV, at the re-arm that flipped a boundary to its fallback, its swap held: + * a source it now waits on is also read by a frame reader outside it — the + * frame waits on the very source, and the fallback can never be seen. (A + * swap nothing holds shows now: the boundary owns its content, A29's + * boundary scope, whatever else reads the source.) Structural, so * reported once, at the change, naming the source; a display-ahead arm * (`latest()` in `on`) is the user's choice and not reported. */ function reportOutsideHold(b: Boundary): void { @@ -607,29 +638,11 @@ function createBoundary( // one; a loading boundary showing content forwards its pending. throw tree._x!._error; } - if (isCollecting(b)) { - // A29's boundary exemption (#3540): a boundary MOUNTED over a held - // value (its first pass; a first pass under it that read a hold was - // collected, `joinPass` — the tree's own, or a render effect's that - // binds the content) shows its fallback now and the content at the - // commit — entering the transaction would make the output itself - // born held, and nothing would show until the commit. The seam - // keeps a held reader until it is committed, then re-derives this - // pass. A boundary with a committed value reads a held tree and - // enters: the outside sees its committed value until the landing, - // which reveals the content — a fallback staged earlier is replaced - // ahead of the commit and never shown. - const self = getOwner() as Computed; - if ( - b._readers.size !== 0 && - self._statusFlags & STATUS_UNINITIALIZED && - !(self._config & CONFIG_HELD) - ) - return fallback(b); - // Readers under it still unready: the fallback, the tree untouched. - // The seam re-derives this pass when they settle. - if (prune(b, true) !== 0) return fallback(b); - } + // Readers under it still unready: the fallback, the tree untouched. + // The seam re-derives this pass when they settle. (A tree it owns + // that read a hold is pending, `owns`; one that is held is part of + // the hold that mounted the boundary, and the output enters it.) + if (isCollecting(b) && prune(b, true) !== 0) return fallback(b); let value: T; try { value = read(tree); @@ -672,6 +685,7 @@ function createBoundary( __OBSERVE__ ? { name: "value", _noSnapshot: true } : { _noSnapshot: true } ); output._config |= CONFIG_REDERIVE; + (output as BoundaryOutput)._scope = owner; b._output = output; return accessor(output); } @@ -723,9 +737,13 @@ const ERROR_SIGNAL = { ownedWrite: true, _noSnapshot: true } as const; * if nothing is pending, the notification is a no-op. The fallback lands * with the same frame as the change that caused it — now, when nothing * else holds that frame; together with the rest of the new page during a - * held navigation, not before it. If the same data is also read outside - * the boundary, the frame waits on it and the fallback can never be seen - * (DEV warns `LOADING_ON_OUTSIDE_HOLD`); the fix is structural — move the + * held navigation, not before it. The re-armed boundary owns its content: + * data the change did not start loading (a flight or a held write from an + * earlier change) shows the fallback now rather than holding the change, + * even where it is also read outside the boundary. If the change itself + * starts the data loading and the data is also read outside the boundary, + * the frame waits on it and the fallback can never be seen (DEV warns + * `LOADING_ON_OUTSIDE_HOLD`); the fix is structural — move the * outside read under the boundary so one hold owns the data. A frame held * past the content's landing by something else (the write's action, other * pending data) also shows no fallback; that is a race the fallback may @@ -787,6 +805,7 @@ export function createErrorBoundary( // Installed at module evaluation — present exactly when something imports a // boundary. An app without one pays the three null checks and nothing else. GlobalQueue._catch = catchStatus; +GlobalQueue._owns = owns; GlobalQueue._hidden = hidden; GlobalQueue._boundarySeam = boundarySeam; GlobalQueue._heldRun = heldRun; diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index e9556d66b..7d0f0d60d 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -896,7 +896,8 @@ export function propagateStatus( if ( passLane === null && (sub._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && - globalQueue._running + globalQueue._running && + !GlobalQueue._owns?.(sub, txOf(sub)) ) joinFuture(txOf(sub)); return; @@ -918,11 +919,14 @@ export function propagateStatus( // recomputing it, and the propagation itself enters the memo's // transaction — the flight flows into a memo that transaction // holds, so the write that started it is held with it. A render - // effect's membership is its pass's (`notify`), never sticky. + // effect's membership is its pass's (`notify`), never sticky. A + // memo a loading boundary owns now is that boundary's (A29's + // boundary scope): its pending is caught there, held by nothing. if ( status === STATUS_PENDING && (sub._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && - !(sub as any)._type + !(sub as any)._type && + !GlobalQueue._owns?.(sub, txOf(sub)) ) joinFuture(txOf(sub)); } diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 9806100e9..fb2d4ef72 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -302,6 +302,17 @@ export function recompute(el: Computed, create: boolean = false): void { // a sync write (#3460). The pass is the lane's if it reads the lane's // world (`read`), and has left it otherwise. if (tx._lane) { + } else if (GlobalQueue._owns?.(el, tx)) { + // Under a loading boundary that owns it now (re-armed since it + // joined — A29's boundary scope) it is the boundary's, not the + // hold's: it leaves the transaction, its staging there void, and a + // read of the hold makes this pass pending under the boundary + // (`scopedRead`). + el._pendingValue = NOT_PENDING; + el._config &= ~CONFIG_HELD; + el._x!._transaction = null; + const i = tx._nodes.indexOf(el); + if (i >= 0) tx._nodes.splice(i, 1); } else if (isEffect !== EFFECT_RENDER && !(el._config & CONFIG_VERDICT)) joinPassTx(tx); else if (tx !== flushTransaction && !(el._statusFlags & STATUS_UNINITIALIZED)) { // Published mainline, it is not held: the frame this pass builds is @@ -943,18 +954,32 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension { * reader is a render effect. A render effect is the frame, not a derivation * (rule 3): in that transaction's own flush, or born into it (uninitialized, * A29), it reads the staged value and holds nothing of its own; otherwise it - * reads the committed value instead (`frameRead`). A node born into the - * future has no committed value: every reader joins it. A first pass is - * something not ready under a loading boundary that has not shown content: - * the boundary collects it and shows its fallback, and the pass is the - * boundary's, not the tick's (A29's boundary exemption, #3540). */ + * reads the committed value instead (`frameRead`). */ function joinPass(c: Computed, el: Signal | Computed): void { c._flags |= REACTIVE_JOINED; + if ((c as any)._type !== EFFECT_RENDER) joinPassTx(txOf(el)); +} + +/** A29's boundary scope (2026-10-06): a read of a held node from under a + * loading boundary that owns its subtree (`GlobalQueue._owns`) — whichever + * pass reads it — joins nothing and serves nothing: the content is pending + * there, the boundary catches it and shows its fallback, and it waits for + * the landing as the hold's stale reader (`_reruns`: re-derived on the + * committed world). Ahead of every hold read (`joinPass`, `frameRead`, a + * derive's draft). Not lane work or a verdict reader: they read the + * screen, not the hold (`frameRead`). */ +export function scopedRead(c: Owner | null, el: Signal | Computed): void { if ( - (el as Computed)._statusFlags & STATUS_UNINITIALIZED || - (c as any)._type !== EFFECT_RENDER + GlobalQueue._owns === undefined || + c === null || + passLane !== null || + (c as Computed)._config & CONFIG_VERDICT ) - joinPassTx(txOf(el), GlobalQueue._catch?.(c, STATUS_PENDING | STATUS_UNINITIALIZED)); + return; + const t = txOf(el); + if (t._lane || !GlobalQueue._owns(c as Computed, t)) return; + staleReader(c as Computed, t); + throw new NotReadyError(el); } /** A15's stale reader (shared-hole and reveal corollaries): a render effect @@ -1624,6 +1649,7 @@ export function read(el: Signal | Computed): T { // future (REACTIVE_JOINED): a first pass that did is born held (A29), // wherever it was created. Lane work sees the screen (`frameRead`). if (el._config & CONFIG_HELD && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) { + scopedRead(c, el); if (frameRead(c as Computed, el)) return el._value as T; joinPass(c as Computed, el); } @@ -1661,9 +1687,12 @@ export function read(el: Signal | Computed): T { !(el._config & CONFIG_OVERRIDE) && !(c._config & CONFIG_CHILDREN_FORBIDDEN) ) { - if (owner._statusFlags & STATUS_UNINITIALIZED || !frameRead(c as Computed, el)) - joinPass(c as Computed, el); - else committed = true; + scopedRead(c, el); + if (owner._statusFlags & STATUS_UNINITIALIZED) { + (c as Computed)._flags |= REACTIVE_JOINED; + joinPassTx(txOf(el)); + } else if (frameRead(c as Computed, el)) committed = true; + else joinPass(c as Computed, el); } } // Lanes: a lane's node (after the pull — the node is current). NOT_PENDING diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index a386a6a38..ed82437c0 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -173,15 +173,11 @@ export let flushTransaction: Transaction | null = null; * into it, and nothing else in the tick is — a write made after the mount * is a mainline write, a render effect mounted beside it a stale reader. * Inside a flush, or in a tick that already has its transaction (an - * action's body), the frame joins instead (`flushTransaction`) — except a - * first pass a loading boundary that has not shown content catches (`own`, - * A29's boundary exemption, #3540): it is the boundary's, not the tick's, - * and a flush that has joined nothing keeps it pass-scoped as outside one. - * Cleared by the flush the join schedules, and at the end of a flush that - * set it. */ + * action's body), the frame joins instead (`flushTransaction`). Cleared by + * the flush the join schedules. */ export let passTx: Transaction | null = null; -export function joinPassTx(t: Transaction, own?: unknown): void { - if ((globalQueue._running && !own) || flushTransaction !== null) return joinFuture(t); +export function joinPassTx(t: Transaction): void { + if (globalQueue._running || flushTransaction !== null) return joinFuture(t); if (passTx === null) passTx = resolveTx(t); else merge(resolveTx(t), passTx); schedule(); @@ -662,15 +658,16 @@ export class GlobalQueue implements IQueue { // Boundaries (boundaries.ts): the display consumers between an observer // and the root. `_catch` — status from a frame reader, nearest boundary // first (true: caught, the root never hears of it; a clear — flags 0 — - // settles the reader there; a pass that read a hold — STATUS_UNINITIALIZED - // in flags, `joinPass` — is caught only as a first pass, by the nearest - // loading boundary if it has not shown content); `_hidden` — a frame - // reader behind a fallback is not on screen and holds nothing; - // `_boundarySeam` — the seam's sweep (readers gone or settled without a - // pass reveal; an `on` re-arm resolves). + // settles the reader there); `_hidden` — a frame reader behind a fallback + // is not on screen and holds nothing; `_boundarySeam` — the seam's sweep + // (readers gone or settled without a pass reveal; an `on` re-arm + // resolves). declare static _catch: - | ((node: Computed, flags: number, error?: unknown) => boolean) + | ((node: Computed, flags: number, error: unknown) => boolean) | undefined; + /** `_owns` — a pass reading a node `t` holds is under a loading boundary + * that owns it (A29's boundary scope): pending there, not `t`'s. */ + declare static _owns: ((c: Computed, t: Transaction) => boolean) | undefined; declare static _hidden: ((r: Computed) => boolean) | undefined; declare static _boundarySeam: (() => void) | undefined; // `_heldRun` — a queued run under a fallback-showing boundary waits for @@ -716,7 +713,6 @@ export class GlobalQueue implements IQueue { if (__DEV__) DEV.hooks.onUpdate?.(); } finally { this._running = false; - passTx = null; } } /** L2 — the seam: end of the pure phase. Commit this flush's staged nodes, diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 00d6d939e..ae891dcd6 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -53,6 +53,7 @@ import { ownedScopeWriteMessage, read as readNode, REACTIVE_WRITE_IN_OWNED_SCOPE_SIGNAL_MESSAGE, + scopedRead, setSignal, slotSignal, stagedRead, @@ -1535,16 +1536,20 @@ function readSource( if (writeOverride && !userWrite) { const leaf = key !== undefined ? target.n?.[key as any] : undefined; if (leaf !== undefined) { - if ((leaf._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD) + if ((leaf._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD) { + scopedRead(context, leaf); joinPassTx(txOf(leaf)); + } } else { const k = target.k; if ( k !== null && (k._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && (key === undefined || heldKeyChanged(target, key)) - ) + ) { + scopedRead(context, k); joinPassTx(txOf(k)); + } } } return pb; diff --git a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts index b6d1c9aad..c13ae93e3 100644 --- a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts +++ b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts @@ -1,13 +1,23 @@ /** - * #3540 under L2 — A29's boundary exemption, in a flush and on a hold. + * #3540 under L2: A29's boundary scope (maintainer, 2026-10-06). * - * A loading boundary that has not shown content mounted while a transaction - * holds what it reads shows its fallback now and its content at the commit: - * the first pass under it is the boundary's, not the tick's. A mount inside - * a flush (a Show opening) publishes with it; a derivation outside the - * boundary in the same flush still holds the tick. Content bound by a render - * effect (the tree never reads the held value) is collected too, so the - * boundary never reveals empty content. + * A loading boundary that has not shown content — or that an `on` change + * has re-armed — owns its subtree: content under it belongs to it, not to + * any hold it reads, whichever computation does the reading (a creation + * pass, a re-running mount effect, the boundary's own render, committed + * content under a re-armed boundary). While that content waits for a hold + * or its own first loads it is pending: the boundary shows its fallback, + * and no hold waits for it; it re-derives at the hold's commit. A boundary + * mounted as part of a hold appears at that hold's commit. Outside such a + * boundary, A15 and the direction rule are unchanged. + * + * Each case is traced on the pre-L2 core (41fdf9696) too; the notes say + * where it differs and the ruling that explains it. Sources: `held` — the + * content reads `x`, written in an action that is still running; `flight` + * — the content reads an async memo over `x`, refetching, which a revealed + * Loading elsewhere holds. Logs within a step are sorted; `o`/`i`/`f`: the + * readers recorded on the outer, inner and fresh boundaries; `pending o`: + * `isPending` of that boundary's value. */ import { describe, expect, it } from "vitest"; import { @@ -20,38 +30,85 @@ import { flush, getOwner, isPending, + latest, untrack } from "../src/index.js"; const tick = async () => { - for (let i = 0; i < 6; i++) await Promise.resolve(); + for (let i = 0; i < 8; i++) await Promise.resolve(); flush(); }; -function hold(write: () => void) { - let release!: () => void; - action(function* () { - write(); - yield new Promise(resolve => (release = resolve)); - })(); - flush(); - return () => release(); -} +/** `{fn()}`, created untracked + * as createComponent does. */ +const Loading = (fn: () => T, fallback: string, on?: () => unknown) => + untrack(() => createLoadingBoundary(fn, () => fallback, on ? { on } : undefined)); -/** `{fn()}`, created untracked as - * createComponent does. */ -const Loading = (fn: () => T, fallback: string) => - untrack(() => createLoadingBoundary(fn, () => fallback)); +/** The readers recorded on the boundary `owner` is under (internal). */ +function readersOf(owner: object | undefined): number | undefined { + if (!owner) return undefined; + const context = (owner as { _context: Record })._context; + for (const key of Object.getOwnPropertySymbols(context)) { + const b = context[key] as { _readers?: Set } | null; + if (b?._readers) return b._readers.size; + } + throw new Error("no boundary"); +} +type Source = "held" | "flight"; type Content = "memo" | "direct" | "bound"; -/** The boundary's content over `x`, logging into `log`. */ -function content(kind: Content, x: () => number, log: string[]) { +/** `x`, and what the content reads (see the header). */ +function world(source: Source, log: string[]) { + const [x, setX] = createSignal(0); + const pending: (() => void)[] = []; + let data: (() => number) | undefined; + if (source === "flight") { + data = createMemo(async () => { + const v = x(); + await new Promise(r => pending.push(r)); + return v; + }); + const holder = Loading(() => `holder ${data!()}`, "holder fallback"); + createRenderEffect(holder, v => { + log.push(v); + }); + } else + createRenderEffect(x, v => { + log.push(`x ${v}`); + }); + const settle = async () => { + while (pending.length) pending.shift()!(); + await tick(); + }; + /** Writes `x` and holds it; resolves to the release. */ + const begin = () => { + if (source === "flight") { + setX(1); + flush(); + return settle; + } + let release!: () => void; + action(function* () { + setX(1); + yield new Promise(r => (release = r)); + })(); + flush(); + return async () => { + release(); + await tick(); + }; + }; + return { read: () => (data ? data() : x()), settle, begin }; +} + +/** The content over `read`: through a memo, directly, or bound by a render + * effect (`

{m()}

` — the tree itself reads nothing). */ +function content(kind: Content, read: () => number, log: string[], tag = "content") { return () => { - if (kind === "direct") return `content ${x()}`; - const m = createMemo(() => `content ${x()}`); + if (kind === "direct") return `${tag} ${read()}`; + const m = createMemo(() => `${tag} ${read()}`); if (kind === "memo") return m(); - //

{m()}

: the content's binding reads the memo, the tree does not. createRenderEffect(m, v => { log.push(`text ${v}`); }); @@ -59,82 +116,138 @@ function content(kind: Content, x: () => number, log: string[]) { }; } -function mount(kind: Content, x: () => number, log: string[]) { - const view = Loading(content(kind, x, log), "fallback"); - createRenderEffect(view, v => { - log.push(`view ${v}`); - }); +type State = { log: string[]; readers: [string, number | undefined][]; pending: string[] }; +const format = (s: State) => + [ + [...s.log].sort().join(" · "), + ...(s.readers.length ? [s.readers.map(([k, n]) => k + n).join(" ")] : []), + ...(s.pending.length ? [`pending ${s.pending.join("")}`] : []) + ].join(" | "); + +/** Steps: the mounting flush, the hold's release, the source settling (and, + * for `gate`, the gate resolving). */ +async function trace( + steps: (() => Promise | void)[], + log: string[], + readers: () => [string, number | undefined][] = () => [], + pending: () => string[] = () => [] +) { + const out: string[] = []; + let seen = 0; + for (const step of steps) { + await step(); + out.push(format({ log: log.slice(seen), readers: readers(), pending: pending() })); + seen = log.length; + } + return out; } -const shapes = [ - ["memo", "through a memo"], - ["direct", "directly"], - ["bound", "bound by a render effect under the boundary"] -] as const; - -/** The commit's reveal: the content's own entries, in any order. */ -const revealed = (kind: Content) => - kind === "bound" ? ["text content 1", "view p"] : ["view content 1"]; +describe("#3540: a fresh Loading mounted over a held value", () => { + // A held / flight, in a flush (a Show opening) — pre-L2 shows the + // committed value (`open true · view content 0`) and updates at the + // commit; L2 never serves content the committed value of a held node + // (A29, it would tear): the boundary owns it, so it is pending and the + // fallback shows now (the #3540 ruling). `next` holds the whole mount + // (`open true` waits for the commit): the regression this fixes. From + // mainline: as pre-L2 and next — the bound content too (`

{m()}

`: + // the binding is pending under the boundary; `next` showed `p` with no + // text until the commit). + const expected: Record = { + "held memo flush": ["open true · view fallback", "view content 1 · x 1", ""], + "held memo mainline": ["view fallback", "view content 1 · x 1", ""], + "held direct flush": ["open true · view fallback", "view content 1 · x 1", ""], + "held direct mainline": ["view fallback", "view content 1 · x 1", ""], + "held bound flush": ["open true · view fallback", "text content 1 · view p · x 1", ""], + "held bound mainline": ["view fallback", "text content 1 · view p · x 1", ""], + "flight memo flush": ["open true · view fallback", "holder 1 · view content 1", ""], + "flight memo mainline": ["view fallback", "holder 1 · view content 1", ""], + "flight direct flush": ["open true · view fallback", "holder 1 · view content 1", ""], + "flight direct mainline": ["view fallback", "holder 1 · view content 1", ""], + "flight bound flush": ["open true · view fallback", "holder 1 · text content 1 · view p", ""], + "flight bound mainline": ["view fallback", "holder 1 · text content 1 · view p", ""] + }; + for (const source of ["held", "flight"] as Source[]) + for (const kind of ["memo", "direct", "bound"] as Content[]) + for (const where of ["flush", "mainline"]) + it(`${source}, content ${kind}, mounted from ${where === "flush" ? "a flush" : "mainline"}`, async () => { + const log: string[] = []; + const [open, setOpen] = createSignal(false); + let w!: ReturnType; + const mount = () => { + const view = Loading(content(kind, w.read, log), "fallback"); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + }; + createRoot(() => { + w = world(source, log); + if (where === "flush") + createRenderEffect( + () => (open() ? (mount(), true) : false), + v => { + log.push(`open ${v}`); + } + ); + }); + flush(); + await w.settle(); + const end = w.begin(); + log.length = 0; + const steps = [ + () => { + if (where === "flush") setOpen(true); + else createRoot(mount); + flush(); + }, + end, + w.settle + ]; + expect(await trace(steps, log)).toEqual(expected[`${source} ${kind} ${where}`]); + }); -describe("#3540 in a flush: a fresh Loading mounted over a held value shows its fallback now", () => { - function setup(kind: Content, nested = false) { - const [x, setX] = createSignal(0); - const [open, setOpen] = createSignal(false); - const log: string[] = []; - createRoot(() => { - createRenderEffect(x, v => { - log.push(`x ${v}`); - }); - // - const show = () => - createRenderEffect( - () => { - if (!open()) return false; - mount(kind, x, log); - return true; - }, - v => { - log.push(`open ${v}`); - } - ); - if (!nested) return show(); - // Under an outer Loading that has shown content. - const outer = Loading(() => (show(), "outer"), "outer fallback"); - createRenderEffect(outer, v => { - log.push(`outer ${v}`); + for (const source of ["held", "flight"] as Source[]) + it(`${source}, nested under an outer Loading that has shown content: the inner fallback now, the outer keeps its content`, async () => { + const log: string[] = []; + const [open, setOpen] = createSignal(false); + let w!: ReturnType; + createRoot(() => { + w = world(source, log); + const outer = Loading(() => { + createRenderEffect( + () => { + if (!open()) return false; + const view = Loading(content("memo", w.read, log), "fallback"); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + return true; + }, + v => { + log.push(`open ${v}`); + } + ); + return "outer"; + }, "outer fallback"); + createRenderEffect(outer, v => { + log.push(`outer ${v}`); + }); }); - }); - flush(); - if (nested) expect(log).toContain("outer outer"); - const release = hold(() => setX(1)); - log.length = 0; - return { setOpen, log, release }; - } - - for (const [kind, how] of shapes) - it(`content reads the held value ${how}: the mount publishes, the content reveals at the commit`, async () => { - const s = setup(kind); - s.setOpen(true); flush(); - expect(s.log).toEqual(["view fallback", "open true"]); - - s.release(); - await tick(); - expect(s.log.slice(2).sort()).toEqual(["x 1", ...revealed(kind)].sort()); + await w.settle(); + const end = w.begin(); + log.length = 0; + const steps = [() => (setOpen(true), flush()), end, w.settle]; + expect(await trace(steps, log)).toEqual([ + "open true · view fallback", + source === "held" ? "view content 1 · x 1" : "holder 1 · view content 1", + "" + ]); }); - it("nested under an outer Loading that has shown content: the inner fallback now, the outer keeps its content", async () => { - const s = setup("memo", true); - s.setOpen(true); - flush(); - expect(s.log).toEqual(["view fallback", "open true"]); - - s.release(); - await tick(); - expect(s.log.slice(2).sort()).toEqual(["view content 1", "x 1"]); - }); - - it("a derivation outside the boundary in the same flush still holds the tick (membership is the tick's)", async () => { + // The label, created in the mount, reads the hold and joins it, so the + // mount is part of the hold: the boundary mounted with it appears at the + // commit, its fallback never seen (as a boundary under a held Show). + it("a boundary mounted by a pass that joined the hold appears at its commit, no fallback", async () => { const [x, setX] = createSignal(0); const [open, setOpen] = createSignal(false); const log: string[] = []; @@ -146,7 +259,10 @@ describe("#3540 in a flush: a fresh Loading mounted over a held value shows its createRenderEffect(label, v => { log.push(v); }); - mount("direct", x, log); + const view = Loading(() => `content ${x()}`, "fallback"); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); return true; }, v => { @@ -155,19 +271,21 @@ describe("#3540 in a flush: a fresh Loading mounted over a held value shows its ); }); flush(); - const release = hold(() => setX(1)); + let release!: () => void; + action(function* () { + setX(1); + yield new Promise(r => (release = r)); + })(); + flush(); log.length = 0; setOpen(true); flush(); - expect(log).not.toContain("open true"); - expect(log.filter(l => l.startsWith("label"))).toEqual([]); + expect(log).toEqual([]); release(); await tick(); - expect(log).toContain("open true"); - expect(log).toContain("label 1"); - expect(log.at(-1)).toBe("view content 1"); + expect([...log].sort()).toEqual(["label 1", "open true", "view content 1"]); }); it("the boundary's hold stays its own: a mount over another hold after the flush reveals at that hold's release", async () => { @@ -175,19 +293,30 @@ describe("#3540 in a flush: a fresh Loading mounted over a held value shows its const [y, setY] = createSignal(0); const [open, setOpen] = createSignal(false); const log: string[] = []; + const view = (read: () => number, into: string[]) => { + const v = Loading(() => `content ${read()}`, "fallback"); + createRenderEffect(v, s => { + into.push(`view ${s}`); + }); + }; createRoot(() => { createRenderEffect( - () => { - if (!open()) return false; - mount("direct", x, log); - return true; - }, + () => (open() ? (view(x, log), true) : false), v => { log.push(`open ${v}`); } ); }); flush(); + const hold = (write: () => void) => { + let release!: () => void; + action(function* () { + write(); + yield new Promise(r => (release = r)); + })(); + flush(); + return () => release(); + }; const releaseX = hold(() => setX(1)); const releaseY = hold(() => setY(1)); log.length = 0; @@ -198,7 +327,7 @@ describe("#3540 in a flush: a fresh Loading mounted over a held value shows its // Mounted from mainline, outside a flush, over y's hold only. const other: string[] = []; - createRoot(() => mount("direct", y, other)); + createRoot(() => view(y, other)); flush(); expect(other).toEqual(["view fallback"]); @@ -213,245 +342,241 @@ describe("#3540 in a flush: a fresh Loading mounted over a held value shows its }); }); -describe("#3540 on a hold: a fresh Loading mounted from mainline over a held value", () => { - for (const [kind, how] of shapes) - it(`content reads the held value ${how}: the fallback now, the content at the commit`, async () => { - const [x, setX] = createSignal(0); - const release = hold(() => setX(1)); - const log: string[] = []; - createRoot(() => mount(kind, x, log)); - flush(); - expect(log).toEqual(["view fallback"]); - - release(); - await tick(); - expect(log.slice(1).sort()).toEqual(revealed(kind).sort()); - }); -}); - -/** The readers recorded on the boundary `owner` is under (internal: the - * catch's registrations). */ -function readersOf(owner: object): number { - const context = (owner as { _context: Record })._context; - for (const key of Object.getOwnPropertySymbols(context)) { - const b = context[key] as { _readers?: Set } | null; - if (b?._readers) return b._readers.size; - } - throw new Error("no boundary"); -} - -describe("#3540: the first-pass catch belongs to the boundary that has not shown content, and to no other", () => { +describe("#3540: the catcher is whoever `catchStatus` would catch at — at any depth, unchanged", () => { /** - * A revealed outer `Loading`; a transaction holding `x`; a Show under the - * outer one mounts, in a flush, a fresh computation over `x` with a first - * load of its own (`slow`) — under an inner `Loading` that has not shown - * content (`inner`), or directly under the revealed outer one. + * A Show mounts a fresh computation in the flush, under `inner` (a + * revealed Loading), inside `outer`: + * - `unrevealed`: outer has not shown content (a gate sibling is loading); + * - `rearmed outer`: outer revealed, its `on` reads the Show's signal; + * - `revealed`: outer revealed, plain. + * Or under one revealed Loading only: `rearmed` (its `on` reads the + * Show's signal) or `plain`. Steps: the mount, the release, the source + * settling, the gate. */ - function setup(inner: boolean) { - const [x, setX] = createSignal(0); - const [open, setOpen] = createSignal(false); - const log: string[] = []; - let resolveSlow!: () => void; - let outerOwner!: object; - let freshOwner!: object; - let outer!: () => unknown; - createRoot(() => { - createRenderEffect(x, v => { - log.push(`x ${v}`); - }); - outer = Loading(() => { - outerOwner = getOwner()!; - createRenderEffect( - () => { - if (!open()) return false; - const body = () => { - freshOwner = getOwner()!; - const m = createMemo(async () => { - const v = x(); - await new Promise(r => (resolveSlow = r)); - return `fresh ${v}`; - }); - createRenderEffect(m, v => { - log.push(`text ${v}`); - }); - return "p"; - }; - if (!inner) return body(); - const view = Loading(body, "inner fallback"); - createRenderEffect(view, v => { + type Shape = "unrevealed" | "rearmed outer" | "revealed" | "rearmed" | "plain"; + const expected: Record = { + // The ancestor catches, as pre-L2 (which also runs the binding behind + // the fallback: `text fresh 0` — L2 holds a run behind a fallback, + // 2026-10-02). Held or in flight alike: the content is pending, and the + // boundaries on the way record it. + "unrevealed held memo": [ + " | o2 i1", + "x 1 | o1 i0", + " | o1 i0", + "gate gate · open fresh 1 · outer outer" + ], + "unrevealed held bound": [ + " | o2 i1", + "x 1 | o1 i0", + " | o1 i0", + "gate gate · open p · outer outer · text fresh 1" + ], + "unrevealed flight memo": [ + " | o2 i1", + "holder 1 | o1 i0", + " | o1 i0", + "gate gate · open fresh 1 · outer outer" + ], + "unrevealed flight bound": [ + " | o2 i1", + "holder 1 | o1 i0", + " | o1 i0", + "gate gate · open p · outer outer · text fresh 1" + ], + // A re-armed ancestor owns its subtree: the content is pending, held or + // in flight, and its fallback shows now — nothing else holds the + // re-arming frame (#3575). In flight, as pre-L2. Held: pre-L2 shows the + // committed value (`open fresh 0`, a tear under L2's A29); `next` + // holds the whole mount for the commit. + "rearmed outer held memo": [ + "outer outer fallback | o1 i1", + "open fresh 1 · outer outer · x 1 | o0 i0", + " | o0 i0", + "" + ], + "rearmed outer held bound": [ + "outer outer fallback | o1 i1", + "open p · outer outer · text fresh 1 · x 1 | o0 i0", + " | o0 i0", + "" + ], + "rearmed outer flight memo": [ + "outer outer fallback | o1 i1", + "holder 1 · open fresh 1 · outer outer | o0 i0", + " | o0 i0", + "" + ], + "rearmed outer flight bound": [ + "outer outer fallback | o1 i1", + "holder 1 · open p · outer outer · text fresh 1 | o0 i0", + " | o0 i0", + "" + ], + // The nearest boundary re-armed by the mounting flush: as the re-armed + // ancestor. + "rearmed held memo": [ + "inner inner fallback | i1", + "inner inner · open fresh 1 · x 1 | i0", + " | i0", + "" + ], + "rearmed held bound": [ + "inner inner fallback | i1", + "inner inner · open p · text fresh 1 · x 1 | i0", + " | i0", + "" + ], + "rearmed flight memo": [ + "inner inner fallback | i1", + "holder 1 · inner inner · open fresh 1 | i0", + " | i0", + "" + ], + "rearmed flight bound": [ + "inner inner fallback | i1", + "holder 1 · inner inner · open p · text fresh 1 | i0", + " | i0", + "" + ], + // No boundary would catch it: the mount joins the transaction and waits + // for its commit, as on next. Pre-L2 shows the committed value now; L2 + // joins the hold (A29). + "plain held memo": [" | i0", "open fresh 1 · x 1 | i0", " | i0", ""], + "plain held bound": [" | i0", "open p · text fresh 1 · x 1 | i0", " | i0", ""], + "plain flight memo": [" | i1", "holder 1 · open fresh 1 | i0", " | i0", ""], + "plain flight bound": [" | i1", "holder 1 · open p · text fresh 1 | i0", " | i0", ""], + "revealed held memo": [" | o0 i0", "open fresh 1 · x 1 | o0 i0", " | o0 i0", ""], + "revealed held bound": [" | o0 i0", "open p · text fresh 1 · x 1 | o0 i0", " | o0 i0", ""], + "revealed flight memo": [" | o1 i1", "holder 1 · open fresh 1 | o0 i0", " | o0 i0", ""], + "revealed flight bound": [ + " | o1 i1", + "holder 1 · open p · text fresh 1 | o0 i0", + " | o0 i0", + "" + ] + }; + for (const shape of ["unrevealed", "rearmed outer", "revealed", "rearmed", "plain"] as Shape[]) + for (const source of ["held", "flight"] as Source[]) + for (const kind of ["memo", "bound"] as Content[]) + it(`${shape}, ${source}, content ${kind}`, async () => { + const log: string[] = []; + const [open, setOpen] = createSignal(false); + let resolveGate: (() => void) | undefined; + let w!: ReturnType; + let outerOwner: object | undefined; + let innerOwner!: object; + let outer: (() => unknown) | undefined; + let inner!: () => unknown; + const innerFn = () => { + innerOwner = getOwner()!; + createRenderEffect( + () => (open() ? content(kind, w.read, log, "fresh")() : false), + v => { + log.push(`open ${v}`); + } + ); + return "inner"; + }; + const showInner = (on?: () => unknown) => { + inner = Loading(innerFn, "inner fallback", on); + createRenderEffect(inner, v => { log.push(`inner ${v}`); }); - return true; - }, - v => { - log.push(`open ${v}`); - } - ); - return "outer"; - }, "outer fallback"); - createRenderEffect(outer, v => { - log.push(`outer ${v}`); - }); - }); - flush(); - expect(log).toEqual(["x 0", "open false", "outer outer"]); - const release = hold(() => setX(1)); - log.length = 0; - setOpen(true); - flush(); - const state = () => ({ - log: [...log], - outerPending: isPending(() => outer()), - outerReaders: readersOf(outerOwner), - freshReaders: readersOf(freshOwner) - }); - return { release, resolve: () => resolveSlow(), state }; - } - - it("under an inner Loading that has not shown content: the inner one catches it; the outer one records nothing, is not pending, and the commit does not wait for it", async () => { - const s = setup(true); - expect(s.state()).toEqual({ - log: ["inner inner fallback", "open true"], - outerPending: false, - outerReaders: 0, - freshReaders: 2 - }); - - s.release(); - await tick(); - // The transaction commits without the fresh computation's first load. - expect(s.state()).toMatchObject({ - log: ["inner inner fallback", "open true", "x 1"], - outerPending: false, - outerReaders: 0 - }); - - s.resolve(); - await tick(); - expect(s.state()).toEqual({ - log: ["inner inner fallback", "open true", "x 1", "text fresh 1", "inner p"], - outerPending: false, - outerReaders: 0, - freshReaders: 0 - }); - }); - - it("directly under the revealed Loading (none has not shown content): not caught — it joins the transaction, as on next", async () => { - const s = setup(false); - const trace: unknown[] = [s.state()]; - s.release(); - await tick(); - trace.push(s.state()); - s.resolve(); - await tick(); - trace.push(s.state()); - // The Show's mount waits with the transaction, the outer content stays, - // and the outer boundary holds the one pending reader the frame - // forwarded (the memo) — never the born-held binding. - const held = { log: [], outerPending: false, outerReaders: 1, freshReaders: 1 }; - expect(trace).toEqual([ - held, - held, - { - log: ["text fresh 1", "x 1", "open p"], - outerPending: false, - outerReaders: 0, - freshReaders: 0 - } - ]); - }); - - it("under a revealed Loading inside one that has not shown content: the nearest one is asked only — not caught, as on next", async () => { - const [x, setX] = createSignal(0); - const [open, setOpen] = createSignal(false); - let resolveGate!: () => void; - const log: string[] = []; - let outerOwner!: object; - let innerOwner!: object; - createRoot(() => { - const outer = Loading(() => { - outerOwner = getOwner()!; - // A revealed inner Loading whose tree mounts the fresh computation. - const inner = Loading(() => { - innerOwner = getOwner()!; - if (!open()) return "closed"; - const m = createMemo(() => `fresh ${x()}`); - return m(); - }, "inner fallback"); - createRenderEffect(inner, v => { - log.push(`inner ${v}`); - }); - // A sibling still loading: the outer one has not shown content. - const gate = createMemo(() => new Promise(r => (resolveGate = () => r("gate")))); - createRenderEffect(gate, v => { - log.push(`gate ${v}`); + }; + createRoot(() => { + w = world(source, log); + if (shape === "rearmed" || shape === "plain") + return showInner(shape === "rearmed" ? open : undefined); + outer = Loading( + () => { + outerOwner = getOwner()!; + showInner(); + if (shape === "unrevealed") { + const gate = createMemo( + () => new Promise(r => (resolveGate = () => r("gate"))) + ); + createRenderEffect(gate, v => { + log.push(`gate ${v}`); + }); + } + return "outer"; + }, + "outer fallback", + shape === "rearmed outer" ? open : undefined + ); + createRenderEffect(outer, v => { + log.push(`outer ${v}`); + }); + }); + flush(); + await w.settle(); + const end = w.begin(); + log.length = 0; + const steps = [ + () => (setOpen(true), flush()), + end, + w.settle, + async () => { + resolveGate?.(); + await tick(); + } + ]; + const readers = (): [string, number | undefined][] => + outer + ? [ + ["o", readersOf(outerOwner)], + ["i", readersOf(innerOwner)] + ] + : [["i", readersOf(innerOwner)]]; + const pending = () => [ + ...(outer && isPending(() => outer!()) ? ["o"] : []), + ...(isPending(() => inner()) ? ["i"] : []) + ]; + const out = await trace(steps, log, readers, pending); + // The gate step reports the log only. + out[3] = out[3].split(" | ")[0]; + expect(out).toEqual(expected[`${shape} ${source} ${kind}`]); }); - return "outer"; - }, "outer fallback"); - createRenderEffect(outer, v => { - log.push(`outer ${v}`); - }); - }); - flush(); - const release = hold(() => setX(1)); - const state = () => ({ - log: [...log], - outerReaders: readersOf(outerOwner), - innerReaders: readersOf(innerOwner) - }); - const trace: unknown[] = [state()]; - setOpen(true); - flush(); - trace.push(state()); - release(); - await tick(); - trace.push(state()); - resolveGate(); - await tick(); - trace.push(state()); - // The outer boundary only ever waits on the gate; the revealed inner one - // records nothing. - const waiting = { - log: ["inner closed", "outer outer fallback"], - outerReaders: 1, - innerReaders: 0 - }; - expect(trace).toEqual([ - waiting, - waiting, - waiting, - { - log: ["inner closed", "outer outer fallback", "gate gate", "inner fresh 1", "outer outer"], - outerReaders: 0, - innerReaders: 0 - } - ]); - }); -}); -describe("#3540 and `on` (a dependency list, #3575): the first-pass catch adds nothing to what `on` does", () => { - /** A revealed ``; a transaction holding `x`; a Show in - * its content mounts, in a flush, a fresh computation over `x`. `rearm`: - * the Show's own signal is in `on`, so the mounting flush re-arms it. */ - function trace(rearm: boolean) { - const [x, setX] = createSignal(0); - const [open, setOpen] = createSignal(false); - const log: string[] = []; - let owner!: object; - createRoot(() => { - createRenderEffect(x, v => { - log.push(`x ${v}`); - }); - const view = untrack(() => - createLoadingBoundary( - () => { - owner = getOwner()!; + /** + * A revealed outer Loading; a Show under it mounts, in the flush, a fresh + * computation with a slow first load of its own, under an inner Loading + * that has not shown content — or directly under the outer one. Steps: + * the mount, the release, the slow load, the source settling. + */ + for (const source of ["held", "flight"] as Source[]) + for (const withInner of [true, false]) + it(`outer revealed, ${withInner ? "inner unrevealed" : "no inner"}, ${source}: isPending and what the commit waits for`, async () => { + const log: string[] = []; + const [open, setOpen] = createSignal(false); + let resolveSlow: (() => void) | undefined; + let outerOwner!: object; + let freshOwner: object | undefined; + let outer!: () => unknown; + let w!: ReturnType; + createRoot(() => { + w = world(source, log); + outer = Loading(() => { + outerOwner = getOwner()!; createRenderEffect( () => { if (!open()) return false; - const m = createMemo(() => `fresh ${x()}`); - createRenderEffect(m, v => { - log.push(`text ${v}`); + const body = () => { + freshOwner = getOwner()!; + const m = createMemo(async () => { + const v = w.read(); + await new Promise(r => (resolveSlow = r)); + return `fresh ${v}`; + }); + createRenderEffect(m, v => { + log.push(`text ${v}`); + }); + return "p"; + }; + if (!withInner) return body(); + const view = Loading(body, "inner fallback"); + createRenderEffect(view, v => { + log.push(`inner ${v}`); }); return true; }, @@ -459,115 +584,350 @@ describe("#3540 and `on` (a dependency list, #3575): the first-pass catch adds n log.push(`open ${v}`); } ); - return "content"; - }, - () => "fallback", - { on: () => (rearm ? open() : undefined) } - ) - ); - createRenderEffect(view, v => { - log.push(`view ${v}`); + return "outer"; + }, "outer fallback"); + createRenderEffect(outer, v => { + log.push(`outer ${v}`); + }); + }); + flush(); + await w.settle(); + const end = w.begin(); + log.length = 0; + const slow = async () => { + resolveSlow?.(); + await tick(); + }; + const steps = [() => (setOpen(true), flush()), end, slow, w.settle]; + const out = await trace( + steps, + log, + () => [ + ["o", readersOf(outerOwner)], + ["f", readersOf(freshOwner)] + ], + () => (isPending(() => outer()) ? ["o"] : []) + ); + const x1 = source === "held" ? "x 1" : "holder 1"; + // Inner unrevealed: it catches; the outer records nothing, is never + // pending, and the commit does not wait for the slow load — as + // pre-L2 (`next` holds the Show's `open true` for the commit). No + // inner: the mount joins the transaction and the commit waits for + // the slow load, as on next; pre-L2 commits `x` first (held: L2's + // A29 — the mount is the hold's). + expect(out).toEqual( + withInner + ? [ + "inner inner fallback · open true | o0 f1", + `${x1} | o0 f1`, + "inner p · text fresh 1 | o0 f0", + " | o0 f0" + ] + : [ + " | o1 f1", + " | o1 f1", + [x1, "open p", "text fresh 1"].sort().join(" · ") + " | o0 f0", + " | o0 f0" + ] + ); }); - }); - flush(); - const release = hold(() => setX(1)); +}); + +describe("#3540: the boundary scope — committed content, held mounts, no catcher, verdicts", () => { + const verdict = (fn: () => unknown, how: "pending" | "latest") => { + try { + return String(how === "pending" ? isPending(fn) : latest(fn)); + } catch (e) { + return `throws ${(e as Error).constructor.name}`; + } + }; + /** Steps: the change, the hold's commit, the source settling — each the + * log since the last (sorted) and `state()`. */ + async function steps( + log: string[], + w: ReturnType, + change: () => void, + state: () => Record + ) { + const end = w.begin(); log.length = 0; - const state = () => ({ log: [...log], readers: readersOf(owner) }); - return { release, setOpen, state }; + const out: string[] = []; + let seen = 0; + const take = () => { + const s = Object.entries(state()) + .map(([k, v]) => `${k}=${v}`) + .join(" "); + out.push(`${[...log.slice(seen)].sort().join(" · ")} | ${s}`); + seen = log.length; + }; + change(); + flush(); + take(); + await end(); + take(); + await w.settle(); + take(); + return out; } - for (const rearm of [false, true]) - it(`revealed, ${rearm ? "re-armed by" : "not re-armed by"} the mounting flush: not caught — the mount joins the transaction, as on next`, async () => { - const s = trace(rearm); - s.setOpen(true); - flush(); - const steps: unknown[] = [s.state()]; - s.release(); - await tick(); - steps.push(s.state()); - expect(steps).toEqual([ - { log: [], readers: 0 }, - { log: ["text fresh 1", "x 1", "open true"], readers: 0 } - ]); - }); - - for (const rearm of [false, true]) - it(`revealed, its tree mounting in the flush that ${rearm ? "re-arms" : "does not re-arm"} it: not caught, as on next`, async () => { - const [x, setX] = createSignal(0); - const [open, setOpen] = createSignal(false); - const log: string[] = []; - let owner!: object; - createRoot(() => { - createRenderEffect(x, v => { - log.push(`x ${v}`); + // A fresh boundary mounted over the hold (memo content). While its + // fallback shows, its value is not pending (nothing stale is shown) and + // the content has no value: it is the boundary's, loading — `latest` + // throws, as for any first load. Pre-L2 serves the committed or staged + // value (`content 0` in a flush, a tear under A29; `content 1` from + // mainline); `next` reports the boundary pending while it holds the + // mount, and serves the staged value. + const observe = (x1: string) => [ + "view fallback | readers=1 view=false m=false latest=throws NotReadyError", + `${[x1, "view content 1"].sort().join(" · ")} | readers=0 view=false m=false latest=content 1`, + " | readers=0 view=false m=false latest=content 1" + ]; + for (const source of ["held", "flight"] as Source[]) + for (const where of ["flush", "mainline"]) + it(`${source}, a fresh boundary from ${where}: isPending and latest`, async () => { + const log: string[] = []; + const [open, setOpen] = createSignal(false); + let w!: ReturnType; + let m: (() => string) | undefined; + let view: (() => unknown) | undefined; + let owner: object | undefined; + const mount = () => { + view = Loading(() => { + owner = getOwner()!; + m = createMemo(() => `content ${w.read()}`); + return m(); + }, "fallback"); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + }; + createRoot(() => { + w = world(source, log); + if (where === "flush") + createRenderEffect( + () => (open() ? (mount(), true) : false), + v => { + log.push(`open ${v}`); + } + ); }); - const view = untrack(() => - createLoadingBoundary( - () => { - owner = getOwner()!; - if (!open()) return "closed"; - return createMemo(() => `fresh ${x()}`)(); - }, - () => "fallback", - { on: () => (rearm ? open() : undefined) } - ) + flush(); + await w.settle(); + const out = await steps( + log, + w, + () => (where === "flush" ? setOpen(true) : createRoot(mount)), + () => ({ + readers: readersOf(owner), + view: verdict(() => view!(), "pending"), + m: verdict(() => m!(), "pending"), + latest: verdict(() => m!(), "latest") + }) ); - createRenderEffect(view, v => { - log.push(`view ${v}`); - }); + const expected = observe(source === "held" ? "x 1" : "holder 1"); + if (where === "flush") + expected[0] = expected[0].replace("view fallback", "open true · view fallback"); + expect(out).toEqual(expected); }); - flush(); - const release = hold(() => setX(1)); - log.length = 0; - setOpen(true); - flush(); - const steps: unknown[] = [{ log: [...log], readers: readersOf(owner) }]; - release(); - await tick(); - steps.push({ log: [...log], readers: readersOf(owner) }); - expect(steps).toEqual([ - { log: [], readers: 0 }, - { log: ["x 1", "view fresh 1"], readers: 0 } - ]); - }); - for (const inFlush of [true, false]) - it(`not yet revealed, mounted ${inFlush ? "in a flush" : "from mainline"} over the held value: the fallback now, the content at the commit, as a Loading without \`on\``, async () => { - const [x, setX] = createSignal(0); - const [open, setOpen] = createSignal(!inFlush); - const [key] = createSignal(0); - const release = hold(() => setX(1)); - const log: string[] = []; - createRoot(() => - createRenderEffect( - () => { - if (!open()) return false; - const view = untrack(() => - createLoadingBoundary( - () => `content ${x()}`, - () => "fallback", - { on: key } - ) + // Committed content under a revealed boundary, re-run by a plain write + // to `key` while the hold is up. With `on: key` the boundary is re-armed + // and owns its subtree: the content that reads the hold waits behind the + // fallback, and the hold does not wait for it (the ruling's override of + // A15 for committed content). Pre-L2 and `next` show no fallback: the + // content joins the hold (`next` reports the boundary pending). Without + // `on`: A15, the content joins the hold — as pre-L2 and next. (`memo`: + // the boundary's own render reads `m`, so its re-run creates a fresh + // memo — no value until it loads; `bound`: the binding re-runs over the + // committed memo, pending with its committed value.) + const committed: Record = { + "held rearmed memo": [ + "view fallback | readers=1 view=false m=false latest=throws NotReadyError", + "view c1 1 · x 1 | readers=0 view=false m=false latest=c1 1", + " | readers=0 view=false m=false latest=c1 1" + ], + "held rearmed bound": [ + "view fallback | readers=1 view=false m=true latest=c0 0", + "text c1 1 · view p · x 1 | readers=0 view=false m=false latest=c1 1", + " | readers=0 view=false m=false latest=c1 1" + ], + "held plain memo": [ + " | readers=0 view=true m=false latest=c1 1", + "view c1 1 · x 1 | readers=0 view=false m=false latest=c1 1", + " | readers=0 view=false m=false latest=c1 1" + ], + "held plain bound": [ + " | readers=0 view=false m=true latest=c1 1", + "text c1 1 · x 1 | readers=0 view=false m=false latest=c1 1", + " | readers=0 view=false m=false latest=c1 1" + ], + "flight rearmed memo": [ + "view fallback | readers=1 view=false m=true latest=c0 0", + "holder 1 · view c1 1 | readers=0 view=false m=false latest=c1 1", + " | readers=0 view=false m=false latest=c1 1" + ], + "flight rearmed bound": [ + "view fallback | readers=1 view=false m=true latest=c0 0", + "holder 1 · text c1 1 · view p | readers=0 view=false m=false latest=c1 1", + " | readers=0 view=false m=false latest=c1 1" + ], + "flight plain memo": [ + " | readers=1 view=true m=true latest=c0 0", + "holder 1 · view c1 1 | readers=0 view=false m=false latest=c1 1", + " | readers=0 view=false m=false latest=c1 1" + ], + "flight plain bound": [ + " | readers=1 view=false m=true latest=c0 0", + "holder 1 · text c1 1 | readers=0 view=false m=false latest=c1 1", + " | readers=0 view=false m=false latest=c1 1" + ] + }; + for (const source of ["held", "flight"] as Source[]) + for (const withOn of [true, false]) + for (const kind of ["memo", "bound"] as Content[]) + it(`${source}, committed content ${withOn ? "under a re-armed boundary" : "under a plain boundary"}, ${kind}`, async () => { + const log: string[] = []; + const [key, setKey] = createSignal(0); + let w!: ReturnType; + let m!: () => string; + let view!: () => unknown; + let owner: object | undefined; + createRoot(() => { + w = world(source, log); + view = Loading( + () => { + owner = getOwner()!; + m = createMemo(() => `c${key()} ${w.read()}`); + if (kind === "memo") return m(); + createRenderEffect(m, v => { + log.push(`text ${v}`); + }); + return "p"; + }, + "fallback", + withOn ? key : undefined ); createRenderEffect(view, v => { log.push(`view ${v}`); }); - return true; - }, - v => { - log.push(`open ${v}`); - } - ) - ); - flush(); - if (inFlush) { - log.length = 0; - setOpen(true); + }); + flush(); + await w.settle(); + const out = await steps( + log, + w, + () => setKey(1), + () => ({ + readers: readersOf(owner), + view: verdict(() => view(), "pending"), + m: verdict(() => m(), "pending"), + latest: verdict(() => m(), "latest") + }) + ); + expect(out).toEqual(committed[`${source} ${withOn ? "rearmed" : "plain"} ${kind}`]); + }); + + // A boundary mounted as part of the hold — ``'s memo + // creates it, at top level or in a revealed boundary: it appears at the + // hold's commit, its fallback never seen — as pre-L2. (In flight `next` + // flashes the fallback at the commit.) + for (const source of ["held", "flight"] as Source[]) + for (const nested of [false, true]) + it(`${source}, a boundary mounted by the hold${nested ? ", nested" : ""}: no fallback, it appears at the commit`, async () => { + const log: string[] = []; + let w!: ReturnType; + let view: (() => unknown) | undefined; + let shown!: () => unknown; + createRoot(() => { + w = world(source, log); + const body = () => { + shown = createMemo(() => { + if (w.read() < 1) return null; + return (view = Loading(() => `content ${w.read()}`, "fallback")); + }); + createRenderEffect( + () => { + const v = shown() as (() => unknown) | null; + return v ? v() : "none"; + }, + v => { + log.push(`view ${v}`); + } + ); + return "outer"; + }; + if (!nested) return body(); + const outer = Loading(body, "outer fallback"); + createRenderEffect(outer, v => { + log.push(`outer ${v}`); + }); + }); flush(); - } - expect(log).toEqual(["view fallback", "open true"]); - release(); - await tick(); - expect(log).toEqual(["view fallback", "open true", "view content 1"]); - }); + await w.settle(); + const out = await steps( + log, + w, + () => {}, + () => ({ + view: view ? verdict(() => view!(), "pending") : "-", + shown: verdict(() => shown(), "pending") + }) + ); + const v0 = source === "held" ? "false" : "-"; + expect(out).toEqual([ + ` | view=${v0} shown=true`, + `${source === "held" ? "view content 1 · x 1" : "holder 1 · view content 1"} | view=false shown=false`, + " | view=false shown=false" + ]); + }); + + // No boundary would catch it: a fresh mount reading the hold joins it + // and waits for its commit (A15), as on next. Pre-L2 shows the committed + // value in a flush (`text fresh 0`). + for (const source of ["held", "flight"] as Source[]) + for (const where of ["flush", "mainline"]) + it(`${source}, no catcher, from ${where}: the mount joins the hold`, async () => { + const log: string[] = []; + const [open, setOpen] = createSignal(false); + let w!: ReturnType; + let m: (() => string) | undefined; + const mount = () => { + m = createMemo(() => `fresh ${w.read()}`); + createRenderEffect(m, v => { + log.push(`text ${v}`); + }); + }; + createRoot(() => { + w = world(source, log); + if (where === "flush") + createRenderEffect( + () => (open() ? (mount(), true) : false), + v => { + log.push(`open ${v}`); + } + ); + }); + flush(); + await w.settle(); + const out = await steps( + log, + w, + () => (where === "flush" ? setOpen(true) : createRoot(mount)), + () => ({ + m: verdict(() => m!(), "pending"), + latest: verdict(() => m!(), "latest") + }) + ); + const first = source === "held" ? "fresh 1" : "throws NotReadyError"; + const landed = [ + source === "held" ? "x 1" : "holder 1", + ...(where === "flush" ? ["open true"] : []), + "text fresh 1" + ].sort(); + expect(out).toEqual([ + ` | m=false latest=${first}`, + `${landed.join(" · ")} | m=false latest=fresh 1`, + " | m=false latest=fresh 1" + ]); + }); }); diff --git a/packages/signals/tests/loading-on-frame-following-3540.test.ts b/packages/signals/tests/loading-on-frame-following-3540.test.ts index 5d9815117..550467cc6 100644 --- a/packages/signals/tests/loading-on-frame-following-3540.test.ts +++ b/packages/signals/tests/loading-on-frame-following-3540.test.ts @@ -374,6 +374,77 @@ describe("3. an outside hold on the SAME source: the frame waits, the fallback c }); }); +// The same source read outside, but its flight comes from an earlier write +// the `on` change is not part of: nothing holds the change's frame, the +// re-armed boundary owns its content (A29's boundary scope, 2026-10-06), so +// the fallback shows now — and nothing is reported. +describe("3b. an outside reader of a flight the `on` change did not start: the fallback shows now, not reported", () => { + for (const outside of ["effect", "loading"]) + test(`outside ${outside === "effect" ? "render effect" : "revealed Loading"}`, async () => { + const d = captureWarnings(); + const [x, setX] = createSignal(0); + const [key, setKey] = createSignal(0); + const pending: (() => void)[] = []; + const log: string[] = []; + let dispose!: () => void; + createRoot(dispose_ => { + dispose = dispose_; + const data = createMemo( + async () => { + const v = x(); + await new Promise(r => pending.push(r)); + return v; + }, + { name: "data" } + ); + const read = + outside === "effect" + ? data + : untrack(() => + createLoadingBoundary( + () => `holder ${data()}`, + () => "holder fallback" + ) + ); + createRenderEffect(read, v => { + log.push(`outside ${v}`); + }); + const view = untrack(() => + createLoadingBoundary( + () => `c${key()} ${data()}`, + () => "fallback", + { on: key } + ) + ); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + }); + flush(); + const settle = async () => { + while (pending.length) pending.shift()!(); + for (let i = 0; i < 8; i++) await microtask(); + flush(); + }; + await settle(); + log.length = 0; + setX(1); + flush(); + expect(log).toEqual([]); + setKey(1); + flush(); + expect(log).toEqual(["view fallback"]); + await settle(); + expect(log.slice(1).sort()).toEqual([ + outside === "effect" ? "outside 1" : "outside holder 1", + "view c1 1" + ]); + expect(d.codes()).toEqual([]); + d.stop(); + dispose(); + }); +}); + describe("4. `on: () => latest(id)`: the display-ahead read shows the fallback now, beside the held frame", () => { for (const write of ["plain", "action"] as Write[]) { test(`${write} write, shell lands first: [A] → [A + spinner] → [B + spinner] → [B + comments]`, async () => { diff --git a/packages/solid/CHEATSHEET.md b/packages/solid/CHEATSHEET.md index 33e04daa3..ab3ff8556 100644 --- a/packages/solid/CHEATSHEET.md +++ b/packages/solid/CHEATSHEET.md @@ -655,7 +655,7 @@ If your training data is 1.x, these are the corrections. **Read this before gene - **Stores: setters take a draft callback** — mutate the draft in place by default. Returning a new value is shallow (array index-replace, object top-level diff); reach for it for filter/remove. Keyed reconcile is a _projection-fn_ feature, not a setter feature. - **`undefined` is a real value in `merge`** — it overrides rather than "skip this key". - **Async lives in computations** — return a Promise/AsyncIterable from `createMemo`/`createStore(fn)`/`createProjection`. Pending reads participate in ``. -- **`Loading` covers unresolved branches** — once content has rendered, revalidation keeps it visible. Use `isPending(() => x())` for in-flight-change indicators or render guards; it reads `x` and participates in Loading like that read. `` is a dependency list, not a key: its value is never compared; a change to anything it reads makes the boundary stop waiting on its current content and show the fallback if something under it is pending (nothing otherwise). The fallback _follows the frame_: it lands with the change that caused it — immediately when nothing else holds that frame, together with the rest of the new page during a held navigation (never a spinner beside a page the change has not reached). `on={[a(), b()]}` for several. If the same data is also read outside the boundary, the frame waits on it and the fallback can never be seen — DEV warns `LOADING_ON_OUTSIDE_HOLD`; fix the structure (move the outside read under the boundary so one hold owns the data). A frame held past the content's landing by something else (the write's action, other pending data) shows no fallback either — a race, not reported; show the wait with `isPending()`. A display-ahead read in `on` (`on={latest(id)}`) shows the fallback _now_, beside the still-held frame — a capability, not the recommended shape. A fallback that names what is loading should read `latest(id)`, since `id()` is still the committed value while the new one loads. +- **`Loading` covers unresolved branches** — once content has rendered, revalidation keeps it visible. Use `isPending(() => x())` for in-flight-change indicators or render guards; it reads `x` and participates in Loading like that read. `` is a dependency list, not a key: its value is never compared; a change to anything it reads makes the boundary stop waiting on its current content and show the fallback if something under it is pending (nothing otherwise). The fallback _follows the frame_: it lands with the change that caused it — immediately when nothing else holds that frame, together with the rest of the new page during a held navigation (never a spinner beside a page the change has not reached). `on={[a(), b()]}` for several. The re-armed boundary owns its content: data still loading from an earlier change shows the fallback now, wherever else it is read. If the data the change itself starts loading is also read outside the boundary, the frame waits on it and the fallback can never be seen — DEV warns `LOADING_ON_OUTSIDE_HOLD`; fix the structure (move the outside read under the boundary so one hold owns the data). A frame held past the content's landing by something else (the write's action, other pending data) shows no fallback either — a race, not reported; show the wait with `isPending()`. A display-ahead read in `on` (`on={latest(id)}`) shows the fallback _now_, beside the still-held frame — a capability, not the recommended shape. A fallback that names what is loading should read `latest(id)`, since `id()` is still the committed value while the new one loads. - **`isPending` ≠ 1.x `.loading`** — it fires while a value _change_ is in flight (an input changed, or `affects()` declared one), not for every fetch. A bare `refresh()`/poll re-asks the same question and is silent. For a reload that should read as pending: `affects(x); refresh(x)`. For a "saving…" affordance: a co-written optimistic flag. - **No `Suspense.Provider` or single error path** — async errors flow to `` (or effect `error`); no inline `resource.error` branching. - **`createRoot` is owned by parent by default** — disposed when parent disposes. To detach: `runWithOwner(null, fn)`. diff --git a/packages/solid/src/client/flow.ts b/packages/solid/src/client/flow.ts index 676ac6532..fbb038536 100644 --- a/packages/solid/src/client/flow.ts +++ b/packages/solid/src/client/flow.ts @@ -458,10 +458,14 @@ export function Errored(props: { * nothing else holds that frame; together with the rest of the new page * during a held navigation (a write inside an `action`, or one whose data * other readers are still waiting on) — never a spinner beside a page the - * change has not reached yet. If the same data is also read outside the - * boundary, the frame waits on it and the fallback can never be seen; DEV - * warns `LOADING_ON_OUTSIDE_HOLD`, and the fix is structural — move the - * outside read under the boundary so one hold owns the data. A frame held + * change has not reached yet. The re-armed boundary owns its content: data + * the change did not start loading (a flight or a held write from an + * earlier change) shows the fallback now rather than holding the change, + * even where it is also read outside the boundary. If the change itself + * starts the data loading and the data is also read outside the boundary, + * the frame waits on it and the fallback can never be seen; DEV warns + * `LOADING_ON_OUTSIDE_HOLD`, and the fix is structural — move the outside + * read under the boundary so one hold owns the data. A frame held * past the content's landing by something else (the write's action, other * pending data) shows no fallback either; that is a race, a legitimate * outcome, and not reported — show the wait with `isPending()` instead. From a965717c89a75501b448e2f618b0a6172771f06b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 12:59:48 -0700 Subject: [PATCH 07/10] proto(signals): an owned held computation re-derives at the read (#3540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semantic fuzzer (rev 19, seed 91501, branch-boundaries #870): an `on` re-arm left the tree held by an earlier hold; the output read it, the scope made the read pending, and the tree was recorded as a reader that had already settled — the boundary redrew forever. A held computation read from the scope that owns it is content, not a hold: it leaves the transaction (`leaveHold`, shared with `recompute`'s head) and re-derives at the read. Pins the stale-content finding (boundaries #1078) as `it.fails`: membership decided mid-flush is not revisited when the flush later joins the hold. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 2 +- packages/signals/src/core/core.ts | 45 +++++--- .../loading-fallback-in-flush-3540.test.ts | 101 ++++++++++++++++++ 4 files changed, 134 insertions(+), 16 deletions(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 800de63b3..035e45e2c 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:295` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:93` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | boundaries.ts×2 action.ts×1 async.ts×1 constants.ts×1 core.ts×11 effect.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | boundaries.ts×2 action.ts×1 async.ts×1 constants.ts×1 core.ts×12 effect.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:251` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:125` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index fc9374b52..212a88f48 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -55,7 +55,7 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t - **A first load derived from a hold lands into it** (#3800): a first load born pending in a frame that read a hold lands into that hold if the hold is still live, else as its own commit; the hold commits without it. **Pending** — `fix/create-time-holds` (`91e474506`); [#3800](https://github.com/solidjs/solid/issues/3800) is open. - **A first pass under a loading boundary that has not shown content is the boundary's, not the tick's** — in a flush as outside one (A29's boundary exemption, #3540). The out-of-flush form is on `next`; the in-flush form (a fresh `Loading` over a held value shows its fallback in a flush) is **pending** — `fix/create-time-holds` (`ca71e5d9d`). -**A boundary that has not shown content owns its subtree — A29's boundary scope (2026-10-06, maintainer ruling; amendment — #3540's L2 regression; the bullet above and A15 are unchanged outside such a boundary).** The ruling, in substance: content under a loading boundary that has not shown content — or that an `on` change has re-armed — belongs to that boundary, not to any hold it reads, whichever computation does the reading (a creation pass, a re-running mount effect, the boundary's own render, or committed content under a re-armed boundary). While that content waits for a hold or its own first loads, it is pending: the boundary shows its fallback, and no hold waits for it. Whether the fallback is seen depends on whether the boundary's own mount is held: a boundary mounted as part of a hold (`` with `x` held, or nested in held content) appears at that hold's commit. Outside such a boundary, A15 and the direction rule are unchanged: committed work that reads a hold joins it, never-committed work waits on every hold it read, and if no boundary would catch it, it stays hidden with the hold. The `on` case is the one place the rule overrides A15 for committed content: committed content under a re-armed boundary that reads a hold waits behind the fallback rather than joining the hold. Membership is decided by the scope, not by the pass: the reading computation's nearest collecting loading boundary (the one `catchStatus` would catch at) owns the read, unless the boundary's own display is the hold's anyway — the flush has joined that hold, the boundary's output is held by it while not showing content, or the pass that is mounting the boundary has joined it. Readers that waited re-derive at the hold's commit as A15's stale readers do; no reader is registered after the commit. `isPending` of the boundary's value is false while its fallback shows (nothing stale is shown), and content that has not loaded under it has no value (`latest` throws, as for any first load). Pre-L2 served the committed value in these shapes (a fresh boundary in a flush, and committed content under a re-armed boundary over a held write — `content 0`); under A29 that tears, and the fallback is the ruled display. **Mechanism:** `GlobalQueue._owns(c, t)` (boundaries.ts, `owns`: `catcherOf` over `boundaryOf` — the output carries its boundary scope, `_scope`), asked by `read()` for a held source (`scopedRead`: the reader is a stale reader of `t` and the read throws `NotReadyError(source)`, so the boundary collects it), by `propagateStatus` before joining a pending memo into a hold, and by `recompute`'s head before joining a held node's pass (an owned held node leaves the transaction). Lanes and verdict readers are not scoped. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (fresh mounts in a flush and from mainline: memo, direct and bound content; nested under a boundary that has shown content; a mount that joined the hold appears at its commit; the boundary's hold stays its own; catchers at any depth — unrevealed, re-armed outer or nearest, revealed, plain; committed content under a re-armed or plain boundary; boundaries mounted by the hold, top-level or nested; no catcher; `isPending` and `latest` on each); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. +**A boundary that has not shown content owns its subtree — A29's boundary scope (2026-10-06, maintainer ruling; amendment — #3540's L2 regression; the bullet above and A15 are unchanged outside such a boundary).** The ruling, in substance: content under a loading boundary that has not shown content — or that an `on` change has re-armed — belongs to that boundary, not to any hold it reads, whichever computation does the reading (a creation pass, a re-running mount effect, the boundary's own render, or committed content under a re-armed boundary). While that content waits for a hold or its own first loads, it is pending: the boundary shows its fallback, and no hold waits for it. Whether the fallback is seen depends on whether the boundary's own mount is held: a boundary mounted as part of a hold (`` with `x` held, or nested in held content) appears at that hold's commit. Outside such a boundary, A15 and the direction rule are unchanged: committed work that reads a hold joins it, never-committed work waits on every hold it read, and if no boundary would catch it, it stays hidden with the hold. The `on` case is the one place the rule overrides A15 for committed content: committed content under a re-armed boundary that reads a hold waits behind the fallback rather than joining the hold. Membership is decided by the scope, not by the pass: the reading computation's nearest collecting loading boundary (the one `catchStatus` would catch at) owns the read, unless the boundary's own display is the hold's anyway — the flush has joined that hold, the boundary's output is held by it while not showing content, or the pass that is mounting the boundary has joined it. Readers that waited re-derive at the hold's commit as A15's stale readers do; no reader is registered after the commit. `isPending` of the boundary's value is false while its fallback shows (nothing stale is shown), and content that has not loaded under it has no value (`latest` throws, as for any first load). Pre-L2 served the committed value in these shapes (a fresh boundary in a flush, and committed content under a re-armed boundary over a held write — `content 0`); under A29 that tears, and the fallback is the ruled display. **Mechanism:** `GlobalQueue._owns(c, t)` (boundaries.ts, `owns`: `catcherOf` over `boundaryOf` — the output carries its boundary scope, `_scope`), asked by `read()` for a held source (`scopedRead`: the reader is a stale reader of `t` and the read throws `NotReadyError(source)`, so the boundary collects it), by `propagateStatus` before joining a pending memo into a hold, and by `recompute`'s head before joining a held node's pass (an owned held node leaves the transaction, `leaveHold`); a held computation read from the scope that owns it is content, not a hold — it leaves the transaction and re-derives at the read. Lanes and verdict readers are not scoped. **Open (prototype):** the "the flush is the hold's" exemption is decided mid-flush, so content judged owned before the flush joins the hold has left it while content judged after has not — the hold can land the boundary's stale staging (`tests/loading-fallback-in-flush-3540.test.ts`, the `it.fails` fuzzer finding); deciding that clause at the seam is the fix. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (fresh mounts in a flush and from mainline: memo, direct and bound content; nested under a boundary that has shown content; a mount that joined the hold appears at its commit; the boundary's hold stays its own; catchers at any depth — unrevealed, re-armed outer or nearest, revealed, plain; committed content under a re-armed or plain boundary; boundaries mounted by the hold, top-level or nested; no catcher; `isPending` and `latest` on each); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. **Not yet one-way (recorded, not ruled).** The frame of a tick is still one transaction (the tick ruling above stands), so two shapes keep the hold waiting on first loads it never needed: diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index fb2d4ef72..052d0f007 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -107,7 +107,8 @@ import { joinPassTx, stagedReaders, staleReader, - laneDirty + laneDirty, + type Transaction } from "./scheduler.js"; import type { Computed, @@ -305,14 +306,9 @@ export function recompute(el: Computed, create: boolean = false): void { } else if (GlobalQueue._owns?.(el, tx)) { // Under a loading boundary that owns it now (re-armed since it // joined — A29's boundary scope) it is the boundary's, not the - // hold's: it leaves the transaction, its staging there void, and a - // read of the hold makes this pass pending under the boundary + // hold's: a read of the hold makes this pass pending there // (`scopedRead`). - el._pendingValue = NOT_PENDING; - el._config &= ~CONFIG_HELD; - el._x!._transaction = null; - const i = tx._nodes.indexOf(el); - if (i >= 0) tx._nodes.splice(i, 1); + leaveHold(el, tx); } else if (isEffect !== EFFECT_RENDER && !(el._config & CONFIG_VERDICT)) joinPassTx(tx); else if (tx !== flushTransaction && !(el._statusFlags & STATUS_UNINITIALIZED)) { // Published mainline, it is not held: the frame this pass builds is @@ -967,21 +963,42 @@ function joinPass(c: Computed, el: Signal | Computed): void { * the landing as the hold's stale reader (`_reruns`: re-derived on the * committed world). Ahead of every hold read (`joinPass`, `frameRead`, a * derive's draft). Not lane work or a verdict reader: they read the - * screen, not the hold (`frameRead`). */ -export function scopedRead(c: Owner | null, el: Signal | Computed): void { + * screen, not the hold (`frameRead`). A held computation the same scope + * owns is content, not a hold: it leaves the transaction and re-derives + * here (true — the read goes on as a plain one, pending if it is). */ +export function scopedRead(c: Owner | null, el: Signal | Computed): boolean { if ( GlobalQueue._owns === undefined || c === null || passLane !== null || (c as Computed)._config & CONFIG_VERDICT ) - return; + return false; const t = txOf(el); - if (t._lane || !GlobalQueue._owns(c as Computed, t)) return; + if (t._lane || !GlobalQueue._owns(c as Computed, t)) return false; + if ( + typeof (el as Computed)._fn === "function" && + GlobalQueue._owns(el as Computed, t) + ) { + leaveHold(el as Computed, t); + recompute(el as Computed); + return true; + } staleReader(c as Computed, t); throw new NotReadyError(el); } +/** A held node a loading boundary owns now leaves its transaction: its + * staging there is void, and its next pass derives on the committed world + * (A29's boundary scope). */ +function leaveHold(el: Computed, t: Transaction): void { + el._pendingValue = NOT_PENDING; + el._config &= ~CONFIG_HELD; + el._x!._transaction = null; + const i = t._nodes.indexOf(el); + if (i >= 0) t._nodes.splice(i, 1); +} + /** A15's stale reader (shared-hole and reveal corollaries): a render effect * reading a node held by a transaction that is not the flush's. It is served * the committed value — a pending one's too — publishes mainline, and is @@ -1687,8 +1704,8 @@ export function read(el: Signal | Computed): T { !(el._config & CONFIG_OVERRIDE) && !(c._config & CONFIG_CHILDREN_FORBIDDEN) ) { - scopedRead(c, el); - if (owner._statusFlags & STATUS_UNINITIALIZED) { + if (scopedRead(c, el)) { + } else if (owner._statusFlags & STATUS_UNINITIALIZED) { (c as Computed)._flags |= REACTIVE_JOINED; joinPassTx(txOf(el)); } else if (frameRead(c as Computed, el)) committed = true; diff --git a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts index c13ae93e3..2e30e6703 100644 --- a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts +++ b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts @@ -931,3 +931,104 @@ describe("#3540: the boundary scope — committed content, held mounts, no catch ]); }); }); + +describe("#3540: the boundary scope — semantic fuzzer findings (rev 19, seed 91501)", () => { + const drain = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }; + + // branch-boundaries #870, reduced. An `on` re-arm leaves the tree held by + // an earlier hold, the content it staged there stale: the output's read + // of it is a read of content the boundary owns, so the tree leaves the + // transaction and re-derives (pending under the boundary) rather than + // being recorded as a settled reader — which redrew the boundary forever. + it("a held tree under a re-armed boundary re-derives — no redraw loop", async () => { + const [s1, setS1] = createSignal(0); + const [s2, setS2] = createSignal(0); + const [visible, setShow] = createSignal(true); + const flights: (() => void)[] = []; + const log: unknown[] = []; + createRoot(() => { + const node = createMemo(async () => { + const v = s2(); + await new Promise(r => flights.push(r)); + return v; + }); + const view = Loading(() => (visible() ? `${s1()} ${s2()}` : "hidden"), "loading", s1); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + createRenderEffect(node, v => { + log.push(`node ${v}`); + }); + }); + flush(); + while (flights.length) flights.shift()!(); + await drain(); + flush(); + log.length = 0; + setS1(1); + flush(); + setS2(1); + setShow(false); + flush(); + setS1(0); + flush(); + expect(log).toEqual(["view 1 0", "view loading"]); + setS2(0); + flush(); + await drain(); + expect(log).toEqual(["view 1 0", "view loading"]); + }); + + // boundaries #1078, reduced. OPEN: the re-arm's flush judges the tree + // owned and it leaves the earlier hold; later in the same flush an async + // memo the hold has joins the flush to it, and from then on the output + // is not owned (`flushTransaction === t`) — it keeps its staging there. + // The hold lands after the hide and publishes that stale content, which + // sticks. Membership decided mid-flush is not revisited when the flush + // joins a hold; the fix is to decide that clause at the seam. + it.fails( + "hidden after a re-arm over a held mount: the boundary does not publish stale content", + async () => { + const [s1, setS1] = createSignal(0); + const [visible, setShow] = createSignal(false); + const flights: (() => void)[] = []; + let shown: unknown; + createRoot(() => { + const n0 = createMemo(() => s1()); + const n1 = createMemo(async () => { + const v = n0(); + await new Promise(r => flights.push(r)); + return v; + }); + const view = Loading(() => (visible() ? `content ${s1()}` : "hidden"), "loading", s1); + createRenderEffect(view, v => { + shown = v; + }); + createRenderEffect( + () => (visible() ? n1() : "hidden"), + () => {} + ); + }); + const turn = async (fn: () => void) => { + fn(); + flush(); + await drain(); + flush(); + }; + await turn(() => {}); + while (flights.length) flights.shift()!(); + await turn(() => {}); + await turn(() => setS1(1)); + await turn(() => setShow(true)); + await turn(() => setS1(0)); + await turn(() => setShow(false)); + for (let i = 0; i < 4; i++) { + while (flights.length) flights.shift()!(); + await turn(() => {}); + } + expect(shown).toBe("hidden"); + } + ); +}); From 7439347e27afe128d9841ebc32f9bcc1796ca6c6 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 14:15:17 -0700 Subject: [PATCH 08/10] =?UTF-8?q?fix(signals):=20A29's=20boundary=20scope?= =?UTF-8?q?=20=E2=80=94=20ownership=20decided=20at=20the=20seam,=20behind?= =?UTF-8?q?=20boundary=20hooks=20(#3540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the 2026-10-06 boundary-scope ruling. A loading boundary that has not shown content, or that `on` re-armed, owns its subtree: a read of a hold from under it is pending there (the boundary shows its fallback, no hold waits for it), and the reader re-derives at the hold's commit. - Hooks: the ownership logic lives in boundaries.ts behind `GlobalQueue._owns(c, el)` (asked at the hold's doors: `read`, a store draft, `recompute`'s head, `propagateStatus`) and the new `GlobalQueue._boundaryPark(t)`; core pays the null checks. - The seam: the reads the scope made pending, and the nodes that left a hold, are revisited once the flush has parked (its membership final). A read whose hold the flush joined after it was the hold's after all and re-derives as its work; a swap parked with another change takes the hold its content read (or left) with it. - A re-arm that finds nothing pending owns what an earlier change holds under it with no flight (an action's staged write): it leaves the hold and re-derives now (mount-under-hold `rearm-committed`). Pins the A15 landing tear a re-arm exposes (`it.fails`; boundaries #75's reduction shows it on next without a boundary). Open, not the scope's: a boundary a verdict reader mounts derives in the verdict lane (the first-pass lane rule, #3835's seam). Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/fresh-loading-fallback-in-flush.md | 2 +- documentation/solid-2.0/05-async-data.md | 2 + packages/signals/docs/RULES-INDEX.md | 8 +- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 2 +- packages/signals/src/boundaries.ts | 137 ++++++- packages/signals/src/core/async.ts | 4 +- packages/signals/src/core/core.ts | 58 +-- packages/signals/src/core/scheduler.ts | 14 +- packages/signals/src/store/store.ts | 5 +- .../loading-fallback-in-flush-3540.test.ts | 350 +++++++++++++++--- .../loading-on-frame-following-3540.test.ts | 65 ++++ packages/solid/CHEATSHEET.md | 2 +- 12 files changed, 517 insertions(+), 132 deletions(-) diff --git a/.changeset/fresh-loading-fallback-in-flush.md b/.changeset/fresh-loading-fallback-in-flush.md index c12c435b0..387befbcc 100644 --- a/.changeset/fresh-loading-fallback-in-flush.md +++ b/.changeset/fresh-loading-fallback-in-flush.md @@ -3,4 +3,4 @@ "solid-js": patch --- -A loading boundary that has not shown content — or that an `on` change has re-armed — owns its subtree (#3540 under L2). Content under it that reads a held value or waits on its first loads is pending: the boundary shows its fallback, no hold waits for it, and it re-derives at the hold's commit. A fresh `Loading` mounted over a held value shows its fallback in a flush too, and content bound by a render effect under it no longer reveals empty. Committed content under an `on`-re-armed boundary that reads a value held by another change now waits behind the fallback instead of holding the change. A boundary mounted as part of a hold still appears at that hold's commit, and outside such a boundary nothing changes. DEV's `LOADING_ON_OUTSIDE_HOLD` reports only a re-arm whose fallback is actually held; the `Loading` `on` docs say which data holds the change. +A loading boundary that has not shown content — or that an `on` change has re-armed — owns its subtree (#3540 under L2). Content under it that reads a held value or waits on its first loads is pending: the boundary shows its fallback, no hold waits for it, and it re-derives at the hold's commit. A fresh `Loading` mounted over a held value shows its fallback in a flush too, and content bound by a render effect under it no longer reveals empty. Committed content under an `on`-re-armed boundary that reads a value held by another change now waits behind the fallback instead of holding the change, including content an earlier `action` still holds by a plain write, with nothing loading. A boundary mounted as part of a hold still appears at that hold's commit, and outside such a boundary nothing changes. DEV's `LOADING_ON_OUTSIDE_HOLD` reports only a re-arm whose fallback is actually held; the `Loading` `on` docs say which data holds the change. While such a boundary shows its fallback, `isPending()` of it is `false`, and `latest()` of content under it answers the value that content has — its committed value, or `NotReadyError` for content mounted under it — rather than the held derivation. A hold that content under a re-armed boundary read lands together with the change whose frame shows the fallback, never ahead of it. diff --git a/documentation/solid-2.0/05-async-data.md b/documentation/solid-2.0/05-async-data.md index a3193823c..3dc4d9be0 100644 --- a/documentation/solid-2.0/05-async-data.md +++ b/documentation/solid-2.0/05-async-data.md @@ -47,6 +47,8 @@ Importantly, `Loading` is intended to cover **branch readiness**: it handles a s Nested `Loading` boundaries can be used to avoid blocking large subtrees and to control where loading UI appears. +A `Loading` that has not shown content yet owns what is under it. Content there that reads a value a still-running change holds (a write inside an `action`, data another change is loading) is not part of that change: the boundary shows its fallback now, and the content appears when the change commits. The change does not wait for it. The exception is a boundary mounted by the change itself, for example a `` where `x` is the held write: it appears with that change's commit, with no fallback. While the fallback shows, `isPending()` of the boundary is `false`, as for any first load. Content that has not rendered yet has no value for `latest()` to return either: `latest()` of a memo created under the boundary throws until the commit, as any not-yet-loaded value does. + #### `Loading` `on` prop: dependencies that show the fallback again By default, once a `Loading` boundary has rendered content, it keeps that content visible during revalidation: like every reader of a pending value, it holds the write that made it pending until the data lands. The `on` prop is a **dependency list**: a tracked expression whose value is irrelevant — what matters is what it reads. Whenever anything it reads changes, the boundary stops waiting on its current content: if something under it is pending, it shows its fallback again until the new content is ready; if nothing is pending, nothing happens. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 035e45e2c..26b5c5f4c 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -39,7 +39,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul **src/:** none — every citation resolves. -**tests/:** B0, B6, §0, §16, §19, §20, §21, §27, §28, §31, §39, §41 (test-only citations are informational; `--check` gates src/ only) +**tests/:** B0, B6, §0, §15, §16, §19, §20, §21, §27, §28, §31, §39, §41 (test-only citations are informational; `--check` gates src/ only) ## A — spec propositions @@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:191` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:207` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:243` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:243` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | mount-cases.ts×1 async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×6 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:215` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:69` | async.ts×3 constants.ts×1 lanes.ts×6 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:79` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | @@ -73,12 +73,12 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:295` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:93` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | boundaries.ts×2 action.ts×1 async.ts×1 constants.ts×1 core.ts×12 effect.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | boundaries.ts×3 action.ts×1 async.ts×1 constants.ts×1 core.ts×10 effect.ts×1 scheduler.ts×5 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×7 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:251` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:125` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | | A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:267` | boundaries.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 fuzz-findings-l2.test.ts×4 ispending-in-boundary-on-3528.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | -| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:277` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×4 | a34-writes-then-derivations.test.ts×2 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 fuzz-findings-l2.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | +| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:277` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×4 | mount-cases.ts×1 a34-writes-then-derivations.test.ts×2 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 fuzz-findings-l2.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | ## V — fixed violations | id | status | defined | cited in src | cited in tests | statement (at definition) | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 212a88f48..50b6f5576 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -55,7 +55,7 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t - **A first load derived from a hold lands into it** (#3800): a first load born pending in a frame that read a hold lands into that hold if the hold is still live, else as its own commit; the hold commits without it. **Pending** — `fix/create-time-holds` (`91e474506`); [#3800](https://github.com/solidjs/solid/issues/3800) is open. - **A first pass under a loading boundary that has not shown content is the boundary's, not the tick's** — in a flush as outside one (A29's boundary exemption, #3540). The out-of-flush form is on `next`; the in-flush form (a fresh `Loading` over a held value shows its fallback in a flush) is **pending** — `fix/create-time-holds` (`ca71e5d9d`). -**A boundary that has not shown content owns its subtree — A29's boundary scope (2026-10-06, maintainer ruling; amendment — #3540's L2 regression; the bullet above and A15 are unchanged outside such a boundary).** The ruling, in substance: content under a loading boundary that has not shown content — or that an `on` change has re-armed — belongs to that boundary, not to any hold it reads, whichever computation does the reading (a creation pass, a re-running mount effect, the boundary's own render, or committed content under a re-armed boundary). While that content waits for a hold or its own first loads, it is pending: the boundary shows its fallback, and no hold waits for it. Whether the fallback is seen depends on whether the boundary's own mount is held: a boundary mounted as part of a hold (`` with `x` held, or nested in held content) appears at that hold's commit. Outside such a boundary, A15 and the direction rule are unchanged: committed work that reads a hold joins it, never-committed work waits on every hold it read, and if no boundary would catch it, it stays hidden with the hold. The `on` case is the one place the rule overrides A15 for committed content: committed content under a re-armed boundary that reads a hold waits behind the fallback rather than joining the hold. Membership is decided by the scope, not by the pass: the reading computation's nearest collecting loading boundary (the one `catchStatus` would catch at) owns the read, unless the boundary's own display is the hold's anyway — the flush has joined that hold, the boundary's output is held by it while not showing content, or the pass that is mounting the boundary has joined it. Readers that waited re-derive at the hold's commit as A15's stale readers do; no reader is registered after the commit. `isPending` of the boundary's value is false while its fallback shows (nothing stale is shown), and content that has not loaded under it has no value (`latest` throws, as for any first load). Pre-L2 served the committed value in these shapes (a fresh boundary in a flush, and committed content under a re-armed boundary over a held write — `content 0`); under A29 that tears, and the fallback is the ruled display. **Mechanism:** `GlobalQueue._owns(c, t)` (boundaries.ts, `owns`: `catcherOf` over `boundaryOf` — the output carries its boundary scope, `_scope`), asked by `read()` for a held source (`scopedRead`: the reader is a stale reader of `t` and the read throws `NotReadyError(source)`, so the boundary collects it), by `propagateStatus` before joining a pending memo into a hold, and by `recompute`'s head before joining a held node's pass (an owned held node leaves the transaction, `leaveHold`); a held computation read from the scope that owns it is content, not a hold — it leaves the transaction and re-derives at the read. Lanes and verdict readers are not scoped. **Open (prototype):** the "the flush is the hold's" exemption is decided mid-flush, so content judged owned before the flush joins the hold has left it while content judged after has not — the hold can land the boundary's stale staging (`tests/loading-fallback-in-flush-3540.test.ts`, the `it.fails` fuzzer finding); deciding that clause at the seam is the fix. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (fresh mounts in a flush and from mainline: memo, direct and bound content; nested under a boundary that has shown content; a mount that joined the hold appears at its commit; the boundary's hold stays its own; catchers at any depth — unrevealed, re-armed outer or nearest, revealed, plain; committed content under a re-armed or plain boundary; boundaries mounted by the hold, top-level or nested; no catcher; `isPending` and `latest` on each); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. +**A boundary that has not shown content owns its subtree — A29's boundary scope (2026-10-06, maintainer ruling; amendment — #3540's L2 regression; the bullet above and A15 are unchanged outside such a boundary).** The ruling, in substance: content under a loading boundary that has not shown content — or that an `on` change has re-armed — belongs to that boundary, not to any hold it reads, whichever computation does the reading (a creation pass, a re-running mount effect, the boundary's own render, or committed content under a re-armed boundary). While that content waits for a hold or its own first loads, it is pending: the boundary shows its fallback, and no hold waits for it. Whether the fallback is seen depends on whether the boundary's own mount is held: a boundary mounted as part of a hold (`` with `x` held, or nested in held content) appears at that hold's commit. Outside such a boundary, A15 and the direction rule are unchanged: committed work that reads a hold joins it, never-committed work waits on every hold it read, and if no boundary would catch it, it stays hidden with the hold. The `on` case is the one place the rule overrides A15 for committed content: committed content under a re-armed boundary that reads a hold waits behind the fallback rather than joining the hold. Membership is decided by the scope, not by the pass: the reading computation's nearest collecting loading boundary (the one `catchStatus` would catch at) owns the read, unless the boundary's own display is the hold's anyway — the flush has joined that hold, the boundary's output is held by it while not showing content, or the pass that is mounting the boundary has joined it. "The flush has joined that hold" is the flush's final membership, decided at the seam: a read made pending under the boundary before the flush joined the hold it read was the hold's after all — the boundary's swap parked with the hold, so its fallback is never seen — and the reader re-derives as the hold's, the content entering it to appear at its commit. And the content is behind the fallback only once the swap shows: a boundary whose swap parked with the flush's transaction (the change that re-armed it is held) keeps its content on screen until then, so a hold that content read — or left — lands with that transaction (merged at the seam), never before it. Readers that waited re-derive at the hold's commit as A15's stale readers do; no reader is registered after the commit. `isPending` of the boundary's value is false while its fallback shows (A19: none of the three causes holds — the swap is not held, and the content under it is loading, exception 1; "boundaries … never enter the definition"), as for a boundary showing its fallback over a first load. **A11 under the scope:** a derivation under an owning boundary is not the holder's work, so nothing computes it from the held world before the commit — `latest()` of it answers the value it has: its committed value while it is pending (`isPending` true), and `NotReadyError` if it has none (content mounted under the boundary, A19 exception 1). Next and pre-L2 staged it in the hold, so `latest()` served the held derivation; serving it without that pass joining the hold would need a second, unheld derivation in the hold's world, which no channel provides. Outside an owning boundary A11 is unchanged. Pre-L2 served the committed value in these shapes (a fresh boundary in a flush, and committed content under a re-armed boundary over a held write — `content 0`); under A29 that tears, and the fallback is the ruled display. **Mechanism:** `GlobalQueue._owns(c, el)` (boundaries.ts, `scopedRead`; installed with the boundary module, so a graph without one pays the hook's null checks), asked at the hold's doors: by `read()` for a held source — the reader's nearest collecting loading boundary (`owns`: `catcherOf` over `boundaryOf`; the output carries its boundary scope, `_scope`) owns the read, the reader is a stale reader of the hold and the read throws `NotReadyError(source)`, so the boundary collects it; a held computation read from the scope that owns it is content, not a hold — it leaves the transaction (`leaveHold`) and re-derives at the read — and, as `_owns(el, el)`, by `recompute`'s head before a held node's pass joins its hold and by `propagateStatus` before a pending propagated onto a held memo does: an owned held node leaves the transaction. A re-arm that finds nothing pending under the boundary owns what an earlier change holds there with no flight (a staged write, an action still running): each held computation under it that the scope owns leaves its hold and re-derives in the arming flush (`ownHeld`), so its read of the hold is pending under the boundary. The seam's decision is `GlobalQueue._boundaryPark(t)`'s, called once the flush has parked into `t` (its membership final): what the scope decided this flush — reads made pending and nodes that left a hold — is recorded with the hold and the owning boundary (`scoped`); a hold whose boundary's swap parked with `t` merges into `t`, and the reads whose hold is (now) `t` re-derive next round — the output is then held by `t` showing its fallback, which is not an owning display. Known, A15's and not the scope's: a hold the re-arm releases lands at once, and a reader outside the boundary re-derived at that landing that goes pending on the change's own flight keeps its pre-hold content beside the landed hold (pinned `it.fails`; boundaries #75's reduction shows the same tear without a boundary, on next too). Lanes and verdict readers are not scoped. Open: a boundary a verdict reader mounts (` 0}>` over an action's write) is created by verdict-lane work, and its first passes are routed into that lane (`recompute`'s first-pass lane rule), whose world is the proposal: it shows the held derivation now rather than its fallback (mount-under-hold `verdict` family; the same on pre-L2, next and #3843). The fix belongs to the first-pass lane rule, with #3835's. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (fresh mounts in a flush and from mainline: memo, direct and bound content; nested under a boundary that has shown content; a mount that joined the hold appears at its commit; the boundary's hold stays its own; catchers at any depth — unrevealed, re-armed outer or nearest, revealed, plain; committed content under a re-armed or plain boundary; boundaries mounted by the hold, top-level or nested; no catcher; `isPending` and `latest` on each); a read made pending before the flush joins its hold — one boundary, two, nested, re-armed twice, with and without a later hide, and the #1078 fuzzer finding; a swap parked with another change takes the hold its content read, #476/#1674); `tests/loading-on-frame-following-3540.test.ts` (3b: a flight an earlier change started; 3c: content an earlier action holds by a staged write — memo, direct and bound, re-armed after the action and, as a control, inside it); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. **Not yet one-way (recorded, not ruled).** The frame of a tick is still one transaction (the tick ruling above stands), so two shapes keep the hold waiting on first loads it never needed: diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index e47698e9f..e0cac4ac2 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -69,11 +69,13 @@ import { globalQueue, haltReactivity, joinFuture, + merge, passLane, passTx, resolveTx, schedule, setPassLane, + staleReader, txOf, type Transaction } from "./core/scheduler.js"; @@ -215,30 +217,107 @@ function catcherOf(node: Owner, flags: number): Boundary | undefined { return b; } -/** GlobalQueue._owns — A29's boundary scope (2026-10-06): a loading boundary - * that has not shown content, or that `on` re-armed, owns its subtree. A - * pass under it reading a node `t` holds does not join `t`: the content is - * pending under that boundary — it is the boundary that would catch it — - * and no hold waits for it. Not when the boundary's display is `t`'s - * anyway — then its fallback is never seen, and the content enters `t` to - * appear with its commit: the flush is `t`'s (everything it stages lands - * with `t`, the swap included); the output is held by `t` showing its +/** A29's boundary scope (2026-10-06): a loading boundary that has not shown + * content, or that `on` re-armed, owns its subtree. A pass under it reading + * a node `t` holds does not join `t`: the content is pending under that + * boundary — it is the boundary that would catch it — and no hold waits for + * it. Not when the boundary's display is `t`'s anyway — then its fallback + * is never seen, and the content enters `t` to appear with its commit: the + * flush is `t`'s (everything it stages lands with `t`, the swap included — + * final at the seam, `boundaryPark`); the output is held by `t` showing its * fallback or never committed (the boundary was mounted, or re-armed, as * part of the hold); or, mid-mount, the pass creating the boundary read - * `t`. */ -function owns(c: Computed, t: Transaction): boolean { + * `t`. The boundary that owns `c`'s read of `t`, if one does. */ +function owner(c: Computed, t: Transaction): Boundary | undefined { const b = catcherOf((c as BoundaryOutput)._scope ?? c, STATUS_PENDING); - if (b === undefined) return false; - if (flushTransaction !== null && resolveTx(flushTransaction) === t) return false; + if (b === undefined) return undefined; + if (flushTransaction !== null && resolveTx(flushTransaction) === t) return undefined; const o = b._output; if (o !== null) - return !( - o._config & CONFIG_HELD && + return o._config & CONFIG_HELD && (b._fallback || o._statusFlags & STATUS_UNINITIALIZED) && txOf(o) === t - ); + ? undefined + : b; const p = (b._owner as Root)._parentComputed as Computed | null; - return !(p !== null && p._flags & REACTIVE_JOINED && passTx !== null && resolveTx(passTx) === t); + return p !== null && p._flags & REACTIVE_JOINED && passTx !== null && resolveTx(passTx) === t + ? undefined + : b; +} + +/** GlobalQueue._owns — the boundary scope at the hold's doors: a read of a + * held node (`read`, a derive's draft), and a held node about to join its + * own hold (`c === el`: its pass's head, a pending propagated onto it). + * + * A held node a boundary owns now leaves its transaction (true: it goes on + * as a plain one). A read from under an owning boundary — whichever pass + * reads — joins nothing and serves nothing: the content is pending there, + * the boundary catches it and shows its fallback, and it waits for the + * landing as the hold's stale reader (`_reruns`: re-derived on the + * committed world). A held computation the same scope owns is content, not + * a hold: it leaves the transaction and re-derives here (true — the read + * goes on as a plain one, pending if it is). Not lane work or a verdict + * reader: they read the screen, not the hold (`frameRead`). */ +function scopedRead(c: Computed | null, el: Signal | Computed): boolean { + if (c === null) return false; + const t = txOf(el); + if (t._lane) return false; + if (c === el) return leaveHold(c, t, owner(c, t)); + if (passLane !== null || c._config & CONFIG_VERDICT) return false; + const b = owner(c, t); + if (b === undefined) return false; + if ( + typeof (el as Computed)._fn === "function" && + leaveHold(el as Computed, t, owner(el as Computed, t)) + ) { + recompute(el as Computed); + return true; + } + staleReader(c, t); + if (globalQueue._running) scoped.push([c, t, b, true]); + throw new NotReadyError(el); +} + +/** What the scope decided this flush, mid-pass: a node that left a hold, + * or a read made pending (`read`), with the hold and the boundary that + * owns it. Revisited when the flush parks (`boundaryPark`), dropped at the + * seam's end. */ +const scoped: [node: Computed, hold: Transaction, owner: Boundary, read: boolean][] = []; + +/** GlobalQueue._boundaryPark — the flush parked into `t`: its membership is + * final, and the reads it made pending under an owning boundary are judged + * against it. A boundary's content shows until its swap does, so the hold + * its content read cannot land before the swap: a swap parked with `t` + * takes that hold into `t` (merged — they land as one, the fallback with + * the change that caused it) — whether the content read the hold or left + * it. A read whose hold is `t`, or is now, was + * `t`'s after all — the flush joined it after the read: the boundary's + * swap parked with `t` and its fallback can never be seen, so the reader + * re-derives next round as `t`'s — the output is held by `t` showing its + * fallback then, which is not an owning display (`owner`) — and the content + * enters `t` to appear at its commit. */ +function boundaryPark(t: Transaction): void { + for (const [c, u, b, read] of scoped) { + if (c._flags & REACTIVE_DISPOSED) continue; + const o = b._output; + if (resolveTx(u) !== t && o !== null && o._config & CONFIG_HELD && txOf(o) === t) merge(u, t); + if (read && resolveTx(u) === t) enqueueSub(c); + } +} + +/** A held node the loading boundary `b` owns now leaves its transaction: + * its staging there is void, and its next pass derives on the committed + * world. (Staged again this flush, it is held again if the flush parks + * into `t`.) False when no boundary owns it. */ +function leaveHold(el: Computed, t: Transaction, b: Boundary | undefined): boolean { + if (b === undefined) return false; + el._pendingValue = NOT_PENDING; + el._config &= ~CONFIG_HELD; + el._x!._transaction = null; + const i = t._nodes.indexOf(el); + if (i >= 0) t._nodes.splice(i, 1); + if (globalQueue._running) scoped.push([el, t, b, false]); + return true; } /** GlobalQueue._catch: status from a frame reader, nearest boundary first. @@ -369,8 +448,10 @@ export function ready(b: Boundary): boolean { /** GlobalQueue._boundarySeam, end of the seam: readers that settled, died, * or landed and committed are dropped; a fallback with none left reveals - * next round. An arm resolves: nothing collected, nothing happened. */ + * next round. An arm resolves: nothing collected, nothing happened. The + * flush's scoped reads are done with (`boundaryPark`). */ function boundarySeam(): void { + if (scoped.length !== 0) scoped.length = 0; for (const b of collecting) { const flags = b._output!._flags; if (flags & REACTIVE_DISPOSED) { @@ -498,6 +579,25 @@ function arm(b: Boundary): void { return; } } + if (globalQueue._running) ownHeld(b._owner); +} + +/** The re-armed boundary owns its content (A29's boundary scope): what an + * earlier change holds under it — a write staged with no flight, so nothing + * under it is pending — leaves that hold and re-derives on the committed + * world now, and its read of the hold is pending under the boundary. */ +function ownHeld(o: Owner): void { + for ( + let n = o._firstChild as Computed | null; + n !== null; + n = n._nextSibling as Computed | null + ) { + if (n._config & CONFIG_HELD && !(n as any)._type) { + const t = txOf(n); + if (!t._lane && leaveHold(n, t, owner(n, t))) enqueueSub(n); + } + ownHeld(n); + } } /** The error boundary's `reset`: re-run what threw (a comparator throw @@ -805,7 +905,8 @@ export function createErrorBoundary( // Installed at module evaluation — present exactly when something imports a // boundary. An app without one pays the three null checks and nothing else. GlobalQueue._catch = catchStatus; -GlobalQueue._owns = owns; +GlobalQueue._owns = scopedRead; GlobalQueue._hidden = hidden; GlobalQueue._boundarySeam = boundarySeam; +GlobalQueue._boundaryPark = boundaryPark; GlobalQueue._heldRun = heldRun; diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 7d0f0d60d..529fc7c19 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -897,7 +897,7 @@ export function propagateStatus( passLane === null && (sub._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && globalQueue._running && - !GlobalQueue._owns?.(sub, txOf(sub)) + !GlobalQueue._owns?.(sub, sub) ) joinFuture(txOf(sub)); return; @@ -926,7 +926,7 @@ export function propagateStatus( status === STATUS_PENDING && (sub._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && !(sub as any)._type && - !GlobalQueue._owns?.(sub, txOf(sub)) + !GlobalQueue._owns?.(sub, sub) ) joinFuture(txOf(sub)); } diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 052d0f007..dd7c8fbc5 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -107,8 +107,7 @@ import { joinPassTx, stagedReaders, staleReader, - laneDirty, - type Transaction + laneDirty } from "./scheduler.js"; import type { Computed, @@ -303,12 +302,10 @@ export function recompute(el: Computed, create: boolean = false): void { // a sync write (#3460). The pass is the lane's if it reads the lane's // world (`read`), and has left it otherwise. if (tx._lane) { - } else if (GlobalQueue._owns?.(el, tx)) { + } else if (GlobalQueue._owns?.(el, el)) { // Under a loading boundary that owns it now (re-armed since it - // joined — A29's boundary scope) it is the boundary's, not the - // hold's: a read of the hold makes this pass pending there - // (`scopedRead`). - leaveHold(el, tx); + // joined — A29's boundary scope) it has left the hold: a read of + // the hold makes this pass pending there. } else if (isEffect !== EFFECT_RENDER && !(el._config & CONFIG_VERDICT)) joinPassTx(tx); else if (tx !== flushTransaction && !(el._statusFlags & STATUS_UNINITIALIZED)) { // Published mainline, it is not held: the frame this pass builds is @@ -956,49 +953,6 @@ function joinPass(c: Computed, el: Signal | Computed): void { if ((c as any)._type !== EFFECT_RENDER) joinPassTx(txOf(el)); } -/** A29's boundary scope (2026-10-06): a read of a held node from under a - * loading boundary that owns its subtree (`GlobalQueue._owns`) — whichever - * pass reads it — joins nothing and serves nothing: the content is pending - * there, the boundary catches it and shows its fallback, and it waits for - * the landing as the hold's stale reader (`_reruns`: re-derived on the - * committed world). Ahead of every hold read (`joinPass`, `frameRead`, a - * derive's draft). Not lane work or a verdict reader: they read the - * screen, not the hold (`frameRead`). A held computation the same scope - * owns is content, not a hold: it leaves the transaction and re-derives - * here (true — the read goes on as a plain one, pending if it is). */ -export function scopedRead(c: Owner | null, el: Signal | Computed): boolean { - if ( - GlobalQueue._owns === undefined || - c === null || - passLane !== null || - (c as Computed)._config & CONFIG_VERDICT - ) - return false; - const t = txOf(el); - if (t._lane || !GlobalQueue._owns(c as Computed, t)) return false; - if ( - typeof (el as Computed)._fn === "function" && - GlobalQueue._owns(el as Computed, t) - ) { - leaveHold(el as Computed, t); - recompute(el as Computed); - return true; - } - staleReader(c as Computed, t); - throw new NotReadyError(el); -} - -/** A held node a loading boundary owns now leaves its transaction: its - * staging there is void, and its next pass derives on the committed world - * (A29's boundary scope). */ -function leaveHold(el: Computed, t: Transaction): void { - el._pendingValue = NOT_PENDING; - el._config &= ~CONFIG_HELD; - el._x!._transaction = null; - const i = t._nodes.indexOf(el); - if (i >= 0) t._nodes.splice(i, 1); -} - /** A15's stale reader (shared-hole and reveal corollaries): a render effect * reading a node held by a transaction that is not the flush's. It is served * the committed value — a pending one's too — publishes mainline, and is @@ -1666,7 +1620,7 @@ export function read(el: Signal | Computed): T { // future (REACTIVE_JOINED): a first pass that did is born held (A29), // wherever it was created. Lane work sees the screen (`frameRead`). if (el._config & CONFIG_HELD && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) { - scopedRead(c, el); + GlobalQueue._owns?.(c as Computed, el); if (frameRead(c as Computed, el)) return el._value as T; joinPass(c as Computed, el); } @@ -1704,7 +1658,7 @@ export function read(el: Signal | Computed): T { !(el._config & CONFIG_OVERRIDE) && !(c._config & CONFIG_CHILDREN_FORBIDDEN) ) { - if (scopedRead(c, el)) { + if (GlobalQueue._owns?.(c as Computed, el)) { } else if (owner._statusFlags & STATUS_UNINITIALIZED) { (c as Computed)._flags |= REACTIVE_JOINED; joinPassTx(txOf(el)); diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index ed82437c0..01445aa23 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -665,11 +665,18 @@ export class GlobalQueue implements IQueue { declare static _catch: | ((node: Computed, flags: number, error: unknown) => boolean) | undefined; - /** `_owns` — a pass reading a node `t` holds is under a loading boundary - * that owns it (A29's boundary scope): pending there, not `t`'s. */ - declare static _owns: ((c: Computed, t: Transaction) => boolean) | undefined; + /** `_owns(c, el)` — A29's boundary scope: pass `c`, about to read held `el` + * (`c === el`: about to join its own hold), is under a loading boundary + * that owns it. Throws its pending there; true when the hold was left and + * the read or pass goes on as a plain one. */ + declare static _owns: + | ((c: Computed, el: Signal | Computed) => boolean) + | undefined; declare static _hidden: ((r: Computed) => boolean) | undefined; declare static _boundarySeam: (() => void) | undefined; + // `_boundaryPark` — the flush parked into `t`, its membership final: the + // boundary scope revisits the reads it made pending this flush (A29). + declare static _boundaryPark: ((t: Transaction) => void) | undefined; // `_heldRun` — a queued run under a fallback-showing boundary waits for // the reveal (true: held; the boundary re-queues it by type). The // synchronous first render on creation builds the subtree, attached or @@ -791,6 +798,7 @@ export class GlobalQueue implements IQueue { } pendingNodes.length = 0; GlobalQueue._storePark?.(t); + GlobalQueue._boundaryPark?.(t); // (This flush's runs are stashed with `t` below — after the landings, // so a `t` that lands at this very seam runs them first, ahead of diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index ae891dcd6..f1c24bc5e 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -53,7 +53,6 @@ import { ownedScopeWriteMessage, read as readNode, REACTIVE_WRITE_IN_OWNED_SCOPE_SIGNAL_MESSAGE, - scopedRead, setSignal, slotSignal, stagedRead, @@ -1537,7 +1536,7 @@ function readSource( const leaf = key !== undefined ? target.n?.[key as any] : undefined; if (leaf !== undefined) { if ((leaf._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD) { - scopedRead(context, leaf); + GlobalQueue._owns?.(context as Computed, leaf); joinPassTx(txOf(leaf)); } } else { @@ -1547,7 +1546,7 @@ function readSource( (k._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && (key === undefined || heldKeyChanged(target, key)) ) { - scopedRead(context, k); + GlobalQueue._owns?.(context as Computed, k); joinPassTx(txOf(k)); } } diff --git a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts index 2e30e6703..d72d2fe37 100644 --- a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts +++ b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts @@ -981,54 +981,310 @@ describe("#3540: the boundary scope — semantic fuzzer findings (rev 19, seed 9 expect(log).toEqual(["view 1 0", "view loading"]); }); - // boundaries #1078, reduced. OPEN: the re-arm's flush judges the tree - // owned and it leaves the earlier hold; later in the same flush an async - // memo the hold has joins the flush to it, and from then on the output - // is not owned (`flushTransaction === t`) — it keeps its staging there. - // The hold lands after the hide and publishes that stale content, which - // sticks. Membership decided mid-flush is not revisited when the flush - // joins a hold; the fix is to decide that clause at the seam. - it.fails( - "hidden after a re-arm over a held mount: the boundary does not publish stale content", - async () => { - const [s1, setS1] = createSignal(0); - const [visible, setShow] = createSignal(false); - const flights: (() => void)[] = []; - let shown: unknown; - createRoot(() => { - const n0 = createMemo(() => s1()); - const n1 = createMemo(async () => { - const v = n0(); - await new Promise(r => flights.push(r)); - return v; - }); - const view = Loading(() => (visible() ? `content ${s1()}` : "hidden"), "loading", s1); - createRenderEffect(view, v => { - shown = v; - }); - createRenderEffect( - () => (visible() ? n1() : "hidden"), - () => {} - ); - }); - const turn = async (fn: () => void) => { - fn(); - flush(); - await drain(); - flush(); - }; - await turn(() => {}); + // boundaries #1078, reduced. The re-arm's flush judges the content owned + // and it leaves the earlier hold; later in the same flush an async memo + // that hold has (read outside) joins the flush to it — the flush is the + // hold's after all. Two defects published the content staged before the + // re-arm at the hold's landing, after the hide: the pending propagated + // onto the boundary's held tree left it held with that staging (the tree + // now leaves the hold there), and the reads made pending before the join + // were never revisited (the seam re-derives them). + it("hidden after a re-arm over a held mount: the boundary does not publish stale content", async () => { + const w = seamWorld((s1, visible) => [ + Loading(() => (visible() ? `content ${s1()}` : "hidden"), "loading", s1) + ]); + await w.mount(); + await w.turn(() => w.setS1(0)); + await w.turn(() => w.setShow(false)); + await w.settle(); + expect(w.log).toEqual(["0:hidden"]); + }); +}); + +/** The #1078 world: `n1`, async over `s1`, is read by a render effect + * outside the boundaries while `visible`. Mounted with `s1` = 1 committed + * and `setShow(true)` held by `n1`'s flight: the boundaries' content + * (`content 1`) is staged in that hold, the screen still `hidden`. `s1` is + * every boundary's `on`; writing it re-arms them, and re-asks `n1` — the + * flush joins the hold after the boundaries' content has read it. `log`: + * what each boundary shows, from the hold on. */ +function seamWorld(boundaries: (s1: () => number, visible: () => boolean) => (() => unknown)[]) { + const drain = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }; + const [s1, setS1] = createSignal(0); + const [visible, setShow] = createSignal(false); + const flights: (() => void)[] = []; + const log: string[] = []; + createRoot(() => { + const n0 = createMemo(() => s1()); + const n1 = createMemo(async () => { + const v = n0(); + await new Promise(r => flights.push(r)); + return v; + }); + boundaries(s1, visible).forEach((view, i) => + createRenderEffect(view, v => { + log.push(`${i}:${v}`); + }) + ); + createRenderEffect( + () => (visible() ? n1() : "hidden"), + () => {} + ); + }); + const turn = async (fn: () => void) => { + fn(); + flush(); + await drain(); + flush(); + }; + const settle = async () => { + for (let i = 0; i < 4; i++) { while (flights.length) flights.shift()!(); await turn(() => {}); - await turn(() => setS1(1)); - await turn(() => setShow(true)); - await turn(() => setS1(0)); - await turn(() => setShow(false)); - for (let i = 0; i < 4; i++) { - while (flights.length) flights.shift()!(); - await turn(() => {}); - } - expect(shown).toBe("hidden"); } - ); + }; + const mount = async () => { + await turn(() => {}); + await settle(); + await turn(() => setS1(1)); + await turn(() => setShow(true)); + log.length = 0; + }; + return { setS1, setShow, log, turn, settle, mount }; +} + +// The flush's membership is final at the seam (A29's boundary scope: "the +// flush is `t`'s"). The re-arm makes the content's reads pending under the +// boundary; the flush then joins the hold those reads were of — the +// boundary's swap parks with it, so its fallback can never be seen. The +// seam re-derives those reads as the hold's: the content enters it and +// appears at its commit. Before, the fallback the flush had staged was +// committed with the hold and shown for a round. Pre-L2 and next show the +// re-armed content at the commit too (no fallback). +describe("#3540: the boundary scope — a read made pending before the flush joins its hold", () => { + it("one boundary: the content appears at the hold's commit, no fallback", async () => { + const w = seamWorld((s1, visible) => [ + Loading(() => (visible() ? `content ${s1()}` : "hidden"), "loading", s1) + ]); + await w.mount(); + await w.turn(() => w.setS1(0)); + expect(w.log).toEqual([]); + await w.settle(); + expect(w.log).toEqual(["0:content 0"]); + }); + + it("two boundaries re-armed by the same change: neither shows its fallback", async () => { + const w = seamWorld((s1, visible) => [ + Loading(() => (visible() ? `a ${s1()}` : "hidden"), "loading", s1), + Loading(() => (visible() ? `b ${s1()}` : "hidden"), "loading", s1) + ]); + await w.mount(); + await w.turn(() => w.setS1(0)); + expect(w.log).toEqual([]); + await w.settle(); + expect(w.log.sort()).toEqual(["0:a 0", "1:b 0"]); + }); + + it("two boundaries, then a hide: both land hidden", async () => { + const w = seamWorld((s1, visible) => [ + Loading(() => (visible() ? `a ${s1()}` : "hidden"), "loading", s1), + Loading(() => (visible() ? `b ${s1()}` : "hidden"), "loading", s1) + ]); + await w.mount(); + await w.turn(() => w.setS1(0)); + await w.turn(() => w.setShow(false)); + await w.settle(); + expect(w.log.sort()).toEqual(["0:hidden", "1:hidden"]); + }); + + it("nested: neither the inner nor the outer fallback shows", async () => { + const w = seamWorld((s1, visible) => { + const outer = Loading( + () => { + const inner = Loading(() => (visible() ? `in ${s1()}` : "hidden"), "inner", s1); + return () => `out[${inner()}]`; + }, + "outer", + s1 + ); + return [outer]; + }); + await w.mount(); + await w.turn(() => w.setS1(0)); + expect(w.log).toEqual([]); + await w.settle(); + expect(w.log).toEqual(["0:out[in 0]"]); + }); + + it("nested, then a hide: lands hidden", async () => { + const w = seamWorld((s1, visible) => { + const outer = Loading( + () => { + const inner = Loading(() => (visible() ? `in ${s1()}` : "hidden"), "inner", s1); + return () => `out[${inner()}]`; + }, + "outer", + s1 + ); + return [outer]; + }); + await w.mount(); + await w.turn(() => w.setS1(0)); + await w.turn(() => w.setShow(false)); + await w.settle(); + expect(w.log).toEqual(["0:out[hidden]"]); + }); + + it("re-armed twice under the hold: the last content appears at the commit", async () => { + const w = seamWorld((s1, visible) => [ + Loading(() => (visible() ? `content ${s1()}` : "hidden"), "loading", s1) + ]); + await w.mount(); + await w.turn(() => w.setS1(0)); + await w.turn(() => w.setS1(2)); + expect(w.log).toEqual([]); + await w.settle(); + expect(w.log).toEqual(["0:content 2"]); + }); + + // boundaries #476/#1674 (rev 19, seed 91501), reduced. `show` is held by + // `n1`'s flight, which the boundary's content forwards; `s = 0` re-arms + // the boundary — its content leaves that hold — and starts `n2`, which a + // reader outside reads, so the change's frame, and the boundary's swap + // with it, waits on `n2`. The content stays on screen until the swap, so + // the hold it read lands with the swap's frame (merged at the seam): + // before, it landed at once — `show` true beside content derived from + // `show` false — and the fallback followed a frame later. + it("a swap parked with another change takes the hold its content read with it", async () => { + const drain = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }; + const [s, setS] = createSignal(0); + const [visible, setShow] = createSignal(false); + const manual: (() => void)[] = []; + const frame: Record = {}; + createRoot(() => { + const n0 = createMemo(() => s()); + const n1 = createMemo(() => { + const v = n0(); + return new Promise(r => manual.push(() => r(v))); + }); + const n2 = createMemo(() => Promise.resolve(s())); + createRenderEffect(visible, v => { + frame.show = v; + }); + const view = Loading(() => (visible() ? `content ${n1()}` : "hidden"), "loading", s); + createRenderEffect(view, v => { + frame.view = v; + }); + createRenderEffect( + () => `outside ${n2()}`, + v => { + frame.outside = v; + } + ); + }); + const turn = async (fn: () => void) => { + fn(); + flush(); + const after = { ...frame }; + await drain(); + flush(); + return [after, { ...frame }]; + }; + await turn(() => {}); + manual.shift()!(); + await turn(() => {}); + await turn(() => setS(1)); + await turn(() => setShow(true)); + expect(frame).toEqual({ show: false, view: "hidden", outside: "outside 1" }); + expect(await turn(() => setS(0))).toEqual([ + { show: false, view: "hidden", outside: "outside 1" }, + { show: true, view: "loading", outside: "outside 0" } + ]); + while (manual.length) manual.shift()!(); + await turn(() => {}); + expect(frame).toEqual({ show: true, view: "content 0", outside: "outside 0" }); + }); + + // boundaries #1674 (rev 19, seed 91501), reduced again after the merge + // above. KNOWN, A15's landing (not the scope): `show` is held only by + // the content under the boundary; `s = 0` re-arms it, and the hold — + // which no frame waits on any more — lands at once (the ruling: the + // re-armed boundary owns its content). A reader outside, re-derived at + // that landing, goes pending on `n2`, which `s = 0` started — the + // change's frame — and keeps showing "hidden" beside the landed `show`. + // The same tear without any boundary — a hold landing while a reader of + // it re-derived at the landing is pending on another change's flight — is + // boundaries #75's reduction, which fails on next too; next passes this + // shape only because there `s = 0` re-asks `n1`, joins the hold and lands + // with it. The fix is A15's: such a landing waits for the frame its + // reader went pending in. + it.fails("a hold released by a re-arm lands beside a reader pending on the change", async () => { + const drain = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }; + const [s, setS] = createSignal(0); + const [visible, setShow] = createSignal(true); + const manual: (() => void)[] = []; + const frames: Record[] = []; + const frame: Record = {}; + createRoot(() => { + const n0 = createMemo(() => s()); + const n1 = createMemo(() => { + const v = n0(); + return new Promise(r => manual.push(() => r(v))); + }); + const n2 = createMemo(() => Promise.resolve(n0())); + createRenderEffect(visible, v => { + frame.show = v; + }); + createRenderEffect( + () => (visible() ? `outside ${s()} ${n2()}` : "hidden"), + v => { + frame.outside = v; + } + ); + const view = Loading(() => (visible() ? `content ${n1()}` : "hidden"), "loading", s); + createRenderEffect(view, v => { + frame.view = v; + }); + }); + const turn = async (fn: () => void) => { + fn(); + flush(); + frames.push({ ...frame }); + await drain(); + flush(); + frames.push({ ...frame }); + }; + await turn(() => {}); + manual.shift()!(); + await turn(() => {}); + await turn(() => { + setShow(false); + setS(1); + }); + await turn(() => setShow(true)); + await turn(() => setS(0)); + while (manual.length) { + manual.shift()!(); + await turn(() => {}); + } + expect(frames.filter(f => f.show === true && f.outside === "hidden")).toEqual([]); + }); + + it("re-armed twice, then a hide: lands hidden", async () => { + const w = seamWorld((s1, visible) => [ + Loading(() => (visible() ? `content ${s1()}` : "hidden"), "loading", s1) + ]); + await w.mount(); + await w.turn(() => w.setS1(0)); + await w.turn(() => w.setS1(2)); + await w.turn(() => w.setShow(false)); + await w.settle(); + expect(w.log).toEqual(["0:hidden"]); + }); }); diff --git a/packages/signals/tests/loading-on-frame-following-3540.test.ts b/packages/signals/tests/loading-on-frame-following-3540.test.ts index 550467cc6..9d44a653c 100644 --- a/packages/signals/tests/loading-on-frame-following-3540.test.ts +++ b/packages/signals/tests/loading-on-frame-following-3540.test.ts @@ -445,6 +445,71 @@ describe("3b. an outside reader of a flight the `on` change did not start: the f }); }); +// Content an earlier action holds with no flight (a write it staged): nothing +// under the boundary is pending, but the re-armed boundary owns its content — +// it leaves the hold, the fallback shows now, and the content reveals at the +// action's commit. A re-arm inside the action is the action's frame: no +// fallback, the content lands with the commit. +describe("3c. content an earlier action holds by a staged write: the re-arm shows the fallback now", () => { + for (const content of ["direct", "memo", "bound"] as const) + for (const inside of [false, true]) + test(`${content} content, re-armed ${inside ? "inside" : "after"} the action`, async () => { + const d = captureWarnings(); + const [x, setX] = createSignal(0); + const [key, setKey] = createSignal(0); + const log: string[] = []; + let done!: () => void; + let dispose!: () => void; + createRoot(dispose_ => { + dispose = dispose_; + const view = untrack(() => + createLoadingBoundary( + () => { + if (content === "direct") return () => `c ${x()}`; + const m = createMemo(() => `c ${x()}`); + if (content === "memo") return m; + createRenderEffect(m, v => { + log.push(`bind ${v}`); + }); + return "

"; + }, + () => "fallback", + { on: key } + ) + ); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + }); + flush(); + const shows = (v: number) => + content === "bound" ? [`bind c ${v}`, "view

"] : [`view c ${v}`]; + expect(log).toEqual(shows(0)); + log.length = 0; + action(function* () { + setX(1); + if (inside) setKey(1); + yield new Promise(r => (done = r)); + })(); + flush(); + expect(log).toEqual([]); + if (!inside) { + setKey(1); + flush(); + expect(log).toEqual(["view fallback"]); + } + done(); + for (let i = 0; i < 8; i++) await microtask(); + flush(); + expect(log).toEqual( + inside ? (content === "bound" ? ["bind c 1"] : shows(1)) : ["view fallback", ...shows(1)] + ); + expect(d.codes()).toEqual([]); + d.stop(); + dispose(); + }); +}); + describe("4. `on: () => latest(id)`: the display-ahead read shows the fallback now, beside the held frame", () => { for (const write of ["plain", "action"] as Write[]) { test(`${write} write, shell lands first: [A] → [A + spinner] → [B + spinner] → [B + comments]`, async () => { diff --git a/packages/solid/CHEATSHEET.md b/packages/solid/CHEATSHEET.md index ab3ff8556..97066e5d0 100644 --- a/packages/solid/CHEATSHEET.md +++ b/packages/solid/CHEATSHEET.md @@ -655,7 +655,7 @@ If your training data is 1.x, these are the corrections. **Read this before gene - **Stores: setters take a draft callback** — mutate the draft in place by default. Returning a new value is shallow (array index-replace, object top-level diff); reach for it for filter/remove. Keyed reconcile is a _projection-fn_ feature, not a setter feature. - **`undefined` is a real value in `merge`** — it overrides rather than "skip this key". - **Async lives in computations** — return a Promise/AsyncIterable from `createMemo`/`createStore(fn)`/`createProjection`. Pending reads participate in ``. -- **`Loading` covers unresolved branches** — once content has rendered, revalidation keeps it visible. Use `isPending(() => x())` for in-flight-change indicators or render guards; it reads `x` and participates in Loading like that read. `` is a dependency list, not a key: its value is never compared; a change to anything it reads makes the boundary stop waiting on its current content and show the fallback if something under it is pending (nothing otherwise). The fallback _follows the frame_: it lands with the change that caused it — immediately when nothing else holds that frame, together with the rest of the new page during a held navigation (never a spinner beside a page the change has not reached). `on={[a(), b()]}` for several. The re-armed boundary owns its content: data still loading from an earlier change shows the fallback now, wherever else it is read. If the data the change itself starts loading is also read outside the boundary, the frame waits on it and the fallback can never be seen — DEV warns `LOADING_ON_OUTSIDE_HOLD`; fix the structure (move the outside read under the boundary so one hold owns the data). A frame held past the content's landing by something else (the write's action, other pending data) shows no fallback either — a race, not reported; show the wait with `isPending()`. A display-ahead read in `on` (`on={latest(id)}`) shows the fallback _now_, beside the still-held frame — a capability, not the recommended shape. A fallback that names what is loading should read `latest(id)`, since `id()` is still the committed value while the new one loads. +- **`Loading` covers unresolved branches** — once content has rendered, revalidation keeps it visible. Use `isPending(() => x())` for in-flight-change indicators or render guards; it reads `x` and participates in Loading like that read. `` is a dependency list, not a key: its value is never compared; a change to anything it reads makes the boundary stop waiting on its current content and show the fallback if something under it is pending (nothing otherwise). The fallback _follows the frame_: it lands with the change that caused it — immediately when nothing else holds that frame, together with the rest of the new page during a held navigation (never a spinner beside a page the change has not reached). `on={[a(), b()]}` for several. The re-armed boundary owns its content: data still loading, or held by an action that is still running, from an earlier change shows the fallback now, wherever else it is read. If the data the change itself starts loading is also read outside the boundary, the frame waits on it and the fallback can never be seen — DEV warns `LOADING_ON_OUTSIDE_HOLD`; fix the structure (move the outside read under the boundary so one hold owns the data). A frame held past the content's landing by something else (the write's action, other pending data) shows no fallback either — a race, not reported; show the wait with `isPending()`. A display-ahead read in `on` (`on={latest(id)}`) shows the fallback _now_, beside the still-held frame — a capability, not the recommended shape. A fallback that names what is loading should read `latest(id)`, since `id()` is still the committed value while the new one loads. - **`isPending` ≠ 1.x `.loading`** — it fires while a value _change_ is in flight (an input changed, or `affects()` declared one), not for every fetch. A bare `refresh()`/poll re-asks the same question and is silent. For a reload that should read as pending: `affects(x); refresh(x)`. For a "saving…" affordance: a co-written optimistic flag. - **No `Suspense.Provider` or single error path** — async errors flow to `` (or effect `error`); no inline `resource.error` branching. - **`createRoot` is owned by parent by default** — disposed when parent disposes. To detach: `runWithOwner(null, fn)`. From 60d2f1ded62e818811b83187efbd1a0fecdd45fa Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 14:28:21 -0700 Subject: [PATCH 09/10] docs(signals): regenerate RULES-INDEX without local probe files (#3540) Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- packages/signals/docs/RULES-INDEX.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 26b5c5f4c..4145a33aa 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -39,7 +39,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul **src/:** none — every citation resolves. -**tests/:** B0, B6, §0, §15, §16, §19, §20, §21, §27, §28, §31, §39, §41 (test-only citations are informational; `--check` gates src/ only) +**tests/:** B0, B6, §0, §16, §19, §20, §21, §27, §28, §31, §39, §41 (test-only citations are informational; `--check` gates src/ only) ## A — spec propositions @@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:191` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:207` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:243` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | mount-cases.ts×1 async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×6 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:243` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×6 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:215` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:69` | async.ts×3 constants.ts×1 lanes.ts×6 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:79` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | @@ -78,7 +78,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:125` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | | A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:267` | boundaries.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 fuzz-findings-l2.test.ts×4 ispending-in-boundary-on-3528.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | -| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:277` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×4 | mount-cases.ts×1 a34-writes-then-derivations.test.ts×2 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 fuzz-findings-l2.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | +| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:277` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×4 | a34-writes-then-derivations.test.ts×2 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 fuzz-findings-l2.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | ## V — fixed violations | id | status | defined | cited in src | cited in tests | statement (at definition) | From 3e4b13beaabebbe3781615759ecc6ecfc25fe401 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 19:43:05 -0700 Subject: [PATCH 10/10] fix(signals): rebuild #3540's L2 fix on revision 1; defer re-arm Replaces the boundary-scope machinery (ownership hook, held-node leave, re-arm `ownHeld`, the seam park) with revision 1's fresh-mount fix (0479053414) on today's next: a first pass that reads a hold asks the boundaries up its chain (GlobalQueue._fresh); when a loading boundary that has not shown content catches it, the pass joins pass-scoped (passTx) instead of joining the flush, so the mount publishes and the boundary shows its fallback. A render effect born held under the boundary is caught the same way. catchStatus is unchanged. Re-arm under the boundary scope is deferred to a separate change, pending its ruling: those tests are pinned it.fails, the re-arm amendment text is back to next's wording with a dated note, and async.ts, store.ts and flow.ts are back to next. Refs #3540 Co-authored-by: Claude Co-authored-by: Cursor --- .changeset/fresh-loading-fallback-in-flush.md | 3 +- documentation/solid-2.0/05-async-data.md | 4 +- packages/signals/docs/RULES-INDEX.md | 6 +- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 2 +- packages/signals/src/boundaries.ts | 201 ++----- packages/signals/src/core/async.ts | 10 +- packages/signals/src/core/core.ts | 26 +- packages/signals/src/core/scheduler.ts | 34 +- packages/signals/src/store/store.ts | 8 +- .../loading-fallback-in-flush-3540.test.ts | 559 ++++++++++-------- .../loading-on-frame-following-3540.test.ts | 220 +++---- packages/solid/CHEATSHEET.md | 2 +- packages/solid/src/client/flow.ts | 12 +- 13 files changed, 505 insertions(+), 582 deletions(-) diff --git a/.changeset/fresh-loading-fallback-in-flush.md b/.changeset/fresh-loading-fallback-in-flush.md index 387befbcc..ef486c3c1 100644 --- a/.changeset/fresh-loading-fallback-in-flush.md +++ b/.changeset/fresh-loading-fallback-in-flush.md @@ -1,6 +1,5 @@ --- "@solidjs/signals": patch -"solid-js": patch --- -A loading boundary that has not shown content — or that an `on` change has re-armed — owns its subtree (#3540 under L2). Content under it that reads a held value or waits on its first loads is pending: the boundary shows its fallback, no hold waits for it, and it re-derives at the hold's commit. A fresh `Loading` mounted over a held value shows its fallback in a flush too, and content bound by a render effect under it no longer reveals empty. Committed content under an `on`-re-armed boundary that reads a value held by another change now waits behind the fallback instead of holding the change, including content an earlier `action` still holds by a plain write, with nothing loading. A boundary mounted as part of a hold still appears at that hold's commit, and outside such a boundary nothing changes. DEV's `LOADING_ON_OUTSIDE_HOLD` reports only a re-arm whose fallback is actually held; the `Loading` `on` docs say which data holds the change. While such a boundary shows its fallback, `isPending()` of it is `false`, and `latest()` of content under it answers the value that content has — its committed value, or `NotReadyError` for content mounted under it — rather than the held derivation. A hold that content under a re-armed boundary read lands together with the change whose frame shows the fallback, never ahead of it. +A fresh `Loading` mounted over a held value shows its fallback in a flush too (#3540 under L2): a first pass a loading boundary that has not shown content catches is the boundary's, not the tick's, so the mount publishes and the content reveals at the commit. Content bound by a render effect under the boundary is collected, so the boundary no longer reveals empty content. diff --git a/documentation/solid-2.0/05-async-data.md b/documentation/solid-2.0/05-async-data.md index 3dc4d9be0..a4e983b46 100644 --- a/documentation/solid-2.0/05-async-data.md +++ b/documentation/solid-2.0/05-async-data.md @@ -47,7 +47,7 @@ Importantly, `Loading` is intended to cover **branch readiness**: it handles a s Nested `Loading` boundaries can be used to avoid blocking large subtrees and to control where loading UI appears. -A `Loading` that has not shown content yet owns what is under it. Content there that reads a value a still-running change holds (a write inside an `action`, data another change is loading) is not part of that change: the boundary shows its fallback now, and the content appears when the change commits. The change does not wait for it. The exception is a boundary mounted by the change itself, for example a `` where `x` is the held write: it appears with that change's commit, with no fallback. While the fallback shows, `isPending()` of the boundary is `false`, as for any first load. Content that has not rendered yet has no value for `latest()` to return either: `latest()` of a memo created under the boundary throws until the commit, as any not-yet-loaded value does. +A `Loading` that has not shown content yet owns what is under it. Content there that reads a value a still-running change holds (a write inside an `action`, data another change is loading) is not part of that change: the boundary shows its fallback now, and the content appears when the change commits. The change does not wait for it. The exception is a boundary mounted by the change itself, for example a `` where `x` is the held write: it appears with that change's commit, with no fallback. While the fallback shows, `isPending()` of the boundary is `false`, as for any first load. #### `Loading` `on` prop: dependencies that show the fallback again @@ -83,7 +83,7 @@ The shell keeps showing A until `product(2)` lands; the spinner arrives with B, One shape shows no fallback at all: when the data the boundary is waiting on is also read outside it (a sibling `` over the same `comments(id)`, an `isPending` on it in the header), or the write's `action` stays open until the data lands. The frame waits on that read, so by the time it commits the content is ready and the fallback was never needed. The first of these is structural — no ordering of the flights can show that fallback — and in development the `LOADING_ON_OUTSIDE_HOLD` diagnostic names the source; the fix is to move the outside read under the boundary so one hold owns the data. The second is a race the fallback may still win (an action that ends first shows it with the commit), and is not reported: during an `action`, show the wait with `isPending()` or an optimistic value, which is what a hold's stale content is for. The old content is on screen and valid the whole time; a `Loading` fallback says it is not. -Both shapes are about data the change itself starts loading. The re-armed boundary owns its content, so data still loading from an earlier change (a flight another write started, or an earlier `action`'s held write) does not hold this one: the fallback shows now, even where that data is also read outside the boundary, and nothing is reported. +_Note (2026-10-06):_ how a re-armed boundary treats data an earlier change still holds is deferred to a separate change; until then a re-arm behaves as described here. It is possible to show the fallback beside the still-held frame anyway: a display-ahead read in `on` — `latest(id)`, `isPending()`, an optimistic signal — says the change is already on screen, so the fallback lands there too: diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 4145a33aa..e7708a763 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -55,11 +55,11 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:167` | — | createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | | A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:175` | projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | | A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:183` | core.ts×3 verdict.ts×2 | createMemo.test.ts×1 fuzz-findings-l2.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 latest-isPending-consistency.test.ts×3 verdict-contract.test.ts×2 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | -| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | +| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | latest-isPending-consistency.test.ts×1 loading-fallback-in-flush-3540.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:191` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | | A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:207` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:243` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×6 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:243` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:215` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | | A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:69` | async.ts×3 constants.ts×1 lanes.ts×6 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:79` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×5 flight-owned-transaction.test.ts×1 lane-authority-twins.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:295` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:93` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… | -| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | boundaries.ts×3 action.ts×1 async.ts×1 constants.ts×1 core.ts×10 effect.ts×1 scheduler.ts×5 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×7 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×10 effect.ts×1 scheduler.ts×4 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … | | A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:251` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:125` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 50b6f5576..60dd7cd7a 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -55,7 +55,7 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t - **A first load derived from a hold lands into it** (#3800): a first load born pending in a frame that read a hold lands into that hold if the hold is still live, else as its own commit; the hold commits without it. **Pending** — `fix/create-time-holds` (`91e474506`); [#3800](https://github.com/solidjs/solid/issues/3800) is open. - **A first pass under a loading boundary that has not shown content is the boundary's, not the tick's** — in a flush as outside one (A29's boundary exemption, #3540). The out-of-flush form is on `next`; the in-flush form (a fresh `Loading` over a held value shows its fallback in a flush) is **pending** — `fix/create-time-holds` (`ca71e5d9d`). -**A boundary that has not shown content owns its subtree — A29's boundary scope (2026-10-06, maintainer ruling; amendment — #3540's L2 regression; the bullet above and A15 are unchanged outside such a boundary).** The ruling, in substance: content under a loading boundary that has not shown content — or that an `on` change has re-armed — belongs to that boundary, not to any hold it reads, whichever computation does the reading (a creation pass, a re-running mount effect, the boundary's own render, or committed content under a re-armed boundary). While that content waits for a hold or its own first loads, it is pending: the boundary shows its fallback, and no hold waits for it. Whether the fallback is seen depends on whether the boundary's own mount is held: a boundary mounted as part of a hold (`` with `x` held, or nested in held content) appears at that hold's commit. Outside such a boundary, A15 and the direction rule are unchanged: committed work that reads a hold joins it, never-committed work waits on every hold it read, and if no boundary would catch it, it stays hidden with the hold. The `on` case is the one place the rule overrides A15 for committed content: committed content under a re-armed boundary that reads a hold waits behind the fallback rather than joining the hold. Membership is decided by the scope, not by the pass: the reading computation's nearest collecting loading boundary (the one `catchStatus` would catch at) owns the read, unless the boundary's own display is the hold's anyway — the flush has joined that hold, the boundary's output is held by it while not showing content, or the pass that is mounting the boundary has joined it. "The flush has joined that hold" is the flush's final membership, decided at the seam: a read made pending under the boundary before the flush joined the hold it read was the hold's after all — the boundary's swap parked with the hold, so its fallback is never seen — and the reader re-derives as the hold's, the content entering it to appear at its commit. And the content is behind the fallback only once the swap shows: a boundary whose swap parked with the flush's transaction (the change that re-armed it is held) keeps its content on screen until then, so a hold that content read — or left — lands with that transaction (merged at the seam), never before it. Readers that waited re-derive at the hold's commit as A15's stale readers do; no reader is registered after the commit. `isPending` of the boundary's value is false while its fallback shows (A19: none of the three causes holds — the swap is not held, and the content under it is loading, exception 1; "boundaries … never enter the definition"), as for a boundary showing its fallback over a first load. **A11 under the scope:** a derivation under an owning boundary is not the holder's work, so nothing computes it from the held world before the commit — `latest()` of it answers the value it has: its committed value while it is pending (`isPending` true), and `NotReadyError` if it has none (content mounted under the boundary, A19 exception 1). Next and pre-L2 staged it in the hold, so `latest()` served the held derivation; serving it without that pass joining the hold would need a second, unheld derivation in the hold's world, which no channel provides. Outside an owning boundary A11 is unchanged. Pre-L2 served the committed value in these shapes (a fresh boundary in a flush, and committed content under a re-armed boundary over a held write — `content 0`); under A29 that tears, and the fallback is the ruled display. **Mechanism:** `GlobalQueue._owns(c, el)` (boundaries.ts, `scopedRead`; installed with the boundary module, so a graph without one pays the hook's null checks), asked at the hold's doors: by `read()` for a held source — the reader's nearest collecting loading boundary (`owns`: `catcherOf` over `boundaryOf`; the output carries its boundary scope, `_scope`) owns the read, the reader is a stale reader of the hold and the read throws `NotReadyError(source)`, so the boundary collects it; a held computation read from the scope that owns it is content, not a hold — it leaves the transaction (`leaveHold`) and re-derives at the read — and, as `_owns(el, el)`, by `recompute`'s head before a held node's pass joins its hold and by `propagateStatus` before a pending propagated onto a held memo does: an owned held node leaves the transaction. A re-arm that finds nothing pending under the boundary owns what an earlier change holds there with no flight (a staged write, an action still running): each held computation under it that the scope owns leaves its hold and re-derives in the arming flush (`ownHeld`), so its read of the hold is pending under the boundary. The seam's decision is `GlobalQueue._boundaryPark(t)`'s, called once the flush has parked into `t` (its membership final): what the scope decided this flush — reads made pending and nodes that left a hold — is recorded with the hold and the owning boundary (`scoped`); a hold whose boundary's swap parked with `t` merges into `t`, and the reads whose hold is (now) `t` re-derive next round — the output is then held by `t` showing its fallback, which is not an owning display. Known, A15's and not the scope's: a hold the re-arm releases lands at once, and a reader outside the boundary re-derived at that landing that goes pending on the change's own flight keeps its pre-hold content beside the landed hold (pinned `it.fails`; boundaries #75's reduction shows the same tear without a boundary, on next too). Lanes and verdict readers are not scoped. Open: a boundary a verdict reader mounts (` 0}>` over an action's write) is created by verdict-lane work, and its first passes are routed into that lane (`recompute`'s first-pass lane rule), whose world is the proposal: it shows the held derivation now rather than its fallback (mount-under-hold `verdict` family; the same on pre-L2, next and #3843). The fix belongs to the first-pass lane rule, with #3835's. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (fresh mounts in a flush and from mainline: memo, direct and bound content; nested under a boundary that has shown content; a mount that joined the hold appears at its commit; the boundary's hold stays its own; catchers at any depth — unrevealed, re-armed outer or nearest, revealed, plain; committed content under a re-armed or plain boundary; boundaries mounted by the hold, top-level or nested; no catcher; `isPending` and `latest` on each); a read made pending before the flush joins its hold — one boundary, two, nested, re-armed twice, with and without a later hide, and the #1078 fuzzer finding; a swap parked with another change takes the hold its content read, #476/#1674); `tests/loading-on-frame-following-3540.test.ts` (3b: a flight an earlier change started; 3c: content an earlier action holds by a staged write — memo, direct and bound, re-armed after the action and, as a control, inside it); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. +**A boundary that has not shown content owns its subtree — A29's boundary scope, fresh mounts (2026-10-06, maintainer ruling; amendment — #3540's L2 regression; the bullet above and A15 are unchanged outside such a boundary).** The ruling, in substance, for a boundary that has not shown content: content under it belongs to that boundary, not to any hold it reads, whichever computation does the reading (a creation pass, a re-running mount effect, the boundary's own render). While that content waits for a hold or its own first loads, it is pending: the boundary shows its fallback, and no hold waits for it. Whether the fallback is seen depends on whether the boundary's own mount is held: a boundary mounted as part of a hold (`` with `x` held, or nested in held content) appears at that hold's commit. Outside such a boundary, A15 and the direction rule are unchanged: committed work that reads a hold joins it, never-committed work waits on every hold it read, and if no boundary would catch it, it stays hidden with the hold. The in-flush form lands with `fix/l2-3540-fresh-loading`, standalone of `fix/create-time-holds`. **Mechanism:** a first pass (`STATUS_UNINITIALIZED`) that reads a hold asks the boundaries up its chain (`GlobalQueue._fresh`: `catchStatus` with a pending status — the same walk as any pending read, so revealed boundaries it passes record it as a reader until the commit). When a loading boundary that has not shown content catches it, the pass joins pass-scoped (`passTx`), as a mount outside a flush does, instead of joining the flush: the Show that mounted the boundary publishes, and the boundary shows its fallback. A pass nothing catches joins the flush as before, so a derivation outside the boundary in the same flush still holds the tick. A render effect born held under the boundary (content bound in JSX — the tree never reads the held value) is caught the same way, so the boundary does not reveal empty content, in a flush or out of one; its output's first pass shows the fallback whenever it has collected readers. A flush that set `passTx` clears it at its end, so the boundary's hold does not merge with a later tick's. `isPending` of the boundary's value is false while its fallback shows, as for a boundary showing its fallback over a first load; `latest()` of content under it is A11's, unchanged (the derivation staged in the hold). The `direction-rule-probe` pins are unchanged. **Known gaps** (pinned `it.fails`, the same on next): a boundary mounted by a pass that itself joined the hold (a memo created in the mount that reads the hold) shows its fallback now rather than appearing at the hold's commit; a boundary mounted by a flight's hold, top-level or nested, shows its fallback for a frame at the commit. **Re-arm (2026-10-06, deferred):** the ruling's `on` part — content under a boundary that an `on` change has re-armed, committed content included, belongs to that boundary — is deferred to a separate change pending its ruling; until then a re-arm behaves as on next, and its shapes are pinned `it.fails` with the ruled display. Open: a boundary a verdict reader mounts (` 0}>` over an action's write) is created by verdict-lane work, and its first passes are routed into that lane (`recompute`'s first-pass lane rule), whose world is the proposal: it shows the held derivation now rather than its fallback (mount-under-hold `verdict` family; the same on pre-L2 and next). The fix belongs to the first-pass lane rule, with #3835's. **Pinned by:** `tests/loading-fallback-in-flush-3540.test.ts` (in a flush and from mainline: through a memo, directly, bound; nested under a Loading that has shown content; a derivation outside the boundary still holds the tick; the boundary's hold stays its own; catchers at any depth — unrevealed, revealed, plain, and re-armed in flight; boundaries mounted by the hold; no catcher; `isPending` and `latest` on each; a read made pending before the flush joins its hold, and the #1078, #476/#1674 fuzzer findings; the re-arm shapes, pinned `it.fails`); `tests/loading-on-frame-following-3540.test.ts` (3b, 3c: re-arm over an earlier change's flight or staged write, pinned `it.fails` but for the inside-the-action control); `packages/web/test/loading-fallback-in-flush-3540.spec.tsx`. **Not yet one-way (recorded, not ruled).** The frame of a tick is still one transaction (the tick ruling above stands), so two shapes keep the hold waiting on first loads it never needed: diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index e0cac4ac2..631f60627 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -49,7 +49,6 @@ import { EFFECT_USER, NOT_PENDING, REACTIVE_DISPOSED, - REACTIVE_JOINED, REACTIVE_LANE_READ, REACTIVE_ZOMBIE, STATUS_ERROR, @@ -69,18 +68,16 @@ import { globalQueue, haltReactivity, joinFuture, - merge, passLane, passTx, resolveTx, schedule, setPassLane, - staleReader, txOf, type Transaction } from "./core/scheduler.js"; import { attrHooks } from "./core/attribution-hooks.js"; -import type { Computed, Owner, Root, Signal } from "./core/types.js"; +import type { Computed, Owner, Signal } from "./core/types.js"; import { flatten } from "./flatten.js"; import { accessor, type Accessor } from "./signals.js"; @@ -125,11 +122,6 @@ interface Boundary { _show: (b: Boundary) => unknown; } -/** The output is created beside the boundary, not under it; it is the - * boundary's own render all the same (`owns`): `_scope` is the owner whose - * context names the boundary. */ -type BoundaryOutput = Computed & { _scope?: Owner }; - /** Context key: the nearest boundary of a node, inherited at creation. */ const BOUNDARY = Symbol(__DEV__ ? "boundary" : ""); /** Context key: the reveal controller a Loading boundary created here is a @@ -208,122 +200,10 @@ export function redraw(b: Boundary): void { } } -/** The boundary a status from `node` stops at — the nearest of its type that - * is collecting — or none (the root hears of it). Side-effect free; - * `catchStatus` catches there. */ -function catcherOf(node: Owner, flags: number): Boundary | undefined { - let b = boundaryOf(node); - while (b !== undefined && !(b._type & flags && isCollecting(b))) b = b._parent ?? undefined; - return b; -} - -/** A29's boundary scope (2026-10-06): a loading boundary that has not shown - * content, or that `on` re-armed, owns its subtree. A pass under it reading - * a node `t` holds does not join `t`: the content is pending under that - * boundary — it is the boundary that would catch it — and no hold waits for - * it. Not when the boundary's display is `t`'s anyway — then its fallback - * is never seen, and the content enters `t` to appear with its commit: the - * flush is `t`'s (everything it stages lands with `t`, the swap included — - * final at the seam, `boundaryPark`); the output is held by `t` showing its - * fallback or never committed (the boundary was mounted, or re-armed, as - * part of the hold); or, mid-mount, the pass creating the boundary read - * `t`. The boundary that owns `c`'s read of `t`, if one does. */ -function owner(c: Computed, t: Transaction): Boundary | undefined { - const b = catcherOf((c as BoundaryOutput)._scope ?? c, STATUS_PENDING); - if (b === undefined) return undefined; - if (flushTransaction !== null && resolveTx(flushTransaction) === t) return undefined; - const o = b._output; - if (o !== null) - return o._config & CONFIG_HELD && - (b._fallback || o._statusFlags & STATUS_UNINITIALIZED) && - txOf(o) === t - ? undefined - : b; - const p = (b._owner as Root)._parentComputed as Computed | null; - return p !== null && p._flags & REACTIVE_JOINED && passTx !== null && resolveTx(passTx) === t - ? undefined - : b; -} - -/** GlobalQueue._owns — the boundary scope at the hold's doors: a read of a - * held node (`read`, a derive's draft), and a held node about to join its - * own hold (`c === el`: its pass's head, a pending propagated onto it). - * - * A held node a boundary owns now leaves its transaction (true: it goes on - * as a plain one). A read from under an owning boundary — whichever pass - * reads — joins nothing and serves nothing: the content is pending there, - * the boundary catches it and shows its fallback, and it waits for the - * landing as the hold's stale reader (`_reruns`: re-derived on the - * committed world). A held computation the same scope owns is content, not - * a hold: it leaves the transaction and re-derives here (true — the read - * goes on as a plain one, pending if it is). Not lane work or a verdict - * reader: they read the screen, not the hold (`frameRead`). */ -function scopedRead(c: Computed | null, el: Signal | Computed): boolean { - if (c === null) return false; - const t = txOf(el); - if (t._lane) return false; - if (c === el) return leaveHold(c, t, owner(c, t)); - if (passLane !== null || c._config & CONFIG_VERDICT) return false; - const b = owner(c, t); - if (b === undefined) return false; - if ( - typeof (el as Computed)._fn === "function" && - leaveHold(el as Computed, t, owner(el as Computed, t)) - ) { - recompute(el as Computed); - return true; - } - staleReader(c, t); - if (globalQueue._running) scoped.push([c, t, b, true]); - throw new NotReadyError(el); -} - -/** What the scope decided this flush, mid-pass: a node that left a hold, - * or a read made pending (`read`), with the hold and the boundary that - * owns it. Revisited when the flush parks (`boundaryPark`), dropped at the - * seam's end. */ -const scoped: [node: Computed, hold: Transaction, owner: Boundary, read: boolean][] = []; - -/** GlobalQueue._boundaryPark — the flush parked into `t`: its membership is - * final, and the reads it made pending under an owning boundary are judged - * against it. A boundary's content shows until its swap does, so the hold - * its content read cannot land before the swap: a swap parked with `t` - * takes that hold into `t` (merged — they land as one, the fallback with - * the change that caused it) — whether the content read the hold or left - * it. A read whose hold is `t`, or is now, was - * `t`'s after all — the flush joined it after the read: the boundary's - * swap parked with `t` and its fallback can never be seen, so the reader - * re-derives next round as `t`'s — the output is held by `t` showing its - * fallback then, which is not an owning display (`owner`) — and the content - * enters `t` to appear at its commit. */ -function boundaryPark(t: Transaction): void { - for (const [c, u, b, read] of scoped) { - if (c._flags & REACTIVE_DISPOSED) continue; - const o = b._output; - if (resolveTx(u) !== t && o !== null && o._config & CONFIG_HELD && txOf(o) === t) merge(u, t); - if (read && resolveTx(u) === t) enqueueSub(c); - } -} - -/** A held node the loading boundary `b` owns now leaves its transaction: - * its staging there is void, and its next pass derives on the committed - * world. (Staged again this flush, it is held again if the flush parks - * into `t`.) False when no boundary owns it. */ -function leaveHold(el: Computed, t: Transaction, b: Boundary | undefined): boolean { - if (b === undefined) return false; - el._pendingValue = NOT_PENDING; - el._config &= ~CONFIG_HELD; - el._x!._transaction = null; - const i = t._nodes.indexOf(el); - if (i >= 0) t._nodes.splice(i, 1); - if (globalQueue._running) scoped.push([el, t, b, false]); - return true; -} - /** GlobalQueue._catch: status from a frame reader, nearest boundary first. * A loading boundary on the way records a pending reader whether or not it * catches it (its `on` may collect it later). */ -function catchStatus(node: Computed, flags: number, error: unknown): boolean { +function catchStatus(node: Computed, flags: number, error?: unknown): boolean { if (flags === 0) { // A status cleared. Judged by the node's status now, against each // boundary's own rule (`unsettled`) — the error path clears pending @@ -448,10 +328,8 @@ export function ready(b: Boundary): boolean { /** GlobalQueue._boundarySeam, end of the seam: readers that settled, died, * or landed and committed are dropped; a fallback with none left reveals - * next round. An arm resolves: nothing collected, nothing happened. The - * flush's scoped reads are done with (`boundaryPark`). */ + * next round. An arm resolves: nothing collected, nothing happened. */ function boundarySeam(): void { - if (scoped.length !== 0) scoped.length = 0; for (const b of collecting) { const flags = b._output!._flags; if (flags & REACTIVE_DISPOSED) { @@ -461,8 +339,7 @@ function boundarySeam(): void { // A parked frame's boundary (zombie) keeps its state for its revival. if (flags & REACTIVE_ZOMBIE) continue; prune(b, false); - if (__DEV__ && b._armed && !b._initialized && !b._ahead && b._output!._config & CONFIG_HELD) - reportOutsideHold(b); + if (__DEV__ && b._armed && !b._initialized && !b._ahead) reportOutsideHold(b); b._armed = false; if (b._readers.size === 0) { collecting.delete(b); @@ -472,11 +349,9 @@ function boundarySeam(): void { } } -/** DEV, at the re-arm that flipped a boundary to its fallback, its swap held: - * a source it now waits on is also read by a frame reader outside it — the - * frame waits on the very source, and the fallback can never be seen. (A - * swap nothing holds shows now: the boundary owns its content, A29's - * boundary scope, whatever else reads the source.) Structural, so +/** DEV, at the re-arm that flipped a boundary to its fallback: a source it + * now waits on is also read by a frame reader outside it — the frame waits + * on the very source, and the fallback can never be seen. Structural, so * reported once, at the change, naming the source; a display-ahead arm * (`latest()` in `on`) is the user's choice and not reported. */ function reportOutsideHold(b: Boundary): void { @@ -579,25 +454,6 @@ function arm(b: Boundary): void { return; } } - if (globalQueue._running) ownHeld(b._owner); -} - -/** The re-armed boundary owns its content (A29's boundary scope): what an - * earlier change holds under it — a write staged with no flight, so nothing - * under it is pending — leaves that hold and re-derives on the committed - * world now, and its read of the hold is pending under the boundary. */ -function ownHeld(o: Owner): void { - for ( - let n = o._firstChild as Computed | null; - n !== null; - n = n._nextSibling as Computed | null - ) { - if (n._config & CONFIG_HELD && !(n as any)._type) { - const t = txOf(n); - if (!t._lane && leaveHold(n, t, owner(n, t))) enqueueSub(n); - } - ownHeld(n); - } } /** The error boundary's `reset`: re-run what threw (a comparator throw @@ -738,11 +594,29 @@ function createBoundary( // one; a loading boundary showing content forwards its pending. throw tree._x!._error; } - // Readers under it still unready: the fallback, the tree untouched. - // The seam re-derives this pass when they settle. (A tree it owns - // that read a hold is pending, `owns`; one that is held is part of - // the hold that mounted the boundary, and the output enters it.) - if (isCollecting(b) && prune(b, true) !== 0) return fallback(b); + if (isCollecting(b)) { + // A29's boundary exemption (#3540): a boundary MOUNTED over a held + // value (its first pass; a first pass under it that read a hold was + // collected, `joinPass` — the tree's own, or a render effect's that + // binds the content) shows its fallback now and the content at the + // commit — entering the transaction would make the output itself + // born held, and nothing would show until the commit. The seam + // keeps a held reader until it is committed, then re-derives this + // pass. A boundary with a committed value reads a held tree and + // enters: the outside sees its committed value until the landing, + // which reveals the content — a fallback staged earlier is replaced + // ahead of the commit and never shown. + const self = getOwner() as Computed; + if ( + b._readers.size !== 0 && + self._statusFlags & STATUS_UNINITIALIZED && + !(self._config & CONFIG_HELD) + ) + return fallback(b); + // Readers under it still unready: the fallback, the tree untouched. + // The seam re-derives this pass when they settle. + if (prune(b, true) !== 0) return fallback(b); + } let value: T; try { value = read(tree); @@ -785,7 +659,6 @@ function createBoundary( __OBSERVE__ ? { name: "value", _noSnapshot: true } : { _noSnapshot: true } ); output._config |= CONFIG_REDERIVE; - (output as BoundaryOutput)._scope = owner; b._output = output; return accessor(output); } @@ -837,13 +710,9 @@ const ERROR_SIGNAL = { ownedWrite: true, _noSnapshot: true } as const; * if nothing is pending, the notification is a no-op. The fallback lands * with the same frame as the change that caused it — now, when nothing * else holds that frame; together with the rest of the new page during a - * held navigation, not before it. The re-armed boundary owns its content: - * data the change did not start loading (a flight or a held write from an - * earlier change) shows the fallback now rather than holding the change, - * even where it is also read outside the boundary. If the change itself - * starts the data loading and the data is also read outside the boundary, - * the frame waits on it and the fallback can never be seen (DEV warns - * `LOADING_ON_OUTSIDE_HOLD`); the fix is structural — move the + * held navigation, not before it. If the same data is also read outside + * the boundary, the frame waits on it and the fallback can never be seen + * (DEV warns `LOADING_ON_OUTSIDE_HOLD`); the fix is structural — move the * outside read under the boundary so one hold owns the data. A frame held * past the content's landing by something else (the write's action, other * pending data) also shows no fallback; that is a race the fallback may @@ -905,8 +774,8 @@ export function createErrorBoundary( // Installed at module evaluation — present exactly when something imports a // boundary. An app without one pays the three null checks and nothing else. GlobalQueue._catch = catchStatus; -GlobalQueue._owns = scopedRead; +GlobalQueue._fresh = node => + node._statusFlags & STATUS_UNINITIALIZED && catchStatus(node, STATUS_PENDING); GlobalQueue._hidden = hidden; GlobalQueue._boundarySeam = boundarySeam; -GlobalQueue._boundaryPark = boundaryPark; GlobalQueue._heldRun = heldRun; diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 529fc7c19..e9556d66b 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -896,8 +896,7 @@ export function propagateStatus( if ( passLane === null && (sub._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && - globalQueue._running && - !GlobalQueue._owns?.(sub, sub) + globalQueue._running ) joinFuture(txOf(sub)); return; @@ -919,14 +918,11 @@ export function propagateStatus( // recomputing it, and the propagation itself enters the memo's // transaction — the flight flows into a memo that transaction // holds, so the write that started it is held with it. A render - // effect's membership is its pass's (`notify`), never sticky. A - // memo a loading boundary owns now is that boundary's (A29's - // boundary scope): its pending is caught there, held by nothing. + // effect's membership is its pass's (`notify`), never sticky. if ( status === STATUS_PENDING && (sub._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && - !(sub as any)._type && - !GlobalQueue._owns?.(sub, sub) + !(sub as any)._type ) joinFuture(txOf(sub)); } diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index dd7c8fbc5..a14dfcd34 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -302,10 +302,6 @@ export function recompute(el: Computed, create: boolean = false): void { // a sync write (#3460). The pass is the lane's if it reads the lane's // world (`read`), and has left it otherwise. if (tx._lane) { - } else if (GlobalQueue._owns?.(el, el)) { - // Under a loading boundary that owns it now (re-armed since it - // joined — A29's boundary scope) it has left the hold: a read of - // the hold makes this pass pending there. } else if (isEffect !== EFFECT_RENDER && !(el._config & CONFIG_VERDICT)) joinPassTx(tx); else if (tx !== flushTransaction && !(el._statusFlags & STATUS_UNINITIALIZED)) { // Published mainline, it is not held: the frame this pass builds is @@ -947,10 +943,18 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension { * reader is a render effect. A render effect is the frame, not a derivation * (rule 3): in that transaction's own flush, or born into it (uninitialized, * A29), it reads the staged value and holds nothing of its own; otherwise it - * reads the committed value instead (`frameRead`). */ + * reads the committed value instead (`frameRead`). A node born into the + * future has no committed value: every reader joins it. A first pass is + * something not ready under a loading boundary that has not shown content: + * the boundary collects it and shows its fallback, and the pass is the + * boundary's, not the tick's (A29's boundary exemption, #3540). */ function joinPass(c: Computed, el: Signal | Computed): void { c._flags |= REACTIVE_JOINED; - if ((c as any)._type !== EFFECT_RENDER) joinPassTx(txOf(el)); + if ( + (el as Computed)._statusFlags & STATUS_UNINITIALIZED || + (c as any)._type !== EFFECT_RENDER + ) + joinPassTx(txOf(el), GlobalQueue._fresh?.(c)); } /** A15's stale reader (shared-hole and reveal corollaries): a render effect @@ -1620,7 +1624,6 @@ export function read(el: Signal | Computed): T { // future (REACTIVE_JOINED): a first pass that did is born held (A29), // wherever it was created. Lane work sees the screen (`frameRead`). if (el._config & CONFIG_HELD && !(c._config & CONFIG_CHILDREN_FORBIDDEN)) { - GlobalQueue._owns?.(c as Computed, el); if (frameRead(c as Computed, el)) return el._value as T; joinPass(c as Computed, el); } @@ -1658,12 +1661,9 @@ export function read(el: Signal | Computed): T { !(el._config & CONFIG_OVERRIDE) && !(c._config & CONFIG_CHILDREN_FORBIDDEN) ) { - if (GlobalQueue._owns?.(c as Computed, el)) { - } else if (owner._statusFlags & STATUS_UNINITIALIZED) { - (c as Computed)._flags |= REACTIVE_JOINED; - joinPassTx(txOf(el)); - } else if (frameRead(c as Computed, el)) committed = true; - else joinPass(c as Computed, el); + if (owner._statusFlags & STATUS_UNINITIALIZED || !frameRead(c as Computed, el)) + joinPass(c as Computed, el); + else committed = true; } } // Lanes: a lane's node (after the pull — the node is current). NOT_PENDING diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 01445aa23..b43c474e2 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -173,11 +173,15 @@ export let flushTransaction: Transaction | null = null; * into it, and nothing else in the tick is — a write made after the mount * is a mainline write, a render effect mounted beside it a stale reader. * Inside a flush, or in a tick that already has its transaction (an - * action's body), the frame joins instead (`flushTransaction`). Cleared by - * the flush the join schedules. */ + * action's body), the frame joins instead (`flushTransaction`) — except a + * first pass a loading boundary that has not shown content catches (`own`, + * A29's boundary exemption, #3540): it is the boundary's, not the tick's, + * and a flush that has joined nothing keeps it pass-scoped as outside one. + * Cleared by the flush the join schedules, and at the end of a flush that + * set it. */ export let passTx: Transaction | null = null; -export function joinPassTx(t: Transaction): void { - if (globalQueue._running || flushTransaction !== null) return joinFuture(t); +export function joinPassTx(t: Transaction, own?: unknown): void { + if ((globalQueue._running && !own) || flushTransaction !== null) return joinFuture(t); if (passTx === null) passTx = resolveTx(t); else merge(resolveTx(t), passTx); schedule(); @@ -658,25 +662,17 @@ export class GlobalQueue implements IQueue { // Boundaries (boundaries.ts): the display consumers between an observer // and the root. `_catch` — status from a frame reader, nearest boundary // first (true: caught, the root never hears of it; a clear — flags 0 — - // settles the reader there); `_hidden` — a frame reader behind a fallback - // is not on screen and holds nothing; `_boundarySeam` — the seam's sweep - // (readers gone or settled without a pass reveal; an `on` re-arm - // resolves). + // settles the reader there); `_fresh` — a pass that read a hold (true: a + // first pass a loading boundary that has not shown content caught, + // `joinPass`); `_hidden` — a frame reader behind a fallback is not on + // screen and holds nothing; `_boundarySeam` — the seam's sweep (readers + // gone or settled without a pass reveal; an `on` re-arm resolves). declare static _catch: | ((node: Computed, flags: number, error: unknown) => boolean) | undefined; - /** `_owns(c, el)` — A29's boundary scope: pass `c`, about to read held `el` - * (`c === el`: about to join its own hold), is under a loading boundary - * that owns it. Throws its pending there; true when the hold was left and - * the read or pass goes on as a plain one. */ - declare static _owns: - | ((c: Computed, el: Signal | Computed) => boolean) - | undefined; + declare static _fresh: ((node: Computed) => unknown) | undefined; declare static _hidden: ((r: Computed) => boolean) | undefined; declare static _boundarySeam: (() => void) | undefined; - // `_boundaryPark` — the flush parked into `t`, its membership final: the - // boundary scope revisits the reads it made pending this flush (A29). - declare static _boundaryPark: ((t: Transaction) => void) | undefined; // `_heldRun` — a queued run under a fallback-showing boundary waits for // the reveal (true: held; the boundary re-queues it by type). The // synchronous first render on creation builds the subtree, attached or @@ -720,6 +716,7 @@ export class GlobalQueue implements IQueue { if (__DEV__) DEV.hooks.onUpdate?.(); } finally { this._running = false; + passTx = null; } } /** L2 — the seam: end of the pure phase. Commit this flush's staged nodes, @@ -798,7 +795,6 @@ export class GlobalQueue implements IQueue { } pendingNodes.length = 0; GlobalQueue._storePark?.(t); - GlobalQueue._boundaryPark?.(t); // (This flush's runs are stashed with `t` below — after the landings, // so a `t` that lands at this very seam runs them first, ahead of diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index f1c24bc5e..00d6d939e 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -1535,20 +1535,16 @@ function readSource( if (writeOverride && !userWrite) { const leaf = key !== undefined ? target.n?.[key as any] : undefined; if (leaf !== undefined) { - if ((leaf._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD) { - GlobalQueue._owns?.(context as Computed, leaf); + if ((leaf._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD) joinPassTx(txOf(leaf)); - } } else { const k = target.k; if ( k !== null && (k._config & (CONFIG_HELD | CONFIG_OVERRIDE)) === CONFIG_HELD && (key === undefined || heldKeyChanged(target, key)) - ) { - GlobalQueue._owns?.(context as Computed, k); + ) joinPassTx(txOf(k)); - } } } return pb; diff --git a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts index d72d2fe37..4a27cbad2 100644 --- a/packages/signals/tests/loading-fallback-in-flush-3540.test.ts +++ b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts @@ -1,16 +1,19 @@ /** - * #3540 under L2: A29's boundary scope (maintainer, 2026-10-06). + * #3540 under L2: a fresh Loading mounted under a hold shows its fallback + * (A29's boundary exemption; the fresh-mount part of the 2026-10-06 + * boundary-scope ruling). * - * A loading boundary that has not shown content — or that an `on` change - * has re-armed — owns its subtree: content under it belongs to it, not to - * any hold it reads, whichever computation does the reading (a creation - * pass, a re-running mount effect, the boundary's own render, committed - * content under a re-armed boundary). While that content waits for a hold - * or its own first loads it is pending: the boundary shows its fallback, - * and no hold waits for it; it re-derives at the hold's commit. A boundary - * mounted as part of a hold appears at that hold's commit. Outside such a + * A first pass that reads a hold asks the boundaries up its chain + * (`GlobalQueue._fresh`, the real `catchStatus` walk); when a loading + * boundary that has not shown content catches it, the pass is the + * boundary's, not the tick's: the boundary shows its fallback, the mount + * publishes, and the content appears at the hold's commit. Outside such a * boundary, A15 and the direction rule are unchanged. * + * Re-arm (`on`) under the boundary scope is deferred to a separate change, + * pending its ruling: those shapes keep next's behavior and are pinned + * `it.fails` with the ruled display. + * * Each case is traced on the pre-L2 core (41fdf9696) too; the notes say * where it differs and the ruling that explains it. Sources: `held` — the * content reads `x`, written in an action that is still running; `flight` @@ -244,10 +247,7 @@ describe("#3540: a fresh Loading mounted over a held value", () => { ]); }); - // The label, created in the mount, reads the hold and joins it, so the - // mount is part of the hold: the boundary mounted with it appears at the - // commit, its fallback never seen (as a boundary under a held Show). - it("a boundary mounted by a pass that joined the hold appears at its commit, no fallback", async () => { + it("a derivation outside the boundary in the same flush still holds the tick (membership is the tick's)", async () => { const [x, setX] = createSignal(0); const [open, setOpen] = createSignal(false); const log: string[] = []; @@ -259,7 +259,7 @@ describe("#3540: a fresh Loading mounted over a held value", () => { createRenderEffect(label, v => { log.push(v); }); - const view = Loading(() => `content ${x()}`, "fallback"); + const view = Loading(content("direct", x, log), "fallback"); createRenderEffect(view, v => { log.push(`view ${v}`); }); @@ -281,13 +281,65 @@ describe("#3540: a fresh Loading mounted over a held value", () => { setOpen(true); flush(); - expect(log).toEqual([]); + expect(log).not.toContain("open true"); + expect(log.filter(l => l.startsWith("label"))).toEqual([]); release(); await tick(); - expect([...log].sort()).toEqual(["label 1", "open true", "view content 1"]); + expect(log).toContain("open true"); + expect(log).toContain("label 1"); + expect(log.at(-1)).toBe("view content 1"); }); + // The label, created in the mount, reads the hold and joins it, so the + // mount is part of the hold: the boundary mounted with it appears at the + // commit, its fallback never seen (as a boundary under a held Show). + // Not fixed here: the boundary's first pass still runs its fallback now + // (`view fallback`), as on next. + it.fails( + "a boundary mounted by a pass that joined the hold appears at its commit, no fallback", + async () => { + const [x, setX] = createSignal(0); + const [open, setOpen] = createSignal(false); + const log: string[] = []; + createRoot(() => { + createRenderEffect( + () => { + if (!open()) return false; + const label = createMemo(() => `label ${x()}`); + createRenderEffect(label, v => { + log.push(v); + }); + const view = Loading(() => `content ${x()}`, "fallback"); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + return true; + }, + v => { + log.push(`open ${v}`); + } + ); + }); + flush(); + let release!: () => void; + action(function* () { + setX(1); + yield new Promise(r => (release = r)); + })(); + flush(); + log.length = 0; + + setOpen(true); + flush(); + expect(log).toEqual([]); + + release(); + await tick(); + expect([...log].sort()).toEqual(["label 1", "open true", "view content 1"]); + } + ); + it("the boundary's hold stays its own: a mount over another hold after the flush reveals at that hold's release", async () => { const [x, setX] = createSignal(0); const [y, setY] = createSignal(0); @@ -354,6 +406,10 @@ describe("#3540: the catcher is whoever `catchStatus` would catch at — at any * settling, the gate. */ type Shape = "unrevealed" | "rearmed outer" | "revealed" | "rearmed" | "plain"; + // The fresh pass's own catch walk (`_fresh`) records it on the revealed + // boundaries it passes, as `catchStatus` records any pending read, and + // its pending status is caught again by the normal path: one reader more + // than next on each boundary passed until the commit, none after it. const expected: Record = { // The ancestor catches, as pre-L2 (which also runs the binding behind // the fallback: `text fresh 0` — L2 holds a run behind a fallback, @@ -372,22 +428,23 @@ describe("#3540: the catcher is whoever `catchStatus` would catch at — at any "gate gate · open p · outer outer · text fresh 1" ], "unrevealed flight memo": [ - " | o2 i1", + " | o3 i2", "holder 1 | o1 i0", " | o1 i0", "gate gate · open fresh 1 · outer outer" ], "unrevealed flight bound": [ - " | o2 i1", + " | o3 i2", "holder 1 | o1 i0", " | o1 i0", "gate gate · open p · outer outer · text fresh 1" ], - // A re-armed ancestor owns its subtree: the content is pending, held or - // in flight, and its fallback shows now — nothing else holds the - // re-arming frame (#3575). In flight, as pre-L2. Held: pre-L2 shows the - // committed value (`open fresh 0`, a tear under L2's A29); `next` - // holds the whole mount for the commit. + // A re-armed ancestor: the content is pending and its fallback shows + // now — nothing else holds the re-arming frame (#3575). In flight, as + // pre-L2. Held (the ruled display, pinned `it.fails`: re-arm under the + // boundary scope is deferred): pre-L2 shows the committed value + // (`open fresh 0`, a tear under L2's A29); `next` holds the whole mount + // for the commit. "rearmed outer held memo": [ "outer outer fallback | o1 i1", "open fresh 1 · outer outer · x 1 | o0 i0", @@ -401,13 +458,13 @@ describe("#3540: the catcher is whoever `catchStatus` would catch at — at any "" ], "rearmed outer flight memo": [ - "outer outer fallback | o1 i1", + "outer outer fallback | o2 i2", "holder 1 · open fresh 1 · outer outer | o0 i0", " | o0 i0", "" ], "rearmed outer flight bound": [ - "outer outer fallback | o1 i1", + "outer outer fallback | o2 i2", "holder 1 · open p · outer outer · text fresh 1 | o0 i0", " | o0 i0", "" @@ -427,13 +484,13 @@ describe("#3540: the catcher is whoever `catchStatus` would catch at — at any "" ], "rearmed flight memo": [ - "inner inner fallback | i1", + "inner inner fallback | i2", "holder 1 · inner inner · open fresh 1 | i0", " | i0", "" ], "rearmed flight bound": [ - "inner inner fallback | i1", + "inner inner fallback | i2", "holder 1 · inner inner · open p · text fresh 1 | i0", " | i0", "" @@ -441,15 +498,15 @@ describe("#3540: the catcher is whoever `catchStatus` would catch at — at any // No boundary would catch it: the mount joins the transaction and waits // for its commit, as on next. Pre-L2 shows the committed value now; L2 // joins the hold (A29). - "plain held memo": [" | i0", "open fresh 1 · x 1 | i0", " | i0", ""], - "plain held bound": [" | i0", "open p · text fresh 1 · x 1 | i0", " | i0", ""], - "plain flight memo": [" | i1", "holder 1 · open fresh 1 | i0", " | i0", ""], - "plain flight bound": [" | i1", "holder 1 · open p · text fresh 1 | i0", " | i0", ""], - "revealed held memo": [" | o0 i0", "open fresh 1 · x 1 | o0 i0", " | o0 i0", ""], - "revealed held bound": [" | o0 i0", "open p · text fresh 1 · x 1 | o0 i0", " | o0 i0", ""], - "revealed flight memo": [" | o1 i1", "holder 1 · open fresh 1 | o0 i0", " | o0 i0", ""], + "plain held memo": [" | i1", "open fresh 1 · x 1 | i0", " | i0", ""], + "plain held bound": [" | i1", "open p · text fresh 1 · x 1 | i0", " | i0", ""], + "plain flight memo": [" | i2", "holder 1 · open fresh 1 | i0", " | i0", ""], + "plain flight bound": [" | i2", "holder 1 · open p · text fresh 1 | i0", " | i0", ""], + "revealed held memo": [" | o1 i1", "open fresh 1 · x 1 | o0 i0", " | o0 i0", ""], + "revealed held bound": [" | o1 i1", "open p · text fresh 1 · x 1 | o0 i0", " | o0 i0", ""], + "revealed flight memo": [" | o2 i2", "holder 1 · open fresh 1 | o0 i0", " | o0 i0", ""], "revealed flight bound": [ - " | o1 i1", + " | o2 i2", "holder 1 · open p · text fresh 1 | o0 i0", " | o0 i0", "" @@ -458,85 +515,89 @@ describe("#3540: the catcher is whoever `catchStatus` would catch at — at any for (const shape of ["unrevealed", "rearmed outer", "revealed", "rearmed", "plain"] as Shape[]) for (const source of ["held", "flight"] as Source[]) for (const kind of ["memo", "bound"] as Content[]) - it(`${shape}, ${source}, content ${kind}`, async () => { - const log: string[] = []; - const [open, setOpen] = createSignal(false); - let resolveGate: (() => void) | undefined; - let w!: ReturnType; - let outerOwner: object | undefined; - let innerOwner!: object; - let outer: (() => unknown) | undefined; - let inner!: () => unknown; - const innerFn = () => { - innerOwner = getOwner()!; - createRenderEffect( - () => (open() ? content(kind, w.read, log, "fresh")() : false), - v => { - log.push(`open ${v}`); - } - ); - return "inner"; - }; - const showInner = (on?: () => unknown) => { - inner = Loading(innerFn, "inner fallback", on); - createRenderEffect(inner, v => { - log.push(`inner ${v}`); - }); - }; - createRoot(() => { - w = world(source, log); - if (shape === "rearmed" || shape === "plain") - return showInner(shape === "rearmed" ? open : undefined); - outer = Loading( - () => { - outerOwner = getOwner()!; - showInner(); - if (shape === "unrevealed") { - const gate = createMemo( - () => new Promise(r => (resolveGate = () => r("gate"))) - ); - createRenderEffect(gate, v => { - log.push(`gate ${v}`); - }); + // Re-arm over a held write: deferred to the re-arm change, pending its ruling. + (source === "held" && shape.startsWith("rearmed") ? it.fails : it)( + `${shape}, ${source}, content ${kind}`, + async () => { + const log: string[] = []; + const [open, setOpen] = createSignal(false); + let resolveGate: (() => void) | undefined; + let w!: ReturnType; + let outerOwner: object | undefined; + let innerOwner!: object; + let outer: (() => unknown) | undefined; + let inner!: () => unknown; + const innerFn = () => { + innerOwner = getOwner()!; + createRenderEffect( + () => (open() ? content(kind, w.read, log, "fresh")() : false), + v => { + log.push(`open ${v}`); } - return "outer"; - }, - "outer fallback", - shape === "rearmed outer" ? open : undefined - ); - createRenderEffect(outer, v => { - log.push(`outer ${v}`); + ); + return "inner"; + }; + const showInner = (on?: () => unknown) => { + inner = Loading(innerFn, "inner fallback", on); + createRenderEffect(inner, v => { + log.push(`inner ${v}`); + }); + }; + createRoot(() => { + w = world(source, log); + if (shape === "rearmed" || shape === "plain") + return showInner(shape === "rearmed" ? open : undefined); + outer = Loading( + () => { + outerOwner = getOwner()!; + showInner(); + if (shape === "unrevealed") { + const gate = createMemo( + () => new Promise(r => (resolveGate = () => r("gate"))) + ); + createRenderEffect(gate, v => { + log.push(`gate ${v}`); + }); + } + return "outer"; + }, + "outer fallback", + shape === "rearmed outer" ? open : undefined + ); + createRenderEffect(outer, v => { + log.push(`outer ${v}`); + }); }); - }); - flush(); - await w.settle(); - const end = w.begin(); - log.length = 0; - const steps = [ - () => (setOpen(true), flush()), - end, - w.settle, - async () => { - resolveGate?.(); - await tick(); - } - ]; - const readers = (): [string, number | undefined][] => - outer - ? [ - ["o", readersOf(outerOwner)], - ["i", readersOf(innerOwner)] - ] - : [["i", readersOf(innerOwner)]]; - const pending = () => [ - ...(outer && isPending(() => outer!()) ? ["o"] : []), - ...(isPending(() => inner()) ? ["i"] : []) - ]; - const out = await trace(steps, log, readers, pending); - // The gate step reports the log only. - out[3] = out[3].split(" | ")[0]; - expect(out).toEqual(expected[`${shape} ${source} ${kind}`]); - }); + flush(); + await w.settle(); + const end = w.begin(); + log.length = 0; + const steps = [ + () => (setOpen(true), flush()), + end, + w.settle, + async () => { + resolveGate?.(); + await tick(); + } + ]; + const readers = (): [string, number | undefined][] => + outer + ? [ + ["o", readersOf(outerOwner)], + ["i", readersOf(innerOwner)] + ] + : [["i", readersOf(innerOwner)]]; + const pending = () => [ + ...(outer && isPending(() => outer!()) ? ["o"] : []), + ...(isPending(() => inner()) ? ["i"] : []) + ]; + const out = await trace(steps, log, readers, pending); + // The gate step reports the log only. + out[3] = out[3].split(" | ")[0]; + expect(out).toEqual(expected[`${shape} ${source} ${kind}`]); + } + ); /** * A revealed outer Loading; a Show under it mounts, in the flush, a fresh @@ -614,18 +675,19 @@ describe("#3540: the catcher is whoever `catchStatus` would catch at — at any // pre-L2 (`next` holds the Show's `open true` for the commit). No // inner: the mount joins the transaction and the commit waits for // the slow load, as on next; pre-L2 commits `x` first (held: L2's - // A29 — the mount is the hold's). + // A29 — the mount is the hold's). (Reader counts: see `expected` + // above — the fresh pass's catch walk records it once more.) expect(out).toEqual( withInner ? [ - "inner inner fallback · open true | o0 f1", - `${x1} | o0 f1`, + "inner inner fallback · open true | o0 f2", + `${x1} | o0 f2`, "inner p · text fresh 1 | o0 f0", " | o0 f0" ] : [ - " | o1 f1", - " | o1 f1", + " | o2 f2", + " | o2 f2", [x1, "open p", "text fresh 1"].sort().join(" · ") + " | o0 f0", " | o0 f0" ] @@ -671,14 +733,16 @@ describe("#3540: the boundary scope — committed content, held mounts, no catch } // A fresh boundary mounted over the hold (memo content). While its - // fallback shows, its value is not pending (nothing stale is shown) and - // the content has no value: it is the boundary's, loading — `latest` - // throws, as for any first load. Pre-L2 serves the committed or staged - // value (`content 0` in a flush, a tear under A29; `content 1` from - // mainline); `next` reports the boundary pending while it holds the - // mount, and serves the staged value. - const observe = (x1: string) => [ - "view fallback | readers=1 view=false m=false latest=throws NotReadyError", + // fallback shows, its value is not pending (nothing stale is shown). + // `latest` of the content: held, the derivation staged in the hold + // (`content 1`, A11, as next serves it); in flight, the content has no + // value yet and throws, as for any first load. Pre-L2 serves the + // committed or staged value (`content 0` in a flush, a tear under A29; + // `content 1` from mainline); `next` reports the boundary pending while + // it holds the mount. (Readers: the content's catch walk records it once + // more than the normal path, until the commit.) + const observe = (source: Source, x1: string) => [ + `view fallback | readers=2 view=false m=false latest=${source === "held" ? "content 1" : "throws NotReadyError"}`, `${[x1, "view content 1"].sort().join(" · ")} | readers=0 view=false m=false latest=content 1`, " | readers=0 view=false m=false latest=content 1" ]; @@ -724,7 +788,7 @@ describe("#3540: the boundary scope — committed content, held mounts, no catch latest: verdict(() => m!(), "latest") }) ); - const expected = observe(source === "held" ? "x 1" : "holder 1"); + const expected = observe(source, source === "held" ? "x 1" : "holder 1"); if (where === "flush") expected[0] = expected[0].replace("view fallback", "open true · view fallback"); expect(out).toEqual(expected); @@ -733,10 +797,11 @@ describe("#3540: the boundary scope — committed content, held mounts, no catch // Committed content under a revealed boundary, re-run by a plain write // to `key` while the hold is up. With `on: key` the boundary is re-armed // and owns its subtree: the content that reads the hold waits behind the - // fallback, and the hold does not wait for it (the ruling's override of - // A15 for committed content). Pre-L2 and `next` show no fallback: the - // content joins the hold (`next` reports the boundary pending). Without - // `on`: A15, the content joins the hold — as pre-L2 and next. (`memo`: + // fallback, and the hold does not wait for it (the ruled display, pinned + // `it.fails`: re-arm under the boundary scope is deferred). Pre-L2 and + // `next` show no fallback: the content joins the hold (`next` reports the + // boundary pending). Without `on`: A15, the content joins the hold — as + // pre-L2 and next. (`memo`: // the boundary's own render reads `m`, so its re-run creates a fresh // memo — no value until it loads; `bound`: the binding re-runs over the // committed memo, pending with its committed value.) @@ -752,7 +817,8 @@ describe("#3540: the boundary scope — committed content, held mounts, no catch " | readers=0 view=false m=false latest=c1 1" ], "held plain memo": [ - " | readers=0 view=true m=false latest=c1 1", + // The boundary's render creates a fresh memo; its catch walk records it. + " | readers=1 view=true m=false latest=c1 1", "view c1 1 · x 1 | readers=0 view=false m=false latest=c1 1", " | readers=0 view=false m=false latest=c1 1" ], @@ -785,30 +851,87 @@ describe("#3540: the boundary scope — committed content, held mounts, no catch for (const source of ["held", "flight"] as Source[]) for (const withOn of [true, false]) for (const kind of ["memo", "bound"] as Content[]) - it(`${source}, committed content ${withOn ? "under a re-armed boundary" : "under a plain boundary"}, ${kind}`, async () => { + // Re-arm over committed content: deferred to the re-arm change, pending its ruling. + (withOn ? it.fails : it)( + `${source}, committed content ${withOn ? "under a re-armed boundary" : "under a plain boundary"}, ${kind}`, + async () => { + const log: string[] = []; + const [key, setKey] = createSignal(0); + let w!: ReturnType; + let m!: () => string; + let view!: () => unknown; + let owner: object | undefined; + createRoot(() => { + w = world(source, log); + view = Loading( + () => { + owner = getOwner()!; + m = createMemo(() => `c${key()} ${w.read()}`); + if (kind === "memo") return m(); + createRenderEffect(m, v => { + log.push(`text ${v}`); + }); + return "p"; + }, + "fallback", + withOn ? key : undefined + ); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + }); + flush(); + await w.settle(); + const out = await steps( + log, + w, + () => setKey(1), + () => ({ + readers: readersOf(owner), + view: verdict(() => view(), "pending"), + m: verdict(() => m(), "pending"), + latest: verdict(() => m(), "latest") + }) + ); + expect(out).toEqual(committed[`${source} ${withOn ? "rearmed" : "plain"} ${kind}`]); + } + ); + + // A boundary mounted as part of the hold — ``'s memo + // creates it, at top level or in a revealed boundary: it appears at the + // hold's commit, its fallback never seen — as pre-L2. (In flight `next` + // flashes the fallback at the commit; not fixed here, pinned `it.fails`.) + for (const source of ["held", "flight"] as Source[]) + for (const nested of [false, true]) + (source === "flight" ? it.fails : it)( + `${source}, a boundary mounted by the hold${nested ? ", nested" : ""}: no fallback, it appears at the commit`, + async () => { const log: string[] = []; - const [key, setKey] = createSignal(0); let w!: ReturnType; - let m!: () => string; - let view!: () => unknown; - let owner: object | undefined; + let view: (() => unknown) | undefined; + let shown!: () => unknown; createRoot(() => { w = world(source, log); - view = Loading( - () => { - owner = getOwner()!; - m = createMemo(() => `c${key()} ${w.read()}`); - if (kind === "memo") return m(); - createRenderEffect(m, v => { - log.push(`text ${v}`); - }); - return "p"; - }, - "fallback", - withOn ? key : undefined - ); - createRenderEffect(view, v => { - log.push(`view ${v}`); + const body = () => { + shown = createMemo(() => { + if (w.read() < 1) return null; + return (view = Loading(() => `content ${w.read()}`, "fallback")); + }); + createRenderEffect( + () => { + const v = shown() as (() => unknown) | null; + return v ? v() : "none"; + }, + v => { + log.push(`view ${v}`); + } + ); + return "outer"; + }; + if (!nested) return body(); + const outer = Loading(body, "outer fallback"); + createRenderEffect(outer, v => { + log.push(`outer ${v}`); }); }); flush(); @@ -816,70 +939,20 @@ describe("#3540: the boundary scope — committed content, held mounts, no catch const out = await steps( log, w, - () => setKey(1), + () => {}, () => ({ - readers: readersOf(owner), - view: verdict(() => view(), "pending"), - m: verdict(() => m(), "pending"), - latest: verdict(() => m(), "latest") + view: view ? verdict(() => view!(), "pending") : "-", + shown: verdict(() => shown(), "pending") }) ); - expect(out).toEqual(committed[`${source} ${withOn ? "rearmed" : "plain"} ${kind}`]); - }); - - // A boundary mounted as part of the hold — ``'s memo - // creates it, at top level or in a revealed boundary: it appears at the - // hold's commit, its fallback never seen — as pre-L2. (In flight `next` - // flashes the fallback at the commit.) - for (const source of ["held", "flight"] as Source[]) - for (const nested of [false, true]) - it(`${source}, a boundary mounted by the hold${nested ? ", nested" : ""}: no fallback, it appears at the commit`, async () => { - const log: string[] = []; - let w!: ReturnType; - let view: (() => unknown) | undefined; - let shown!: () => unknown; - createRoot(() => { - w = world(source, log); - const body = () => { - shown = createMemo(() => { - if (w.read() < 1) return null; - return (view = Loading(() => `content ${w.read()}`, "fallback")); - }); - createRenderEffect( - () => { - const v = shown() as (() => unknown) | null; - return v ? v() : "none"; - }, - v => { - log.push(`view ${v}`); - } - ); - return "outer"; - }; - if (!nested) return body(); - const outer = Loading(body, "outer fallback"); - createRenderEffect(outer, v => { - log.push(`outer ${v}`); - }); - }); - flush(); - await w.settle(); - const out = await steps( - log, - w, - () => {}, - () => ({ - view: view ? verdict(() => view!(), "pending") : "-", - shown: verdict(() => shown(), "pending") - }) - ); - const v0 = source === "held" ? "false" : "-"; - expect(out).toEqual([ - ` | view=${v0} shown=true`, - `${source === "held" ? "view content 1 · x 1" : "holder 1 · view content 1"} | view=false shown=false`, - " | view=false shown=false" - ]); - }); + const v0 = source === "held" ? "false" : "-"; + expect(out).toEqual([ + ` | view=${v0} shown=true`, + `${source === "held" ? "view content 1 · x 1" : "holder 1 · view content 1"} | view=false shown=false`, + " | view=false shown=false" + ]); + } + ); // No boundary would catch it: a fresh mount reading the hold joins it // and waits for its commit (A15), as on next. Pre-L2 shows the committed @@ -942,7 +1015,9 @@ describe("#3540: the boundary scope — semantic fuzzer findings (rev 19, seed 9 // of it is a read of content the boundary owns, so the tree leaves the // transaction and re-derives (pending under the boundary) rather than // being recorded as a settled reader — which redrew the boundary forever. - it("a held tree under a re-armed boundary re-derives — no redraw loop", async () => { + // Re-arm under the boundary scope is deferred, pending its ruling: next's + // display here is the held content, no fallback. + it.fails("a held tree under a re-armed boundary re-derives — no redraw loop", async () => { const [s1, setS1] = createSignal(0); const [s2, setS2] = createSignal(0); const [visible, setShow] = createSignal(true); @@ -981,14 +1056,10 @@ describe("#3540: the boundary scope — semantic fuzzer findings (rev 19, seed 9 expect(log).toEqual(["view 1 0", "view loading"]); }); - // boundaries #1078, reduced. The re-arm's flush judges the content owned - // and it leaves the earlier hold; later in the same flush an async memo - // that hold has (read outside) joins the flush to it — the flush is the - // hold's after all. Two defects published the content staged before the - // re-arm at the hold's landing, after the hide: the pending propagated - // onto the boundary's held tree left it held with that staging (the tree - // now leaves the hold there), and the reads made pending before the join - // were never revisited (the seam re-derives them). + // boundaries #1078, reduced. An `on` re-arm over content staged in a + // hold, then a hide, in a flush that an async memo of that hold (read + // outside) joins to it: the content staged before the re-arm must not be + // published at the hold's landing, after the hide. it("hidden after a re-arm over a held mount: the boundary does not publish stale content", async () => { const w = seamWorld((s1, visible) => [ Loading(() => (visible() ? `content ${s1()}` : "hidden"), "loading", s1) @@ -1055,15 +1126,10 @@ function seamWorld(boundaries: (s1: () => number, visible: () => boolean) => (() return { setS1, setShow, log, turn, settle, mount }; } -// The flush's membership is final at the seam (A29's boundary scope: "the -// flush is `t`'s"). The re-arm makes the content's reads pending under the -// boundary; the flush then joins the hold those reads were of — the -// boundary's swap parks with it, so its fallback can never be seen. The -// seam re-derives those reads as the hold's: the content enters it and -// appears at its commit. Before, the fallback the flush had staged was -// committed with the hold and shown for a round. Pre-L2 and next show the -// re-armed content at the commit too (no fallback). -describe("#3540: the boundary scope — a read made pending before the flush joins its hold", () => { +// A re-arm whose content reads a hold, in a flush that then joins that +// hold: the re-armed content enters the hold and appears at its commit, no +// fallback committed with the hold — as pre-L2 and next. +describe("#3540: a read made pending before the flush joins its hold", () => { it("one boundary: the content appears at the hold's commit, no fallback", async () => { const w = seamWorld((s1, visible) => [ Loading(() => (visible() ? `content ${s1()}` : "hidden"), "loading", s1) @@ -1151,12 +1217,9 @@ describe("#3540: the boundary scope — a read made pending before the flush joi // boundaries #476/#1674 (rev 19, seed 91501), reduced. `show` is held by // `n1`'s flight, which the boundary's content forwards; `s = 0` re-arms - // the boundary — its content leaves that hold — and starts `n2`, which a - // reader outside reads, so the change's frame, and the boundary's swap - // with it, waits on `n2`. The content stays on screen until the swap, so - // the hold it read lands with the swap's frame (merged at the seam): - // before, it landed at once — `show` true beside content derived from - // `show` false — and the fallback followed a frame later. + // the boundary and starts `n2`, which a reader outside reads. The hold + // must not land at once — `show` true beside content derived from `show` + // false — with the fallback a frame later. it("a swap parked with another change takes the hold its content read with it", async () => { const drain = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); @@ -1209,20 +1272,16 @@ describe("#3540: the boundary scope — a read made pending before the flush joi expect(frame).toEqual({ show: true, view: "content 0", outside: "outside 0" }); }); - // boundaries #1674 (rev 19, seed 91501), reduced again after the merge - // above. KNOWN, A15's landing (not the scope): `show` is held only by - // the content under the boundary; `s = 0` re-arms it, and the hold — - // which no frame waits on any more — lands at once (the ruling: the - // re-armed boundary owns its content). A reader outside, re-derived at - // that landing, goes pending on `n2`, which `s = 0` started — the - // change's frame — and keeps showing "hidden" beside the landed `show`. - // The same tear without any boundary — a hold landing while a reader of - // it re-derived at the landing is pending on another change's flight — is - // boundaries #75's reduction, which fails on next too; next passes this - // shape only because there `s = 0` re-asks `n1`, joins the hold and lands - // with it. The fix is A15's: such a landing waits for the frame its - // reader went pending in. - it.fails("a hold released by a re-arm lands beside a reader pending on the change", async () => { + // boundaries #1674 (rev 19, seed 91501), reduced again. `show` is held + // only by the content under the boundary; `s = 0` re-arms it. A reader + // outside goes pending on `n2`, which `s = 0` started. Here, as on next, + // the re-arm's content joins the hold, so the hold lands with the change + // and nothing tears. Under the deferred re-arm scope the re-armed + // boundary would release the hold at once, and the reader re-derived at + // that landing would keep showing "hidden" beside the landed `show` — + // A15's landing tear, the same as boundaries #75's reduction without any + // boundary (which fails on next too). + it("a re-arm over held content, beside a reader pending on the change: no tear", async () => { const drain = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); }; diff --git a/packages/signals/tests/loading-on-frame-following-3540.test.ts b/packages/signals/tests/loading-on-frame-following-3540.test.ts index 9d44a653c..e190332a5 100644 --- a/packages/signals/tests/loading-on-frame-following-3540.test.ts +++ b/packages/signals/tests/loading-on-frame-following-3540.test.ts @@ -377,102 +377,44 @@ describe("3. an outside hold on the SAME source: the frame waits, the fallback c // The same source read outside, but its flight comes from an earlier write // the `on` change is not part of: nothing holds the change's frame, the // re-armed boundary owns its content (A29's boundary scope, 2026-10-06), so -// the fallback shows now — and nothing is reported. +// the fallback shows now — and nothing is reported. Re-arm under the +// boundary scope is deferred to a separate change, pending its ruling. describe("3b. an outside reader of a flight the `on` change did not start: the fallback shows now, not reported", () => { for (const outside of ["effect", "loading"]) - test(`outside ${outside === "effect" ? "render effect" : "revealed Loading"}`, async () => { - const d = captureWarnings(); - const [x, setX] = createSignal(0); - const [key, setKey] = createSignal(0); - const pending: (() => void)[] = []; - const log: string[] = []; - let dispose!: () => void; - createRoot(dispose_ => { - dispose = dispose_; - const data = createMemo( - async () => { - const v = x(); - await new Promise(r => pending.push(r)); - return v; - }, - { name: "data" } - ); - const read = - outside === "effect" - ? data - : untrack(() => - createLoadingBoundary( - () => `holder ${data()}`, - () => "holder fallback" - ) - ); - createRenderEffect(read, v => { - log.push(`outside ${v}`); - }); - const view = untrack(() => - createLoadingBoundary( - () => `c${key()} ${data()}`, - () => "fallback", - { on: key } - ) - ); - createRenderEffect(view, v => { - log.push(`view ${v}`); - }); - }); - flush(); - const settle = async () => { - while (pending.length) pending.shift()!(); - for (let i = 0; i < 8; i++) await microtask(); - flush(); - }; - await settle(); - log.length = 0; - setX(1); - flush(); - expect(log).toEqual([]); - setKey(1); - flush(); - expect(log).toEqual(["view fallback"]); - await settle(); - expect(log.slice(1).sort()).toEqual([ - outside === "effect" ? "outside 1" : "outside holder 1", - "view c1 1" - ]); - expect(d.codes()).toEqual([]); - d.stop(); - dispose(); - }); -}); - -// Content an earlier action holds with no flight (a write it staged): nothing -// under the boundary is pending, but the re-armed boundary owns its content — -// it leaves the hold, the fallback shows now, and the content reveals at the -// action's commit. A re-arm inside the action is the action's frame: no -// fallback, the content lands with the commit. -describe("3c. content an earlier action holds by a staged write: the re-arm shows the fallback now", () => { - for (const content of ["direct", "memo", "bound"] as const) - for (const inside of [false, true]) - test(`${content} content, re-armed ${inside ? "inside" : "after"} the action`, async () => { + test.fails( + `outside ${outside === "effect" ? "render effect" : "revealed Loading"}`, + async () => { const d = captureWarnings(); const [x, setX] = createSignal(0); const [key, setKey] = createSignal(0); + const pending: (() => void)[] = []; const log: string[] = []; - let done!: () => void; let dispose!: () => void; createRoot(dispose_ => { dispose = dispose_; + const data = createMemo( + async () => { + const v = x(); + await new Promise(r => pending.push(r)); + return v; + }, + { name: "data" } + ); + const read = + outside === "effect" + ? data + : untrack(() => + createLoadingBoundary( + () => `holder ${data()}`, + () => "holder fallback" + ) + ); + createRenderEffect(read, v => { + log.push(`outside ${v}`); + }); const view = untrack(() => createLoadingBoundary( - () => { - if (content === "direct") return () => `c ${x()}`; - const m = createMemo(() => `c ${x()}`); - if (content === "memo") return m; - createRenderEffect(m, v => { - log.push(`bind ${v}`); - }); - return "

"; - }, + () => `c${key()} ${data()}`, () => "fallback", { on: key } ) @@ -482,32 +424,102 @@ describe("3c. content an earlier action holds by a staged write: the re-arm show }); }); flush(); - const shows = (v: number) => - content === "bound" ? [`bind c ${v}`, "view

"] : [`view c ${v}`]; - expect(log).toEqual(shows(0)); + const settle = async () => { + while (pending.length) pending.shift()!(); + for (let i = 0; i < 8; i++) await microtask(); + flush(); + }; + await settle(); log.length = 0; - action(function* () { - setX(1); - if (inside) setKey(1); - yield new Promise(r => (done = r)); - })(); + setX(1); flush(); expect(log).toEqual([]); - if (!inside) { - setKey(1); - flush(); - expect(log).toEqual(["view fallback"]); - } - done(); - for (let i = 0; i < 8; i++) await microtask(); + setKey(1); flush(); - expect(log).toEqual( - inside ? (content === "bound" ? ["bind c 1"] : shows(1)) : ["view fallback", ...shows(1)] - ); + expect(log).toEqual(["view fallback"]); + await settle(); + expect(log.slice(1).sort()).toEqual([ + outside === "effect" ? "outside 1" : "outside holder 1", + "view c1 1" + ]); expect(d.codes()).toEqual([]); d.stop(); dispose(); - }); + } + ); +}); + +// Content an earlier action holds with no flight (a write it staged): nothing +// under the boundary is pending, but the re-armed boundary owns its content — +// it leaves the hold, the fallback shows now, and the content reveals at the +// action's commit. A re-arm inside the action is the action's frame: no +// fallback, the content lands with the commit. The re-arm after the action +// is pinned: re-arm under the boundary scope is deferred, pending its ruling. +describe("3c. content an earlier action holds by a staged write: the re-arm shows the fallback now", () => { + for (const content of ["direct", "memo", "bound"] as const) + for (const inside of [false, true]) + (inside ? test : test.fails)( + `${content} content, re-armed ${inside ? "inside" : "after"} the action`, + async () => { + const d = captureWarnings(); + const [x, setX] = createSignal(0); + const [key, setKey] = createSignal(0); + const log: string[] = []; + let done!: () => void; + let dispose!: () => void; + createRoot(dispose_ => { + dispose = dispose_; + const view = untrack(() => + createLoadingBoundary( + () => { + if (content === "direct") return () => `c ${x()}`; + const m = createMemo(() => `c ${x()}`); + if (content === "memo") return m; + createRenderEffect(m, v => { + log.push(`bind ${v}`); + }); + return "

"; + }, + () => "fallback", + { on: key } + ) + ); + createRenderEffect(view, v => { + log.push(`view ${v}`); + }); + }); + flush(); + const shows = (v: number) => + content === "bound" ? [`bind c ${v}`, "view

"] : [`view c ${v}`]; + expect(log).toEqual(shows(0)); + log.length = 0; + action(function* () { + setX(1); + if (inside) setKey(1); + yield new Promise(r => (done = r)); + })(); + flush(); + expect(log).toEqual([]); + if (!inside) { + setKey(1); + flush(); + expect(log).toEqual(["view fallback"]); + } + done(); + for (let i = 0; i < 8; i++) await microtask(); + flush(); + expect(log).toEqual( + inside + ? content === "bound" + ? ["bind c 1"] + : shows(1) + : ["view fallback", ...shows(1)] + ); + expect(d.codes()).toEqual([]); + d.stop(); + dispose(); + } + ); }); describe("4. `on: () => latest(id)`: the display-ahead read shows the fallback now, beside the held frame", () => { diff --git a/packages/solid/CHEATSHEET.md b/packages/solid/CHEATSHEET.md index 97066e5d0..bb6ee2567 100644 --- a/packages/solid/CHEATSHEET.md +++ b/packages/solid/CHEATSHEET.md @@ -655,7 +655,7 @@ If your training data is 1.x, these are the corrections. **Read this before gene - **Stores: setters take a draft callback** — mutate the draft in place by default. Returning a new value is shallow (array index-replace, object top-level diff); reach for it for filter/remove. Keyed reconcile is a _projection-fn_ feature, not a setter feature. - **`undefined` is a real value in `merge`** — it overrides rather than "skip this key". - **Async lives in computations** — return a Promise/AsyncIterable from `createMemo`/`createStore(fn)`/`createProjection`. Pending reads participate in ``. -- **`Loading` covers unresolved branches** — once content has rendered, revalidation keeps it visible. Use `isPending(() => x())` for in-flight-change indicators or render guards; it reads `x` and participates in Loading like that read. `` is a dependency list, not a key: its value is never compared; a change to anything it reads makes the boundary stop waiting on its current content and show the fallback if something under it is pending (nothing otherwise). The fallback _follows the frame_: it lands with the change that caused it — immediately when nothing else holds that frame, together with the rest of the new page during a held navigation (never a spinner beside a page the change has not reached). `on={[a(), b()]}` for several. The re-armed boundary owns its content: data still loading, or held by an action that is still running, from an earlier change shows the fallback now, wherever else it is read. If the data the change itself starts loading is also read outside the boundary, the frame waits on it and the fallback can never be seen — DEV warns `LOADING_ON_OUTSIDE_HOLD`; fix the structure (move the outside read under the boundary so one hold owns the data). A frame held past the content's landing by something else (the write's action, other pending data) shows no fallback either — a race, not reported; show the wait with `isPending()`. A display-ahead read in `on` (`on={latest(id)}`) shows the fallback _now_, beside the still-held frame — a capability, not the recommended shape. A fallback that names what is loading should read `latest(id)`, since `id()` is still the committed value while the new one loads. +- **`Loading` covers unresolved branches** — once content has rendered, revalidation keeps it visible. Use `isPending(() => x())` for in-flight-change indicators or render guards; it reads `x` and participates in Loading like that read. `` is a dependency list, not a key: its value is never compared; a change to anything it reads makes the boundary stop waiting on its current content and show the fallback if something under it is pending (nothing otherwise). The fallback _follows the frame_: it lands with the change that caused it — immediately when nothing else holds that frame, together with the rest of the new page during a held navigation (never a spinner beside a page the change has not reached). `on={[a(), b()]}` for several. If the same data is also read outside the boundary, the frame waits on it and the fallback can never be seen — DEV warns `LOADING_ON_OUTSIDE_HOLD`; fix the structure (move the outside read under the boundary so one hold owns the data). A frame held past the content's landing by something else (the write's action, other pending data) shows no fallback either — a race, not reported; show the wait with `isPending()`. A display-ahead read in `on` (`on={latest(id)}`) shows the fallback _now_, beside the still-held frame — a capability, not the recommended shape. A fallback that names what is loading should read `latest(id)`, since `id()` is still the committed value while the new one loads. (2026-10-06: how a re-armed boundary treats data an earlier change still holds is deferred to a separate change; the above stands until then.) - **`isPending` ≠ 1.x `.loading`** — it fires while a value _change_ is in flight (an input changed, or `affects()` declared one), not for every fetch. A bare `refresh()`/poll re-asks the same question and is silent. For a reload that should read as pending: `affects(x); refresh(x)`. For a "saving…" affordance: a co-written optimistic flag. - **No `Suspense.Provider` or single error path** — async errors flow to `` (or effect `error`); no inline `resource.error` branching. - **`createRoot` is owned by parent by default** — disposed when parent disposes. To detach: `runWithOwner(null, fn)`. diff --git a/packages/solid/src/client/flow.ts b/packages/solid/src/client/flow.ts index fbb038536..676ac6532 100644 --- a/packages/solid/src/client/flow.ts +++ b/packages/solid/src/client/flow.ts @@ -458,14 +458,10 @@ export function Errored(props: { * nothing else holds that frame; together with the rest of the new page * during a held navigation (a write inside an `action`, or one whose data * other readers are still waiting on) — never a spinner beside a page the - * change has not reached yet. The re-armed boundary owns its content: data - * the change did not start loading (a flight or a held write from an - * earlier change) shows the fallback now rather than holding the change, - * even where it is also read outside the boundary. If the change itself - * starts the data loading and the data is also read outside the boundary, - * the frame waits on it and the fallback can never be seen; DEV warns - * `LOADING_ON_OUTSIDE_HOLD`, and the fix is structural — move the outside - * read under the boundary so one hold owns the data. A frame held + * change has not reached yet. If the same data is also read outside the + * boundary, the frame waits on it and the fallback can never be seen; DEV + * warns `LOADING_ON_OUTSIDE_HOLD`, and the fix is structural — move the + * outside read under the boundary so one hold owns the data. A frame held * past the content's landing by something else (the write's action, other * pending data) shows no fallback either; that is a race, a legitimate * outcome, and not reported — show the wait with `isPending()` instead.