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 00bbd1f2e..adee7f3b1 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:311` | — | 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:319` | — | 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:327` | — | 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:335` | — | 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:343` | — | 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:351` | 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:157` | — | 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:165` | — | 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:173` | 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:181` | 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:99` | — | 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:189` | — | 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:197` | — | 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:205` | — | 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:241` | 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 … | -| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:213` | 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×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… | -| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:141` | 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:400` | — | 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:407` | — | 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:221` | 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:231` | — | 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:149` | 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:301` | 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:107` | — | 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:293` | — | 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:91` | 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:115` | 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:249` | 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:123` | 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:131` | 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:265` | 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:275` | 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:313` | — | 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:321` | — | 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:329` | — | 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:337` | — | 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:345` | — | 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:353` | 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:159` | — | 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: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… | +| 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 … | +| 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… | +| A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:143` | 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:402` | — | 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:409` | — | 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:223` | 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:233` | — | 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:151` | 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:303` | 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: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 … | +| 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… | ## V — fixed violations | id | status | defined | cited in src | cited in tests | statement (at definition) | |---|---|---|---|---|---| -| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:470` | — | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | -| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:480` | — | 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:486` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:493` | — | 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:505` | 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:472` | — | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | +| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:482` | — | 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:488` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:495` | — | 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:507` | 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:197` | — | 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:205` | — | 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:241` | — | 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:213` | boundaries.ts×1 | derived-write-then-derivation-3733.test.ts×1 fuzz-findings-l2.test.ts×3 spec-async-semantics.test.ts×2 | PROMOTED → A16 (A16's section carries the ruling). | +| B1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | 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:207` | — | 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:243` | — | 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:215` | boundaries.ts×1 | derived-write-then-derivation-3733.test.ts×1 fuzz-findings-l2.test.ts×3 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:141` | — | 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:436` | — | 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:446` | — | — | - [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:143` | — | 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:438` | — | 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:448` | — | — | - [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 e3b714992..d48dfdff0 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 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: - **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..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; @@ -596,25 +609,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); diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 2ee50e388..9806100e9 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -943,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._catch?.(c, STATUS_PENDING | STATUS_UNINITIALIZED)); } /** A15's stale reader (shared-hole and reveal corollaries): a render effect @@ -1653,11 +1661,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 f47e334de..a386a6a38 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,12 +662,14 @@ 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; 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 _hidden: ((r: Computed) => boolean) | undefined; declare static _boundarySeam: (() => void) | undefined; @@ -710,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, 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..b6d1c9aad --- /dev/null +++ b/packages/signals/tests/loading-fallback-in-flush-3540.test.ts @@ -0,0 +1,573 @@ +/** + * #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, + getOwner, + isPending, + 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()); + }); +}); + +/** 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"]); + }); +}); 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(); + }); +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index acb9d32b8..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.84 KB", - "minified": 145599 + "cap": "44.86 KB", + "minified": 145627 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "48.51 KB", - "minified": 157562 + "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 f5e503529..31b19af85 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -2115,8 +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. - limit: "28.87 KB", - capMinified: 91625, + // 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 }, { @@ -2619,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 }, { @@ -3094,8 +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. - limit: "25.13 KB", - capMinified: 78898, + // 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 }, { @@ -3130,8 +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. - limit: "30.93 KB", - capMinified: 99198, + // 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 }, { @@ -3507,6 +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.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)"], @@ -3620,6 +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.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)"],