Skip to content

Commit acefaa5

Browse files
ryansolidClaude via Cursorcursoragent
committed
fix(signals): an on-screen reader whose removal an action adopted follows the lane's work (fuzzer adopted staging)
An action writes `source = 1`; the same tick withdraws the mount around a render effect `E` that reads `m = [latest(source)]` (adopted, O1). `E` is a zombie of the action's frame, still on screen. The lane recomputes `m` and dirties `E`, but `E` was not a lane member, so `laneDirty` left it unmarked; it became a zombie later in the same flush, `_update` deferred it, and the park cancelled its pass. `E` kept `[0]` while a sibling reader of `m` showed `[1]` — the direct `latest()` twin already re-ran by #3444's verdict-reader exception. `laneDirty` now also marks a render effect on no lane. The mark is inert for its pass (`recompute` seats a lane only from the node's own `_x`), and makes its zombie pass the lane's live write, which `_update` runs now — the existing rule for the lane's own dirty members (#3463). Memos stay unmarked: a lane frame's child memo reading the guess keeps the #3662 cancellation (#3698 pins). Rule: the lane-membership ruling (2026-10-05, reading A): an on-screen render effect whose removal an adopted write has staged follows the lane's re-derivation of a memo it reads; A15 #3463 (a zombie is live for every hold until the commit that disposes it); A28 (1). New pin (none existed): `fuzz-findings-l2.test.ts` "adopted staging". Co-authored-by: Claude via Cursor <[email protected]> Co-authored-by: Cursor <[email protected]>
1 parent c4b4383 commit acefaa5

4 files changed

Lines changed: 82 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/signals": patch
3+
---
4+
5+
A render effect still on screen while an action holds its removal follows the lane's re-derivation of a memo it reads, as its direct `latest()` twin does (fuzzer adopted-staging case)

‎packages/signals/docs/RULES-INDEX.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
5959
| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:171` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… |
6060
| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:179` | — | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … |
6161
| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:187` | — | spec-async-semantics.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… |
62-
| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:223` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×15 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … |
62+
| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:223` | async.ts×2 constants.ts×1 core.ts×3 lanes.ts×4 scheduler.ts×14 verdict.ts×3 | async-chain-supersession.test.ts×1 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 l2-contract.test.ts×7 lane-contract.test.ts×1 lane-hold-on-observation.test.ts×1 lane-outside-view.test.ts×1 lane-pass-stamped-effect-3662.test.ts×1 mount-over-foreign-hold-3761.test.ts×2 overlapping-flights.test.ts×3 posture-born-held-and-observation.test.ts×4 posture-store-parity.test.ts×2 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 superseded-source-blocks-3462.test.ts×2 tick-scoped-pass-transaction.test.ts×1 treeshake.test.ts×4 visibility-oracle-store.states.ts×6 visibility-oracle.states.ts×7 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … |
6363
| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:195` | scheduler.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×2 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… |
6464
| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:53` | async.ts×3 constants.ts×1 lanes.ts×5 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×5 optimistic-read-lane-not-transaction-3698.test.ts×2 optimistic-undefined-override.test.ts×1 posture-store-parity.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×2 kanban-a17-fixture.test.ts×3 optimistic-list-mutation-matrix.test.ts×1 optimistic-maparray-index-frame-f1.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle-store.states.ts×24 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×20 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… |
6565
| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:63` | action.ts×1 async.ts×2 core.ts×3 lanes.ts×8 scheduler.ts×2 types.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×1 projection.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×3 lane-contract.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-outside-view.test.ts×1 lane-uninitialized-landing-3648.test.ts×5 optimistic-move-duplicate-3548.test.ts×2 optimistic-read-lane-not-transaction-3698.test.ts×4 posture-store-parity.test.ts×5 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 optimistic-list-mutation-matrix.test.ts×1 optimistic-untracked-reads-f3-f5.test.ts×1 signal-store-twins-qd.test.ts×1 unchanged-presence-no-hold-3743.test.ts×2 superseded-before-first-commit.test.ts×4 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×19 visibility-oracle.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… |
@@ -72,7 +72,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
7272
| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:283` | projection.ts×1 | derived-presence-async-3726.test.ts×2 uninitialized-visibility.test.ts×3 visibility-oracle-store.states.ts×7 visibility-oracle-store.test.ts×1 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… |
7373
| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:89` | — | action-await-contract.test.ts×2 fuzz-findings-l2.test.ts×1 posture-store-parity.test.ts×2 visibility-oracle-store.states.ts×2 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… |
7474
| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:275` | — | loading-value.test.ts×2 visibility-oracle.states.ts×18 visibility-oracle.test.ts×1 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… |
75-
| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:73` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×4 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… |
75+
| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:73` | constants.ts×1 core.ts×12 lanes.ts×2 scheduler.ts×3 types.ts×1 verdict.ts×2 store.ts×4 | createOptimistic.test.ts×5 fuzz-findings-l2.test.ts×6 held-derivation-not-a-proposal-3612.test.ts×1 latest-held-till-flush.test.ts×1 posture-store-parity.test.ts×5 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 optimistic-draft-visibility-3665.test.ts×5 optimistic-list-mutation-matrix.harness.ts×1 optimistic-list-mutation-matrix.test.ts×2 shallow.test.ts×1 woken-transaction-adopts-staged-bump.test.ts×1 treeshake.test.ts×2 verdict-contract.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle.states.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every chan… |
7676
| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:97` | boundaries.ts×1 action.ts×1 constants.ts×1 core.ts×9 effect.ts×1 scheduler.ts×3 signals.ts×1 store.ts×1 | adoption-unchanged-key-read-3706.test.ts×9 body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 boundary-not-born-held-3540.test.ts×4 createProjection.draft-lifetime-3585.test.ts×1 direct-commit-readers-posture.test.ts×1 fuzz-findings-l2.test.ts×6 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 held-truth-lane-only.test.ts×3 l2-contract.test.ts×1 latest-held-till-flush.test.ts×2 mount-over-foreign-hold-3761.test.ts×1 optimistic-read-lane-not-transaction-3698.test.ts×3 posture-born-held-and-observation.test.ts×1 posture-store-parity.test.ts×6 derived-presence-async-3726.test.ts×3 optimistic-untracked-reads-f3-f5.test.ts×1 store-unchanged-read-independent-write-3688.test.ts×1 tick-scoped-pass-transaction.test.ts×2 treeshake.test.ts×3 visibility-oracle-store.states.ts×5 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a … |
7777
| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:231` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×2 held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 held-frame-dependencies.test.ts×2 ispending-in-boundary-on-3528.test.ts×1 lane-frame-deferred-run-3662.test.ts×1 lane-frame-held-lane-3662.test.ts×1 posture-born-held-and-observation.test.ts×1 treeshake.test.ts×2 write-proposals-3494.test.ts×2 zombie-rerun-after-commit-3546.test.ts×2 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… |
7878
| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:105` | boundaries.ts×1 core.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×5 ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … |

‎packages/signals/src/core/scheduler.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,11 +1084,15 @@ export function insertSubs(node: Signal<any> | Computed<any>): void {
10841084
/** Lane work re-staged (lanes.ts; `recompute`): the lane's members among
10851085
* its subscribers re-derive as the lane's work (REACTIVE_LANE_DIRTY), their
10861086
* runs held with it — not as stale readers republishing the committed view
1087-
* (#3460). After `insertSubs`. */
1087+
* (#3460). After `insertSubs`. A render effect on no lane is marked too:
1088+
* inert for its pass (`recompute` seats a lane only from its own `_x`), it
1089+
* is a live write if the effect becomes a zombie later this flush — an
1090+
* on-screen reader whose removal an adopted write stages follows the lane
1091+
* (the lane-membership ruling, 2026-10-05; `_update`). */
10881092
export function laneDirty(node: Signal<any> | Computed<any>, l: Transaction): void {
10891093
for (let s = node._subs; s !== null; s = s._nextSub) {
10901094
const t = s._sub._x?._transaction;
1091-
if (t != null && sameLane(t, l)) s._sub._flags |= REACTIVE_LANE_DIRTY;
1095+
if (t?._lane ? sameLane(t, l) : (s._sub as any)._type) s._sub._flags |= REACTIVE_LANE_DIRTY;
10921096
}
10931097
}
10941098
/** One lane, or two of one link group (#3335): one reveal unit. */

‎packages/signals/tests/fuzz-findings-l2.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,75 @@ describe("fuzz findings on L2 — lanes", () => {
595595
dispose();
596596
});
597597

598+
// Adopted staging (ex-"R-oracle", latest cohort). The lane-membership
599+
// ruling (2026-10-05, reading A): an on-screen render effect whose removal
600+
// an adopted write has staged follows the lane's re-derivation of a memo
601+
// it reads; A15 #3463: a zombie is live for every hold until the commit
602+
// that disposes it; A28 (1): wrapping a `latest` read in a memo does not
603+
// change what it answers. An action writes `source = 1`; the same tick
604+
// withdraws the mount around `E` (adopted, O1), so `E` is a zombie of the
605+
// action's frame. The lane recomputes `m = [latest(source)]`; `E`'s pass
606+
// was deferred as a zombie's and the park cancelled it — `E` kept `[0]`
607+
// while a sibling reader of `m` showed `[1]` (the direct `latest()` twin
608+
// already re-ran, #3444). The lane's re-staging of `m` marks `E` too
609+
// (`laneDirty`: a render effect on no lane), so its zombie pass is a live
610+
// write and runs now, as the lane's dirty members do.
611+
it("adopted staging: a reader whose removal an action adopted follows the lane's re-derivation of a memo it reads (A15 #3463, A28 (1))", async () => {
612+
const [source, setSource] = createSignal(0);
613+
const [mounted, setMounted] = createSignal(true);
614+
let resume!: () => void;
615+
let run!: () => Promise<void>;
616+
let E: unknown = "absent",
617+
S: unknown,
618+
M: unknown;
619+
let dispose!: () => void;
620+
createRoot(d => {
621+
dispose = d;
622+
const m = createMemo(() => [latest(source)]);
623+
createRenderEffect(m, v => {
624+
S = JSON.stringify(v);
625+
});
626+
createRenderEffect(mounted, v => {
627+
M = v;
628+
});
629+
createRenderEffect(
630+
() =>
631+
mounted()
632+
? createRoot(dd => {
633+
createRenderEffect(m, v => {
634+
E = JSON.stringify(v);
635+
});
636+
onCleanup(() => {
637+
E = "absent";
638+
});
639+
return dd;
640+
})
641+
: undefined,
642+
dd => {
643+
if (dd) onCleanup(dd);
644+
}
645+
);
646+
run = action(function* () {
647+
setSource(1);
648+
yield new Promise<void>(r => {
649+
resume = r;
650+
});
651+
});
652+
});
653+
flush();
654+
expect([M, E, S]).toEqual([true, "[0]", "[0]"]);
655+
const p = run();
656+
setMounted(false); // the action's tick: the withdrawal rides with it (O1)
657+
await drain();
658+
// The withdrawal is held: E is still on screen, and shows the lane's frame.
659+
expect([M, E, S]).toEqual([true, "[1]", "[1]"]);
660+
resume();
661+
await p;
662+
await drain();
663+
expect([M, E, S]).toEqual([false, "absent", "[1]"]);
664+
dispose();
665+
});
666+
598667
// F13. A29 (creation-time form): a reader born held is "staged into [the
599668
// transaction], committed with it" — `recompute`'s own note: "An effect
600669
// still carrying an uncommitted staged value re-stages: the commit applies

0 commit comments

Comments
 (0)