From 6478be336742a9c4dadbda429cd8c75789316307 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:12:21 +0000 Subject: [PATCH 1/6] fix(signals): unread key written beside a held sibling publishes at flush (#4146) A setter outside the holding transaction writing a store key that a held container's staging left unchanged wrote it into the held staging, so plain reads waited for the hold's landing, unless a live reader's leaf gave the key its own staging. The key now gets its leaf before the write (R1, #3706), kept while the container is held. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/held-container-unread-key-4146.md | 5 + packages/signals/docs/RULES-INDEX.md | 6 +- packages/signals/src/store/store.ts | 44 +++- .../continuation-sibling-reader-4146.test.ts | 202 ++++++++++++++++++ 4 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 .changeset/held-container-unread-key-4146.md create mode 100644 packages/signals/tests/continuation-sibling-reader-4146.test.ts diff --git a/.changeset/held-container-unread-key-4146.md b/.changeset/held-container-unread-key-4146.md new file mode 100644 index 0000000000..6f57c44da1 --- /dev/null +++ b/.changeset/held-container-unread-key-4146.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A write to a store key that nothing reads no longer waits for an unrelated action that is holding a sibling key on the same record (#4146). Before this fix, if a transaction held the record's container (an action wrote `card.version` and was still pending), a later setter outside that action writing `card.text` put the new value into the held staging. Plain reads of `card.text` then showed the old value until the action finished. With a live render effect reading `card.text`, the same write showed up at the next flush, because the effect's leaf node gave the key its own staging. Node existence is now unobservable here too (R1, #3706: a hold covers individual keys). The setter creates the key's leaf before writing, from the committed and staged values, so the leaf starts out unstaged and held by no transaction. The write then stages on that leaf and becomes visible at the flush (A28). A leaf on a held container is kept alive until the hold lands, so disposing a reader cannot pull the key back into the hold. Keys the hold itself changed, added keys and array writes keep their current behaviour and join the hold (A34). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index fb22f86714..30c1b36e68 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -72,13 +72,13 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:309` | projection.ts×2 | derived-presence-async-3726.test.ts×2 seedless-derived-store.test.ts×1 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:111` | — | 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:301` | — | 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:95` | 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 issue-3800-repro.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… | +| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:95` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×5 | continuation-sibling-reader-4146.test.ts×1 createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 issue-3800-repro.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:119` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×14 effect.ts×1 lanes.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-branch-verdict-reader-4010.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-mount-nested-memo-3835.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 verdict-mount-first-pass-3851.test.ts×2 verdict-mount-loading-flush-loop.test.ts×2 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:257` | 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:131` | 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:139` | 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:273` | boundaries.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 create-deferred-3710.test.ts×1 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:283` | 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-guess-over-held-slot.test.ts×2 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | +| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:283` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×6 | a34-writes-then-derivations.test.ts×2 continuation-sibling-reader-4146.test.ts×1 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-guess-over-held-slot.test.ts×2 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) | @@ -141,7 +141,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | |---|---|---|---|---|---| -| CS-R1 | live | `docs/rules-mining/core-store.md:9` | store.ts×1 | flatten-async-iterable.test.ts×5 syncThenable.test.ts×14 visibility-oracle-store.states.ts×1 | Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).** | +| CS-R1 | live | `docs/rules-mining/core-store.md:9` | store.ts×2 | flatten-async-iterable.test.ts×5 syncThenable.test.ts×14 visibility-oracle-store.states.ts×1 | Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).** | | CS-R2 | live | `docs/rules-mining/core-store.md:13` | — | syncThenable.test.ts×12 visibility-oracle.states.ts×1 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).** | | CS-R2a | live | `docs/INTERNALS-STORE-STATE.md:107` | — | — | Corollary R2a (Ryan, 2026-08-17): **take no responsibility for mutation | | CS-R3 | live | `docs/rules-mining/core-store.md:18` | — | — | A store proxy ingested into another store (deep or shallow) is re-wrapped in the ingesting store's own proxy family — never identity-passed, never raw-marked.** | diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 2b374efd11..c39863900a 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -309,6 +309,13 @@ function sameLogicalSlot(target: StoreTarget, a: any, b: any): boolean { * released at the commit that resolves it (`drainFolds`). */ const deferredReleases = new Set>(); +/** Is the container of slot node `node` (other than the container itself) + * held by a transaction? */ +function heldHost(node: any): boolean { + const k = (node._host as StoreTarget).k; + return k !== null && k !== node && (k._config & CONFIG_HELD) !== 0; +} + function releaseSlot(node: any): void { const t: StoreTarget = node._host; const key: PropertyKey = node._key; @@ -344,8 +351,10 @@ function noteNode(target: StoreTarget, node: Signal, key: PropertyKey): voi // unreachable through the store — a fresh read makes a fresh node. setSlotUnobserved((node: any): void => { // A staged write — or a lane's value (a guess, S4) — is state only the - // node holds: defer the release to the commit that resolves it. - if (node._pendingValue !== NOT_PENDING || node._config & CONFIG_OVERRIDE) + // node holds: defer the release to the commit that resolves it. So is a + // leaf of a held container (#4146): a mainline write it carried is in the + // held staging too, where it would read as the hold's until the landing. + if (node._pendingValue !== NOT_PENDING || node._config & CONFIG_OVERRIDE || heldHost(node)) deferredReleases.add(node); else releaseSlot(node); }); @@ -901,7 +910,8 @@ function drainFolds(): void { if ( node._pendingValue === NOT_PENDING && !(node._config & CONFIG_OVERRIDE) && - node._subs === null + node._subs === null && + !heldHost(node) ) { deferredReleases.delete(node); releaseSlot(node); @@ -2179,6 +2189,7 @@ const traps: ProxyHandler = { if (key === "__proto__") return true; // pollution guard (core R30) const uv = target.s ? value : unwrapValue(value); const pb = ensurePB(target); + if (draft && target.n?.[key as any] === undefined) heldKeyLeaf(target, pb, key); if (Array.isArray(pb)) { touch(target, key); if (key === "length") target.wk = WK_ALL; @@ -2253,6 +2264,33 @@ const traps: ProxyHandler = { } }; +/** #4146 (R1: node existence is unobservable; #3706: the hold's unit is the + * key): a setter not of the holding transaction writing a key that a HELD + * container's staging left unchanged would otherwise write it into the held + * staging — the key would read as the hold's and wait for its landing, while + * the same key with a leaf (a live reader) publishes at this flush. The key + * gets its leaf first, born from the two frames before the write (equal: + * unstaged, held by no one), so the write stages on it like any signal's + * (A28: visible at the flush; A34: a later write by the holder joins it). A + * key the hold changed, an added key, and a structural write stay the + * container's (A34 (1): they join the hold). */ +function heldKeyLeaf(target: StoreTarget, pb: Record, key: PropertyKey): void { + const k = target.k; + if ( + k === null || + k._pendingValue !== pb || + (k._config & (CONFIG_HELD | CONFIG_OVERRIDE)) !== CONFIG_HELD || + !userWrite || + txOf(k) === flushTransaction || + Array.isArray(pb) || + !hasOwn.call(asBacking(k._value), key) || + heldKeyChanged(target, key) + ) + return; + // Released at the drain that finds it unsubscribed and the hold landed. + deferredReleases.add(getNode(target, key)); +} + /** Is `obs` subscribed to the container BY THIS PASS (its enumerator holds * it)? A link left from a previous pass is stale — the reader must subscribe * to the key's presence node or a later add never re-runs it. */ diff --git a/packages/signals/tests/continuation-sibling-reader-4146.test.ts b/packages/signals/tests/continuation-sibling-reader-4146.test.ts new file mode 100644 index 0000000000..2067663306 --- /dev/null +++ b/packages/signals/tests/continuation-sibling-reader-4146.test.ts @@ -0,0 +1,202 @@ +/** + * #4146 — whether a later, independent store write is held while an action's + * continuation write to a SIBLING property stays pending must not depend on + * whether a live render effect reads the written property. + * + * Timeline: an action writes optimistic `card.version`, yields gate 1; its + * continuation writes truth `committed` and `card.version`, then yields gate 2 + * (left open). Outside the action, a plain write sets `card.text`. The plain + * read of `card.text` must be the same in every reader configuration. + */ +import { describe, expect, it } from "vitest"; +import { + action, + createOptimisticStore, + createRenderEffect, + createRoot, + createStore, + flush, + latest +} from "../src/index.js"; + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise(r => (resolve = r)); + return { promise, resolve }; +} + +async function settle() { + for (let i = 0; i < 8; i++) await Promise.resolve(); + await new Promise(r => setTimeout(r, 0)); + flush(); + for (let i = 0; i < 4; i++) await Promise.resolve(); + flush(); +} + +type Reader = "none" | "plain" | "latest" | "effect" | "disposed-effect"; + +async function run(reader: Reader) { + const first = deferred(); + const last = deferred(); + let dispose!: () => void; + const m = createRoot(stop => { + dispose = stop; + const [state, setState] = createStore({ + committed: 0, + cards: [{ version: 0, text: "" }] + }); + const [, setView] = createOptimisticStore(state); + const observations: string[] = []; + const watchText = () => + createRenderEffect( + () => state.cards[0].text, + text => void observations.push(text) + ); + if (reader === "plain") void state.cards[0].text; + if (reader === "latest") latest(() => state.cards[0].text); + if (reader === "effect") watchText(); + if (reader === "disposed-effect") { + const stopReader = createRoot(stop => { + watchText(); + return stop; + }); + flush(); + stopReader(); + } + const go = action(function* () { + setView(s => { + s.cards[0].version = 1; + }); + yield first.promise; + setState(s => { + s.committed = 1; + s.cards[0].version = 1; + }); + yield last.promise; + }); + return { state, setState, observations, go }; + }); + flush(); + const completion = m.go(); + flush(); + first.resolve(); + await settle(); + expect(m.state.cards[0].version).toBe(0); + expect(latest(() => m.state.cards[0].version)).toBe(1); + + m.setState(s => { + s.cards[0].text = "a"; + }); + await settle(); + const pending = { text: m.state.cards[0].text, version: m.state.cards[0].version }; + + last.resolve(); + await completion; + await settle(); + const final = { text: m.state.cards[0].text, version: m.state.cards[0].version }; + dispose(); + flush(); + return { pending, final, observations: m.observations }; +} + +describe("#4146 live reader must not change sibling-write hold membership", () => { + it("plain read of the independent write is reader-invariant", async () => { + const results: Record = {}; + for (const reader of ["none", "plain", "latest", "effect", "disposed-effect"] as const) { + results[reader] = await run(reader); + } + const none = results.none as Awaited>; + for (const reader of ["plain", "latest", "effect", "disposed-effect"] as const) { + const r = results[reader] as Awaited>; + expect({ reader, pending: r.pending }).toEqual({ reader, pending: none.pending }); + expect(r.final).toEqual({ text: "a", version: 1 }); + } + // The held sibling write stays held regardless of readers. + expect(none.pending.version).toBe(0); + // The write to `text` touched nothing the action wrote: it publishes now. + expect(none.pending.text).toBe("a"); + }); + + it("plain store, no continuation: an unread sibling publishes at the flush, not before", async () => { + const gate = deferred(); + const [state, setState] = createStore({ card: { version: 0, text: "" } }); + flush(); + const save = action(function* () { + setState(s => { + s.card.version = 1; + }); + yield gate.promise; + }); + const p = save(); + flush(); + setState(s => { + s.card.text = "a"; + }); + // A28: visible at the flush, not at the write. + expect(state.card.text).toBe(""); + flush(); + expect(state.card.text).toBe("a"); + expect(state.card.version).toBe(0); + gate.resolve(); + await p; + flush(); + expect(state.card).toEqual({ version: 1, text: "a" }); + }); + + it("a reader disposed after the write does not pull the key back into the hold", async () => { + const gate = deferred(); + const [state, setState] = createStore({ card: { version: 0, text: "" } }); + const stop = createRoot(dispose => { + createRenderEffect( + () => state.card.text, + () => {} + ); + return dispose; + }); + flush(); + const save = action(function* () { + setState(s => { + s.card.version = 1; + }); + yield gate.promise; + }); + const p = save(); + flush(); + setState(s => { + s.card.text = "a"; + }); + flush(); + expect(state.card.text).toBe("a"); + stop(); + flush(); + expect(state.card.text).toBe("a"); + expect(state.card.version).toBe(0); + gate.resolve(); + await p; + flush(); + expect(state.card).toEqual({ version: 1, text: "a" }); + }); + + it("contrast: a mainline write to the key the hold changed stays held (A34)", async () => { + const gate = deferred(); + const [state, setState] = createStore({ card: { version: 0, text: "" } }); + flush(); + const save = action(function* () { + setState(s => { + s.card.version = 1; + }); + yield gate.promise; + }); + const p = save(); + flush(); + setState(s => { + s.card.version = 2; + }); + flush(); + expect(state.card.version).toBe(0); + gate.resolve(); + await p; + flush(); + expect(state.card.version).toBe(2); + }); +}); From 05e3798f934a2de7070dd1f0acdd06dfe75d7990 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:49:52 +0000 Subject: [PATCH 2/6] fix(signals): golf the #4146 held-key leaf (+80 B min on store scenarios) Inline the leaf creation into the set trap and fold the held-host check into the existing release conditions; drop conditions the remaining ones already cover. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/store/store.ts | 59 +++++++++++----------------- 2 files changed, 23 insertions(+), 38 deletions(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 30c1b36e68..e36017301d 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -78,7 +78,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:131` | 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:139` | 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:273` | boundaries.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 create-deferred-3710.test.ts×1 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:283` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×6 | a34-writes-then-derivations.test.ts×2 continuation-sibling-reader-4146.test.ts×1 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-guess-over-held-slot.test.ts×2 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | +| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:283` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×5 | a34-writes-then-derivations.test.ts×2 continuation-sibling-reader-4146.test.ts×1 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-guess-over-held-slot.test.ts×2 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | ## V — fixed violations | id | status | defined | cited in src | cited in tests | statement (at definition) | diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index c39863900a..c5fed3ad2e 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -309,13 +309,6 @@ function sameLogicalSlot(target: StoreTarget, a: any, b: any): boolean { * released at the commit that resolves it (`drainFolds`). */ const deferredReleases = new Set>(); -/** Is the container of slot node `node` (other than the container itself) - * held by a transaction? */ -function heldHost(node: any): boolean { - const k = (node._host as StoreTarget).k; - return k !== null && k !== node && (k._config & CONFIG_HELD) !== 0; -} - function releaseSlot(node: any): void { const t: StoreTarget = node._host; const key: PropertyKey = node._key; @@ -354,7 +347,11 @@ setSlotUnobserved((node: any): void => { // node holds: defer the release to the commit that resolves it. So is a // leaf of a held container (#4146): a mainline write it carried is in the // held staging too, where it would read as the hold's until the landing. - if (node._pendingValue !== NOT_PENDING || node._config & CONFIG_OVERRIDE || heldHost(node)) + if ( + node._pendingValue !== NOT_PENDING || + node._config & CONFIG_OVERRIDE || + node._host.k?._config & CONFIG_HELD + ) deferredReleases.add(node); else releaseSlot(node); }); @@ -911,7 +908,7 @@ function drainFolds(): void { node._pendingValue === NOT_PENDING && !(node._config & CONFIG_OVERRIDE) && node._subs === null && - !heldHost(node) + !((node as any)._host.k?._config & CONFIG_HELD) ) { deferredReleases.delete(node); releaseSlot(node); @@ -2189,7 +2186,22 @@ const traps: ProxyHandler = { if (key === "__proto__") return true; // pollution guard (core R30) const uv = target.s ? value : unwrapValue(value); const pb = ensurePB(target); - if (draft && target.n?.[key as any] === undefined) heldKeyLeaf(target, pb, key); + // #4146 (R1: node existence is unobservable; #3706: the hold's unit is + // the key): a user setter writing a key a HELD container's staging left + // unchanged would write it into the held staging — read as the hold's + // until its landing, while the same key with a leaf (a live reader) + // publishes at the flush. The key gets its leaf first, born from the two + // frames (equal: unstaged, held by no one), and the write stages on it + // (A28; a write by the holder joins it, A34). A key the hold changed and + // an added key stay the container's (A34 (1)). Kept while the container + // is held (`deferredReleases`). + if ( + userWrite && + (target.k as any)?._config & CONFIG_HELD && + key in pb && + !heldKeyChanged(target, key) + ) + deferredReleases.add(getNode(target, key)); if (Array.isArray(pb)) { touch(target, key); if (key === "length") target.wk = WK_ALL; @@ -2264,33 +2276,6 @@ const traps: ProxyHandler = { } }; -/** #4146 (R1: node existence is unobservable; #3706: the hold's unit is the - * key): a setter not of the holding transaction writing a key that a HELD - * container's staging left unchanged would otherwise write it into the held - * staging — the key would read as the hold's and wait for its landing, while - * the same key with a leaf (a live reader) publishes at this flush. The key - * gets its leaf first, born from the two frames before the write (equal: - * unstaged, held by no one), so the write stages on it like any signal's - * (A28: visible at the flush; A34: a later write by the holder joins it). A - * key the hold changed, an added key, and a structural write stay the - * container's (A34 (1): they join the hold). */ -function heldKeyLeaf(target: StoreTarget, pb: Record, key: PropertyKey): void { - const k = target.k; - if ( - k === null || - k._pendingValue !== pb || - (k._config & (CONFIG_HELD | CONFIG_OVERRIDE)) !== CONFIG_HELD || - !userWrite || - txOf(k) === flushTransaction || - Array.isArray(pb) || - !hasOwn.call(asBacking(k._value), key) || - heldKeyChanged(target, key) - ) - return; - // Released at the drain that finds it unsubscribed and the hold landed. - deferredReleases.add(getNode(target, key)); -} - /** Is `obs` subscribed to the container BY THIS PASS (its enumerator holds * it)? A link left from a previous pass is stale — the reader must subscribe * to the key's presence node or a later add never re-runs it. */ From 5d8a76c2181c4c94a01e5fe414ea317a7265b2b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:53:46 +0000 Subject: [PATCH 3/6] size: raise store caps for the #4146 held-key leaf CI measured +80 B minified on the two store scenarios. Caps at CI brotli + 10 B, recorded minified = CI's minified. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- scripts/size/scenarios.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 21691cce83..2c7ff30692 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1081,8 +1081,9 @@ module.exports = [ // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. // store delete key-count fix (#4132, 2026-10-17): 14.79 -> 14.82 KB, measured at 14,802 B brotli / 44,865 B minified (+12 B brotli over the cap, +33 B minified over the recorded size): deleteProperty decrements `kc` for a draft's own key, so a keyed store that deletes keys no longer drifts into overlay rebuilds. Correctness; local measurement, CI re-bases. - limit: "14.82 KB", - capMinified: 44865, + // Unread key beside a held sibling publishes at flush (#4146, 2026-10-19, over next @ 2aa9e86a): 14.82 -> 14.84 KB, measured in CI at 14,826 B brotli / 44,962 B minified (+80 B minified over this PR's base): a mainline write to a key the hold never changed creates that key's leaf instead of writing into the held staging, so whether the write is held no longer depends on a live reader (R1). Correctness; only runs when the container is held. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "14.84 KB", + capMinified: 44962, alias }, { @@ -2594,8 +2595,9 @@ module.exports = [ // A held error reaches a derivation of `latest` (#4113, #4106, 2026-10-17, merged over next @ 01270641): 30.09 -> 30.14 KB, measured locally at 30,125 B brotli / 94,611 B minified (+32 B minified over the recorded size): `laneRead` and `verdictValue` hand a held error to an override/derived read the way they hand a value, and `stageError` schedules the verdict wake. Cap at measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer (2026-10-17). // store delete key-count fix (#4132, 2026-10-17): 30.09 -> 30.14 KB, measured at 30,129 B brotli / 94,616 B minified (+39 B brotli over the cap, +37 B minified over the recorded size): deleteProperty decrements `kc` for a draft's own key, so a keyed store that deletes keys no longer drifts into overlay rebuilds. Correctness; local measurement, CI re-bases. // Merged over next @ 7093b074b after #4129 raised this same cap 30.09 -> 30.14 KB for its own bytes (2026-10-17): the two together measured locally at 30,116 B brotli / 94,644 B minified, under the cap, so the cap stays at 30.14 KB; recorded minified 94,611 -> 94,644. - limit: "30.14 KB", - capMinified: 94644, + // Unread key beside a held sibling publishes at flush (#4146, 2026-10-19, over next @ 2aa9e86a): 30.14 -> 30.20 KB, measured in CI at 30,189 B brotli / 94,727 B minified (+80 B minified over this PR's base): a mainline write to a key the hold never changed creates that key's leaf instead of writing into the held staging, so whether the write is held no longer depends on a live reader (R1). Correctness; only runs when the container is held. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "30.20 KB", + capMinified: 94727, alias }, { From f313d05e73bb55a7d655d16fe2a8625d19c43abe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 17:56:52 +0000 Subject: [PATCH 4/6] docs(signals): regenerate RULES-INDEX for the golfed #4146 leaf Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- packages/signals/docs/RULES-INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index e36017301d..30c1b36e68 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -78,7 +78,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:131` | 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:139` | 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:273` | boundaries.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 create-deferred-3710.test.ts×1 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:283` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×5 | a34-writes-then-derivations.test.ts×2 continuation-sibling-reader-4146.test.ts×1 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-guess-over-held-slot.test.ts×2 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | +| A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:283` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×6 | a34-writes-then-derivations.test.ts×2 continuation-sibling-reader-4146.test.ts×1 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-guess-over-held-slot.test.ts×2 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | ## V — fixed violations | id | status | defined | cited in src | cited in tests | statement (at definition) | From 4b3e3681516a3a83e81e004e492fd03395620cef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:35:18 +0000 Subject: [PATCH 5/6] fix(signals): share slotKept, give every held-container write its leaf (#4146) Golf from review of #4159 (-32 B min on the store scenarios): - G1: one `slotKept(node)` predicate for the slot-release handler and the deferred-release drain (no behaviour change). - G2: drop `key in pb` and `!heldKeyChanged` from the set trap. `bornStaged` already holds a leaf only for a key the held staging changed, so a changed key still joins the hold (A34), and an added key (or an index appended to a held array) now gets the same unheld leaf a reader's would. Its value shows at the flush whether or not anything reads it (R1: node existence is unobservable). Before, an unread added key waited for the hold while a read one published. New test pins that reader-invariance; it fails without G2. The changeset no longer says array writes join the hold: an index the hold left unchanged publishes at the flush, and appended indexes/added keys publish their value while length/key membership waits for the landing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- .changeset/held-container-unread-key-4146.md | 2 +- packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/store/store.ts | 42 +++++-------- .../continuation-sibling-reader-4146.test.ts | 61 +++++++++++++++++++ 4 files changed, 79 insertions(+), 28 deletions(-) diff --git a/.changeset/held-container-unread-key-4146.md b/.changeset/held-container-unread-key-4146.md index 6f57c44da1..fe4f1ad549 100644 --- a/.changeset/held-container-unread-key-4146.md +++ b/.changeset/held-container-unread-key-4146.md @@ -2,4 +2,4 @@ "@solidjs/signals": patch --- -A write to a store key that nothing reads no longer waits for an unrelated action that is holding a sibling key on the same record (#4146). Before this fix, if a transaction held the record's container (an action wrote `card.version` and was still pending), a later setter outside that action writing `card.text` put the new value into the held staging. Plain reads of `card.text` then showed the old value until the action finished. With a live render effect reading `card.text`, the same write showed up at the next flush, because the effect's leaf node gave the key its own staging. Node existence is now unobservable here too (R1, #3706: a hold covers individual keys). The setter creates the key's leaf before writing, from the committed and staged values, so the leaf starts out unstaged and held by no transaction. The write then stages on that leaf and becomes visible at the flush (A28). A leaf on a held container is kept alive until the hold lands, so disposing a reader cannot pull the key back into the hold. Keys the hold itself changed, added keys and array writes keep their current behaviour and join the hold (A34). +A write to a store key that nothing reads no longer waits for an unrelated action that is holding a sibling key on the same record (#4146). Before this fix, if a transaction held the record's container (an action wrote `card.version` and was still pending), a later setter outside that action writing `card.text` put the new value into the held staging. Plain reads of `card.text` then showed the old value until the action finished. With a live render effect reading `card.text`, the same write showed up at the next flush, because the effect's leaf node gave the key its own staging. Node existence is now unobservable here too (R1, #3706: a hold covers individual keys). The setter creates the key's leaf before writing, from the committed and staged values, so the leaf starts out unstaged and held by no transaction. The write then stages on that leaf and becomes visible at the flush (A28). A leaf on a held container is kept alive until the hold lands, so disposing a reader cannot pull the key back into the hold. A write to a key the hold itself changed still joins the hold (A34). Array elements follow the same per-key rule, so writing an index the hold left unchanged also shows at the flush. An added key, or an index appended to a held array, now behaves the same whether or not something reads it: its value shows at the flush, while the record's key list (or the array's length) still waits for the hold to land. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 30c1b36e68..c23c3aa66d 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -141,7 +141,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | |---|---|---|---|---|---| -| CS-R1 | live | `docs/rules-mining/core-store.md:9` | store.ts×2 | flatten-async-iterable.test.ts×5 syncThenable.test.ts×14 visibility-oracle-store.states.ts×1 | Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).** | +| CS-R1 | live | `docs/rules-mining/core-store.md:9` | store.ts×2 | continuation-sibling-reader-4146.test.ts×2 flatten-async-iterable.test.ts×5 syncThenable.test.ts×14 visibility-oracle-store.states.ts×1 | Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).** | | CS-R2 | live | `docs/rules-mining/core-store.md:13` | — | syncThenable.test.ts×12 visibility-oracle.states.ts×1 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).** | | CS-R2a | live | `docs/INTERNALS-STORE-STATE.md:107` | — | — | Corollary R2a (Ryan, 2026-08-17): **take no responsibility for mutation | | CS-R3 | live | `docs/rules-mining/core-store.md:18` | — | — | A store proxy ingested into another store (deep or shallow) is re-wrapped in the ingesting store's own proxy family — never identity-passed, never raw-marked.** | diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index c5fed3ad2e..c91b725461 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -342,17 +342,16 @@ function noteNode(target: StoreTarget, node: Signal, key: PropertyKey): voi // dispatches CONFIG_SLOT_NODE nodes here (graph.ts) instead of holding a // per-node closure in a per-node NodeExtension. A dropped node is // unreachable through the store — a fresh read makes a fresh node. +// A staged write — or a lane's value (a guess, S4) — is state only the node +// holds: its release waits for the commit that resolves it. So does a leaf of +// a held container (#4146): a mainline write it carried is in the held +// staging too, where it would read as the hold's until the landing. +const slotKept = (node: any): any => + node._pendingValue !== NOT_PENDING || + node._config & CONFIG_OVERRIDE || + node._host.k?._config & CONFIG_HELD; setSlotUnobserved((node: any): void => { - // A staged write — or a lane's value (a guess, S4) — is state only the - // node holds: defer the release to the commit that resolves it. So is a - // leaf of a held container (#4146): a mainline write it carried is in the - // held staging too, where it would read as the hold's until the landing. - if ( - node._pendingValue !== NOT_PENDING || - node._config & CONFIG_OVERRIDE || - node._host.k?._config & CONFIG_HELD - ) - deferredReleases.add(node); + if (slotKept(node)) deferredReleases.add(node); else releaseSlot(node); }); @@ -904,12 +903,7 @@ function drainFolds(): void { } if (deferredReleases.size !== 0) { for (const node of deferredReleases) { - if ( - node._pendingValue === NOT_PENDING && - !(node._config & CONFIG_OVERRIDE) && - node._subs === null && - !((node as any)._host.k?._config & CONFIG_HELD) - ) { + if (node._subs === null && !slotKept(node)) { deferredReleases.delete(node); releaseSlot(node); } @@ -2191,16 +2185,12 @@ const traps: ProxyHandler = { // unchanged would write it into the held staging — read as the hold's // until its landing, while the same key with a leaf (a live reader) // publishes at the flush. The key gets its leaf first, born from the two - // frames (equal: unstaged, held by no one), and the write stages on it - // (A28; a write by the holder joins it, A34). A key the hold changed and - // an added key stay the container's (A34 (1)). Kept while the container - // is held (`deferredReleases`). - if ( - userWrite && - (target.k as any)?._config & CONFIG_HELD && - key in pb && - !heldKeyChanged(target, key) - ) + // frames (`bornStaged`: a key the hold left unchanged — or added — is + // unstaged and held by no one; a key the hold changed is born held, A34 + // (1)), and the write stages on it (A28; a write by the holder joins it, + // A34) — as it would with a reader's leaf. Kept while the container is + // held (`deferredReleases`). + if (userWrite && (target.k as any)?._config & CONFIG_HELD) deferredReleases.add(getNode(target, key)); if (Array.isArray(pb)) { touch(target, key); diff --git a/packages/signals/tests/continuation-sibling-reader-4146.test.ts b/packages/signals/tests/continuation-sibling-reader-4146.test.ts index 2067663306..40f8962d52 100644 --- a/packages/signals/tests/continuation-sibling-reader-4146.test.ts +++ b/packages/signals/tests/continuation-sibling-reader-4146.test.ts @@ -177,6 +177,67 @@ describe("#4146 live reader must not change sibling-write hold membership", () = expect(state.card).toEqual({ version: 1, text: "a" }); }); + it("an added key on a held container reads the same with or without a reader (R1)", async () => { + // #4146, R1 (node existence is unobservable): a key the setter adds beside + // the held sibling gets a leaf born unstaged, as a reader's would be, so + // whether something reads the key cannot decide whether its value waits + // for the hold. Same for an index appended to a held array. + async function added(reader: boolean) { + const gate = deferred(); + const [state, setState] = createStore<{ + card: { version: number; text: string; extra?: string }; + list: number[]; + }>({ card: { version: 0, text: "" }, list: [1, 2] }); + const stop = createRoot(dispose => { + if (reader) + createRenderEffect( + () => [state.card.extra, state.list[2]], + () => {} + ); + return dispose; + }); + flush(); + const save = action(function* () { + setState(s => { + s.card.version = 1; + s.list[1] = 20; + }); + yield gate.promise; + }); + const p = save(); + flush(); + setState(s => { + s.card.extra = "x"; + s.list[2] = 30; + }); + flush(); + const pending = { + extra: state.card.extra, + has: "extra" in state.card, + keys: Object.keys(state.card), + version: state.card.version, + appended: state.list[2], + length: state.list.length + }; + gate.resolve(); + await p; + flush(); + const final = { card: { ...state.card }, list: [...state.list] }; + stop(); + return { pending, final }; + } + const none = await added(false); + const read = await added(true); + expect(read).toEqual(none); + expect(none.pending.extra).toBe("x"); + expect(none.pending.appended).toBe(30); + expect(none.pending.version).toBe(0); + expect(none.final).toEqual({ + card: { version: 1, text: "", extra: "x" }, + list: [1, 20, 30] + }); + }); + it("contrast: a mainline write to the key the hold changed stays held (A34)", async () => { const gate = deferred(); const [state, setState] = createStore({ card: { version: 0, text: "" } }); From 57d9872cdaeac45f36010de0dedac2e9d38d28f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:38:50 +0000 Subject: [PATCH 6/6] size: re-set #4146 caps from CI after the golf +48 B minified on the store scenarios (was +80). The compiled hydrating app crossed its brotli cap by 18 B, so it gets a ledger entry too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2 --- scripts/size/scenarios.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 2c7ff30692..c444ef5435 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1081,9 +1081,9 @@ module.exports = [ // only: cap at measured + 10 B rounded up to 0.01 KB; recorded minified // never raised. // store delete key-count fix (#4132, 2026-10-17): 14.79 -> 14.82 KB, measured at 14,802 B brotli / 44,865 B minified (+12 B brotli over the cap, +33 B minified over the recorded size): deleteProperty decrements `kc` for a draft's own key, so a keyed store that deletes keys no longer drifts into overlay rebuilds. Correctness; local measurement, CI re-bases. - // Unread key beside a held sibling publishes at flush (#4146, 2026-10-19, over next @ 2aa9e86a): 14.82 -> 14.84 KB, measured in CI at 14,826 B brotli / 44,962 B minified (+80 B minified over this PR's base): a mainline write to a key the hold never changed creates that key's leaf instead of writing into the held staging, so whether the write is held no longer depends on a live reader (R1). Correctness; only runs when the container is held. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "14.84 KB", - capMinified: 44962, + // Unread key beside a held sibling publishes at flush (#4146, 2026-10-19, over next @ 2aa9e86a): 14.82 -> 14.83 KB, measured in CI at 14,812 B brotli / 44,930 B minified (+48 B minified over this PR's base): a mainline write to a held container creates that key's leaf instead of writing into the held staging, so whether the write is held no longer depends on a live reader (R1). Correctness; only runs when the container is held. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "14.83 KB", + capMinified: 44930, alias }, { @@ -2595,9 +2595,9 @@ module.exports = [ // A held error reaches a derivation of `latest` (#4113, #4106, 2026-10-17, merged over next @ 01270641): 30.09 -> 30.14 KB, measured locally at 30,125 B brotli / 94,611 B minified (+32 B minified over the recorded size): `laneRead` and `verdictValue` hand a held error to an override/derived read the way they hand a value, and `stageError` schedules the verdict wake. Cap at measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer (2026-10-17). // store delete key-count fix (#4132, 2026-10-17): 30.09 -> 30.14 KB, measured at 30,129 B brotli / 94,616 B minified (+39 B brotli over the cap, +37 B minified over the recorded size): deleteProperty decrements `kc` for a draft's own key, so a keyed store that deletes keys no longer drifts into overlay rebuilds. Correctness; local measurement, CI re-bases. // Merged over next @ 7093b074b after #4129 raised this same cap 30.09 -> 30.14 KB for its own bytes (2026-10-17): the two together measured locally at 30,116 B brotli / 94,644 B minified, under the cap, so the cap stays at 30.14 KB; recorded minified 94,611 -> 94,644. - // Unread key beside a held sibling publishes at flush (#4146, 2026-10-19, over next @ 2aa9e86a): 30.14 -> 30.20 KB, measured in CI at 30,189 B brotli / 94,727 B minified (+80 B minified over this PR's base): a mainline write to a key the hold never changed creates that key's leaf instead of writing into the held staging, so whether the write is held no longer depends on a live reader (R1). Correctness; only runs when the container is held. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. - limit: "30.20 KB", - capMinified: 94727, + // Unread key beside a held sibling publishes at flush (#4146, 2026-10-19, over next @ 2aa9e86a): 30.14 -> 30.17 KB, measured in CI at 30,159 B brotli / 94,695 B minified (+48 B minified over this PR's base): a mainline write to a held container creates that key's leaf instead of writing into the held staging, so whether the write is held no longer depends on a live reader (R1). Correctness; only runs when the container is held. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "30.17 KB", + capMinified: 94695, alias }, { @@ -4059,8 +4059,9 @@ module.exports = [ // minified never raised. // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 31.72 -> 31.96 KB, measured at 31,950 B brotli / 101,408 B minified (+230 B brotli over the cap, +648 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap. // store delete key-count fix (#4132, 2026-10-17): 31.96 -> 31.98 KB, measured at 31,962 B brotli / 101,441 B minified (+2 B brotli over the cap, +33 B minified over the recorded size; CI and local agree): deleteProperty decrements `kc` for a draft's own key, so a keyed store that deletes keys no longer drifts into overlay rebuilds. Correctness. - limit: "31.98 KB", - capMinified: 101441, + // Unread key beside a held sibling publishes at flush (#4146, 2026-10-19, over next @ 2aa9e86a): 31.98 -> 32.01 KB, measured in CI at 31,998 B brotli / 101,499 B minified (+48 B minified over this PR's base; the store bytes the compiled app retains): a mainline write to a held container creates that key's leaf instead of writing into the held staging, so whether the write is held no longer depends on a live reader (R1). Correctness; only runs when the container is held. Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + limit: "32.01 KB", + capMinified: 101499, alias }, {