feat(signals): A28 — a write becomes visible at flush, to every channel (read-side) - #3473
Conversation
…el (read-side) 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>
🦋 Changeset detectedLatest commit: 749de32 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 |
Coverage Report for CI Build 35016221428Coverage remained the same at 71.619%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will improve performance by 8.04%
Performance Changes
Tip Curious why performance improved? Comment Comparing |
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # scripts/size/.size-limit.js
|
Rule
A28. Between
set(x)and the flush that carries it, the write is unflushed: not the committed value, not the staged valuelatest()/isPending()serve, and not an input to a derivation created meanwhile.latest(x)answers the flushed staged world;isPending(x)is false;until()'s predicate evaluated in the carrying flush sees it. Optimistic writes match (A28 (5), "match React"):setOptimistic(v)becomes the active override at the carrying flush — plain reads,snapshot(),in, keys,length,isPending()see nothing before — while the writer's own channels (functional updater, store draft,affects()declaration walk) 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).Supersedes the #2922 mid-tick
latest()pull (flush()first to read your own write). Supersedes #3337's mechanism.Mechanism — read-side, plain write path untouched
unflushedValue, core.ts): outside a flush, a node with an ambient staged value was written since the last flush — ambient staging commits at flush end, so nothing else leaves a node in that state. Exempt: engine companions (_parentSource), writes inside a creation-time recompute (CONFIG_PROMOTED).unflushedStaged): set when a node is staged (queuePendingNode) or a held node rewritten outside a flush, cleared at flush start. Inside a flush it's one boolean that short-circuits.stashHeldRewrite; a write withcontext !== null→notePromotedWrite; companion re-sync registration inside the branch only companion nodes take.setSignalstays inlinable (~360 B bytecode; the arms inline were 443 B and cost 10–20% on the write-loop benches — see below).REACTIVE_MISSED_WAKE) so the carrying flush re-runs them.resyncUnflushedCompanions) soisPending()/latest()mirror the flushed world; a companion created lazily against an unflushed source joins that re-sync (joinUnflushedResync) — brought current by the optimistic write, so a derivation overlatest()direct-commits as the optimistic view it is instead of being staged under whatever hold the round entered (a memo, and an effect, overlatest()of a held source created mid-tick answered the previous value / never ran until the fetch settled).visibleOverride,heldFoldTransition/foreignHold/heldFromReaderfor born-holding keys;optimisticView(draft)distinguishes writer from reader channels.Performance
tests/core/reactivity.bench.ts, alternating pairs on a quiet machine, medians (the benches areflush(() => { N writes }), so the write loop is the benchmark):nextupdate1to1update1to1000propagation:diamondpropagation:avoidableAn earlier shape of this branch measured −13…−19% on diamond/avoidable:
setSignal's bytecode had grown 303 → 443 B (TurboFan inline limit 460, cumulative 920) and the A28 arms ran on every outside-flush write. Both fixed as above.Size
Core floor 23,750 → 24,478 minified (+728 B; conscious bump, notes in
treeshake.test.tsand.size-limit.js). Brotli: +179…+339 B across the nine scenarios.Tests
latest-held-till-flush.test.ts(A28 (1)–(4), held and ambient writes, companions created after the write, 2.0.0-rc.7 spooky action at a distance because of lazy latest computed creation #3336 born-holding; the memo/effect-over-latest()cases above),born-held.test.ts,body-end-supersession-visibility.test.ts,superseded-before-first-commit.test.ts.latest-repeated-writes(2.0.0-beta.20:latest()only works once on memos #2922),latest-unobserved-memo,latest-probe-order-independence,isPending-memo-consistency,latest-plain-write-purity,projection-transition-isolation,createOptimisticStore,snapshot-derived-store-rows,question-scoped-pending,optimistic-store-layer-scope,shallow.latest()throws in every uninitialized scope). OL-R2 / OS-R1 / CS-R34 superseded. Rules index regenerated.signals 2,070 / solid 621 / web 804 green on the rebase over #3464–#3471.