diff --git a/.changeset/held-container-unread-key-4146.md b/.changeset/held-container-unread-key-4146.md new file mode 100644 index 000000000..fe4f1ad54 --- /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. 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 812da42ba..6ab1517e4 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 | 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 2b374efd1..c91b72546 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -342,11 +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. - if (node._pendingValue !== NOT_PENDING || node._config & CONFIG_OVERRIDE) - deferredReleases.add(node); + if (slotKept(node)) deferredReleases.add(node); else releaseSlot(node); }); @@ -898,11 +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 - ) { + if (node._subs === null && !slotKept(node)) { deferredReleases.delete(node); releaseSlot(node); } @@ -2179,6 +2180,18 @@ const traps: ProxyHandler = { if (key === "__proto__") return true; // pollution guard (core R30) const uv = target.s ? value : unwrapValue(value); const pb = ensurePB(target); + // #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 (`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); if (key === "length") target.wk = WK_ALL; 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 000000000..40f8962d5 --- /dev/null +++ b/packages/signals/tests/continuation-sibling-reader-4146.test.ts @@ -0,0 +1,263 @@ +/** + * #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("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: "" } }); + 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); + }); +}); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index fa7b4639c..82a0b29e2 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1083,8 +1083,10 @@ module.exports = [ // 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. // Re-recorded on next @ fb6006eb (2026-10-19, accepted by the maintainer): recorded minified 44,865 -> 44,924 B, measured locally (linux) at 14,798 B brotli. #4156 grew this scenario without re-recording it, so next sat past the 20 B minified allowance and brotli noise failed unrelated PRs. Cap unchanged. - limit: "14.82 KB", - capMinified: 44924, + // 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. + // Unread key beside a held sibling publishes at flush (#4146), re-measured after merging next @ 8c75f82f (2026-10-19): 14.82 -> 14.85 KB, measured locally (linux) at 14,837 B brotli / 44,972 B minified (+48 B minified over next). + limit: "14.85 KB", + capMinified: 44972, alias }, { @@ -2603,8 +2605,10 @@ module.exports = [ // 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. // Lane frame member cancels its lane dirty (#4137, 2026-10-19, over next @ 2aa9e86a): 30.14 -> 30.20 KB, measured in CI at 30,185 B brotli / 94,763 B minified (+116 B minified over this PR's base): GlobalQueue._update drops a lane-dirty zombie whose parking owner is lane work instead of re-running it, so a branch its own lane withdrew no longer reads a stale condition. Correctness; gated on laneStageHook (core floor unchanged). Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. // Stale staged error cleared on re-run (#4109, 2026-10-19, over next @ fda29a4c): 30.20 KB -> 30.23 KB, measured in CI at 30,217 B brotli / 94,834 B minified (+45 B minified over next): a re-run pass drops an earlier pass's staged error before it computes, so a recompute that no longer throws does not publish the old one. Correctness; Size-Exception accepted by the maintainer (2026-10-19). Cap at measured + 10 B rounded up to 0.01 KB; recorded minified = measured. + // 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. + // Unread key beside a held sibling publishes at flush (#4146), re-measured after merging next @ 8c75f82f (2026-10-19): 30.23 KB (unchanged), measured locally (linux) at 30,220 B brotli / 94,882 B minified (+48 B minified over next). limit: "30.23 KB", - capMinified: 94834, + capMinified: 94882, alias }, { @@ -3921,8 +3925,9 @@ module.exports = [ // mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 25.66 -> 25.95 KB, measured at 25,940 B brotli / 80,846 B minified (+280 B brotli over the cap, +640 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): 25.95 -> 26.03 KB, measured at 26,011 B brotli / 80,879 B minified (+61 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. // Re-recorded on next @ fb6006eb (2026-10-19, accepted by the maintainer): recorded minified 80,879 -> 80,929 B, measured locally (linux) at 25,989 B brotli. #4156 grew this scenario without re-recording it, so next sat past the 20 B minified allowance and brotli noise failed unrelated PRs. Cap unchanged. + // Unread key beside a held sibling publishes at flush (#4146), re-measured after merging next @ 8c75f82f (2026-10-19): 26.03 KB (unchanged), recorded minified 80,929 -> 80,977 B, measured locally (linux) at 26,025 B brotli / 80,977 B minified (+48 B minified over next). limit: "26.03 KB", - capMinified: 80929, + capMinified: 80977, alias }, { @@ -4071,8 +4076,10 @@ module.exports = [ // 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. // Re-recorded on next @ fb6006eb (2026-10-19, accepted by the maintainer): recorded minified 101,441 -> 101,500 B, measured locally (linux) at 31,972 B brotli. #4156 grew this scenario without re-recording it, so next sat past the 20 B minified allowance and brotli noise failed unrelated PRs. Cap unchanged. - limit: "31.98 KB", - capMinified: 101500, + // 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. + // Unread key beside a held sibling publishes at flush (#4146), re-measured after merging next @ 8c75f82f (2026-10-19): 31.98 -> 32.02 KB, measured locally (linux) at 32,002 B brotli / 101,548 B minified (+48 B minified over next). + limit: "32.02 KB", + capMinified: 101548, alias }, {