Skip to content

fix(signals): writes become visible at flush — latest() reads the flushed staged world (A28) - #3337

Open
ryansolid wants to merge 10 commits into
nextfrom
latest-held-till-flush
Open

fix(signals): writes become visible at flush — latest() reads the flushed staged world (A28)#3337
ryansolid wants to merge 10 commits into
nextfrom
latest-held-till-flush

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 10, 2026

Copy link
Copy Markdown
Member

Summary

A write is unflushed between set() and the next flush(): not the committed value, 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) is 30 and latest(doubled) is 60 in the same instant.

This gives latest one rule regardless of reader — event handler, memo, prop getter. Wrapping a latest read in a memo answers the same as the bare read, so the visibility mismatch GabbeV raised (which motivated a separate readStaged) 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, mark CONFIG_UNFLUSHED (stashing the last-flushed staged value in _x._flushedStaged when rewriting a node a transition already holds), schedule. Consumers promote:

  • flush() promotes at the start of each round and before every clock++ (fast path, stash path, completion path, and inside finalizePureQueue before 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.
  • recompute promotes at its tail for the writes it issued.

Promotion clears the mark, restores the flushed view, syncs companions (isPending/latest shadows), 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:

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.
  • Async memo landings and store landings go through the same mark + promotion (were asymmetric: asyncWrite synced companions eagerly).

#3336 — lazily created companions and store keys carry the hold (second commit)

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, 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 core read()'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 its deep() 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: recompute called unflushedCursor() + promoteUnflushed(from) through the scheduler module on every run, and promoteUnflushed truncated 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 in core.ts so 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:diamond and update1to1000 back to parity; dbmon/deep-reconcile within noise. Residual: update1to1 stays ~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 useOptimistic shows 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() and isPending() 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 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 (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 (_parentSource set) 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 the affects() declaration walk — tagging a parent covers the whole record as the writer sees it, the row this tick pushed included (05bcc711; 50225d04 had left the walk on the reader view, which made a same-tick affects(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 in question-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 (next itself is 22,457 after #3370; budget 22,900 B; +409 B over next). A28: +316 B, the write path itself, part paid by the §12d removal. Hot-path fix: +27 B. A28(5): +52 B — the _pendingOverride slot initializer, the promote arm, the hook slot; the install rides the optimistic module. The remaining ~14 B is how the lane-authority dispatch minifies in promoteUnflushed's override arm versus inline in asyncWrite. Brotli caps ratcheted per scenario with notes; this branch's own cost over next (= 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 to fix/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/src is a pure move — and the floor measures the same 22,866 B fae8b76 did. Head 20f31433. signals 1767 passed / 1 skipped · solid 595 · web 734 · tsc -p tsconfig.build.json clean · full pnpm build · size-limit green.

next's heap-mark-incremental mid-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 next and 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 onto next as #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()) and promoteUnflushed'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.ts imports both attrHooks and markUnflushed; getNode's born-holding block re-composes #3370's held-adoption case with #3336's held-fold case through one stageHeldKey(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 keeping next's eager subscriber walk and getting A28's read-side semantics by marking late linkers at read plus a _writeClock stamp 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

@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 20f3143

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch

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

@GabbeV

GabbeV commented Sep 10, 2026

Copy link
Copy Markdown

AI review based on the context where i discovered the issues:

The pre-flush change addresses the immediate-write visibility mismatch described in this PR. Testing 0f4d5f7b leaves a question about the boundary between “flushed staged world” and A28’s phrase “no held lane withholds.”

Consider this graph, with everything created inside a root:

const [input, setInput] = createSignal(0);
const value = () => latest(input);
const identity = createMemo(value);

const requests = [];
const details = createMemo(() => {
  const n = identity();
  return new Promise(resolve => {
    requests.push(() => resolve(n));
  });
});

let shownValue;
let shownDetails;

createRenderEffect(identity, n => {
  shownValue = n;
});
createRenderEffect(details, n => {
  shownDetails = n;
});

const update = action(function* () {
  setInput(1);
  yield new Promise(() => {}); // Keep the parent action open.
});

After resolving the initial request and letting everything settle:

update();

// Before flush:
value();       // 0
identity();    // 0
shownValue;    // 0
shownDetails;  // 0

flush();

// The request for details(1) has started but remains unresolved:
value();       // 1
identity();    // 1
shownValue;    // 0
shownDetails;  // 0

// Resolve details(1), then let the runtime settle:
// All four become 1, while the parent action remains open.

The getter and synchronous memo agree, so this does not contradict the synchronous memo-transparency case demonstrated by the PR. However, outside reads advance before the lane’s effects publish.

Is that intentional under “flushed staged world,” or should the observed downstream async also hold these reads? The PR’s acknowledgement of post-flush tearing suggests this is intentional, whereas “no held lane withholds” sounds like it promises publication atomicity for the lane.

There’s a separate scope question around “every write.” Replacing the latest-based input with an explicit optimistic override:

const [value, setValue] = createOptimistic(input);
// Same identity memo and observed async details.

const update = action(function* () {
  setInput(1);
  setValue(1);
  yield new Promise(() => {});
});

update();

// Before flush:
value();     // 1
identity();  // 0
shownValue;  // 0

That remains observable on this PR. Is optimistic-write timing deliberately excluded from A28 for now?

The history-dependent Show repro in #3336 also still reproduces on this head. Since #3347 explicitly identifies that as follow-up work, that looks like an acknowledged omission rather than a new finding.

@ryansolid
ryansolid force-pushed the latest-held-till-flush branch from 0f4d5f7 to ba538c2 Compare September 10, 2026 19:57
@coveralls

coveralls commented Sep 10, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34587680444

Warning

No base build found for commit b5bd6fb on next.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 71.842%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 1007
Covered Lines: 772
Line Coverage: 76.66%
Relevant Branches: 790
Covered Branches: 519
Branch Coverage: 65.7%
Branches in Coverage %: Yes
Coverage Strength: 15.01 hits per line

💛 - Coveralls

@codspeed-hq

codspeed-hq Bot commented Sep 10, 2026

Copy link
Copy Markdown

Merging this PR will regress 3 benchmarks

⚡ 3 improved benchmarks
❌ 3 regressed benchmarks
✅ 154 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
input burst: 200 single-key writes, 1 subscriber 4.6 ms 5 ms -7.63%
selection map: toggle 2 of 1000 subscribed keys 4.4 ms 4.7 ms -6.96%
updateSignals:update1to1 62 ms 65.8 ms -5.68%
projection derive: write one NESTED field (reference) 807.9 µs 211.7 µs ×3.8
propagation:avoidable 1.7 ms 1.6 ms +6.05%
propagation:diamond 1.7 ms 1.6 ms +5.99%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing latest-held-till-flush (20f3143) with next (b5bd6fb)

Open in CodSpeed

@ryansolid
ryansolid force-pushed the latest-held-till-flush branch from ba538c2 to b722898 Compare September 10, 2026 20:42
ryansolid added a commit that referenced this pull request Sep 10, 2026
…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>
@ryansolid

Copy link
Copy Markdown
Member Author

Thanks — three good questions, taking them in order.

1. Post-flush reads advance before the lane's effects publish — intentional. A28 draws exactly one line: the flush. Before it, nothing shows the write (no channel can show downstream of an unflushed write). After it, latest() reads the flushed staged value whether or not a transaction still holds that value from the committed view. The hold governs what effects publish (the frame stays coherent: shownValue/shownDetails move together when details(1) lands); latest is the channel that opts into reading ahead of the frame, and the ruling was that it does so only once a flush has carried the write. So value() === identity() === 1 while shownValue === 0 is the shape A28 describes, not a leak.

You're right that "no held lane withholds" reads like a publication-atomicity promise for the lane — it was meant to say the opposite (a hold doesn't withhold from latest). Reworded in 2e7b986: "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)".

2. Optimistic-write timing — excluded from A28 today, and that is an honest gap rather than a ruling. A28 took the staged write path: stage + mark unflushed + schedule, promoted at flush. setValue(1) on a createOptimistic is not a staging — it installs the override directly onto the node (A17), which is why value() answers 1 before the flush while identity() is still 0. That is the same tear A28 exists to rule out for plain writes, so the question of whether overrides should be held to the same flush boundary is real. I'd rather not rule it from inside this PR; flagging it for Ryan as a follow-up so it gets a deliberate verdict (and a spec line either way).

3. #3336 Show repro — fixed in the second commit on this branch (b722898: a store key first read under a hold is born with the committed value and the held write staged as the holding transaction's, so it reverts with the hold rather than at the reader's flush end). It reproduced on the head you tested because that head predated it.

Claude via Cursor

@GabbeV

GabbeV commented Sep 10, 2026

Copy link
Copy Markdown

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.

ryansolid added a commit that referenced this pull request Sep 11, 2026
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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
…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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
…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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
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>
@ryansolid
ryansolid force-pushed the latest-held-till-flush branch from 05bcc71 to 451f087 Compare September 11, 2026 07:19
ryansolid added a commit that referenced this pull request Sep 11, 2026
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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
…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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
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>
@ryansolid
ryansolid force-pushed the latest-held-till-flush branch from 451f087 to 027fda2 Compare September 11, 2026 08:05
ryansolid added a commit that referenced this pull request Sep 11, 2026
…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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
…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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
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>
ryansolid added a commit that referenced this pull request Sep 11, 2026
`next`'s #3367/#3368 store bytes (via #3337) under the store twins:
createStore 15.45 -> 15.72 KB (15678 B), hydrating + stores 28.50 ->
28.66 KB (28620 B). Core floor unchanged at 22,866.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Sep 11, 2026
… 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>
ryansolid and others added 10 commits September 11, 2026 02:59
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants