fix(signals): writes become visible at flush — latest() reads the flushed staged world (A28) - #3337
fix(signals): writes become visible at flush — latest() reads the flushed staged world (A28)#3337ryansolid wants to merge 10 commits into
Conversation
🦋 Changeset detectedLatest commit: 20f3143 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
AI review based on the context where i discovered the issues:
|
0f4d5f7 to
ba538c2
Compare
Coverage Report for CI Build 34587680444Warning No base build found for commit Coverage: 71.842%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
Merging this PR will regress 3 benchmarks
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing |
ba538c2 to
b722898
Compare
…test() answers Review on #3337 read "no held lane withholds" as a promise of publication atomicity for the lane. It is the opposite: latest() reads the flushed staged value whether or not a transaction still holds it from the committed view; the hold is about effects. Reworded. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
|
The main issue with the latest ruling is that it might not be obvious that something is read through latest when reading a prop for example, leading to issues where the jsx made a decision using one value and then the event handler sees another value. However I also understand the desire to avoid needing a latest of latest and things like that. It would be nice if some naming pattern made it natural to have one util for the reactive side and another for the imperative side so that this hidden latest through a prop getter wasn't an issue but maybe this async derived from latest is going to be used so little that this will never matter in practice anyway. |
CodSpeed flagged 13 benchmarks on #3337 (up to -13.9%). Root cause: every recompute called unflushedCursor() + promoteUnflushed(from) through the scheduler module, and promoteUnflushed truncated an empty list on each run (unflushedNodes.length = from is a runtime call, not inlined). - Move the unflushed list, markUnflushed and promoteUnflushed into core.ts so recompute compares the length locally and only calls promote when the run actually wrote something. - promoteUnflushed returns before truncating an empty list. - Plain (non-optimistic) nodes skip the hasActiveOverride probe. Measured (dev tier, update1to1): base 0.65 ms, #3337 head 0.87, fixed 0.71. diamond and update1to1000 back to parity; the residual on the one-write-one-memo path is the deferred subscriber walk A28 requires (one extra pass over written nodes per flush) and is the price of "writes visible at flush", not an implementation cost. Size: raw floor +27 B (22,252 -> 22,279); brotli scenarios move by up to +130 B from compression reordering of the moved block. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Core floor 22,737 -> 22,816 (+27 B hot-path fix, +52 B A28 for optimistic writes, both from the base branch); brotli caps ratcheted to the measured artifacts on six scenarios. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Core floor 22,737 -> 22,816 (+27 B hot-path fix, +52 B A28 for optimistic writes, both from the base branch); brotli caps ratcheted to the measured artifacts on six scenarios. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…bcc71 The affects() declaration walk composing the tick's optimistic writes (one argument) lands +44 B brotli on the store-heavy scenario here; 28.30 -> 28.35 KB, measured at 28344 B. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…test() answers Review on #3337 read "no held lane withholds" as a promise of publication atomicity for the lane. It is the opposite: latest() reads the flushed staged value whether or not a transaction still holds it from the committed view; the hold is about effects. Reworded. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
CodSpeed flagged 13 benchmarks on #3337 (up to -13.9%). Root cause: every recompute called unflushedCursor() + promoteUnflushed(from) through the scheduler module, and promoteUnflushed truncated an empty list on each run (unflushedNodes.length = from is a runtime call, not inlined). - Move the unflushed list, markUnflushed and promoteUnflushed into core.ts so recompute compares the length locally and only calls promote when the run actually wrote something. - promoteUnflushed returns before truncating an empty list. - Plain (non-optimistic) nodes skip the hasActiveOverride probe. Measured (dev tier, update1to1): base 0.65 ms, #3337 head 0.87, fixed 0.71. diamond and update1to1000 back to parity; the residual on the one-write-one-memo path is the deferred subscriber walk A28 requires (one extra pass over written nodes per flush) and is the price of "writes visible at flush", not an implementation cost. Size: raw floor +27 B (22,252 -> 22,279); brotli scenarios move by up to +130 B from compression reordering of the moved block. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
05bcc71 to
451f087
Compare
Core floor 22,737 -> 22,816 (+27 B hot-path fix, +52 B A28 for optimistic writes, both from the base branch); brotli caps ratcheted to the measured artifacts on six scenarios. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…bcc71 The affects() declaration walk composing the tick's optimistic writes (one argument) lands +44 B brotli on the store-heavy scenario here; 28.30 -> 28.35 KB, measured at 28344 B. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Core floor 22,816 -> 22,866 (+50 B, `next`'s #3350/#3351 via #3337), budget 22,950. Five brotli caps ratcheted with notes for the same bytes under the lane-authority seams (createStore 15.45, isPending/latest 10.98, simple app 11.32, hydrating+stores 28.50, CSR 14.15 KB). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
451f087 to
027fda2
Compare
…test() answers Review on #3337 read "no held lane withholds" as a promise of publication atomicity for the lane. It is the opposite: latest() reads the flushed staged value whether or not a transaction still holds it from the committed view; the hold is about effects. Reworded. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
CodSpeed flagged 13 benchmarks on #3337 (up to -13.9%). Root cause: every recompute called unflushedCursor() + promoteUnflushed(from) through the scheduler module, and promoteUnflushed truncated an empty list on each run (unflushedNodes.length = from is a runtime call, not inlined). - Move the unflushed list, markUnflushed and promoteUnflushed into core.ts so recompute compares the length locally and only calls promote when the run actually wrote something. - promoteUnflushed returns before truncating an empty list. - Plain (non-optimistic) nodes skip the hasActiveOverride probe. Measured (dev tier, update1to1): base 0.65 ms, #3337 head 0.87, fixed 0.71. diamond and update1to1000 back to parity; the residual on the one-write-one-memo path is the deferred subscriber walk A28 requires (one extra pass over written nodes per flush) and is the price of "writes visible at flush", not an implementation cost. Size: raw floor +27 B (22,252 -> 22,279); brotli scenarios move by up to +130 B from compression reordering of the moved block. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Core floor 22,737 -> 22,816 (+27 B hot-path fix, +52 B A28 for optimistic writes, both from the base branch); brotli caps ratcheted to the measured artifacts on six scenarios. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…bcc71 The affects() declaration walk composing the tick's optimistic writes (one argument) lands +44 B brotli on the store-heavy scenario here; 28.30 -> 28.35 KB, measured at 28344 B. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Core floor 22,816 -> 22,866 (+50 B, `next`'s #3350/#3351 via #3337), budget 22,950. Five brotli caps ratcheted with notes for the same bytes under the lane-authority seams (createStore 15.45, isPending/latest 10.98, simple app 11.32, hydrating+stores 28.50, CSR 14.15 KB). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
… override supersession with provenance (#3335 #3334 #3330 #3331) (#3370) Four lane-authority fixes plus their optimistic-store twins and a review re-rule, all pre-existing on next. #3347 re-based directly onto next (landing dispatch is eager, as next's is; the store twins carry the held-adoption born-holding case only — #3336's fold case stays with #3337). #3335 — merged lane hold is per node, not per transaction. laneHeld looked up a lane's pending nodes in its own transaction's _asyncReporters; lanes merge across transactions, so a merged reveal lost the other member's async. waitingTransition(node) finds the live transaction blocked on a node, whichever recorded it. #3334 — a reveal holds on the lane flight it discovers, regardless of stamp. read()'s pending branch showed a stale reader the committed value of a node pending in another transaction (the stamp is bookkeeping, not evidence the inputs are held); handleAsync's settle re-entry entered the lane owner's transaction instead of the waiter's. #3330 — a lane recompute compares against the slot it publishes (INV-11). An OPT-dirty recompute compared against a transaction-staged _pendingValue while publishing to _value and called an identical result unchanged, revealing the override without its derivation. laneReadsCommitted records a reader only when the commit changes what it read. #3331 — own-source arrival supersedes the override, with action provenance (A18). A differing arrival marks the node CONFIG_OVERRIDE_SUPERSEDED: tracked readers see the staged truth (_supersededRead), the lane cascade is demoted and re-derives as held transaction work, the override's downstream flight is inert; untracked reads and the applied frame keep the override to the commit. Equal arrivals confirm silently (the authoritative-observer wake lives in the same hook). The scheduler carries the running action's sequence (`origin`) through each slice and the landing's propagation; an answer from an older action holds silently instead of superseding. Store twins: a held adoption under a live transaction holds on optimistic families too and stages its nodes at the outermost setter exit (stageHeldAdoptions); a key first read under a held adoption is born holding (stageHeldKey); notifyOptimisticWrites judges against the view readers see; the authoritative landing on an override-covered node dispatches to the engine (_landOnOverride). heldFromStale records a reader served another transaction's committed value for that transaction's commit replay. A settle that reverts optimism re-derives its contested effects after the revert. A15 reveal corollary re-ruled: the pending-branch carve-out returns, gated on input visibility (CONFIG_INPUTS_PUBLISHED, a live lane, or an uninitialized node refuse it); recompute drops a stale _gatedSubs recording it is about to apply; a same-value re-prediction renews _overrideStamp. Floor 21,994 -> 22,457 (+463 B; budget 22,500); .size-limit.js caps re-measured against next @ 4935c7d. Spec A15/A17/A18 amendments and the 2026-09-09 re-ruling log; INTERNALS-ASYNC §1–§3/§5; INTERNALS-STORE §3. Closes #3335, closes #3334, closes #3330, closes #3331. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…shed staged world (A28) A write is unflushed between set() and the next flush(): not committed, not the staged value latest()/isPending() serve, and not an input to any recompute. latest(count) after setCount(30) answers the pre-write value until the flush that carries the write; after it, latest(count) and latest(doubled) agree in the same instant. One rule for every reader — handler, memo, prop getter — so a latest read wrapped in a memo answers the same as the bare read, and the visibility mismatch that motivated a separate readStaged does not arise. Mechanics: every write takes one path — stage, mark CONFIG_UNFLUSHED (stash the last-flushed staged value in _flushedStaged when rewriting a held node), schedule. Consumers promote: flush() at the start of each round and before every clock++, recompute at its tail for writes it issued. Promotion clears the mark, restores the flushed view, syncs companions, walks subscribers. Removed: the eager/deferred write heuristic, the #2922 mid-tick shadow pull in latestRead, and the _notifiedAt / notifyEpoch duplicate-walk dedupe. Core floor 22,247 B (budget 22,350 B; was 21,931 B) — a conscious bump for a write path with no special cases. Spec: A28 in SPEC-ASYNC-SEMANTICS.md. Migration note under latest(fn). Co-authored-by: Claude via Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
…; every store channel answers like read() (#3336) A `latest()` shadow or `isPending` companion created for a node whose write a live transaction already holds was backfilled through the ambient batch, so the backfill reverted at round end and the companion showed the committed value while its node was pending. `backfillCompanion` runs the backfill as the holding transaction's batch, where a companion that had existed at write time would have been placed. Stores: a key first read under a hold is born holding (`stageHeldKey` — committed value, the transaction's write staged), and the first tracked read serves the node's value rather than the backing. The backing-level visibility decision carries core read()'s committed clause (`heldFromReader`): while a live transaction holds the pending backing, a stale (render) reader and an owner-less reader see committed through every channel — untracked reads, `in`, `Object.keys`, `deep()`/`snapshot()`, the adoption-hold view — as the tracked read already did through the node. Non-stale owner-context readers keep speculation; a pending backing with no transaction keeps the snapshot peek. Pinned in tests/latest-held-till-flush.test.ts; SPEC A28 consequence (3), INTERNALS-ASYNC §4, INTERNALS-STORE invariants. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…28, #3336) Every scenario carries the core write-path change (markUnflushed / promoteUnflushed, unflushedView; the notify-epoch machinery it replaces is deleted) and the store scenarios the #3336 half (keys born holding, every store read channel answering like read()). Limits are the measured macOS artifacts plus ~20 B, rounded up to the next 0.01 kB, with per-scenario notes; in-package floor 21,936 -> 22,252. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…test() answers Review on #3337 read "no held lane withholds" as a promise of publication atomicity for the lane. It is the opposite: latest() reads the flushed staged value whether or not a transaction still holds it from the committed view; the hold is about effects. Reworded. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
CodSpeed flagged 13 benchmarks on #3337 (up to -13.9%). Root cause: every recompute called unflushedCursor() + promoteUnflushed(from) through the scheduler module, and promoteUnflushed truncated an empty list on each run (unflushedNodes.length = from is a runtime call, not inlined). - Move the unflushed list, markUnflushed and promoteUnflushed into core.ts so recompute compares the length locally and only calls promote when the run actually wrote something. - promoteUnflushed returns before truncating an empty list. - Plain (non-optimistic) nodes skip the hasActiveOverride probe. Measured (dev tier, update1to1): base 0.65 ms, #3337 head 0.87, fixed 0.71. diamond and update1to1000 back to parity; the residual on the one-write-one-memo path is the deferred subscriber walk A28 requires (one extra pass over written nodes per flush) and is the price of "writes visible at flush", not an implementation cost. Size: raw floor +27 B (22,252 -> 22,279); brotli scenarios move by up to +130 B from compression reordering of the moved block. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…rides) An optimistic write is a write: setOptimistic / an optimistic store setter parks the value in `_x._pendingOverride` and marks the node unflushed; the flush's promotion installs it as the active override (promoteOverride: install, sync companions, walk the lane). Until then plain reads, snapshot(), isPending() and the affects() declaration walk answer the flushed value — the same rule every other write follows, and the visibility React's useOptimistic gives. An ambient write (no action in flight) is shown by its flush to effects and reverted at the flush's end. Writer channels still compose on the tick's own writes: the functional updater reads the parked value, and store drafts read through `draftOverride` (parked write ahead of the flushed override) at seeding, in-draft reads, the draft length view and notifyOptimisticWrites' diff base — so two `count++` are +2, a push after a push lands in the next slot, and a toggle toggled back emits the cancelling write. Engine companions (the latest() shadow, the isPending() verdict signal — `_parentSource` set) are the system's own overrides written inside the flush and install eagerly. A record born in the same tick's optimistic write is declared on the draft (`affects(s.rows[i], key)` inside the setter) or after a yield; a same-tick `affects(state)` snapshots the pre-write view. Three affects tests and ~30 pre-flush reads re-expected; A28(5) in the spec, MIGRATION note, mined rules R2/R1/R34 marked superseded. Size: floor +52 B (22,279 -> 22,331); brotli caps ratcheted per scenario. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…ic writes The walk is a writer channel: tagging a parent covers the whole record as the writer sees it, the row this tick's optimistic push added included — as it already did for a plain store's pending backing. 50225d0 left the optimistic resolver on the reader view (active overrides only), so a same-tick `affects(state)` after a push missed the pushed row while the same shape on a plain store covered it; the test was re-expected to bless the asymmetry instead of catching it. Resolver now reads through `draftOverride` (parked write ahead of the flushed override). Only the slot form on a row born this tick names the draft (`affects(s.rows[2], key)` inside the setter): the row is not readable through the store until the flush. A28(5), MIGRATION, changeset and the mined rules corrected; the original "visible at declaration time" test restored, the yield inserted into the overlapping-marks test removed. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Core floor 22,331 -> 22,381 (+50 B, all `next`'s #3350/#3351; the branch's delta over `next` is 387 B), budget 22,450. Five brotli caps ratcheted with per-scenario notes for the same bytes landing under the A28 seams (createStore 15.15, isPending/latest 10.50, simple app 11.10, hydrating+stores 27.90, attribution 26.80 KB). heap-mark-incremental's mid-tick-pull test re-expected under A28: each row's first run answers the flushed value; the promotion lands the last write and every row's effect settles on it within the same flush. The test's concern (CHECK propagating past a node inserted into an already- marked heap) is still exercised — a stale tail would be n=0. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
`next`'s #3367/#3368 (narrow-store write floor, `$OWNER` stamp) under the A28 seams: createStore 15.15 -> 15.42 KB (15384 B), hydrating + stores 27.90 -> 28.15 KB (28112 B), hydrating 18.48 -> 18.52 KB (18484 B, layout). Core floor unchanged at 22,381. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…thority merged) Co-authored-by: Cursor <cursoragent@cursor.com>
027fda2 to
20f3143
Compare
Summary
A write is unflushed between
set()and the nextflush(): not the committed value, not the staged valuelatest()/isPending()serve, and not an input to any recompute.latest(count)aftersetCount(30)answers the pre-write value until the flush that carries the write; after it,latest(count)is 30 andlatest(doubled)is 60 in the same instant.This gives
latestone rule regardless of reader — event handler, memo, prop getter. Wrapping alatestread in a memo answers the same as the bare read, so the visibility mismatch GabbeV raised (which motivated a separatereadStaged) does not arise: 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.Ruled 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." Recorded as A28 in
SPEC-ASYNC-SEMANTICS.md.Mechanics
Every write takes one path: stage
_pendingValue, markCONFIG_UNFLUSHED(stashing the last-flushed staged value in_x._flushedStagedwhen rewriting a node a transition already holds), schedule. Consumers promote:flush()promotes at the start of each round and before everyclock++(fast path, stash path, completion path, and insidefinalizePureQueuebefore the heap runs) so writes issued by commit hooks and boundary sweeps land in the same round — otherwise the error-boundary retry loop (owner._time < clock) never converges.recomputepromotes at its tail for the writes it issued.Promotion clears the mark, restores the flushed view, syncs companions (
isPending/latestshadows), and walks subscribers. Reads of an unflushed node (read,readNodeFast,flushedStaged()in verdict) serve the flushed view.Removed, because the single path makes them redundant:
globalQueue._running || context._fn) — tried two variants, both left gaps (store projections, error-boundary lazy builds);latest()only works once on memos #2922 mid-tick shadow pull inlatestRead(2.0.0-beta.20:latest()only works once on memos #2922 is re-pinned under the new semantics:latest(memo)recomputes at each flush, not each write);_notifiedAt/notifyEpoch/reaskArmedduplicate-walk dedupe (§12d) — the unflushed mark is the dedupe.Behavior changes pinned in tests
latest(x)/isPending(x)pre-flush for an unflushed write: pre-write value /false(latest-held-till-flush.test.ts— 12 cases incl. held transitions, memo transparency, lazily created companions).createTrackedEffect"documented gap" (a same-pass write to a not-yet-read signal wasn't seen) is closed: the effect now re-runs next round.asyncWritesynced companions eagerly).#3336 — lazily created companions and store keys carry the hold (second commit)
A
latest()shadow orisPendingcompanion created for a node whose write a live transaction already holds was backfilled through the ambient batch, so the backfill reverted at round end and the companion showed the committed value while its node was pending.backfillCompanionruns the backfill as the holding transaction's batch, where a companion that had existed at write time would have been placed.Stores, same rule through every channel: a key first read under a hold is born holding (
stageHeldKey— committed value, the transaction's write staged), and the backing-level visibility decision carries coreread()'s committed clause (heldFromReader/foreignHold): while a live foreign transaction holds the pending backing, a stale (render) reader and an owner-less reader see committed through untracked reads,in,Object.keys,deep()/snapshot()and the adoption-hold view — as the tracked read already did through the node. A stale reader the holding transaction itself recomputes sees the staged world (core:activeTransition !== el._transition), so it composes its view — and itsdeep()subscriptions — from one world. Non-stale owner-context readers keep speculation; a pending backing with no transaction keeps the same-tick snapshot peek.Pinned in
latest-held-till-flush.test.ts(21 cases); SPEC A28 consequence (3), INTERNALS-ASYNC §4, INTERNALS-STORE invariants.CodSpeed regression — fixed, with a residual (
244efce3)CodSpeed flagged 13 benchmarks (up to −13.9%). Root cause was implementation, not the design:
recomputecalledunflushedCursor()+promoteUnflushed(from)through the scheduler module on every run, andpromoteUnflushedtruncated an empty list each time (length =is a runtime call, not inlined). Under the test transform's live-binding getters that is two cross-module calls per recompute. Fix: the list lives incore.tsso recompute compares lengths locally and calls promote only when the run wrote something; promote returns before truncating an empty list; plain nodes skip the override probe.Measured (dev tier,
update1to1): base 0.65 ms → head 0.87 → fixed 0.71.propagation:diamondandupdate1to1000back to parity; dbmon/deep-reconcile within noise. Residual:update1to1stays ~5–10% slower, and that part is the design — A28 adds one phase (write → mark+push → flush walks the list → seeds the heap), i.e. one extra pass over written nodes per flush. On the one-write-one-memo bench that constant reads as a percentage; anywhere the flush does real work it amortizes out. It is the price of "writes visible at flush", stated here as a trade rather than a regression.Optimistic writes are writes — A28(5) (
50225d04,05bcc711)Ruled 2026-09-10: "My gut is to match. I'm gathering React does." — React's
useOptimisticshows the optimistic value on the next render, never synchronously.setOptimistic(x)/ an optimistic store setter now parks the value (_x._pendingOverride) and marks the node unflushed; the flush's promotion installs it as the active override (promoteOverride). Until then plain reads,snapshot()andisPending()answer the flushed value. An ambient write (no action in flight) is shown by its flush to effects ([1, 2, 1]) and reverted at its end; one an action holds stays readable after the flush.Writer channels still compose on the tick's own writes: the functional updater reads the parked value, and store drafts read through
draftOverride(parked write ahead of the flushed override) at seeding, in-draft reads, the draft length view andnotifyOptimisticWrites' diff base — so twocount++are +2, a push after a push lands in the next slot, and a toggle toggled back emits the cancelling write (without this the second toggle diffed against the flushed view and emitted nothing, leaving the first parked — a real bug, not a re-expectation). Engine companions (_parentSourceset) are the system's own overrides written inside the flush and install eagerly.The writer channels compose on the tick's own parked writes: the functional updater, a store setter's draft (two
count++are +2; a push after a push lands in the next slot; a toggle toggled back cancels), and theaffects()declaration walk — tagging a parent covers the whole record as the writer sees it, the row this tick pushed included (05bcc711;50225d04had left the walk on the reader view, which made a same-tickaffects(state)miss the pushed row while the same shape on a plain store covered it). Only the slot form on a row born this tick names the draft (affects(s.rows[i], key)inside the setter) —state.rows[i]is not readable before the flush. Pinned inquestion-scoped-pending.test.ts; MIGRATION note added; mined rules R2/R1/R34 marked superseded.31 tests re-expected across 6 files (pre-flush reads of the optimistic value; the 3.6 slot-form declaration moved onto the draft).
Size
Core floor 22,866 B on
next@ b5bd6fb (nextitself is 22,457 after #3370; budget 22,900 B; +409 B overnext). A28: +316 B, the write path itself, part paid by the §12d removal. Hot-path fix: +27 B. A28(5): +52 B — the_pendingOverrideslot initializer, the promote arm, the hook slot; the install rides the optimistic module. The remaining ~14 B is how the lane-authority dispatch minifies inpromoteUnflushed's override arm versus inline inasyncWrite. Brotli caps ratcheted per scenario with notes; this branch's own cost overnext(= over #3370) per scenario: core floor +155 B, createStore +383 (A28 + #3336's store half), isPending/latest +270 (the optimistic module), simple app +140, hydrating +176 / +427 with every store family, CSR +141, observe +216, attribution +146.Verification
Rebased on
next@ b5bd6fb (2026-09-11, after #3370 merged). Source after the rebase is identical tofix/lane-authority@ fae8b76 (the lane fixes as originally developed on top of this branch) apart from the order of two adjacent function declarations —git diff fae8b766 HEAD -- packages/signals/srcis a pure move — and the floor measures the same 22,866 B fae8b76 did. Head20f31433. signals 1767 passed / 1 skipped · solid 595 · web 734 ·tsc -p tsconfig.build.jsonclean · fullpnpm build·size-limitgreen.next'sheap-mark-incrementalmid-tick-pull test remains re-expected under A28 ([n=0 ×3, n=26 ×3]: each row's first run answers the flushed value, the promotion lands the last write within the same flush). This is the one observable place the deferred mechanism differs from an eager one (an eager walk gives[22, 24, 26, 26, 26]— each effect sees the write before it within one flush); the A28 reading is that a run does not derive from its own unflushed write. Worth pinning as an explicit A28 consequence in the spec rather than inheriting it from the mechanism.Not in this PR
#3330, #3331, #3333, #3334, #3335 all reproduce identically on
nextand on this branch — they are lane/optimistic-layer bugs about which record is authoritative (hold-is-per-async-node, compare-against-published-view, arrival-supersedes-override), not write-path bugs. They were #3347 (stacked on this branch), now re-based directly ontonextas #3370 so they ship in this rc while this PR is considered longer.Rebase onto #3370 — done (2026-09-11)
#3370 merged as
next@ b5bd6fb; this branch was rebased onto it. The conflicts resolved as predicted:asyncWrite's pending-node branch keeps the A28 deferral (markUnflushed+schedule()) andpromoteUnflushed's override arm dispatches_supersedeOverride(the A18 supersession decision lands at promotion, still under the flight's provenance);recompute's override branches take #3370's;optimistic.tsimports bothattrHooksandmarkUnflushed;getNode's born-holding block re-composes #3370's held-adoption case with #3336's held-fold case through onestageHeldKey(node, nv, txn); spec, treeshake note and size caps re-measured on the merged base.Alternative mechanism considered and rejected
A spike (
spike/a28-eager-walk, local) tried keepingnext's eager subscriber walk and getting A28's read-side semantics by marking late linkers at read plus a_writeClockstamp for the repeat-write skip. Clean negative: Tier-1 propagation benches −20% (the per-write repeat-path check is four to five loads where the deferred list's is one bit test), core floor +616 B over this branch, and the unflushed list survives anyway for overrides and companions. The deferred list is the cheaper A28 implementation; this PR's mechanism is unchanged.Co-authored-by: Claude via Cursor