Skip to content

Commit 1bc236d

Browse files
ryansolidClaude via Cursorcursoragent
committed
fix(signals): a derived write's mask lasts its frame, not the hold (#3733)
Regression from b0db6c9 (#2692): the manual-write mask on createSignal(fn) / createStore(fn) was only cleared at commit, so inside a held transaction it lasted the whole hold and later source changes never re-derived. The mask now refuses only the write's own frame (a `_manualWriteTime === clock` compare at heap insert); the flag stays as A34's proposal mark. The #3026 refresh() lift is subsumed by the same check. Co-authored-by: Claude via Cursor <[email protected]> Co-authored-by: Cursor <[email protected]>
1 parent 309b087 commit 1bc236d

15 files changed

Lines changed: 675 additions & 103 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/signals": patch
3+
---
4+
5+
A manual write to a writable derived value (`createSignal(fn)`, `createStore(fn)`) inside an action no longer blocks later source changes for the rest of the hold. The write still wins over its own frame's recompute (#2692); a source change in a later frame re-runs the derivation with the write as `prev` (or in the draft), under the transaction and revealed with it. This was a regression since #2692, whose mask only lifted at the transaction's commit (#3733).

‎documentation/solid-2.0/02-signals-derived-ownership.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ const [cached, setCached] = createSignal((prev = props.something) => prev);
110110
// setValue(...) writes like a normal signal; the compute receives prev on recompute.
111111
```
112112

113-
Within a synchronous frame the write wins over a recompute queued in the same tick (either order). Across a hold it is not a proposal: while a transaction holds a value the compute derived (an async dependent of the source is still in flight), a write from outside that transaction joins it and becomes the `prev` of the transaction's re-derivation instead of replacing it — the frame that reveals is `fn(inputs)`, never a value the writer computed against the old frame. Writes made inside the transaction keep last-write-wins. The same holds for the derived `createStore` below.
113+
A write to a writable derived value (`createSignal(fn)`, `createStore(fn)`) wins over its sources' changes in the same batch. A source change in a later update re-runs the function, which receives your write as `prev` (or as the draft for `createStore(fn)`). To keep a local value across source changes, read it from `prev`. Inside an action and across async holds this works the same way: a hold changes when the result is shown, not what it is.
114+
115+
A held derivation is not a write: while a transaction holds a value the compute derived (an async dependent of the source is still in flight), a write from outside that transaction joins it and becomes the `prev` of the transaction's re-derivation instead of replacing it — the frame that reveals is `fn(inputs)`, never a value the writer computed against the old frame. A second write to the same value still replaces the first. The same holds for the derived `createStore` below.
114116

115117
#### Function-form `createStore` (derived/projection store)
116118

‎packages/signals/docs/INTERNALS-ASYNC-STATE.md‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎packages/signals/docs/INTERNALS-STORE-STATE.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -674,9 +674,9 @@ implementation, deduplicated across reports.
674674
writes must not entangle lanes (opt R38); equal-value action writes must
675675
still register ownership and dirty downstream (lanes R17); same-value
676676
manual writes on derived stores must mask the recompute for the tick (core
677-
R31; within a synchronous frame — across a hold the write is the
678-
re-derivation's `prev`, A34 (3), #3612) despite equality-checked core
679-
signals.
677+
R31; for the write's own frame — a later frame's source change, and a
678+
hold's re-derivation, take the write as `prev`, A34 (3), #3612, #3733)
679+
despite equality-checked core signals.
680680
- **RUL-11 — SPEC'D (2026-08-17): §6d.** Sticky descendants flag ported from
681681
`STORE_DESC`; reference-skip precedes reachability; monotone by design.
682682
- **RUL-12 — Smaller rulings, each with a proposed default** (proceeding on

‎packages/signals/docs/RULES-INDEX.md‎

Lines changed: 46 additions & 46 deletions
Large diffs are not rendered by default.

‎packages/signals/docs/SPEC-ASYNC-SEMANTICS.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,17 @@ The hold does not vanish; it moves onto the boundary (#3459). The readers behind
232232

233233
### A34. A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none
234234

235-
**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.
235+
**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).
236236
**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)
237237
**Mechanism (index, 2026-09-16; 2026-09-17):** `setSignal`, on a node stamped by a transaction that is not active, enters it at once inside a flush (as before) and from mainline records it in `batchJoins` (scheduler.ts) and schedules — before the equality gate, so a repeat of the held value proposes too, and the repeat's own flush drains it (left for the next flush, the join adopted an unrelated tick — #3519 review); `flush()` enters each recorded transaction at its start (`initTransition`, adopting the tick's ambient batch; inside the flush's `try`, so a throwing comparator in the drop below cannot leave `_running` set; the fast sync path defers while a join waits), and mainline code between the write and the flush never sees `activeTransition` set. `initTransition`'s adoption loop drops an _unstamped_ node (`_transition === null`) — a signal, or a writable memo under `REACTIVE_MANUAL_WRITE` (`createSignal(fn)`'s setter; a computed's other stagings are its pass's result) — whose staged value equals its committed one (`_equals`; unstaged through `commitPendingNode`, which also snaps companions and clears the manual-write flag; not stamped, not pushed) — a node already a transaction's, folded in as a parked batch merges, carries a flushed proposal a later rewrite brought back to the committed value and is held, not proposal-free (fuzzer latest-2 #1470: dropped, its dead stamp made the commit skip the authoritative value that followed); `computePendingState` (verdict.ts) reads a staged value equal to the committed one as not pending.
238238

239239
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.
240240

241-
**A held derivation is not a proposal (3)** — **amended 2026-09-23** (#3612, sshockwave), maintainer: "#2692's 'manual write wins' precedence applies within a synchronous frame. Across a hold, a held derivation is not a proposal: a mainline write to a node whose held staging is a pass result (stamped `_transition = T` by another transaction, `REACTIVE_MANUAL_WRITE` NOT set on the node / its `_firewall`) becomes `prev` for the transaction's re-derivation and does not suppress it. The write still joins T per A34(1) (writer's tick reveals with T; NO intermediate mainline commit). Writes made inside the transaction (T's staging already a manual proposal) keep today's A34(1) last-write-wins. Applies to `createSignal(fn)` and `createStore(fn)` (CS-R31); projections have no setter." (1) speaks of two _suggestions_; a writable memo's staging under a transaction that re-derived it is not one — it is `f(a)` for the held `a`, which nobody proposed — and the mainline writer read the committed frame (`b() === 100`) when it wrote. Shape: `a = 1`, `b = createSignal(() => a() * 100)`, an async memo over `a` in flight for `a = 2`, a render effect reading all three. `setA(2)` holds; `b` re-derives to 200 under T. A mainline `setB(101)` mid-hold: the tick joins T (no frame shows 101), the updater form composes on the committed 100 (`setB(p => p + 1)` sees 100, not the staged 200), and T re-derives `b` with `prev = 101` before its reveal — `a=2 b=200`, one coherent frame; a `prev`-reading derivation folds the write (`max(prev, a())` with `setHi(5)` reveals `hi=5`). `latest(b)` shows T's derivation throughout (200: the A28 stash until the flush, the re-derived staging after), never the write. A later `setA(3)` re-derives again (`a=3 b=300`), the same whether or not anything probed `isPending(b)` between the writes — the manual-write mask is state, not scheduling, and a pull that recomputed nothing no longer lifts it (`updateIfNecessary`). Unchanged: the same frame — `setA(2); setB(101)`, either order, hold or not — is #2692's (`b=101`); a write made under T (the action that opened it, a compute-phase `ownedWrite`) masks the node and a later mainline write is a second proposal on it, last write wins; a write to a node no transaction re-derived (an unrelated hold) commits at once. A store's setter (`createStore(fn)`) is the twin: a leaf the fold staged under T re-runs the fold under T, the write being the draft's prior state. Mechanism: `heldDerivation` / `rederiveHeld` (core.ts), asked by `setMemo` and the derived store's setter (`derivedStoreWrite`, store.ts, from its leaf notifications) — never by `setSignal` itself, whose other stamped-node writers (an async landing, a companion) are the transaction's own.
241+
**A held derivation is not a proposal (3)** — **amended 2026-09-23** (#3612, sshockwave), maintainer: "#2692's 'manual write wins' precedence applies within a synchronous frame. Across a hold, a held derivation is not a proposal: a mainline write to a node whose held staging is a pass result (stamped `_transition = T` by another transaction, `REACTIVE_MANUAL_WRITE` NOT set on the node / its `_firewall`) becomes `prev` for the transaction's re-derivation and does not suppress it. The write still joins T per A34(1) (writer's tick reveals with T; NO intermediate mainline commit). Writes made inside the transaction (T's staging already a manual proposal) keep today's A34(1) last-write-wins. Applies to `createSignal(fn)` and `createStore(fn)` (CS-R31); projections have no setter."
242+
243+
**Amended 2026-10-01 (#3733)** — A write made inside the transaction masks the node for the write's frame only. A source change in a later frame, whether from mainline or the action's own, re-derives under T with the staged write as `prev` (or the draft), revealed with T. A second write to the node is still (1)'s last-write-wins. The mask is lifted by the end of its frame, the commit, or `refresh()`.
244+
245+
(1) speaks of two _suggestions_; a writable memo's staging under a transaction that re-derived it is not one — it is `f(a)` for the held `a`, which nobody proposed — and the mainline writer read the committed frame (`b() === 100`) when it wrote. Shape: `a = 1`, `b = createSignal(() => a() * 100)`, an async memo over `a` in flight for `a = 2`, a render effect reading all three. `setA(2)` holds; `b` re-derives to 200 under T. A mainline `setB(101)` mid-hold: the tick joins T (no frame shows 101), the updater form composes on the committed 100 (`setB(p => p + 1)` sees 100, not the staged 200), and T re-derives `b` with `prev = 101` before its reveal — `a=2 b=200`, one coherent frame; a `prev`-reading derivation folds the write (`max(prev, a())` with `setHi(5)` reveals `hi=5`). `latest(b)` shows T's derivation throughout (200: the A28 stash until the flush, the re-derived staging after), never the write. A later `setA(3)` re-derives again (`a=3 b=300`), the same whether or not anything probed `isPending(b)` between the writes — the manual-write mask is state, not scheduling, and a pull that recomputed nothing no longer lifts it (`updateIfNecessary`). Unchanged: the same frame — `setA(2); setB(101)`, either order, hold or not — is #2692's (`b=101`); a write made under T (the action that opened it, a compute-phase `ownedWrite`) is a proposal, and a later mainline write to the node is a second proposal on it, last write wins — its mask lasts the write's frame only, so a source change in a later frame re-derives under T with the write as `prev` (#3733); a write to a node no transaction re-derived (an unrelated hold) commits at once. A store's setter (`createStore(fn)`) is the twin: a leaf the fold staged under T re-runs the fold under T, the write being the draft's prior state. Mechanism: `heldDerivation` / `rederiveHeld` (core.ts), asked by `setMemo` and the derived store's setter (`derivedStoreWrite`, store.ts, from its leaf notifications) — never by `setSignal` itself, whose other stamped-node writers (an async landing, a companion) are the transaction's own.
242246

243247
## Loading window and seeds
244248

‎packages/signals/docs/rules-mining/core-store.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,11 @@ Files: **CS** = `tests/store/createStore.test.ts`, **SP** = `tests/store/storePa
135135

136136
- CS "ignores prototype pollution keys in draft setters"; SP "storePath prototype pollution guard".
137137

138-
**R31. Derived-store manual writes win over the recompute within a synchronous frame: manual setStore beats a queued recompute in the same flush; a SAME-VALUE manual write still holds against the recompute for that tick; next source change reclaims. Across a hold the write is `prev` for the re-derivation: a setter reaching a leaf the fold staged under another transaction (a held pass result, not a proposal — `REACTIVE_MANUAL_WRITE` clear on the projection node) joins that transaction and re-runs the fold under it with the written draft as the prior state, instead of masking it (A34 (3), #3612).**
138+
**R31. Derived-store manual writes win over the recompute for their own frame.** Within the frame, a manual `setStore` beats the recompute queued for that flush, in either call order, since propagation is deferred to the flush. A SAME-VALUE manual write still holds for that frame. A source change in a later frame re-runs the fold with the written draft as the prior state, and the fold decides what to keep. That applies inside an action and across a hold too. There, the re-run happens under the transaction and reveals with it. A setter reaching a leaf the fold staged under another transaction (a held pass result, not a proposal) is likewise prior state for that transaction's re-run (A34 (3), #3612).
139139

140-
- CS "derived store manual writes" (#2692 ×2); `tests/held-derivation-not-a-proposal-3612.test.ts` (store twin).
140+
_Amended 2026-10-01 (#3733)._ The mask was meant to last a frame but only lifted at `commitPendingNode`. Inside a transaction that is the whole hold, so later source changes were dropped. That was a regression from `b0db6c90f` (#2692). #3026 had lifted it for `refresh()` only.
141+
142+
- CS "derived store manual writes" (#2692 ×2); `tests/held-derivation-not-a-proposal-3612.test.ts` (store twin); `tests/frame-scoped-mask-3733.test.ts` (the frame, either order; later frames, inside an action and across a hold).
141143
- **CONFLICT (framing + mechanics):** "keeps the override for the tick" — override layers deleted. Manual-write-precedence-until-next-recompute incl. same-value writes must be reproduced by node/lane precedence; equality-checked signal write would no-op yet the mask must hold.
142144

143145
**R32. A setter-staged replacement followed by reconcile lands the reconciled value — staged writes fold into the diff.** Aligned: O7's resolution (a test already exists).

0 commit comments

Comments
 (0)