Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/a28-writes-visible-at-flush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@solidjs/signals": patch
---

A write becomes visible at flush — to every channel (A28). Between `set(x)` and the flush that carries it, the write is not the committed value, not the staged value `latest()` / `isPending()` serve, and not an input to any derivation created meanwhile: `latest(x)` answers the pre-write value, `isPending(x)` is false, `until()`'s predicate evaluated in the carrying flush sees it. Optimistic writes are writes too (A28 (5), "match React"): `setOptimistic(v)` becomes the active override at the flush that carries it — plain reads, `snapshot()`, `in`, keys, `length` and `isPending()` see nothing before — while the writer's own channels (a functional updater, the store draft, the `affects()` declaration walk) compose on it. A rewrite of a node a transaction holds keeps the staged value the last flush left for `latest()`/verdicts until the next flush. Companions and store keys first materialized under a hold are born as the holding transaction's (#3336).

Landed as a read-side rule rather than #3337's deferred subscriber walk: "unflushed" is structural (an ambient staged value outside a flush), the plain write path is untouched, and readers served the flushed value are latched for the carrying flush. Supersedes the #2922 mid-tick `latest()` pull (`flush()` first to read your own write) and re-pins the pre-A28 expectations accordingly.
395 changes: 198 additions & 197 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t

**History (superseded mechanism, kept verbatim).** The 2026-07-07b mechanical model — replaced by the 2026-09-09 supersession, which the statement now leads with; take the mechanism from the statement, not from here: Mechanically: authoritative values arriving under an active override hold in `_pendingValue` like any other transition write and **elevate to `_value` only at their transition's commit** (`_value` changes at commit points, period); the elevation is unobservable under the override mask (A17); reverting is a pure drop — there is no revert target and reverts commit nothing. This supersedes the earlier "bound to its own async source, not its transition" formulation, which was implemented by escaping the transition commit (revert-target commit at revert) and allowed a mid-flight arrival to reveal before its own transition completed.

### A28. A write becomes visible at flush — to every channel

**Status:** **ruled, mechanism landed 2026-09-15** — maintainer ruling 2026-09-08 ("since we can't derive downstream before flush happens I do like latest being invisible until flush"; "nothing should be 30 pre flush… because double count can't be 60"; "latest opts into the tearing but really only after a flush"); optimistic writes 2026-09-10 ("My gut is to match. I'm gathering React does."; "yeah lets match"). Landed as a READ-SIDE rule (see Mechanism), superseding #3337's deferred-walk mechanism; the runtime's pre-flush cells in both oracles read the rule.
**Pinned by:** `tests/latest-held-till-flush.test.ts` (A28 (1)–(4): held and ambient writes, companions created after the write, #3336 born-holding store keys and companions); `tests/visibility-oracle.test.ts` and `tests/visibility-oracle-store.test.ts` (the pre-flush states — every cell cites A28); `tests/latest-repeated-writes.test.ts` (#2922 re-pinned); `tests/latest-unobserved-memo.test.ts`; `tests/latest-probe-order-independence.test.ts`; `tests/isPending-memo-consistency.test.ts`; `tests/latest-plain-write-purity.test.ts`; `tests/store/projection-transition-isolation.test.ts`; optimistic (5): `tests/createOptimistic.test.ts`, `tests/store/createOptimisticStore.test.ts`, `tests/question-scoped-pending.test.ts` (same-tick `affects(parent)` covers the pushed row), `tests/optimistic-store-layer-scope.test.ts`, `tests/store/shallow.test.ts`.
**Mechanism (index, 2026-09-15 — as landed):** read-side, no marker on the plain write path. "Unflushed" is structural: outside a flush (`!globalQueue._running`), a node with an ambient staged value (`_pendingValue` set, `_transition` null) was written since the last flush — ambient staging commits at flush end, so nothing else leaves a node in that state (`unflushedValue`, core.ts). Exempt: engine companions (`_parentSource` — written at the source's write to mirror it, installing eagerly, A8) and writes issued inside a creation-time recompute (`CONFIG_PROMOTED`, set only when `!_running && context !== null` — A28 (4): promoted at that recompute's end). A rewrite of a HELD node stashes the staged value the last flush left (`_x._flushedStaged`, `unflushedRewrites`) and `latest()`/verdicts answer with it until the next flush. Optimistic writes (5): `_overrideTime === clock` outside a flush is the unflushed override (`unflushedOverride`); readers skip it (`read()`'s override arm, store `nodeValue`/`visibleOverride`/`optimisticView(…, draft=false)`), the writer's channels — the draft, the `affects()` declaration walk — compose on it. Readers served the flushed value because of an unflushed write are latched for the carrying flush (`REACTIVE_MISSED_WAKE`, `markLateLinker`) — the late-linker case #3337 deferred the walk for. Companion-bearing nodes written outside a flush are re-synced as the flush begins (`unflushedCompanions`, `resyncUnflushedCompanions` inside the running window); a companion created lazily backfills as the owner's transaction's write (`backfillCompanion`, #3336) and, when the source is unflushed at its creation, joins that re-sync (`joinUnflushedResync`) — brought current by the optimistic write like a companion that existed at the write, so a derivation over `latest()` direct-commits as the optimistic view it is instead of being staged under whatever hold the round entered. The read sites test one module flag (`unflushedStaged`, set when a node is staged or a held node rewritten outside a flush, cleared at flush start) rather than `_running`; the write-path arms (`stashHeldRewrite`, `notePromotedWrite`) are cold helpers gated on loads the write already pays. No mid-tick `latest()` pull (#2922 superseded). A write to a held node from mainline no longer enters the transaction eagerly (the node is already its; entering captured the caller's block). Store: a key first read under a held FOLD is born holding (`heldFoldTransition`, `stageHeldKey`), stale/owner-less readers of a transaction-held backing see committed through every channel (`heldFromReader`, `foreignHold`) — #3336's store half, lifted from #3337.

(**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every channel.** Between `set(x)` and the next `flush()` the write is _unflushed_: it is not the committed value, not the staged value `latest()`/`isPending()` serve, and not an input to any recompute. `latest(x)` reads the **flushed staged world** — the newest value a flush has processed, held or not (a transaction's hold governs what _effects_ publish, not what `latest` answers; `latest` opts into that tearing, but only once a flush has carried the write) — so `setCount(30); latest(count)` is the pre-write answer until the flush that carries the write, after which `latest(count)` is 30 and `latest(doubled)` is 60 in the same instant (the derivation flushed with it). Nothing is ever 30 while its derivations are still 20-shaped: there is no "read your own write" channel that bypasses the flush, because no channel can show downstream of an unflushed write, and a channel that shows the write alone tears against every derivation. Consequences: (1) `latest` is one rule regardless of reader — event handler, memo, prop getter — so wrapping a `latest` read in a memo does not change what it answers (the visibility mismatch that motivated a separate `readStaged` does not arise); (2) `isPending(x)` is false for an unflushed write (nothing is observable yet to be pending _from_); (3) a companion created lazily after several flushes needs no retained history — the flushed staged value is the only answer it could ever have given — and it answers **as if it had always existed** (#3336): its backfill is the write of the transaction _holding_ that value, not of the ambient window the reader happens to run in, so it lives and reverts with the hold instead of reverting at the reader's flush end; the same one level down for a store key first read under a hold — the node is born with the committed value and the held write staged as the holding transaction's, so plain reads and `latest()` answer for an unobserved key exactly as they do for one some other reader had materialized before the hold; (4) `flush()` is the boundary, so the imperative idiom is `set(x); flush(); latest(x)`, and within a recompute or a flush every write issued is promoted at the end of that recompute/round so the graph never runs a round against a value it wrote but cannot see; (5) (**extended 2026-09-10**, "let's match" — React's `useOptimistic` shows the optimistic value on the next render, never synchronously) **an optimistic write is a write**: `setOptimistic(x)` becomes the _active_ override (A17) at the flush that carries it, and until then no reader — plain, `snapshot()`, `isPending()` — sees it. An ambient one (no action in flight) is installed at the flush's start and reverted at its end, so effects are the channel that shows it (`[1, 2, 1]`); one an action holds stays readable after the flush for the action's lifetime. The writer's own composition channels see the parked write exactly as they see a plain write's staged value: a functional updater, and a store draft (`setState(s => …)` composes on the tick's earlier setters — two `count++` are +2, a push after a push lands in the next slot, a toggle toggled back diffs against the first and emits the write that cancels it). The `affects()` declaration walk is a writer channel too: tagging a parent covers the whole record as the writer sees it, so a same-tick `affects(state)` after an optimistic push covers the pushed row (the walk composes the tick's parked writes, as it already walks a plain store's pending backing). Only the slot form on a row born this tick needs the draft — `state.rows[2]` is not readable before the flush, so it is named as `affects(s.rows[2], key)` inside the setter (the same target the flush will serve). Engine companions (the `latest()` shadow, the `isPending()` verdict signal — `_parentSource` set) are the system's own overrides, written inside the flush after its promotions to mirror flushed state (A8), and install eagerly.

### A11. Sync derivations of held sources are visible through `latest()`/`isPending()`

**Status:** **ruled** — #2831 finding 3
Expand Down
Loading
Loading