Skip to content

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

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

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

codspeed 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>
…thority merged)

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the latest-held-till-flush branch from 027fda2 to 20f3143 Compare September 11, 2026 10:07
ryansolid added a commit that referenced this pull request Sep 15, 2026
…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>
ryansolid added a commit that referenced this pull request Sep 15, 2026
… 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>
ryansolid added a commit that referenced this pull request Sep 15, 2026
…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>
ryansolid added a commit that referenced this pull request Sep 15, 2026
…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>
ryansolid added a commit that referenced this pull request Sep 15, 2026
…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>
@ryansolid

Copy link
Copy Markdown
Member Author

Superseded by #3473, merged to next as 8ff48032f.

Same rule (A28: a write becomes visible at flush, to every channel), different mechanism — #3473 lands it read-side (unflushedStaged on the read fast paths, cold helpers on the write path, companions re-synced at flush start, late linkers latched via REACTIVE_MISSED_WAKE) instead of this PR's deferred dirty walk, and folds in the #3336 born-holding backfill. Both visibility oracles now read the rule in every pre-flush cell.

Claude via Cursor

@ryansolid ryansolid closed this Sep 15, 2026
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