Skip to content

fix(signals): lane authority — merged hold, reveal-on-flight, INV-11, override supersession with provenance (#3335 #3334 #3330 #3331) - #3347

Closed
ryansolid wants to merge 10 commits into
latest-held-till-flushfrom
fix/lane-authority
Closed

fix(signals): lane authority — merged hold, reveal-on-flight, INV-11, override supersession with provenance (#3335 #3334 #3330 #3331)#3347
ryansolid wants to merge 10 commits into
latest-held-till-flushfrom
fix/lane-authority

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 10, 2026

Copy link
Copy Markdown
Member

Four lane-authority fixes in @solidjs/signals, one commit per issue, each with its changeset, plus a fifth commit that pins every rule against its optimistic-store twin and a sixth from review (the A15 reveal carve-out re-ruled, a provenance gap). All pre-existing on next. Stacked on #3337 (latest-held-till-flush): the A28 write path and the #3336 store rules are the base these compose with; the base retargets to next when #3337 merges.

#3335 — merged lane hold is per node, not per transaction

laneHeld looked up a lane's _pendingAsync nodes in its own transaction's _asyncReporters. Lanes merge across transactions (#2912), so after a merge the root's transaction knew only one member's observations and the other member's async no longer held the merged reveal. New waitingTransition(node) finds the live transaction blocked on a node, whichever recorded it; laneHeld asks per node.

#3334 — a reveal holds on the lane flight it discovers, regardless of stamp

Two halves of one tear: read()'s pending branch carved out nodes pending in another transaction for stale readers and showed their committed value (the stamp is bookkeeping, not evidence the inputs are held — they may already be lane-revealed); and handleAsync's settle re-entry entered the lane owner's transaction, folding the waiting reveal into the owner's still-running action. The reader now throws (A15); the landing enters the waiter.

#3330 — a lane recompute compares against the slot it publishes (INV-11)

A lane (OPT-dirty) recompute compared against a transaction-staged _pendingValue while publishing to _value, called an identical result "unchanged", and revealed the override without its derivation (v=1 d=0). The gate now compares against the slot the run publishes to. Found alongside: laneReadsCommitted replayed lane readers at commit even when the staged value already equalled the committed one — a duplicate frame; recorded only when the commit changes what was read.

#3331 — own-source arrival supersedes the override, with action provenance

Per the ruling ("a new value from the source should remove the optimism immediately… folded into the parent transition"): a differing arrival marks the node superseded — tracked readers see the staged truth, the lane cascade is demoted and re-derives as held transaction work (downstream async restarts now, no waterfall), the override's own downstream flight is inert — while untracked reads and the applied frame keep the override until commit. Equal arrivals confirm silently. Scope covers the sync-wrapper shape (createOptimistic(() => asyncMemo())) — "if the source recomputes it doesn't matter if it is async or not" — with a same-batch ordering guard.

Provenance. Two rapid actions on one node merge into one transaction, and the older action's refetch can land after the newer override. That answer must not leak back in ("getting it not to flicker was super important"). _overrideOwner is post-merge and can't tell the actions apart, so the scheduler carries the running action's invocation sequence (origin) through each action slice and the flush that ends its window; every flight captures it at registration and its landing propagates under it. The override stamps it; a differing arrival from an older action holds silently to the commit. Mainline (no action) is always current. The original no-double-flicker pin is unchanged and green; the "second action while first still in flight" pin had its second resolver repaired (it used to orphan action 1, so its final assertions passed against a transaction that never closed) and is expected to the same rule.

Store twins (fifth commit)

Every rule above is pinned against the equivalent optimistic-store shape (tests/store/lane-authority-twins.test.ts). #3335 and #3334 already held; #3330 and #3331 did not:

  • 2.0.0-rc.7 Optimitstic set after short delay in action doesn't propagate through memo #3330 twin. An adoption under a live transaction now holds on optimistic families too — a sync derive adopting truth under the action's transaction is held truth, not lane business. Unheld, handlers read the swapped-in backing early, latest() inverted, and the optimistic write compared equal to it and wrote no override; notifyOptimisticWrites now judges against the view readers see. A held adoption stages its nodes under the transaction at the outermost setter exit (stageHeldAdoptions) so the commit promotes silently instead of re-running every subscriber against a frame the lane already published. Plain-store reconcile inside an action holds the same way; a key first read under a held adoption is born holding (one stageHeldKey shared with 2.0.0-rc.7 spooky action at a distance because of lazy latest computed creation #3336's fold case).
  • 2.0.0-rc.7 async downstream of optimistic delays reveal of final result #3331 twin. The authoritative landing on an override-covered store node (setSignal under projectionWriteActive) dispatches to the engine (_landOnOverridesupersedeOverride) instead of staging silently; supersededRead serves committed truth once a mainline landing has committed ahead of the revert.
  • Core, surfaced by the twin and reproduced with a plain signal: a reader that first links to a node while another transaction holds a staged write read committed and never learned of the commit (commits are silent). heldFromStale records such readers for the transaction's commit replay, skipping effects the transaction itself computed.
  • Composition with A28 (rebase): the async landing's supersession decision runs at the write's promotion (promoteUnflushed's override arm hands the node to supersedeOverride, still under the flight's provenance). A settle that reverts optimism re-derives its contested effects (2.0.0-rc.7 render effect depending on non async values from two concurrent updates commit with the wrong data #3322) after the revert, with the gated replay — ahead of the heap run the truth is committed but the overrides still display, and the re-derive composed the two (the 2.0.0-rc until doesnt entangle the update resolving it with the action update #3164 tear; surfaced by deep() over an optimistic store).

Review follow-ups (sixth commit)

From GabbeV's review on this PR:

Docs / bytes

SPEC A15 lanes corollary, A17 amendment, A18 supersession + scope/ordering/provenance, 2026-09-09 re-ruling log; INTERNALS-ASYNC §1–§3, §5 INV-11, late readers, contested post-revert; INTERNALS-STORE §3 adoption under a live transaction. Core floor: 22,866 / 22,950 (22,737 before the rebase onto #3337's hot-path fix and A28-for-optimistic commits, +79 B from the base) — a conscious bump over #3337's 22,331 (+~160 B lane-authority seams, +126 B store-twin seams (_landOnOverride dispatch, heldFromStale), +~40 B contested post-revert, +99 B gated carve-out). .size-limit.js scenarios reconciled for the stack with per-scenario notes (e.g. core floor 8.32 → 8.54 KB brotli; the isPending/latest scenario carries the engine, +450 B; hydrating + every store family 28.30 → 28.35 KB after #3337's affects walk fix, +44 B here).

Verification

Rebased on #3337 @ 027fda2 (itself on next @ 4935c7d). #3337's promoteUnflushed moved into core.ts; this branch's override arm (_supersedeOverride) is re-applied there, after the A28(5) _promoteOverride arm for parked optimistic writes. signals 1767 passed / 1 skipped · solid 595 · web 734 · tsc clean · full pnpm build · npm run size green locally on both branches.

@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fae8b76

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 found these issues:

The JSX repros for #3330, #3331, #3334 and #3335 behave as expected on 03334af0. There is a reproducible gap in the new provenance protection, relative to the PR’s stated rule that an older action’s response must not restart work beneath a newer override.

The graph is essentially the existing provenance test:

const source = createMemo(() => fetchSource());
const [value, predict] = createOptimistic(source);
const details = createMemo(() => fetchDetails(value()));

// Observe value and details, plus isPending(details).
// Initially everything has settled at 0.

const save = action(function* (guess, responseReady) {
  predict(guess);
  yield responseReady;
  refresh(source);
});

Here fetchSource and fetchDetails are controlled requests. Each details request resolves to its captured input. The following sequence illustrates the difference:

// A and B have separate responseReady gates.

save(5, responseReadyA);
flush();

save(5, responseReadyB);
flush();

// Resolve the optimistic details requests and settle.
// The displayed frame is 5:5, with isPending(details) === false.

// Release A's gate, allowing its refresh(source) to start.
// Resolve that source request with 2.
// Keep B's gate unresolved.

At that point:

// Intended by the older-response protection:
latest(value);      // 5
isPending(details); // false
// No details(2) request.

// Actual:
latest(value);      // 2
isPending(details); // true
// A details(2) request starts.

The main displayed value/details frame remains 5:5; this is unnecessary corrective work and a reappearing pending indicator, not a claim that the main displayed value immediately reverts to 2.

The control is particularly useful: change only A’s prediction from 5 to 3, keeping B’s prediction at 5. Then the old response is correctly suppressed: no details(2) request, and the pending indicator stays false.

This reproduces in both dev and prod. The cause appears to be the same-value fast path:

if (!valueChanged) {
  // Joins the action's lifetime...
  return value;
}

// ...but the newer invocation is only recorded down here.
extension._overrideStamp = origin;

The second action renews the lifetime of the override without renewing its provenance. An isolated experiment updating _overrideStamp in that early-return path removes the failure and preserves the distinct-value control. That’s causal evidence for the missing bookkeeping, rather than validation of a complete fix.

One separate semantic question concerns the changed initialized-memo test. Simplified:

const [a, setA] = createSignal(0);
const shared = createMemo(() => fetchValue(a()));
const [show, setShow] = createSignal(false);

// One existing reader observes shared.
// Another renders show() ? shared() : "hidden".

After shared has published "v0":

setA(1);
flush();
// shared's new request is pending; the source update is held.

setShow(true);
flush();

The previous test allowed the new reader to show the existing committed "v0". The amended test keeps it hidden until "v1" is ready.

The #3334 repro establishes why the reveal must wait when the input has already been published. When the source update itself is still held, what consistency violation would showing its corresponding committed memo value introduce? Is the broader wait intentional to support the single-graph implementation, or is it a conservative consequence of removing the transaction-stamp exception?

@GabbeV

GabbeV commented Sep 10, 2026

Copy link
Copy Markdown

Reacts behavior for the show during load case interestingly changed behavior in 19.3 and now don't entangle because of them enabling parallel transitions:
19.2.0
19.3.0

@GabbeV

GabbeV commented Sep 10, 2026

Copy link
Copy Markdown

Follow-up AI review: two additional ordinary-signal publication failures still reproduce on this PR's head 03334af0, the updated #3337 head ba538c28, and next at a1ed7f37. These are pre-existing failures, not regressions introduced by this PR.

Both were independently reproduced with core primitives and with compiled JSX in jsdom using real timers, without the fuzzer's hooks or completion probes. The core reproductions below were checked on both PR heads.

Each case uses a fresh root and starts after its initial async values have settled. observe represents a separate render effect writing to a plain output record:

const rendered = {};
const observe = (name, read) =>
  createRenderEffect(read, value => { rendered[name] = value; });
const delay = (ms, value) => new Promise(r => setTimeout(r, ms, value));

A: a reader revealed during a held update never catches up when it commits.

const [input, setInput] = createSignal(0);
const [show, setShow] = createSignal(false);
const shared = createMemo(() => input());
const slow = createMemo(() => delay(1000, shared()));
const fast = createMemo(async () => shared());

observe("input", input);
observe("show", show);
observe("A", () => `${input()}:${slow()}`);
observe("B", () => show() ? `${input()}:${fast()}` : "hidden");

After initialization, run from an event handler:

setInput(2);
await delay(500);
setShow(true);
await delay(2000);

// Expected: input=2, show=true, A="2:2", B="2:2"
// Actual:   input=2, show=true, A="2:2", B="0:0"

During the hold, B reveals the coherent committed pair 0:0. The failure is that B stays there after the slow request resolves and input/A publish 2/2:2. This does not depend on choosing whether the initial reveal should wait or show committed data; the final view is inconsistent after all work finishes.

B: one conditional reader remains hidden after the shared visibility signal commits true.

const [input, setInput] = createSignal(0);
const [show, setShow] = createSignal(true);
const details = createMemo(() => delay(1500, input()));

observe("input", input);
observe("show", show);
observe("A", () => show() ? input() : "hidden");
observe("B", () => show() ? details() : "hidden");

After initialization, run from an event handler:

// Queue the hide before setInput queues Solid's flush.
Promise.resolve().then(() => setShow(false));
setInput(3);
await delay(500);
setShow(true);
await delay(500);
setInput(0);
await delay(2000);

// Expected: input=0, show=true, A=0,        B=0
// Actual:   input=0, show=true, A="hidden", B=0

Both readers initially hide. The attempt to show them again waits while details is pending; input is then changed back to 0. Once all requests have settled, show publishes true and B publishes 0, but A remains hidden. There are no optimistic/latest reads or Loading boundaries in either case.

Posting these together because they seem adjacent to the reveal/publication work. Their root causes have not been established, and they need not be folded into this PR; they can be split into separate issues if that is easier to track.

@GabbeV

GabbeV commented Sep 10, 2026

Copy link
Copy Markdown

Another potentially related case: removing the last observer of pending async work doesn't seem to release the update waiting for it.

After the initial value settles, click the button. After 500ms, Show removes the only reader of details, but input remains at 0 until the now-unobserved request finishes at 1500ms.

I'd expect input to publish 2 when that reader disappears, since nothing displayed needs the pending result anymore. Could this be the removal side of the changing-observers problem addressed here?

import { createMemo, createSignal, Show } from "solid-js";

const delay = (ms, value) =>
  new Promise(r => setTimeout(r, ms, value));

export default function App() {
  const [input, setInput] = createSignal(0);
  const [show, setShow] = createSignal(true);
  const details = createMemo(() => delay(1500, input()));
  const forwarded = createMemo(() => details());

  return <>
    <button onClick={async () => {
      setInput(2);
      await delay(500);
      setShow(false);
    }}>Run</button>
    <p>Input: {input()}</p>
    <p>Show: {String(show())}</p>
    <Show when={show()}>
      <p>Details: {forwarded()}</p>
    </Show>
  </>;
}

AI-assisted investigation reproduced this with compiled JSX in jsdom on next (a1ed7f37), #3337 (ba538c28), and this PR (03334af0), independently of the fuzzer that found it. The shared root cause is only a hypothesis; happy to split this into an issue if it's separate.

@GabbeV

GabbeV commented Sep 10, 2026

Copy link
Copy Markdown

Made playgrounds for them if you want to try them more easily.

https://s.olid.uk/id/UnddL9GdQhOxx7vp3_EU8A
https://s.olid.uk/id/kynw9OOMRcyPWzafZnXKBQ
https://s.olid.uk/id/0CwjsfLWQmyWPJ8IyuE_7w

Testing the third one made me realize that that one might be working as intended. It is just that we could resolve faster given the observer is removed but there is no inconsistency in not doing it.

@ryansolid
ryansolid changed the base branch from next to latest-held-till-flush September 10, 2026 20:54
@coveralls

coveralls commented Sep 10, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34574149299

Warning

No base build found for commit 451f087 on latest-held-till-flush.
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.03 hits per line

💛 - Coveralls

@codspeed-hq

codspeed-hq Bot commented Sep 10, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 151 untouched benchmarks


Comparing fix/lane-authority (b6d266e) with latest-held-till-flush (b722898)

Open in CodSpeed

ryansolid added a commit that referenced this pull request Sep 10, 2026
…unless its inputs are visible; same-value re-prediction renews provenance (A15 re-rule, #3331)

Review on #3347 (GabbeV). Two fixes and a re-ruling.

A15 reveal corollary, re-ruled: #3334 removed read()'s pending-branch
carve-out outright — a stale (render) reader of a node pending in another
transaction always held on the flight. That made a new reader of a memo
whose input write is ITSELF still held wait for the landing, though showing
the committed pair introduces no inconsistency (parallel transactions;
effects don't entangle; React 19.3 stopped entangling the same shape). The
carve-out returns, gated on input visibility: the reader shows committed,
does not entangle, and is recorded for the transaction's commit replay
(heldFromStale) — refused, holding instead, when the committed value would
tear against the frame: the flight's inputs were PUBLISHED while it was
pending (CONFIG_INPUTS_PUBLISHED, set by commitPendingNode's computed
branch when a commit leaves the node in the air, #3305; cleared by
notifyStatus when the node next enters pending from a settled state), the
node rides a live lane (GlobalQueue._laneLive -> resolveLane, exact rather
than sticky; optimistic / latest, #3334), or the node is uninitialized. The
initialized-memo pin and its store twin return to "show v0, then v1".

Replay hygiene the carve-out surfaced: an effect recorded in _gatedSubs
that later recomputes UNDER the recording transaction sees its staged view
and is applied by the commit (ownership) — the stale recording published
the frame twice. recompute drops it at its start; a lane's committed-view
read re-records during the run, so the lane replay is untouched.

Provenance (#3331 follow-up): a same-value optimistic write by a newer
action took the fast path — entangling the transaction — without renewing
_overrideStamp, so the OLDER action's slow source superseded a value the
user had just re-confirmed: a corrective downstream refetch and a pending
flip for nothing. The fast path now renews the stamp to a newer origin.

Pinned: tests/reveal-carve-out.test.ts (GabbeV's "revealed reader never
catches up" and "conditional reader stays hidden" shapes, a replacement
flight over published inputs, a retired lane), the same-value provenance
twin in spec-async-semantics. SPEC A15 + re-ruling log, INTERNALS §3.
Core floor 22,638 -> 22,737 (conscious). Size-limit scenarios reconciled
for the lane-authority stack with per-scenario notes.

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 10, 2026
…unless its inputs are visible; same-value re-prediction renews provenance (A15 re-rule, #3331)

Review on #3347 (GabbeV). Two fixes and a re-ruling.

A15 reveal corollary, re-ruled: #3334 removed read()'s pending-branch
carve-out outright — a stale (render) reader of a node pending in another
transaction always held on the flight. That made a new reader of a memo
whose input write is ITSELF still held wait for the landing, though showing
the committed pair introduces no inconsistency (parallel transactions;
effects don't entangle; React 19.3 stopped entangling the same shape). The
carve-out returns, gated on input visibility: the reader shows committed,
does not entangle, and is recorded for the transaction's commit replay
(heldFromStale) — refused, holding instead, when the committed value would
tear against the frame: the flight's inputs were PUBLISHED while it was
pending (CONFIG_INPUTS_PUBLISHED, set by commitPendingNode's computed
branch when a commit leaves the node in the air, #3305; cleared by
notifyStatus when the node next enters pending from a settled state), the
node rides a live lane (GlobalQueue._laneLive -> resolveLane, exact rather
than sticky; optimistic / latest, #3334), or the node is uninitialized. The
initialized-memo pin and its store twin return to "show v0, then v1".

Replay hygiene the carve-out surfaced: an effect recorded in _gatedSubs
that later recomputes UNDER the recording transaction sees its staged view
and is applied by the commit (ownership) — the stale recording published
the frame twice. recompute drops it at its start; a lane's committed-view
read re-records during the run, so the lane replay is untouched.

Provenance (#3331 follow-up): a same-value optimistic write by a newer
action took the fast path — entangling the transaction — without renewing
_overrideStamp, so the OLDER action's slow source superseded a value the
user had just re-confirmed: a corrective downstream refetch and a pending
flip for nothing. The fast path now renews the stamp to a newer origin.

Pinned: tests/reveal-carve-out.test.ts (GabbeV's "revealed reader never
catches up" and "conditional reader stays hidden" shapes, a replacement
flight over published inputs, a retired lane), the same-value provenance
twin in spec-async-semantics. SPEC A15 + re-ruling log, INTERNALS §3.
Core floor 22,638 -> 22,737 (conscious). Size-limit scenarios reconciled
for the lane-authority stack with per-scenario notes.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member Author

All four threads addressed in 4638b34 (sixth commit on this branch). Thanks for the quality of these — the causal isolation on the stamp and the React 19.2 → 19.3 comparison both saved real time.

Same-value fast path / _overrideStamp — confirmed and fixed exactly where you pointed: the early return now renews the stamp when the incoming origin is newer. Your two-same-value shape is pinned in spec-async-semantics.test.ts ("a same-value re-prediction by a newer action renews the override's provenance"): after A's source answers 2 with B's gate still open, latest(value) stays 5, isPending(details) stays false, no details(2) request; B's later answer still supersedes. The distinct-value control still passes.

The initialized-memo question — ruled your way. You had it right: when the source update is itself still held, showing the corresponding committed memo value introduces no inconsistency. The broad wait was a conservative consequence of removing the transaction-stamp exception in #3334, not a semantic goal. Ryan's call, informed by the 19.3 change you linked: we support parallel transactions, effects don't entangle, and we would rather get the semantics right now than ship an intermediate break. The carve-out is back, gated on input visibility rather than on the stamp:

A stale (render) reader of a node pending in another transaction shows the committed value, does not entangle, and is recorded for that transaction's commit replay — unless the flight's inputs are visible: they were published while it was pending (CONFIG_INPUTS_PUBLISHED, set when a commit leaves the node in the air — your #3305 shape), the node rides a live lane (optimistic / latest — the #3334 shape), or the node is uninitialized. Then the reader holds.

The stamp comparison was never the evidence; the mark is. The amended test and its store twin return to "show v0, then v1"; every #3334 / #3305 hold pin still passes.

Cases A and B — both were fixed by the fifth commit's heldFromStale recording (a reader that first links to a node while another transaction holds its write is recorded for that transaction's commit replay; before, commits were silent to it, which is exactly the "revealed reader never catches up" and "conditional reader stays hidden" you saw). I bisected your reproductions to confirm that commit alone flips them, and they are now pinned as written in tests/reveal-carve-out.test.ts (real timers, same mid-frame and final frames). The re-ruled carve-out surfaced one more thing worth knowing about: a recorded effect that later recomputes under the recording transaction is applied by the commit itself, so the stale recording published the frame twice — recompute now drops it at its start.

Third case (removing the last observer) — agree with your own conclusion: working as intended. Releasing the update early when its last reader leaves would be an optimization with no coherence benefit, and it is not one we want to take on here.

Core floor moved 22,638 → 22,737 for the gated carve-out; the size-limit scenarios on both PRs are reconciled with per-scenario notes.

Claude via Cursor

ryansolid added a commit that referenced this pull request Sep 11, 2026
…unless its inputs are visible; same-value re-prediction renews provenance (A15 re-rule, #3331)

Review on #3347 (GabbeV). Two fixes and a re-ruling.

A15 reveal corollary, re-ruled: #3334 removed read()'s pending-branch
carve-out outright — a stale (render) reader of a node pending in another
transaction always held on the flight. That made a new reader of a memo
whose input write is ITSELF still held wait for the landing, though showing
the committed pair introduces no inconsistency (parallel transactions;
effects don't entangle; React 19.3 stopped entangling the same shape). The
carve-out returns, gated on input visibility: the reader shows committed,
does not entangle, and is recorded for the transaction's commit replay
(heldFromStale) — refused, holding instead, when the committed value would
tear against the frame: the flight's inputs were PUBLISHED while it was
pending (CONFIG_INPUTS_PUBLISHED, set by commitPendingNode's computed
branch when a commit leaves the node in the air, #3305; cleared by
notifyStatus when the node next enters pending from a settled state), the
node rides a live lane (GlobalQueue._laneLive -> resolveLane, exact rather
than sticky; optimistic / latest, #3334), or the node is uninitialized. The
initialized-memo pin and its store twin return to "show v0, then v1".

Replay hygiene the carve-out surfaced: an effect recorded in _gatedSubs
that later recomputes UNDER the recording transaction sees its staged view
and is applied by the commit (ownership) — the stale recording published
the frame twice. recompute drops it at its start; a lane's committed-view
read re-records during the run, so the lane replay is untouched.

Provenance (#3331 follow-up): a same-value optimistic write by a newer
action took the fast path — entangling the transaction — without renewing
_overrideStamp, so the OLDER action's slow source superseded a value the
user had just re-confirmed: a corrective downstream refetch and a pending
flip for nothing. The fast path now renews the stamp to a newer origin.

Pinned: tests/reveal-carve-out.test.ts (GabbeV's "revealed reader never
catches up" and "conditional reader stays hidden" shapes, a replacement
flight over published inputs, a retired lane), the same-value provenance
twin in spec-async-semantics. SPEC A15 + re-ruling log, INTERNALS §3.
Core floor 22,638 -> 22,737 (conscious). Size-limit scenarios reconciled
for the lane-authority stack with per-scenario notes.

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
…unless its inputs are visible; same-value re-prediction renews provenance (A15 re-rule, #3331)

Review on #3347 (GabbeV). Two fixes and a re-ruling.

A15 reveal corollary, re-ruled: #3334 removed read()'s pending-branch
carve-out outright — a stale (render) reader of a node pending in another
transaction always held on the flight. That made a new reader of a memo
whose input write is ITSELF still held wait for the landing, though showing
the committed pair introduces no inconsistency (parallel transactions;
effects don't entangle; React 19.3 stopped entangling the same shape). The
carve-out returns, gated on input visibility: the reader shows committed,
does not entangle, and is recorded for the transaction's commit replay
(heldFromStale) — refused, holding instead, when the committed value would
tear against the frame: the flight's inputs were PUBLISHED while it was
pending (CONFIG_INPUTS_PUBLISHED, set by commitPendingNode's computed
branch when a commit leaves the node in the air, #3305; cleared by
notifyStatus when the node next enters pending from a settled state), the
node rides a live lane (GlobalQueue._laneLive -> resolveLane, exact rather
than sticky; optimistic / latest, #3334), or the node is uninitialized. The
initialized-memo pin and its store twin return to "show v0, then v1".

Replay hygiene the carve-out surfaced: an effect recorded in _gatedSubs
that later recomputes UNDER the recording transaction sees its staged view
and is applied by the commit (ownership) — the stale recording published
the frame twice. recompute drops it at its start; a lane's committed-view
read re-records during the run, so the lane replay is untouched.

Provenance (#3331 follow-up): a same-value optimistic write by a newer
action took the fast path — entangling the transaction — without renewing
_overrideStamp, so the OLDER action's slow source superseded a value the
user had just re-confirmed: a corrective downstream refetch and a pending
flip for nothing. The fast path now renews the stamp to a newer origin.

Pinned: tests/reveal-carve-out.test.ts (GabbeV's "revealed reader never
catches up" and "conditional reader stays hidden" shapes, a replacement
flight over published inputs, a retired lane), the same-value provenance
twin in spec-async-semantics. SPEC A15 + re-ruling log, INTERNALS §3.
Core floor 22,638 -> 22,737 (conscious). Size-limit scenarios reconciled
for the lane-authority stack with per-scenario notes.

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
…unless its inputs are visible; same-value re-prediction renews provenance (A15 re-rule, #3331)

Review on #3347 (GabbeV). Two fixes and a re-ruling.

A15 reveal corollary, re-ruled: #3334 removed read()'s pending-branch
carve-out outright — a stale (render) reader of a node pending in another
transaction always held on the flight. That made a new reader of a memo
whose input write is ITSELF still held wait for the landing, though showing
the committed pair introduces no inconsistency (parallel transactions;
effects don't entangle; React 19.3 stopped entangling the same shape). The
carve-out returns, gated on input visibility: the reader shows committed,
does not entangle, and is recorded for the transaction's commit replay
(heldFromStale) — refused, holding instead, when the committed value would
tear against the frame: the flight's inputs were PUBLISHED while it was
pending (CONFIG_INPUTS_PUBLISHED, set by commitPendingNode's computed
branch when a commit leaves the node in the air, #3305; cleared by
notifyStatus when the node next enters pending from a settled state), the
node rides a live lane (GlobalQueue._laneLive -> resolveLane, exact rather
than sticky; optimistic / latest, #3334), or the node is uninitialized. The
initialized-memo pin and its store twin return to "show v0, then v1".

Replay hygiene the carve-out surfaced: an effect recorded in _gatedSubs
that later recomputes UNDER the recording transaction sees its staged view
and is applied by the commit (ownership) — the stale recording published
the frame twice. recompute drops it at its start; a lane's committed-view
read re-records during the run, so the lane replay is untouched.

Provenance (#3331 follow-up): a same-value optimistic write by a newer
action took the fast path — entangling the transaction — without renewing
_overrideStamp, so the OLDER action's slow source superseded a value the
user had just re-confirmed: a corrective downstream refetch and a pending
flip for nothing. The fast path now renews the stamp to a newer origin.

Pinned: tests/reveal-carve-out.test.ts (GabbeV's "revealed reader never
catches up" and "conditional reader stays hidden" shapes, a replacement
flight over published inputs, a retired lane), the same-value provenance
twin in spec-async-semantics. SPEC A15 + re-ruling log, INTERNALS §3.
Core floor 22,638 -> 22,737 (conscious). Size-limit scenarios reconciled
for the lane-authority stack with per-scenario notes.

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 and others added 6 commits September 11, 2026 01:05
`laneHeld` looked up a lane's `_pendingAsync` nodes in its own
transaction's `_asyncReporters`. Lanes merge across transactions (#2912:
ownership never travels through lanes), so after a merge the root's
transaction holds the observations of only one member — the async the
other member's transaction observed no longer held the merged reveal, and
the frame tore.

The observation is a fact about the node: `waitingTransition(node)` finds
the live transaction blocked on it, whichever recorded it, and `laneHeld`
asks per node. Same rule the transaction itself uses (unrendered async and
fallback-caught async hold nothing).

Pinned in tests/lane-hold-on-observation.test.ts; INTERNALS §2 hold rule.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…ess of stamp (#3334)

Two halves of one tear. `read()`'s pending branch let a stale (render)
reader of another transaction carve out a node pending in a foreign
transaction and show its committed value — on the theory that the stamp
meant that transaction also held the node's inputs. It does not: the
stamp is pending-node bookkeeping, and the inputs may already be on
screen (committed #3305, lane-revealed #3334). A render reader landing on
a pending node now throws whichever transaction the node is stamped with
(A15: observed async settles as one unit).

And `handleAsync`'s settle-time re-entry entered the lane owner's
transaction for a lane-routed landing, folding every transaction waiting
on the flight into the owner's action — a reveal that discovered the
flight then waited on the action instead of on the flight. It enters the
waiter: the transaction whose blocker this landing clears
(`waitingTransition`).

Pinned in spec A15 (#3334 describe) and
tests/stale-read-uninitialized-cross-transition.test.ts (re-expected).
INTERNALS §3.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…#3330)

`recompute`'s equality gate compared a lane (OPT-dirty) recompute against
`_pendingValue` — a value an earlier action write had staged for the
transaction — while the lane publishes to `_value`. An identical result
was called "unchanged", the override revealed without its derivation, and
the frame showed `v=1 d=0`.

INV-11: the gate compares against the slot this run publishes to — the
override for an override-covered node, `_value` for a lane direct commit,
`_pendingValue` for a transaction-staged run.

Found under the same repro: `laneReadsCommitted` recorded every lane
reader of a staged node for commit-time replay, including when the staged
value already equals the committed one (the lane published it) — the
replay re-ran effects against an unchanged frame. Recorded only when the
commit will change what the reader saw.

Pinned in spec A17 (#3330 pin and re-apply companion); INTERNALS §2, §5
INV-11.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… provenance (#3331)

A18 supersession. An optimistic override used to stay the graph's value
until its transaction committed, even after the node's own source had
answered with something else — the override's downstream flight ran to
completion and only then did the truth's start, doubling the delay the
reporter saw. Now a differing arrival marks the node
`CONFIG_OVERRIDE_SUPERSEDED`: tracked readers see the staged truth
(`read()` → `_supersededRead`), the node's lane cascade is demoted and
notified on the plain channel so downstream async restarts immediately as
held transaction work, and the override's own downstream flight is inert
when it lands. Untracked reads and the applied frame keep the override
until the commit. An equal arrival confirms silently (or un-supersedes);
the authoritative-observer wake (until()'s predicate, #3164) lives in the
same engine hook. `runEffect` hands a lane runner for a lane-less effect
to the regular queue so the still-held transaction stashes it.

Scope: "the source" is whatever recomputes the node — its own async
landing (asyncWrite) or a sync recompute driven by an upstream change
(`createOptimistic(() => asyncMemo())`), both recompute branches.
Ordering: a source write and an override in the same batch derive nothing
new (`_overrideTime` vs clock).

Provenance: only the override's own question or a newer one supersedes.
Two rapid actions on one node merge into one transaction, and the older
action's refetch can land after the newer override — that answer must not
leak back in over the user's latest intent ("getting it not to flicker was
super important"). Transactions merge, so `_overrideOwner` cannot tell the
actions apart; the scheduler carries the running action's invocation
sequence (`origin`: set by action() per slice, cleared at the end of
flush(); captured per flight in handleAsync and re-armed by asyncWrite for
the landing's propagation), the override stamps it (`_overrideStamp`), and
a differing arrival from an older action holds silently to the commit.
Mainline (no action) is always current.

Pinned: spec A18 (#3331 describe: own-async, sync-wrapper, same-batch,
provenance, simple graph; entangled pin re-expected);
createOptimistic.test.ts (no-double-flicker pin unchanged; "second action
while first still in flight" resolver repaired and re-expected). SPEC A17
amendment and the 2026-09-09 re-ruling log for the whole lane-authority
series, INTERNALS §1/§3, treeshake NOTE (22,049 / 22,050, no bump).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…f a hold re-derive at the commit (#3330, #3331)

Every rule pinned for signals in #3335/#3334/#3330/#3331 is pinned against
the equivalent optimistic-store shape (tests/store/lane-authority-twins).

optimistic families too — a sync derive adopting truth under the action's
transaction is held truth, not lane business; unheld, handlers read the
swapped-in backing early, latest() inverted, and the optimistic write
compared equal to it and wrote no override. `notifyOptimisticWrites` judges
the write against the view readers see (`heldMaskView(t) ?? t.v`). A held
adoption notifies its nodes at the outermost setter exit
(`stageHeldAdoptions`), staged under the transaction, so the commit promotes
silently instead of delivering the adopted values as fresh writes that
re-ran every subscriber against a frame the lane had already published.
Plain-store reconcile inside an action holds the same way; a key first read
under a held adoption is born holding (one `stageHeldKey` with the #3336
fold case: committed from the view readers see, the held value staged).

(setSignal under projectionWriteActive) dispatches to the engine
(`_landOnOverride` → supersedeOverride) instead of staging silently on the
plain path; `supersededRead` serves the committed truth once a mainline
landing has committed ahead of the override's revert.

Core, surfaced by the store twin and reproduced with a plain signal: a
reader that first links to a node while another transaction holds a staged
write read the committed value and never learned of the commit (commits are
silent; the staging walk was the notification). `heldFromStale` records such
readers for the transaction's commit replay, skipping effects the
transaction itself computed (parked run / contested re-derive) — replaying
those published the frame twice.

On the A28 write path the async landing's supersession decision runs at
the write's promotion (promoteUnflushed's override arm hands the node to
supersedeOverride, still under the flight's provenance). A settle that
reverts optimism re-derives its contested effects (#3322) after the revert,
with the gated replay: ahead of the heap run the truth is committed but the
overrides still display, and the re-derive composed the two — the #3164
tear, surfaced by deep() over an optimistic store whose held adoption the
committing transaction's own readers see. Core floor 22,252 → 22,638
(conscious).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…unless its inputs are visible; same-value re-prediction renews provenance (A15 re-rule, #3331)

Review on #3347 (GabbeV). Two fixes and a re-ruling.

A15 reveal corollary, re-ruled: #3334 removed read()'s pending-branch
carve-out outright — a stale (render) reader of a node pending in another
transaction always held on the flight. That made a new reader of a memo
whose input write is ITSELF still held wait for the landing, though showing
the committed pair introduces no inconsistency (parallel transactions;
effects don't entangle; React 19.3 stopped entangling the same shape). The
carve-out returns, gated on input visibility: the reader shows committed,
does not entangle, and is recorded for the transaction's commit replay
(heldFromStale) — refused, holding instead, when the committed value would
tear against the frame: the flight's inputs were PUBLISHED while it was
pending (CONFIG_INPUTS_PUBLISHED, set by commitPendingNode's computed
branch when a commit leaves the node in the air, #3305; cleared by
notifyStatus when the node next enters pending from a settled state), the
node rides a live lane (GlobalQueue._laneLive -> resolveLane, exact rather
than sticky; optimistic / latest, #3334), or the node is uninitialized. The
initialized-memo pin and its store twin return to "show v0, then v1".

Replay hygiene the carve-out surfaced: an effect recorded in _gatedSubs
that later recomputes UNDER the recording transaction sees its staged view
and is applied by the commit (ownership) — the stale recording published
the frame twice. recompute drops it at its start; a lane's committed-view
read re-records during the run, so the lane replay is untouched.

Provenance (#3331 follow-up): a same-value optimistic write by a newer
action took the fast path — entangling the transaction — without renewing
_overrideStamp, so the OLDER action's slow source superseded a value the
user had just re-confirmed: a corrective downstream refetch and a pending
flip for nothing. The fast path now renews the stamp to a newer origin.

Pinned: tests/reveal-carve-out.test.ts (GabbeV's "revealed reader never
catches up" and "conditional reader stays hidden" shapes, a replacement
flight over published inputs, a retired lane), the same-value provenance
twin in spec-async-semantics. SPEC A15 + re-ruling log, INTERNALS §3.
Core floor 22,638 -> 22,737 (conscious). Size-limit scenarios reconciled
for the lane-authority stack with per-scenario notes.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid and others added 4 commits September 11, 2026 01:05
Core floor 22,737 -> 22,816 (+27 B hot-path fix, +52 B A28 for optimistic
writes, both from the base branch); brotli caps ratcheted to the measured
artifacts on six scenarios.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…bcc71

The affects() declaration walk composing the tick's optimistic writes
(one argument) lands +44 B brotli on the store-heavy scenario here;
28.30 -> 28.35 KB, measured at 28344 B.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Core floor 22,816 -> 22,866 (+50 B, `next`'s #3350/#3351 via #3337),
budget 22,950. Five brotli caps ratcheted with notes for the same bytes
under the lane-authority seams (createStore 15.45, isPending/latest
10.98, simple app 11.32, hydrating+stores 28.50, CSR 14.15 KB).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
`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

Copy link
Copy Markdown
Member Author

Superseded by #3370, which is this same series re-based directly onto next so the lane-authority fixes can ship in this rc independently of #3337 (the A28 write path, which is being held for more thought).

Same six fix commits and changesets. The only re-port is where this branch composed with A28: the async landing's supersession decision dispatches eagerly from asyncWrite (as next does) instead of at promoteUnflushed, and the store twins carry only the held-adoption born-holding case — #3336's held-fold case stays with #3337. Floor is 21,994 → 22,457 on next (vs. +485 B here on the stack). Review comments on this PR were addressed in the sixth commit, which carried over unchanged.

When #3370 merges, #3337 rebases onto it and moves the _supersedeOverride dispatch back into promoteUnflushed's override arm — the shape this branch (@ fae8b76) already has.

Claude via Cursor

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