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>
…thority merged) Co-authored-by: Cursor <cursoragent@cursor.com>
027fda2 to
20f3143
Compare
…pe; rule the oracle's open cells latestRead's NotReady fallback served the visible value when the caller was unowned OR the source was initialized; an uninitialized source read from an event handler therefore returned undefined — a value latest<T>'s type excludes. The condition is now the initialized case only. isPending is unchanged (an unowned probe answers false, which boolean admits). Rulings recorded in SPEC-ASYNC-SEMANTICS.md (2026-09-14, from the visibility oracle's first run): - A7 amended: before the first landing latest() throws, never undefined. - A16 wording corrected: the boundary is ownership (context === null), not tracking — untrack() inside an owner propagates NotReady. - A17 authoritative-reader carve-out: until()'s predicate reads the landed world (staged values included, before they are visible), never the caller's optimism. - A32 (new): children-forbidden readers (createTrackedEffect, onSettled) see the frame — committed values and a displayed override — never a held write; they cannot enter a hold on their own. - Observer dependence of a fresh derivation over a pending node recorded as inherent (the frame is identical either way). Oracle: 18 unspecified cells → 10, of which 7 are pre-flush cells parked on A28 (#3337). Three violation cells stand. Co-authored-by: Cursor <cursoragent@cursor.com>
… of an uninitialized source throws; A7/A16/A17/A32 rulings (#3449) * test(signals): rules index must be current — --check compares the committed index with a regeneration The gate verified citations and pins but not that docs/RULES-INDEX.md itself was regenerated after the docs changed; it could go stale silently. --check now regenerates in memory and compares with the committed file, normalized for what prettier does on commit (cell padding, escaped underscores, blank lines around headings, dash runs). Co-authored-by: Cursor <cursoragent@cursor.com> * test(signals): visibility oracle — node state × reader kind → what is served Ten node states (committed, staged ambient, held by an action, override active, override ambient, superseded with an initialized / an uninitialized downstream, pending own async, uninitialized, loading window) × nine reader kinds (untracked, derives-from, published, pre-existing, stale foreign, children-forbidden, latest, isPending, authoritative). 90 cells. Each expected value cites the rule that fixes it; cells the spec does not fix are pinned as `observed` and listed by the report (VISIBILITY_ORACLE_REPORT=<file>); cells where the runtime disagrees with the rule are pinned at current behavior as `violation` so the suite stays green and the disagreement stays visible — fixing the runtime fails the cell, which is then flipped to `rule`. Harness lessons recorded in the file: every open-ended promise is released between cells (a never-settling action left its transaction live across the next cell); pre-flush states are built synchronously (an async build crosses the scheduler's microtask flush); the stale reader lets NotReady propagate so a suspended pass reads HELD. Three violations (A18 c/d, A29) and eighteen unspecified cells in the first run — reported separately. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(signals): latest() of an uninitialized source throws in every scope; rule the oracle's open cells latestRead's NotReady fallback served the visible value when the caller was unowned OR the source was initialized; an uninitialized source read from an event handler therefore returned undefined — a value latest<T>'s type excludes. The condition is now the initialized case only. isPending is unchanged (an unowned probe answers false, which boolean admits). Rulings recorded in SPEC-ASYNC-SEMANTICS.md (2026-09-14, from the visibility oracle's first run): - A7 amended: before the first landing latest() throws, never undefined. - A16 wording corrected: the boundary is ownership (context === null), not tracking — untrack() inside an owner propagates NotReady. - A17 authoritative-reader carve-out: until()'s predicate reads the landed world (staged values included, before they are visible), never the caller's optimism. - A32 (new): children-forbidden readers (createTrackedEffect, onSettled) see the frame — committed values and a displayed override — never a held write; they cannot enter a hold on their own. - Observer dependence of a fresh derivation over a pending node recorded as inherent (the frame is identical either way). Oracle: 18 unspecified cells → 10, of which 7 are pre-flush cells parked on A28 (#3337). Three violation cells stand. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
…eak and body-end visibility Oracle grows from 90 to 207 cells. The harness (cell vocabulary, readers, hold management, runner) moves to tests/visibility-oracle.harness.ts, shared by the signal oracle (now 14 states: + body ended, un-superseded, held truth, loading window over a held input) and a new store oracle (8 states through plain stores, optimistic stores and projections). Two runtime fixes the new cells found: - latest(() => store.key) on an unresolved projection returned the SEED. read() routes a latest() read to the companion before its firewall logic, and the leaf's own _value is the seed; latestRead now judges "uninitialized" on the leaf's owner (the firewall) and throws like every other read (A25, A7). - The body-end supersession window (#3427) read differently from a landing supersession: a stale re-run showed the committed truth beside a display still showing the override, a fresh mainline memo published it, isPending read false while latest read the truth. One cause: an override written inside an action never passes the adoption loop that stamps _transition, and body-end stages nothing that would queue it, so the node carried no stamp. Ownership for an override node is _overrideOwner (#2912); supersededRead and the verdict resolve it now. Pinning that exposed a fourth: a latest() pull through supersededRead fell to enterStagedRead's initTransition path and captured the caller's block — verdict pulls are observations and never enter from mainline. Rulings recorded: A27 (verdict-quiet is a hydration invariant; the window's first real landing is initial-load class even over a held input), A18 body-end visibility, A29 verdict-pull note, A7 owner-judged. Also a type fix in born-held (bornHeld narrowed to never). Signals 2005 / solid 618 / web 783 green; brotli caps pass (core floor 8806 of 8850). Oracle: 207 cells, zero violations, 15 observed (12 on A28/#3337). Co-authored-by: Cursor <cursoragent@cursor.com>
…and body-end visibility fixes (#3455) * test(signals): store oracle + four signal states; fix latest() seed leak and body-end visibility Oracle grows from 90 to 207 cells. The harness (cell vocabulary, readers, hold management, runner) moves to tests/visibility-oracle.harness.ts, shared by the signal oracle (now 14 states: + body ended, un-superseded, held truth, loading window over a held input) and a new store oracle (8 states through plain stores, optimistic stores and projections). Two runtime fixes the new cells found: - latest(() => store.key) on an unresolved projection returned the SEED. read() routes a latest() read to the companion before its firewall logic, and the leaf's own _value is the seed; latestRead now judges "uninitialized" on the leaf's owner (the firewall) and throws like every other read (A25, A7). - The body-end supersession window (#3427) read differently from a landing supersession: a stale re-run showed the committed truth beside a display still showing the override, a fresh mainline memo published it, isPending read false while latest read the truth. One cause: an override written inside an action never passes the adoption loop that stamps _transition, and body-end stages nothing that would queue it, so the node carried no stamp. Ownership for an override node is _overrideOwner (#2912); supersededRead and the verdict resolve it now. Pinning that exposed a fourth: a latest() pull through supersededRead fell to enterStagedRead's initTransition path and captured the caller's block — verdict pulls are observations and never enter from mainline. Rulings recorded: A27 (verdict-quiet is a hydration invariant; the window's first real landing is initial-load class even over a held input), A18 body-end visibility, A29 verdict-pull note, A7 owner-judged. Also a type fix in born-held (bornHeld narrowed to never). Signals 2005 / solid 618 / web 783 green; brotli caps pass (core floor 8806 of 8850). Oracle: 207 cells, zero violations, 15 observed (12 on A28/#3337). Co-authored-by: Cursor <cursoragent@cursor.com> * chore(size): isPending/latest cap 11.25 -> 11.30 KB for the body-end visibility fixes (#3455) Measured at 11267 B against next's 11213 (+54): supersededRead's owner resolution and committed-truth entry, the verdict's body-end A18 (d) branch, and uninitializedSource's owner walk — all in the verdict/ optimistic modules this scenario retains. Core floor and +createStore stay in cap. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude via Cursor <noreply@cursor.com>
…el (read-side) (#3473) Between set(x) and the flush that carries it the write is unflushed: not the committed value, not the staged value latest()/isPending() serve, and not an input to a derivation created meanwhile. Optimistic writes match (A28 (5)): setOptimistic(v) becomes the active override at the carrying flush; the writer's own channels compose on it. A rewrite of a held node keeps the staged value the last flush left for the verdict channels 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), 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), and the write-path arms are cold helpers gated on loads the write already pays (`_transition`, `context`) — setSignal stays within every setter's inlining budget (~360 B bytecode; the arms inline cost 140 B and 10–20% on the write-loop benches). Readers served the flushed value are latched for the carrying flush (REACTIVE_MISSED_WAKE). A companion created lazily while its source carries an unflushed write joins the flush-start re-sync like one that existed at the write, so a derivation over latest() direct-commits as the optimistic view it is rather than being staged under whatever hold the round entered (a memo — and an effect — over latest() of a held source created mid-tick answered the previous value, or never ran, until the fetch settled). Supersedes the #2922 mid-tick latest() pull (flush() first to read your own write); OL-R2 / OS-R1 / CS-R34 superseded. Spec: A28 ruled + mechanism, A29 creation-time form, A7 amendment (latest() throws in every uninitialized scope). Oracles: every pre-flush cell reads the rule; no violation cells remain. Size: core floor 23,752 → 24,480 (conscious bump, notes in treeshake.test.ts and .size-limit.js). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
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