diff --git a/.changeset/reconcile-unchanged-presence-no-hold.md b/.changeset/reconcile-unchanged-presence-no-hold.md new file mode 100644 index 000000000..57e76b7cb --- /dev/null +++ b/.changeset/reconcile-unchanged-presence-no-hold.md @@ -0,0 +1,9 @@ +--- +"@solidjs/signals": patch +--- + +A store fold's presence notification (`in` subscribers) now diffs the old and new presence of each observed key before writing the node, as the value notification already does (#3743). `setSignal` joins a held node's transaction before its equality gate (A34 (1): a write to a held node is a proposal, the same value or another), so a `reconcile()` outside an action that had deleted an observed key — the incoming snapshot leaving it absent too — made the whole tick the action's: an unrelated `a.value` stayed stale until the action settled. A real presence change still notifies and still proposes on a held node. + +The adoption's diff base now materializes a nested prototype-overlay draft (a wide owned record) before it is taken: through the overlay's prototype a key the draft had deleted still read as present, so the presence skip fired on a real deletion and a reconcile restoring the key never re-proposed on the held leaf (it committed the draft's `undefined` beside a backing that had the key). + +Two optimistic-store fixes the diff uncovered: a container carrying an arrangement guess is now told of an arrangement change whether or not anything subscribes to the container (for a guessed container the write is the landing that judges the guess — before, only the unconditional presence write reached the lane, so a newer question's rows landing beneath an optimistic push published beside it when the reader subscribed to leaves only); and an older truth re-based under an arrangement guess no longer re-stages a slot whose row the committed backing already shows by key, nor an unchanged `length` (a spurious frame for leaf-only readers). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 6f6f0b3fa..7b49eaa48 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -62,7 +62,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:221` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×13 verdict.ts×2 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 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:193` | 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:51` | async.ts×3 constants.ts×1 lanes.ts×5 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | lane-uninitialized-landing-3648.test.ts×5 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:61` | 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 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… | +| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:61` | 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:121` | constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×1 | 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:380` | — | 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:387` | — | 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… | @@ -78,7 +78,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:103` | boundaries.ts×1 core.ts×1 verdict.ts×1 | 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:111` | 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:245` | boundaries.ts×1 scheduler.ts×1 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.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:255` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×3 | a34-writes-then-derivations.test.ts×2 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-list-mutation-matrix.test.ts×1 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:255` | 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 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) | @@ -390,7 +390,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | §6c | live | `docs/INTERNALS-STORE-STATE.md:334` | — | createProjection.async.test.ts×1 flight-owned-transaction.test.ts×1 | Store-wide status gating (RUL-7) | | §6d | live | `docs/INTERNALS-STORE-STATE.md:347` | reconcile.ts×2 target.ts×2 | — | Diff reachability (RUL-11) | | §7 | live | `docs/INTERNALS-STORE-STATE.md:359` | optimistic.ts×1 projection.ts×1 | — | Projections & optimism layering | -| §7b | live | `docs/INTERNALS-STORE-STATE.md:369` | scheduler.ts×1 affects.ts×1 optimistic.ts×2 projection.ts×1 reconcile.ts×1 store.ts×6 target.ts×4 | optimistic-chained-revert-3672-memo.test.ts×1 | Chained backing (cross-store) — spec | +| §7b | live | `docs/INTERNALS-STORE-STATE.md:369` | scheduler.ts×1 affects.ts×1 optimistic.ts×2 projection.ts×1 reconcile.ts×1 store.ts×7 target.ts×4 | optimistic-chained-revert-3672-memo.test.ts×1 | Chained backing (cross-store) — spec | | §8 | live | `docs/INTERNALS-STORE-STATE.md:440` | — | l2-contract.test.ts×1 reconcile-resend-identity.test.ts×1 | Assumptions / open questions | | §8b | live | `docs/INTERNALS-STORE-STATE.md:496` | — | — | Suite-mined rules (2026-08-16) — index & rulings needed | | §9 | live | `docs/INTERNALS-STORE-STATE.md:732` | — | — | Decision log | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 5761090d6..8213dab1a 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -62,7 +62,7 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t **Status:** **ruled, amended in place** 2026-07-07 (promoted from B4) — maintainer rulings, 2026-07-07 (original) and 2026-07-07b (re-rule: "the non-blocking aspect… only gates the reveal"); mechanism re-ruled 2026-09-09 (#3331, supersession: "a new value from the source should remove the optimism immediately") with scope and provenance ruled 2026-09-10; store/node ownership corollaries 2026-07-17/18 (#2899, #2912); body-end corollary 2026-09-14 (#3427: the action body ending supersedes the overrides still in force when nothing authoritative is in flight); **amended 2026-10-03 (L2, Q-D)** — provenance generalized to stores and made one predicate (`stale(el, q)`: a landing asking an older question than the guess is held beneath it, never shown — a keyed store landing is re-based over the older truth by row key); the body-end corollary (#3427) applies to a store guess as to a signal's (#3662 step 1 re-pinned); a corrected lane that had shown keeps its runs **Pinned by:** `tests/spec-async-semantics.test.ts` (same pins — behavior coincides in unmerged graphs); `tests/optimistic-store-layer-scope.test.ts` (store corollary: disjoint-key independence, nested rows, same-key entanglement, delete survival, ambient flush-end); `tests/optimistic-lane-transaction-ownership.test.ts` (node corollary: shared-subscriber lane merge with swapped write order, three-action signal hijack); `tests/optimistic-lane-release.test.ts` (body-end corollary, and #3426: the last async reader unmounting releases the frame) -**Mechanism (L2, 2026-10-04):** the guess lives as long as its lane, and the lane as long as the parent transaction (`dissolveLane(l, null)` at the parent's landing — `endLanes` — lands the truth staged beneath the guess or reverts to the value it covered; a derivation's latest commits). **A newer truth from the source:** `laneWrite` on a guess → `supersede(n, value, changed)`: a differing truth **dissolves the lane into the parent** (`dissolveLane(l, parent, n)` — the guess and everything derived from it are void, what the screen showed stays the screen until the commit, the truth is the parent's held write (`holdNode(n, parent)`, `joinFuture(parent)`), the graph re-derives from it there (`insertSubs`); a lane that had shown keeps its runs); an equal truth is a silent confirmation (staged beneath the guess for the commit; only `CONFIG_AUTHORITATIVE` readers re-run). **Provenance (Q-D):** every flight is stamped with the question it answers (`handleAsync`: `_x._q = question || MAINLINE_QUESTION`; an action's slices run under their own question, a derive's continuation under the flight's). `stale(el, q)` — `q !== 0 && q < el._x._q` — is the one predicate: `laneWrite` holds a landing asking an older question than the guess beneath it (`_pendingValue`, `CONFIG_HELD`; for a store container `_laneRebase` re-bases the keyed truth under the arrangement guess), and `laneOutcome` judges a derivation's pass by the question its changed dependencies answered (`answered(el)`). **Body-end (#3427):** `laneCorrections` at the seam, once the parent's action has returned (`_acted`, `_open === 0`) and neither the parent's own flights (`ownFlights`) nor a guess's own refetch (`guessFlights`) are up: each guess is superseded by its truth — the landing held beneath it, else the value it covered (`covered`) — the same for a store guess (the slot's committed value through `_slotCovered`). +**Mechanism (L2, 2026-10-04):** the guess lives as long as its lane, and the lane as long as the parent transaction (`dissolveLane(l, null)` at the parent's landing — `endLanes` — lands the truth staged beneath the guess or reverts to the value it covered; a derivation's latest commits). **A newer truth from the source:** `laneWrite` on a guess → `supersede(n, value, changed)`: a differing truth **dissolves the lane into the parent** (`dissolveLane(l, parent, n)` — the guess and everything derived from it are void, what the screen showed stays the screen until the commit, the truth is the parent's held write (`holdNode(n, parent)`, `joinFuture(parent)`), the graph re-derives from it there (`insertSubs`); a lane that had shown keeps its runs); an equal truth is a silent confirmation (staged beneath the guess for the commit; only `CONFIG_AUTHORITATIVE` readers re-run). **Provenance (Q-D):** every flight is stamped with the question it answers (`handleAsync`: `_x._q = question || MAINLINE_QUESTION`; an action's slices run under their own question, a derive's continuation under the flight's). `stale(el, q)` — `q !== 0 && q < el._x._q` — is the one predicate: `laneWrite` holds a landing asking an older question than the guess beneath it (`_pendingValue`, `CONFIG_HELD`; for a store container `_laneRebase` re-bases the keyed truth under the arrangement guess — a slot the lane does not own becomes its only where the composed row is another row by key, the committed `length` likewise), and `laneOutcome` judges a derivation's pass by the question its changed dependencies answered (`answered(el)`). **A store landing reaches a guess through the fold's diff** (`notifyKeyDiff`/`notifyKeyValue`, `notifyFoldTail`; #3743): a guessed leaf or presence node is written where the truth changed its value or presence; the container's arrangement guess is written by every landing that changes the arrangement — whether or not anything subscribes to the container (`notifyFoldTail`/`notifyWrites`: for a guessed container the write is the judgement, not a subscriber notification), so a newer question's rows landing beneath an optimistic push are its answer even where no guessed key's truth moved. **Body-end (#3427):** `laneCorrections` at the seam, once the parent's action has returned (`_acted`, `_open === 0`) and neither the parent's own flights (`ownFlights`) nor a guess's own refetch (`guessFlights`) are up: each guess is superseded by its truth — the landing held beneath it, else the value it covered (`covered`) — the same for a store guess (the slot's committed value through `_slotCovered`). **Statement (current).** (was B4; **refined by re-rule 2026-07-07b**) An override's lifetime is bound to **its own transition** — which, because lanes keep their transitions separate from unrelated work, contains exactly the override's own async cascade. **Supersession (re-ruled 2026-09-09, #3331): own-source arrival removes the optimism from the graph immediately; the display keeps it until the transaction commits.** Maintainer: "a new value from the source should remove the optimism immediately.. if it matches then no more work, if it doesn't match then that work gets folded into the parent transition"; "when the optimism drops we might not see it until end of transition because it folds into the parent's transition." This replaces the mechanical sentence above ("elevate to `_value` only at their transition's commit; the elevation is unobservable under the override mask") — that model let the override's own downstream flight serialize ahead of the truth's, doubling the delay the reporter saw. Now: (a) a landing that _equals_ the override confirms silently — nothing re-runs, the lane's in-flight work completes the frame; (b) a landing that _differs_ marks the node superseded: its subscribers recompute from the arrived value on the plain channel (their lane affinity is dropped, so this is held transaction work, not lane work), downstream async restarts from the truth _now_, and the override's own downstream flight is inert when it lands; (c) untracked reads and the applied screen keep the override until the transaction — holding for whatever the corrected derivations observe (A15) — commits and clears the override; (d) `latest` returns the arrived value, `isPending` reads `true` iff the arrival differs (consequence (3) unchanged in statement, now true in mechanism). **(d) before the first commit (2026-09-15, visibility oracle):** the verdict holds for a node that has never committed — its first landing held by a reveal that never landed — because the override is the observable value; A19 exception (1) ("uninitialized is loading, not pending: no observable value exists") does not apply under a displayed override. Pinned: `tests/superseded-before-first-commit.test.ts`. A later landing on the same node that equals the override un-supersedes it (the override is again the graph's value). **Scope (ruled 2026-09-10): "the source" is whatever recomputes the node** — its own async landing, or a synchronous recompute driven by an upstream change (`createOptimistic(() => userCategory())` over an async memo is the common real-world shape): "if the source recomputes it doesn't matter if it is async or not." **Ordering:** a new value from the source is one that _postdates_ the override — a source write and an override in the same batch derive nothing new (the override is written over that batch's truth knowingly and stays the graph's value until the commit reveals it). **Provenance (ruled 2026-09-10):** "a new value from the source" answers the override's _own_ question or a newer one. Two rapid actions on one node merge into one transaction, and the older action's refetch can land after the newer override; that answer is a question the user has since changed — it is staged for the commit like any landing (and reveals then iff it is still the truth) but does **not** supersede: no downstream re-derivation, no pending flip on downstream readers. "A slow source shouldn't leak back in like that." Only the override's own action, a later action, or mainline (no action — a fresh question by definition) supersedes. **Body-end corollary (2026-09-14, #3427):** the action bodies were the optimism's justification; once they have all ended, an override still in force is superseded by the truth already at hand — the staged value an equal (A17-silent) landing left, else the committed value — exactly as an arriving differing truth would be, _unless_ the transaction is still waiting on authoritative work: an override node's own source in flight (that answer confirms or supersedes on arrival), or a held flight that does not derive from an override (a plain load the action asked for; a co-written `saving` flag stays through it — the optimistic world is one, A17). Flights that derive from an override are questions about a guess that is about to revert, and nobody reads their answer: the graph re-derives from the truth as the transaction's held work and settles when _that_ lands, so the correction starts at the body's end instead of after the obsolete flight (which used to reveal the obsolete optimistic frame first, then revert and re-ask — a waterfall with a flash). Display is unchanged: the override stays on screen until the commit (c). Optimistic store edits keep the settle-then-revert order (their truth is a base layer under an overlay, with no tracked/displayed split), and companions snap at settlement. **Visibility during the body-end window (2026-09-15, visibility oracle):** identical to a landing supersession — the display and stale readers keep the override, a fresh derivation is held (A29), `latest()` answers the truth and `isPending()` reads true iff it differs — although nothing is staged (the truth at hand is the committed value). The node carries no `_transition` stamp in this window (an override written inside an action never passes the adoption loop that stamps one), so the read path and the verdict resolve the owning transaction through `_overrideOwner` (node corollary, #2912). Pinned: `tests/body-end-supersession-visibility.test.ts`. Consequences: (1) in unmerged graphs, own-source resolution IS the lane-transition's completion, so the correction reveals on arrival — the original A18 pins hold unchanged; (2) matching confirmations collapse silently (revert sees value == override, nobody re-runs); (3) when the override's transition genuinely merges with unrelated async, the correction reveals atomically with that merged completion — verdict during the window per A24 (amended 2026-07-13; was "false throughout" under the A20 mask): a held correction that _differs_ from the displayed override reads pending; a matching confirm stays quiet — corrections still _propagate_ internally on arrival (fresh readers/async drivers see the hold), so downstream refetches start immediately and no waterfalls form; only the reveal is gated. **Store corollary (2026-07-17, #2899): the optimistic layer obeys the same per-transaction lifetime.** `createOptimisticStore`'s override layer is one record per store target, but each entry is owned by the transaction that wrote it (`STORE_OPTIMISTIC_OWNERS` stamps, merge chains resolved): a settling action consumes only its own keys, so concurrent actions on disjoint keys revert independently — first-settling no longer wipes the other's live overrides. Same-key writes still entangle through the shared node (one joint settle); ambient (transaction-less) entries clear at plain flush end; a derived store's projection landing still consumes the whole layer (fresh authority supersedes every tentative write). **Node corollary (2026-07-18, #2912): ownership never travels through lanes.** Lanes are scheduling affinity — a shared subscriber (one effect reading keys touched by two actions) merges them correctly for flushing, but the merged root's `_transition` must not answer "which transaction owns this override": that let one action's settle revert another's live override, and same-key follow-up writes entangle with the wrong transaction. Every optimistic write stamps `_overrideOwner` on the node (post-merge, so entangled writers share the joint root; cleared at settle); `resolveTransition` prefers a live owner stamp over the lane, falling back to lane `_transition` for nodes without overrides (async routing) exactly as before. Pinned: `tests/spec-async-semantics.test.ts` ("#3331" describe: own-async, sync-wrapper, same-batch, provenance, simple graph; A18 entangled pin re-expected: the merged correction reveals as one frame, never the committed-behind-the-mask tear); `tests/createOptimistic.test.ts` (CategoryDisplay no-double-flicker pin, unchanged: the older action's answer never moves the graph; "second action while first still in flight" pin, resolver repaired and re-expected to the same rule). @@ -255,8 +255,8 @@ The hold does not vanish; it moves onto the boundary (#3459). The readers behind ### A34. A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none **Status:** **ruled** 2026-09-16 (#3494) — maintainer: "a write to the same signal already set to that value would entangle I think. Unless it's committed, both are suggesting a value. If one finished before the other that would be odd." Reverses a mechanism choice #3473 landed unstated (a mainline write to a held node stopped entering the transaction — the entry leaked `activeTransition` into the caller's block, A29); the leak is fixed differently, the grouping restored. **Amended 2026-09-23 (#3612)** — a held derivation is not a proposal, see (3) below. **Amended 2026-10-01 (#3733)**: a manual write's mask lasts its frame, not the hold. See (3). **Amended 2026-10-01 (#3733, rule B)**: writes apply first, then derivations re-run. See (3).; **L2 (2026-10-01)**: (1) is `read`/`setSignal`'s join of a held node's transaction and (2) the seam's net-to-committed check before `holdNode`; a held derivation is not a proposal (3) is `CONFIG_MANUAL_WRITE` on the written node only -**Pinned by:** `tests/write-proposals-3494.test.ts` (the contract question: repeating a held value holds the tick with it, as a differing write does; a coalesced toggle stamps nothing and pends nothing; the torn `[1, 0, 1]` effect input; the lost hide after a coalesced toggle; #3519 review: a lone same-value write to a held node does not capture the next unrelated tick; a writable memo written back to its committed value proposes nothing); `tests/held-derivation-not-a-proposal-3612.test.ts` (the amendment: the report's shape and its store twin; the updater's `prev` is the committed frame; a `prev`-reading derivation folds the write; `a` moving again after the write re-derives regardless of an `isPending` probe in between; #2692 preserved within a frame, hold or not; a write made inside the transaction keeps last-write-wins; an unrelated hold commits the write at once) -**Mechanism (L2, 2026-10-04):** (1) a write to a held node joins its transaction — `setSignal` stages the value and the pass or tick that wrote it is the holder's (inside a flush `joinFuture`; from mainline the next flush's seam holds the tick's stagings with it, `holdNode` over `pendingNodes` — the batch is one proposal). (2) the seam's net-to-committed check before `holdNode`: a staged node whose value equals the committed one, not pending, with no parked children, is unstaged and holds nothing. (3) a held derivation is not a proposal — `CONFIG_MANUAL_WRITE` marks the written node only (`markManualWrite`), lifted by a pass that re-derives it or by the commit; a derive's re-run under the hold (`recompute`'s head joins) replaces the held answer. **Rule B (#3733):** a manual write never refuses the re-run; a source change re-runs the derivation with the write as `prev` (`refresh()` in the write's frame included — `markRefresh`); a derived store's derive reading a key a transaction holds through its draft joins that transaction (`readSource`'s draft arm) so the re-derived result reveals with the action. +**Pinned by:** `tests/write-proposals-3494.test.ts` (the contract question: repeating a held value holds the tick with it, as a differing write does; a coalesced toggle stamps nothing and pends nothing; the torn `[1, 0, 1]` effect input; the lost hide after a coalesced toggle; #3519 review: a lone same-value write to a held node does not capture the next unrelated tick; a writable memo written back to its committed value proposes nothing); `tests/held-derivation-not-a-proposal-3612.test.ts` (the amendment: the report's shape and its store twin; the updater's `prev` is the committed frame; a `prev`-reading derivation folds the write; `a` moving again after the write re-derives regardless of an `isPending` probe in between; #2692 preserved within a frame, hold or not; a write made inside the transaction keeps last-write-wins; an unrelated hold commits the write at once); `tests/store/unchanged-presence-no-hold-3743.test.ts` (#3743: a `reconcile()` repeating an unchanged absence to a presence node an action holds is no proposal — the unrelated `a.value` publishes now; restoring the key is one and rides the action; the wide-object overlay draft and its value-node twin; a projection adopting away from a chained store) +**Mechanism (L2, 2026-10-04):** (1) a write to a held node joins its transaction — `setSignal` stages the value and the pass or tick that wrote it is the holder's (inside a flush `joinFuture`; from mainline the next flush's seam holds the tick's stagings with it, `holdNode` over `pendingNodes` — the batch is one proposal). (2) the seam's net-to-committed check before `holdNode`: a staged node whose value equals the committed one, not pending, with no parked children, is unstaged and holds nothing. (3) a held derivation is not a proposal — `CONFIG_MANUAL_WRITE` marks the written node only (`markManualWrite`), lifted by a pass that re-derives it or by the commit; a derive's re-run under the hold (`recompute`'s head joins) replaces the held answer. **Rule B (#3733):** a manual write never refuses the re-run; a source change re-runs the derivation with the write as `prev` (`refresh()` in the write's frame included — `markRefresh`); a derived store's derive reading a key a transaction holds through its draft joins that transaction (`readSource`'s draft arm) so the re-derived result reveals with the action. **A store fold's notifications are diffs** (#3296, #3743): an adoption (`reconcile`, a setter's returned replacement, a projection landing) writes a leaf (`notifyKeyDiff`/`notifyKeyValue`) or a presence node (`notifyFoldTail`) only where `old` → `neu` changed it, so a key the adoption left as it was — an absence repeated to a node an action holds — is no proposal and joins nothing; a key it changed is written and, on a held node, proposes under (1). (The diff base is the view the nodes were last told: a nested overlay draft is materialized before it is taken, `applyAdopt`.) A write proposes a value for a node. **Held, both are suggestions (1):** while a transaction holds a proposal for a node (staged, uncommitted), a further write to that node — the same value again or another — is a second suggestion for the same slot, and the two cannot finish at different times: the writer's tick joins the hold and reveals with it. `setB(1)` held on an async reader of `b`, then `setA(1); setB(1)` in a later tick: A reveals with B when the flight lands, never alone (before #3473 it did, through the eager entry; after, `A: 1` published at once beside the held `B: 0` — gabbev's "verified grouping difference"). Same for `setA(1); setB(2)`, which the flight route already held. Inside an action body the same write merges the action's transaction with the holder's, as it always did. **Committed, a repeat is nothing (2):** a tick whose writes net to the node's committed value made no proposal — `setShow(false); setShow(true)` on a committed `true`. The node is not staged, not stamped into whatever transaction the tick opens, and pends nothing: `isPending(show)` stays false (A19: the observable value is final), and a later `setShow(false)` is a plain mainline write — the effect's pass is the writer's (A15 shared-hole), it publishes "hidden" at once, and dropping its read of the flight retires it as a reporter (O3) so the hold on `count` releases in the same drain. Before, the coalesced toggle left `show` staged at its own value, the hold's adoption stamped it, the verdict read it pending, and the hide was captured by the stamp — and then lost. Consequence for A26 (2): "a signal already written under the transaction rejoins it" is now literal — only a signal the transaction actually holds a value for is its. Consequence for A28's mechanism: the write-side entry is deferred, not removed. diff --git a/packages/signals/src/store/optimistic.ts b/packages/signals/src/store/optimistic.ts index 93a38bac8..d6922291c 100644 --- a/packages/signals/src/store/optimistic.ts +++ b/packages/signals/src/store/optimistic.ts @@ -264,9 +264,12 @@ function reconcileDraft(draft: any, incoming: any, keyFn: KeyFn | null): void { * row target adopted it, its proxy survives), guessed rows the truth lacks * kept (the guess's own landing judges them), truth rows the guess lacks * appended. The composed arrangement becomes the lane's value on every - * index whose row the committed backing does not already show (a node is - * born for it as the lane's derivation), on `length`, and as the - * `LaneView`'s base for structural reads. Unkeyed families compose + * index whose row the committed backing does not already show — compared + * BY KEY, never by raw identity: the same key's new object is the same row + * (its target adopted it) and the slot stays the committed backing's — (a + * node is born for it as the lane's derivation), on `length` only when the + * committed length differs, and as the `LaneView`'s base for structural + * reads. Unkeyed families compose * positionally — the guess stands as written (the documented residue). */ GlobalQueue._laneRebase = (el: Signal, truth: unknown): void => { const t = (el as any)._host as StoreTarget | undefined; @@ -310,9 +313,12 @@ GlobalQueue._laneRebase = (el: Signal, truth: unknown): void => { const nodes = t.n; for (let i = 0; i < rows.length; i++) { const node = nodes?.[i]; + // A slot the lane does not own shows the committed row: it becomes the + // lane's only where the composed row is another row (by key — the same + // key's new object is the same row, its target adopted it). if (node === undefined) { - if (rows[i] !== base[i]) laneSet(getNode(t, i), rows[i], lane); - } else if (!(node._config & CONFIG_OVERRIDE) || !sameRow(node._x!._lane, rows[i], keyFn)) + if (!sameRow(base[i], rows[i], keyFn)) laneSet(getNode(t, i), rows[i], lane); + } else if (!sameRow(node._config & CONFIG_OVERRIDE ? node._x!._lane : base[i], rows[i], keyFn)) laneSet(node, rows[i], lane); } if (nodes !== null) { @@ -322,7 +328,8 @@ GlobalQueue._laneRebase = (el: Signal, truth: unknown): void => { laneSet(node, undefined, lane); } const len = nodes.length; - if (len !== undefined && len._x?._lane !== rows.length) laneSet(len, rows.length, lane); + if (len !== undefined && (len._config & CONFIG_OVERRIDE || base.length !== rows.length)) + laneSet(len, rows.length, lane); } }; diff --git a/packages/signals/src/store/reconcile.ts b/packages/signals/src/store/reconcile.ts index 8659df926..162f7e109 100644 --- a/packages/signals/src/store/reconcile.ts +++ b/packages/signals/src/store/reconcile.ts @@ -51,7 +51,6 @@ export function reconcileState( const t: StoreTarget | undefined = state?.[$TARGET]; if (t === undefined || t.px !== state) throw new Error(__DEV__ ? "reconcile target is not a store proxy" : ""); - if (t.ovl) materializePB(t); const keyFn: KeyFn | null = key === null ? null : typeof key === "string" ? (item: any) => item?.[key] : (key as KeyFn); // A user's reconcile on an optimistic family (S4): not an adoption of @@ -92,7 +91,12 @@ export function reconcileState( * changed observed key, the structural nodes once — descending into * changed child pairs that are proxied below. */ function applyAdopt(t: StoreTarget, incoming: any, keyFn: KeyFn | null, proj = false): void { - const prev = t.pb ?? t.v; + // The diff base is the view the nodes were last told (#3296). A nested + // overlay draft (`Object.create(v)`, #3044) reads a key the draft deleted + // as present through its prototype; materialized first — `adoptPB` does + // so anyway, after the base was taken — so a deletion the draft made is + // a deletion here (#3743: the presence and value diffs both read `old`). + const prev = t.ovl ? materializePB(t) : (t.pb ?? t.v); if (incoming === prev && !isOwned(prev)) return; const fam = t.fam; // Q-D: a landing on an optimistic family is reconciled against the VIEW diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 396c9552d..2e79348d0 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -1259,9 +1259,11 @@ function notifyWrites(t: StoreTarget): void { } } // The container: structural readers hear membership/arrangement changes - // only (R9) — the compare is the store's, against the committed frame. + // only (R9) — the compare is the store's, against the committed frame. A + // container carrying an arrangement guess is told whether or not anyone + // subscribes: the write is a landing on the guess (`notifyContainer`). const k = t.k; - if (k !== null && k._subs !== null) { + if (k !== null && (k._subs !== null || k._config & CONFIG_OVERRIDE)) { let changed: boolean; if (t.ovl) { changed = t.del !== null && t.del.size !== 0; @@ -1368,7 +1370,25 @@ export function notifyKeyValue( } /** The structural half of an adoption's notifications (reconcile does the - * leaves itself): presence by `in`, the container by membership. */ + * leaves itself): presence by `in`, the container by membership. Presence + * is DIFFED `old` → `neu` like the leaves (#3743): a presence node is + * written only where `in` changed between the view it was last told + * (#3296) and the adoption — `setSignal` joins a held node's transaction + * before its equality gate (A34 (1)), so repeating an unchanged absence to + * a node an action holds would make the whole tick the action's (an + * unrelated `a.value` stayed stale until the action settled). A real + * change still writes, and on a held node still proposes. A live chained + * `old` (a store proxy, §7b) reflects the inner store, not what the node + * was last told — written unconditionally, as before. + * + * The container is told of an arrangement change when it has structural + * subscribers OR carries an arrangement guess (S4): for a guessed + * container the write is the landing that judges the guess (Q-D, plan + * sec. 39 — confirm, supersede, or hold beneath), not a subscriber + * notification, and a landing whose truth changed no guessed key's value + * or presence (a newer question's rows beneath an optimistic push) still + * answers the arrangement — before, only the presence write reached the + * lane, by accident of being unconditional. */ export function notifyFoldTail( t: StoreTarget, old: Record, @@ -1376,10 +1396,12 @@ export function notifyFoldTail( ): void { const has = t.h; if (has !== null) { - for (const key of Reflect.ownKeys(has)) setSignal(has[key as any], key in neu); + const live: StoreTarget | undefined = (old as any)[$TARGET]; + for (const key of Reflect.ownKeys(has)) + if (live || key in old !== key in neu) setSignal(has[key as any], key in neu); } const k = t.k; - if (k !== null && k._subs !== null) { + if (k !== null && (k._subs !== null || k._config & CONFIG_OVERRIDE)) { const changed = Array.isArray(neu) && Array.isArray(old) ? arrayStructureChanged(old as any[], neu as any[]) @@ -1401,18 +1423,7 @@ export function notifyFold( if (nodes !== null) { for (const key of Reflect.ownKeys(nodes)) notifyKeyDiff(nodes[key as any], key, old, neu); } - const has = t.h; - if (has !== null) { - for (const key of Reflect.ownKeys(has)) setSignal(has[key as any], key in neu); - } - const k = t.k; - if (k !== null && k._subs !== null) { - const changed = - Array.isArray(neu) && Array.isArray(old) - ? arrayStructureChanged(old as any[], neu as any[]) - : membershipChanged(old, neu); - if (changed) notifyContainer(k, neu); - } + notifyFoldTail(t, old, neu); } // --------------------------------------------------------------------------- diff --git a/packages/signals/tests/store/unchanged-presence-no-hold-3743.test.ts b/packages/signals/tests/store/unchanged-presence-no-hold-3743.test.ts new file mode 100644 index 000000000..29ef03fad --- /dev/null +++ b/packages/signals/tests/store/unchanged-presence-no-hold-3743.test.ts @@ -0,0 +1,543 @@ +/** + * #3743 — a fold repeats no unchanged presence to a held node. + * + * A fold's notifications are a DIFF against the view the nodes were last + * told (#3296): the value loop (`notifyKeyDiff`/`notifyKeyValue`) already + * wrote only changed keys; the presence loop (`notifyFoldTail`) wrote every + * observed `in` node with `key in neu`. `setSignal` joins a held node's + * transaction before its equality gate (A34 (1): a write to a held node is a + * second proposal, the same value or another), so a `reconcile()` outside an + * action that had deleted an observed key — the incoming snapshot leaving + * it absent too — made the whole mainline tick the action's: an unrelated + * `a.value` stayed stale until the action settled. A real presence change + * still writes and still proposes on a held node (the "contrast" cases). + * + * Wide object: an owned record above the overlay threshold drafts as a + * prototype overlay (`Object.create(v)`, #3044), which reads a key the draft + * deleted as present through its prototype; `applyAdopt` materializes it + * before taking its diff base, so the presence AND value diffs see the + * deletion (the value-node twin is pinned here too — before, the restoring + * reconcile skipped the held leaf and it committed the draft's `undefined`). + * + * Optimistic twin (S4, last case): with presence diffed, a landing that + * changes no guessed key's truth reaches the lane only through the + * container's arrangement guess — judged by every arrangement-changing + * landing, whether or not anything subscribes to the container. + */ +import { describe, expect, it } from "vitest"; +import { + action, + createOptimisticStore, + createProjection, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + reconcile +} from "../../src/index.js"; + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise(r => (resolve = r)); + return { promise, resolve }; +} + +describe("#3743 unchanged presence under an open action", () => { + it("reconcile reveals an unrelated value while an action holds a deleted key", async () => { + const gate = deferred(); + const [state, setState] = createStore<{ a: { value: number }; b: { failed?: boolean } }>({ + a: { value: 0 }, + b: { failed: true } + }); + const values: number[] = []; + const presence: boolean[] = []; + createRoot(() => { + createRenderEffect( + () => state.a.value, + v => void values.push(v) + ); + createRenderEffect( + () => "failed" in state.b, + v => void presence.push(v) + ); + }); + flush(); + expect(values).toEqual([0]); + expect(presence).toEqual([true]); + + const removeFlag = action(function* removeFlag() { + setState(d => { + delete d.b.failed; + }); + yield gate.promise; + }); + const p = removeFlag(); + flush(); + expect(values).toEqual([0]); + expect(presence).toEqual([true]); + + setState(reconcile({ a: { value: 1 }, b: {} })); + flush(); + expect(values).toEqual([0, 1]); + expect(presence).toEqual([true]); + + gate.resolve(); + await p; + flush(); + expect(values).toEqual([0, 1]); + expect(presence).toEqual([true, false]); + }); + + it("contrast: reconcile restoring the deleted key proposes on the held node and rides the action", async () => { + const gate = deferred(); + const [state, setState] = createStore<{ a: { value: number }; b: { failed?: boolean } }>({ + a: { value: 0 }, + b: { failed: true } + }); + const values: number[] = []; + const presence: boolean[] = []; + createRoot(() => { + createRenderEffect( + () => state.a.value, + v => void values.push(v) + ); + createRenderEffect( + () => "failed" in state.b, + v => void presence.push(v) + ); + }); + flush(); + + const removeFlag = action(function* removeFlag() { + setState(d => { + delete d.b.failed; + }); + yield gate.promise; + }); + const p = removeFlag(); + flush(); + + setState(reconcile({ a: { value: 1 }, b: { failed: true } })); + flush(); + expect(values).toEqual([0]); + expect(presence).toEqual([true]); + + gate.resolve(); + await p; + flush(); + expect(values).toEqual([0, 1]); + expect(presence.at(-1)).toBe(true); + expect("failed" in state.b).toBe(true); + }); + + describe("wide object (overlay draft)", () => { + const WIDE = 40; + type Wide = { [k: `k${number}`]: number; failed?: boolean }; + const wide = (): Wide => + Object.fromEntries(Array.from({ length: WIDE }, (_, i) => [`k${i}`, i])); + + function setup() { + const [state, setState] = createStore<{ + a: { value: number }; + b: Wide; + }>({ a: { value: 0 }, b: { ...wide(), failed: true } }); + const values: number[] = []; + const presence: boolean[] = []; + createRoot(() => { + createRenderEffect( + () => state.a.value, + v => void values.push(v) + ); + createRenderEffect( + () => "failed" in state.b, + v => void presence.push(v) + ); + }); + flush(); + setState(d => { + d.b.k0 = -1; + }); + flush(); + expect(values).toEqual([0]); + expect(presence).toEqual([true]); + return { state, setState, values, presence }; + } + + it("reconcile reveals an unrelated value while an action holds a deleted key", async () => { + const gate = deferred(); + const { state, setState, values, presence } = setup(); + const removeFlag = action(function* removeFlag() { + setState(d => { + delete d.b.failed; + }); + yield gate.promise; + }); + const p = removeFlag(); + flush(); + + setState(reconcile({ a: { value: 1 }, b: { ...wide(), k0: -1 } })); + flush(); + expect(values).toEqual([0, 1]); + expect(presence).toEqual([true]); + + gate.resolve(); + await p; + flush(); + expect(values).toEqual([0, 1]); + expect(presence).toEqual([true, false]); + expect("failed" in state.b).toBe(false); + }); + + it("contrast: reconcile restoring the deleted key proposes on the held node and rides the action", async () => { + const gate = deferred(); + const { state, setState, values, presence } = setup(); + const removeFlag = action(function* removeFlag() { + setState(d => { + delete d.b.failed; + }); + yield gate.promise; + }); + const p = removeFlag(); + flush(); + + setState(reconcile({ a: { value: 1 }, b: { ...wide(), k0: -1, failed: true } })); + flush(); + expect(values).toEqual([0]); + expect(presence).toEqual([true]); + + gate.resolve(); + await p; + flush(); + expect(values).toEqual([0, 1]); + expect(presence.at(-1)).toBe(true); + expect("failed" in state.b).toBe(true); + }); + + it("value twin: reconcile restoring the deleted key re-proposes on the held leaf, which lands true", async () => { + // The same overlay base, read as a VALUE: through the overlay's + // prototype the deleted key still read `true`, equal to the restored + // `true`, so the leaf (staged `undefined` by the draft) was skipped and + // committed `undefined` at the landing beside a backing that had it. + const gate = deferred(); + const [state, setState] = createStore<{ + a: { value: number }; + b: Wide; + }>({ a: { value: 0 }, b: { ...wide(), failed: true } }); + const values: number[] = []; + const failed: (boolean | undefined)[] = []; + createRoot(() => { + createRenderEffect( + () => state.a.value, + v => void values.push(v) + ); + createRenderEffect( + () => state.b.failed, + v => void failed.push(v) + ); + }); + flush(); + setState(d => { + d.b.k0 = -1; + }); + flush(); + expect(values).toEqual([0]); + expect(failed).toEqual([true]); + + const removeFlag = action(function* removeFlag() { + setState(d => { + delete d.b.failed; + }); + yield gate.promise; + }); + const p = removeFlag(); + flush(); + + setState(reconcile({ a: { value: 1 }, b: { ...wide(), k0: -1, failed: true } })); + flush(); + // A34 (1): the restore is a second proposal on the held leaf — the + // tick rides the action. + expect(values).toEqual([0]); + expect(failed).toEqual([true]); + + gate.resolve(); + await p; + flush(); + expect(values).toEqual([0, 1]); + expect(failed.at(-1)).toBe(true); + expect(state.b.failed).toBe(true); + }); + }); + + it("a projection adopting away from a chained store still writes presence the inner store changed", () => { + const [inner, setInner] = createStore<{ k?: number }>({ k: 1 }); + const [mode, setMode] = createSignal<"inner" | "empty" | "plain">("inner"); + const presence: boolean[] = []; + createRoot(() => { + const proj = createProjection( + () => { + const m = mode(); + return m === "inner" ? inner : m === "empty" ? {} : { k: 1 }; + }, + {} as { k?: number } + ); + createRenderEffect( + () => "k" in proj, + v => void presence.push(v) + ); + }); + flush(); + expect(presence).toEqual([true]); + + setInner(d => { + delete d.k; + }); + flush(); + expect(presence).toEqual([true, false]); + + setMode("empty"); + flush(); + expect(presence.at(-1)).toBe(false); + + setMode("plain"); + flush(); + expect(presence.at(-1)).toBe(true); + }); + + // The twin the diff uncovered (S4): a landing on an optimistic family + // judges the container's arrangement guess whether or not anything + // subscribes to the container. Here a newer question's rows land beneath + // an optimistic push whose guessed keys (`2`, `length`, `2 in`) the truth + // left as they were, and the reader subscribes to leaves only (index + // reads — no `in`, no enumeration). Before, the unconditional presence + // write was the one landing that reached the lane; with presence diffed, + // only the container's judgement folds the landing into the retaining + // action (A18: a differing truth supersedes the guess, held by the parent + // for its commit — #2719/#3164's "the view keeps the old question plus the + // optimism, the truth reveals at settle"). + it("a landing that changes no guessed key still judges the container's arrangement guess (leaf-only reader)", async () => { + type Comment = { id: number; text: string }; + const serverComments: Comment[][] = [ + [ + { id: 0, text: "Issue 0 A" }, + { id: 1, text: "Issue 0 B" } + ], + [ + { id: 2, text: "Issue 1 A" }, + { id: 3, text: "Issue 1 B" } + ] + ]; + const fetches: Array<{ issueId: number; resolve: () => void }> = []; + const rendered: (string | undefined)[][] = []; + const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flush(); + }; + let setIssue!: (issue: number) => number; + let comments!: readonly Comment[]; + let setComments!: (fn: (comments: Comment[]) => void) => void; + createRoot(() => { + const [issueId, setIssueId] = createSignal(0); + setIssue = setIssueId; + [comments, setComments] = createOptimisticStore( + () => + new Promise(resolve => { + const requestedIssue = issueId(); + fetches.push({ + issueId: requestedIssue, + resolve: () => resolve(structuredClone(serverComments[requestedIssue])) + }); + }), + [] as Comment[] + ); + createRenderEffect( + () => { + const out: (string | undefined)[] = []; + for (let i = 0; i < comments.length; i++) out.push(comments[i]?.text); + return out; + }, + v => void rendered.push(v) + ); + }); + flush(); + fetches.shift()!.resolve(); + await settle(); + expect(rendered.at(-1)).toEqual(["Issue 0 A", "Issue 0 B"]); + + const gate = deferred(); + const addComment = action(function* addComment() { + setComments(draft => { + draft.push({ id: -1, text: "Optimistic" }); + }); + yield gate.promise; + }); + const add = addComment(); + flush(); + expect(rendered.at(-1)).toEqual(["Issue 0 A", "Issue 0 B", "Optimistic"]); + + const nextIssue = action(function* nextIssue() { + setIssue(1); + yield new Promise(resolve => queueMicrotask(resolve)); + }); + const next = nextIssue(); + flush(); + fetches.find(fetch => fetch.issueId === 1)!.resolve(); + await next; + await settle(); + // Mid-hold: the issue-1 truth is held by the retaining action — never + // the new rows beside the old optimistic one. + expect(rendered.at(-1)).toEqual(["Issue 0 A", "Issue 0 B", "Optimistic"]); + + gate.resolve(); + await add; + await settle(); + expect(rendered.at(-1)).toEqual(["Issue 1 A", "Issue 1 B"]); + }); + + // The setter half of the same gate (`notifyWrites`): the derive writes + // its draft imperatively — a store setter's exit, not a reconcile of a + // returned value. An optimistic swap of rows `0` and `1` guesses those + // two leaves and the container's arrangement; the derive then appends a + // row at index `2` — `length` and `2` are its written keys, neither of + // them guessed (an array index write cannot avoid `length`, so the guess + // under test holds no `length`). The reader subscribes to leaves only, + // so nothing subscribes to the container; the write is still a landing + // on its arrangement guess, and judging it is what folds the newer + // question's row into the retaining action (A18: a differing truth + // supersedes the guess, held by the parent for its commit; the screen + // keeps the guess until then). Were the container gated on subscribers + // alone, the new row would publish beneath the swapped pair mid-hold. + it("a setter that changes no guessed key still judges the container's arrangement guess (leaf-only reader)", async () => { + type Comment = { id: number; text: string }; + const rendered: (string | undefined)[][] = []; + const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flush(); + }; + let setMore!: (more: boolean) => boolean; + let comments!: readonly Comment[]; + let setComments!: (fn: (comments: Comment[]) => void) => void; + createRoot(() => { + const [more, setMoreComments] = createSignal(false); + setMore = setMoreComments; + [comments, setComments] = createOptimisticStore( + draft => { + // One row write at index `2`: never `0`, never `1`. + if (more()) draft[2] = { id: 2, text: "C" }; + }, + [ + { id: 0, text: "A" }, + { id: 1, text: "B" } + ] as Comment[] + ); + createRenderEffect( + () => { + const out: (string | undefined)[] = []; + for (let i = 0; i < comments.length; i++) out.push(comments[i]?.text); + return out; + }, + v => void rendered.push(v) + ); + }); + flush(); + expect(rendered.at(-1)).toEqual(["A", "B"]); + + const gate = deferred(); + const swap = action(function* swap() { + setComments(draft => { + const [a, b] = [draft[0], draft[1]]; + draft[0] = b; + draft[1] = a; + }); + yield gate.promise; + }); + const swapping = swap(); + flush(); + expect(rendered.at(-1)).toEqual(["B", "A"]); + + const loadMore = action(function* loadMore() { + setMore(true); + yield new Promise(resolve => queueMicrotask(resolve)); + }); + const loading = loadMore(); + flush(); + await loading; + await settle(); + // Mid-hold: the new row is held by the retaining action — never + // beneath the swapped pair. + expect(rendered.at(-1)).toEqual(["B", "A"]); + + gate.resolve(); + await swapping; + await settle(); + expect(rendered.at(-1)).toEqual(["A", "B", "C"]); + }); + + // The setter path's open twin (#3743's comment thread): a mainline setter + // mid-hold writes `a.value` and `b.value` while an action holds `b.flag`. + // A34 (1): an explicit repeat of the held key is a proposal and joins; a + // key the setter did NOT write is not — `a.value` publishes with the + // mainline tick, `b.flag` stays held. Current L2 joins: the written keys + // `notifyWrites` visits are `t.wk`, retained for the pending fold and + // CUMULATIVE across the batch's setters, so the later setter re-notifies + // the held `flag` node with its unchanged value and `setSignal` joins + // before the equality gate (the write is a proposal). The fix direction is + // per-setter written keys: the notify visits the keys THIS setter wrote, + // the fold keeps the batch's union. + it.fails( + "a later mainline setter that does not write the held key publishes on the mainline tick", + async () => { + type State = { a: { value: number }; b: { value: number; flag: boolean } }; + const values: number[] = []; + const flags: boolean[] = []; + let state!: State; + let setState!: (fn: (draft: State) => void) => void; + createRoot(() => { + [state, setState] = createStore({ a: { value: 0 }, b: { value: 0, flag: true } }); + createRenderEffect( + () => state.a.value, + v => void values.push(v) + ); + createRenderEffect( + () => state.b.flag, + v => void flags.push(v) + ); + }); + flush(); + expect(values).toEqual([0]); + expect(flags).toEqual([true]); + + const gate = deferred(); + const toggle = action(function* toggle() { + setState(draft => { + draft.b.flag = false; + }); + yield gate.promise; + }); + const run = toggle(); + flush(); + expect(flags).toEqual([true]); + + setState(draft => { + draft.a.value = 1; + draft.b.value = 1; + }); + flush(); + // Spec: `a.value` is nobody's proposal — it publishes now; the held + // key stays held. + expect(values).toEqual([0, 1]); + expect(flags).toEqual([true]); + + gate.resolve(); + await run; + await Promise.resolve(); + flush(); + expect(values).toEqual([0, 1]); + expect(flags).toEqual([true, false]); + } + ); +}); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index a8b1b7ad9..4fab2f1a5 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -2,7 +2,7 @@ "signals: core floor (createSignal/Memo/Effect/Root/flush)": "7.33 KB", "app: render + one signal (the simple-app floor)": "9.81 KB", "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": "17.64 KB", - "page: base server components (hydrating + dynamic + frames + sf reference)": "44.03 KB", + "page: base server components (hydrating + dynamic + frames + sf reference)": "44.05 KB", "page: live server components (base + live/GET + action + isPending/latest)": "47.67 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 6d461d24d..a3c12f639 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -3142,6 +3142,15 @@ module.exports = [ // Twelve equivalent encodings measured; this is the only one over by // page base alone. Accepted by the maintainer. The cap is frozen again // at 46.25 KB. + // Size-Exception (#3743, 2026-10-04): 44.03 -> 44.05 KB, measured at + // 44,031 B against `next` @ 1a3f87fd1's 44,029 (+2 B; 1 B over the cap; + // -97 B minified). +2 B br / -97 B min — #3743 fold presence diff; + // brotli layout on the 150 KB bundle. The store engine reaches this + // page through the frames client's container-trace materializer; the + // `+ createStore` scenario carries the same change at +5 B / -97 B + // minified, the live page at -5 B. Cap set at measured + 10 B rounded + // up to 0.01 KB. Accepted by the maintainer (2026-10-04). The cap is + // frozen again at 44.05 KB. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], alias: pageAlias },