diff --git a/.changeset/lane-takeover-held-effect.md b/.changeset/lane-takeover-held-effect.md new file mode 100644 index 000000000..7c5bc5e68 --- /dev/null +++ b/.changeset/lane-takeover-held-effect.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Fix a render effect a lane took over from a held transaction: it keeps running as the lane's work while it waits on the lane's flight, and drops the hold when it leaves the lane (#3766). diff --git a/.changeset/optimistic-over-held-row.md b/.changeset/optimistic-over-held-row.md new file mode 100644 index 000000000..1bc3d6e4b --- /dev/null +++ b/.changeset/optimistic-over-held-row.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Fix an optimistic store over a derived store hiding its update after an action's local write to the same row: the row a held write staged is no longer guessed over the container's slot (#3796). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 3325f5743..2b199c54f 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -61,7 +61,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:201` | — | 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… | | A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:237` | 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 … | | A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:209` | 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… | -| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:67` | 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… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:67` | async.ts×3 constants.ts×1 lanes.ts×6 scheduler.ts×2 verdict.ts×1 map.ts×1 store.ts×3 | fuzz-findings-l2.test.ts×6 lane-uninitialized-landing-3648.test.ts×5 optimistic-over-held-row-3796.test.ts×1 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… | | A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:77` | 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… | | A19 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:137` | async.ts×1 constants.ts×1 core.ts×6 scheduler.ts×2 types.ts×1 verdict.ts×4 store.ts×1 | fuzz-findings-l2.test.ts×4 lane-uninitialized-landing-3648.test.ts×2 mount-over-foreign-hold-3761.test.ts×2 spec-async-semantics.test.ts×3 derived-presence-async-3726.test.ts×2 superseded-before-first-commit.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×14 visibility-oracle.test.ts×1 write-proposals-3494.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPendi… | | A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:396` | — | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | @@ -74,8 +74,8 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:289` | — | 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… | | A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:87` | 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… | | A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:111` | 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 … | -| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:245` | 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… | -| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:119` | 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 … | +| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:245` | async.ts×3 attribution.ts×1 constants.ts×1 core.ts×1 effect.ts×1 lanes.ts×1 scheduler.ts×5 | async-landing-deps-3461.test.ts×3 fuzz-findings-l2.test.ts×3 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… | +| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:119` | boundaries.ts×1 core.ts×1 lanes.ts×1 verdict.ts×2 | fuzz-findings-l2.test.ts×6 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 … | | A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:127` | core.ts×4 lanes.ts×1 store.ts×1 | visibility-oracle-store.states.ts×8 visibility-oracle-store.test.ts×1 visibility-oracle.states.ts×9 visibility-oracle.test.ts×1 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | | A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:261` | boundaries.ts×1 scheduler.ts×1 | async-chain-supersession.test.ts×2 boundary-not-born-held-3540.test.ts×2 fuzz-findings-l2.test.ts×2 ispending-in-boundary-on-3528.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | | A34 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:271` | constants.ts×1 core.ts×6 lanes.ts×2 scheduler.ts×2 store.ts×4 | a34-writes-then-derivations.test.ts×2 createMemo.test.ts×1 derived-write-then-derivation-3733.test.ts×1 finalize-reentry.test.ts×2 fuzz-findings-l2.test.ts×5 held-derivation-not-a-proposal-3612.test.ts×6 optimistic-list-mutation-matrix.test.ts×1 unchanged-presence-no-hold-3743.test.ts×3 woken-transaction-adopts-staged-bump.test.ts×1 transition-corpse-revival.test.ts×1 treeshake.test.ts×2 visibility-oracle.states.ts×2 write-proposals-3494.test.ts×5 | [ruled 2026-09-16 (#3494)] A write is a proposal: one on a held node entangles its tick; one that nets to the committed value is none — A write proposes a value for a node. **Held, both are suggestion… | diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index a2ea151ae..a266b7085 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -46,6 +46,7 @@ import { REACTIVE_FRAME_READ, REACTIVE_IN_HEAP, REACTIVE_JOINED, + REACTIVE_LANE_DIRTY, REACTIVE_LANE_READ, REACTIVE_PROBE_UNANSWERED, REACTIVE_SCREEN_READ, @@ -294,21 +295,25 @@ export function verdictLane(t: Transaction): Transaction { * pass's answer replaces it, and the frame's hold on it is over. An * effect's value slot is private (no lane value). A pass in a lane's seat * that read none of the lane's world has left it (false): its result is the - * frame's — a derivation whose branch no longer reaches the guess. A first - * pass under a lane is the lane's (ruling A); a guess is written, not - * derived — it never leaves this way. */ + * frame's — a derivation whose branch no longer reaches the guess — and no + * hold the lane took over from a transaction stays on it (#3698: lane work + * never makes its node transaction work; a born-held effect's hold went to + * the lane). A pass interrupted before it got there (pending, errored) did + * not leave (A30: it never got there). A first pass under a lane is the + * lane's (ruling A); a guess is written, not derived — it never leaves this + * way. */ export function laneStage( el: Computed, l: Transaction, create: boolean, errored: boolean ): boolean { - if (!create && !(el._flags & REACTIVE_LANE_READ) && !(el._config & CONFIG_GUESS)) { + if (!(create || errored || el._flags & REACTIVE_LANE_READ || el._config & CONFIG_GUESS)) { if (el._x !== null) { if (el._x._transaction?._lane) el._x._transaction = null; el._x._lane = NOT_PENDING; } - el._config &= ~CONFIG_OVERRIDE; + el._config &= ~(CONFIG_OVERRIDE | CONFIG_HELD); return false; } if (!(el as any)._type) { @@ -325,6 +330,11 @@ export function laneStage( // leaves the guess. if (!errored) el._config &= ~CONFIG_GUESS; } + // A leaf has no lane value to seat it (`recompute`): one left waiting on + // the lane's flight runs its next pass as the lane's — the landing that + // wakes it is the lane's re-staging (A31), and the lane holds its render + // effects until its derivations land (A17; #3766, F5). + else if (errored) el._flags |= REACTIVE_LANE_DIRTY; list(el, l); return true; } diff --git a/packages/signals/src/store/optimistic.ts b/packages/signals/src/store/optimistic.ts index d6922291c..1a23ec5e5 100644 --- a/packages/signals/src/store/optimistic.ts +++ b/packages/signals/src/store/optimistic.ts @@ -53,6 +53,7 @@ import { installOptHooks, LaneView, nameStore, + resolveChainedRaw, sameKey, storeSetter, targetsEqual, @@ -115,7 +116,11 @@ export function notifyOptimisticWrites( let structural = false; for (const key of Reflect.ownKeys(pb)) { if ((isArr && key === "length") || key === $OWNER) continue; - const nv = unwrapValue(pb[key as any]); + // A chained draft holds the inner store's raws; a raw is the inner slot it + // resolves to (as a read of it serves) — the row a held write staged is + // the same row, not a guess over the slot (#3796). + const raw = pb[key as any]; + const nv = unwrapValue(inner && raw ? resolveChainedRaw(t, key, raw) : raw); if (!(key in old)) { guess(getNode(t, key), key, false, nv); guess(getHasNode(t, key), key, true, true); diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index 6cf7a31a0..00d6d939e 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -1816,7 +1816,7 @@ function holdWithDerive(target: StoreTarget, node: Signal): void { * at this key, is served as the inner store's wrapper — never a fresh * raw-keyed one — so the chained targets are the same objects across * settled and pending views. */ -function resolveChainedRaw(target: StoreTarget, key: PropertyKey, v: object): any { +export function resolveChainedRaw(target: StoreTarget, key: PropertyKey, v: object): any { const innerT: StoreTarget = (target.v as any)[$TARGET]; if (innerT.ch) { const iv = resolveChainedRaw(innerT, key, v); diff --git a/packages/signals/tests/fuzz-findings-l2.test.ts b/packages/signals/tests/fuzz-findings-l2.test.ts index 2c0a2822a..6c219dea4 100644 --- a/packages/signals/tests/fuzz-findings-l2.test.ts +++ b/packages/signals/tests/fuzz-findings-l2.test.ts @@ -322,28 +322,143 @@ describe("fuzz findings on L2 — lanes", () => { // state as a unit)"; lanes stage (#3479): "the lane's readers … see the // optimistic frame … and neither is torn". A second guess on a lane that // has shown re-asks the derivation chain; the lane should hold its readers - // on the shown frame until the new derivation lands. Instead the reader - // publishes the new guess beside the previous guess's derivation. + // on the shown frame until the new derivation lands. The reader's first + // pass after the guess was the lane's (the guess dirtied it) and went + // pending on `d1`; the landing of `d0`'s re-ask woke it with no lane seat + // (a leaf carries no lane value), so it ran as a stale reader and + // published the new guess beside the previous guess's derivation. A leaf + // left waiting on the lane's flight now runs its next pass as the lane's + // (A31: "a member the lane's re-staging dirtied runs as the lane's") — + // brenelz's #3794 mark — and a pass interrupted before it reached the + // lane's world does not count as leaving it (A30). + it("F5: a second guess on a shown lane does not tear against the first guess's derivation (A17)", async () => { + const [source, setSource] = createSignal(0); + const frames: string[] = []; + let resume!: () => void; + let run!: () => Promise; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [view, setView] = createOptimistic(() => source()); + const d0 = createMemo(() => Promise.resolve(view())); + const d1 = createMemo(() => { + const v = d0(); + return Promise.resolve(v); + }); + createRenderEffect( + () => [view(), d1()], + t => { + frames.push(t.join(",")); + } + ); + run = action(function* () { + setView(1); + yield new Promise(r => { + resume = r; + }); + setView(2); + yield new Promise(r => { + resume = r; + }); + setSource(0); + }); + }); + await drain(); + expect(frames).toEqual(["0,0"]); + const p = run(); + await drain(6); + expect(frames).toEqual(["0,0", "1,1"]); + resume(); // second guess: 2 + await drain(6); + // Every delivered frame is coherent: d1 derived from the view beside it. + for (const f of frames) { + const [v, d] = f.split(","); + expect(d, `frame ${f}`).toBe(v); + } + resume(); + await p; + await drain(6); + dispose(); + }); + + // F5 (held reader leaves): the reader the lane took over above was born + // held by the mount's transaction; a lane pass of it that reads none of + // the lane's world leaves the lane, and with it the hold the lane took over + // (#3698: lane work never makes its node transaction work). Before, the + // hold flag outlived the lane stamp and the next pass resolved a null + // transaction (`txOf` → `resolveTx(null)`; fuzzer optimistic-readiness + // seed 3289 case 112 under the first version of the mark). + it("F5 (held reader leaves): a born-held reader the lane took over leaves it whole (A17, #3698)", async () => { + let out: unknown = "absent"; + let release!: () => void; + let setOn!: (v: boolean) => void; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [s] = createSignal(0); + const [on, set] = createSignal(true); + setOn = set; + const slow = createMemo(() => { + const v = s(); + return new Promise(r => (release = () => r(v))); + }); + const fast = createMemo(() => Promise.resolve(s())); + const late = createMemo(() => Promise.resolve(slow())); + const verdict = createMemo(() => isPending(() => [fast(), late()])); + createRenderEffect( + () => (on() ? `${verdict()}:${late()}` : "off"), + v => { + out = v; + } + ); + }); + await drain(4); + setOn(false); + await drain(4); + expect(out).toBe("off"); + release(); + await drain(4); + setOn(true); + await drain(4); + expect(out).toBe("false:0"); + dispose(); + }); + + // F5b. The same rule seen from a reader the guess does not hold: a render + // effect on a shown lane re-run by an unrelated write while the lane is + // held on a re-guess's derivation is a stale reader (#3460) and should show + // the screen — the guess the lane revealed — not the re-guess the lane has + // not revealed yet, which no other reader shows. `display()` serves a + // guess's lane slot once the lane has shown, and a re-guess writes that + // slot directly (a derivation of a shown lane stages instead). it.fails( - "F5: a second guess on a shown lane does not tear against the first guess's derivation (A17)", + "F5b: a stale reader of a held re-guess shows the revealed guess (A17 #3460)", async () => { const [source, setSource] = createSignal(0); - const frames: string[] = []; + const [t, setT] = createSignal(0); + const gates: (() => void)[] = []; + let a = "", + b = ""; let resume!: () => void; let run!: () => Promise; let dispose!: () => void; createRoot(d => { dispose = d; const [view, setView] = createOptimistic(() => source()); - const d0 = createMemo(() => Promise.resolve(view())); - const d1 = createMemo(() => { - const v = d0(); - return Promise.resolve(v); + const d0 = createMemo(() => { + const v = view(); + return new Promise(r => gates.push(() => r(v))); }); createRenderEffect( - () => [view(), d1()], - t => { - frames.push(t.join(",")); + () => [t(), view()], + v => { + a = v.join(","); + } + ); + createRenderEffect( + () => [view(), d0()], + v => { + b = v.join(","); } ); run = action(function* () { @@ -358,21 +473,26 @@ describe("fuzz findings on L2 — lanes", () => { setSource(0); }); }); + flush(); + gates.shift()!(); await drain(); - expect(frames).toEqual(["0,0"]); const p = run(); - await drain(6); - expect(frames).toEqual(["0,0", "1,1"]); - resume(); // second guess: 2 - await drain(6); - // Every delivered frame is coherent: d1 derived from the view beside it. - for (const f of frames) { - const [v, d] = f.split(","); - expect(d, `frame ${f}`).toBe(v); - } + await drain(); + gates.shift()!(); + await drain(); + expect([a, b]).toEqual(["0,1", "1,1"]); + resume(); // re-guess 2: the lane holds on d0's re-ask + await drain(); + setT(1); // an unrelated write re-runs the first reader + await drain(); + expect([a, b]).toEqual(["1,1", "1,1"]); + gates.shift()!(); + await drain(); + expect([a, b]).toEqual(["1,2", "2,2"]); resume(); await p; - await drain(6); + for (const g of gates) g(); + await drain(); dispose(); } ); diff --git a/packages/signals/tests/ispending-memo-gate-3766.test.ts b/packages/signals/tests/ispending-memo-gate-3766.test.ts new file mode 100644 index 000000000..e3abe2d51 --- /dev/null +++ b/packages/signals/tests/ispending-memo-gate-3766.test.ts @@ -0,0 +1,132 @@ +import { + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending +} from "../src/index.js"; + +const delay = (value: T, ms: number): Promise => + new Promise(resolve => setTimeout(() => resolve(value), ms)); +const wait = (ms: number) => new Promise(r => setTimeout(r, ms)); + +describe("#3766 memo over isPending read beside a second async memo", () => { + for (const variant of ["memo-first", "gate-first", "inline"] as const) { + it(`initial mount: ${variant}`, async () => { + const seen: string[] = []; + createRoot(() => { + const source = createMemo(() => delay(10, 10)); + const gate = createMemo(() => delay(100, 20)); + const pending = createMemo(() => isPending(source)); + createRenderEffect( + () => + variant === "memo-first" + ? `${pending()} | ${gate()}` + : variant === "gate-first" + ? `${gate()} | ${pending()}` + : `${isPending(source)} | ${gate()}`, + v => { + seen.push(v); + } + ); + }); + flush(); + await wait(100); + flush(); + expect(seen).toEqual([variant === "gate-first" ? "100 | false" : "false | 100"]); + }); + } + + it("initial mount: a second reader still waiting holds the first", async () => { + const seen: string[] = []; + createRoot(() => { + const source = createMemo(() => delay(10, 10)); + const gate = createMemo(() => delay(100, 20)); + const late = createMemo(() => delay(200, 40)); + const pending = createMemo(() => isPending(source)); + createRenderEffect( + () => `a ${pending()} | ${gate()}`, + v => { + seen.push(v); + } + ); + createRenderEffect( + () => `b ${pending()} | ${late()}`, + v => { + seen.push(v); + } + ); + }); + flush(); + await wait(100); + flush(); + expect(seen).toEqual(["a false | 100", "b false | 200"]); + }); + + it("initial mount: a plain write while the reader waits on gate", async () => { + const seen: string[] = []; + let setLabel!: (v: string) => void; + createRoot(() => { + const [label, set] = createSignal("a"); + setLabel = set; + const source = createMemo(() => delay(10, 10)); + const gate = createMemo(() => delay(100, 60)); + const pending = createMemo(() => isPending(source)); + createRenderEffect( + () => `${label()} ${pending()} | ${gate()}`, + v => { + seen.push(v); + } + ); + }); + flush(); + await wait(30); + setLabel("b"); + flush(); + expect(seen).toEqual([]); + await wait(100); + flush(); + expect(seen).toEqual(["b false | 100"]); + }); + + it("update: two readers of gate agree", async () => { + let a = ""; + let b = ""; + let setCount!: (v: number) => void; + createRoot(() => { + const [count, set] = createSignal(1); + setCount = set; + const source = createMemo(() => delay(count(), 10)); + const gate = createMemo(() => delay(count() * 100, 20)); + const pending = createMemo(() => isPending(source)); + const slow = createMemo(() => { + const value = pending(); + return delay(value, value ? 30 : 300); + }); + createRenderEffect( + () => `${pending()} | ${gate()}`, + v => { + a = v; + } + ); + createRenderEffect( + () => `${slow()} | ${gate()}`, + v => { + b = v; + } + ); + }); + flush(); + await wait(400); + flush(); + expect([a, b]).toEqual(["false | 100", "false | 100"]); + setCount(2); + flush(); + for (let i = 0; i < 50; i++) { + await wait(10); + expect(a.split(" | ")[1]).toBe(b.split(" | ")[1]); + } + expect([a, b]).toEqual(["false | 200", "false | 200"]); + }); +}); diff --git a/packages/signals/tests/optimistic-over-held-row-3796.test.ts b/packages/signals/tests/optimistic-over-held-row-3796.test.ts new file mode 100644 index 000000000..7d6e93dc8 --- /dev/null +++ b/packages/signals/tests/optimistic-over-held-row-3796.test.ts @@ -0,0 +1,68 @@ +import { + action, + createOptimisticStore, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush +} from "../src/index.js"; + +const tick = () => new Promise(r => setTimeout(r, 0)); +async function drain(n = 3) { + for (let i = 0; i < n; i++) { + await tick(); + flush(); + } +} + +type Card = { id: number; title: string; failed?: boolean; saving?: boolean }; + +// #3796: an action's write to a row of the derived store an optimistic store +// wraps holds that row's staging; the optimistic write that follows edits the +// row's keys, not the container's slot — the slot is the same row in both +// frames and is no guess. The edit shows while the action is pending (A17). +describe("optimistic store over a row the action holds (#3796)", () => { + for (const localWrite of [true, false]) + it(`shows the guesses while pending${localWrite ? " after a local write" : ""}`, async () => { + const shown: string[] = []; + let save!: () => Promise; + let complete!: () => void; + createRoot(() => { + const [server, setServer] = createSignal([{ id: 1, title: "Old" }]); + const [local, setLocal] = createStore(draft => { + const s = server(); + draft.length = 0; + for (const r of s) draft.push({ ...r, failed: false }); + }, []); + const [cards, setOptimistic] = createOptimisticStore(local); + createRenderEffect( + () => `${cards[0]?.title} / saving: ${!!cards[0]?.saving}`, + v => { + shown.push(v); + } + ); + save = action(function* () { + if (localWrite) + setLocal(draft => { + draft[0].failed = false; + }); + setOptimistic(draft => { + draft[0].title = "New"; + draft[0].saving = true; + }); + yield new Promise(r => (complete = r)); + setServer([{ id: 1, title: "New" }]); + }); + }); + flush(); + expect(shown).toEqual(["Old / saving: false"]); + const p = save(); + await drain(); + expect(shown).toEqual(["Old / saving: false", "New / saving: true"]); + complete(); + await p; + await drain(); + expect(shown.at(-1)).toBe("New / saving: false"); + }); +}); diff --git a/packages/web/test/ispending-memo-gate-3766.spec.tsx b/packages/web/test/ispending-memo-gate-3766.spec.tsx new file mode 100644 index 000000000..c943db132 --- /dev/null +++ b/packages/web/test/ispending-memo-gate-3766.spec.tsx @@ -0,0 +1,70 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ + +import { describe, expect, test } from "vitest"; +import { createMemo, createSignal, flush, isPending } from "solid-js"; +import { render } from "@solidjs/web"; + +const delay = (value: T, ms: number): Promise => + new Promise(resolve => setTimeout(() => resolve(value), ms)); +const wait = (ms: number) => new Promise(r => setTimeout(r, ms)); + +// Port of https://s.olid.uk/id/lg40u1dpQkC5mIUmTn_Grw, delays divided by 10. +describe("#3766 memo over isPending read beside a second async memo", () => { + test("mounts once both first flights land", async () => { + const div = document.createElement("div"); + const dispose = render(() => { + const source = createMemo(() => delay(10, 10)); + const gate = createMemo(() => delay(100, 20)); + const pending = createMemo(() => isPending(source)); + return

{`${pending()} | ${gate()}`}

; + }, div); + await wait(100); + flush(); + expect(div.querySelector("p")?.textContent).toBe("false | 100"); + dispose(); + }); + + test("two readers of gate never disagree across an update", async () => { + const div = document.createElement("div"); + let setCount!: (v: number) => void; + const dispose = render(() => { + const [count, set] = createSignal(1); + setCount = set; + const source = createMemo(() => delay(count(), 10)); + const gate = createMemo(() => delay(count() * 100, 20)); + const pending = createMemo(() => isPending(source)); + const slow = createMemo(() => { + const value = pending(); + return delay(value, value ? 30 : 300); + }); + return ( + <> +

{`${pending()} | ${gate()}`}

+

{`${slow()} | ${gate()}`}

+ + ); + }, div); + const rows = () => Array.from(div.querySelectorAll("p")).map(p => p.textContent!.split(" | ")); + await wait(400); + flush(); + expect(rows()).toEqual([ + ["false", "100"], + ["false", "100"] + ]); + setCount(2); + flush(); + for (let i = 0; i < 50; i++) { + await wait(10); + const [a, b] = rows(); + expect(a[1]).toBe(b[1]); + } + expect(rows()).toEqual([ + ["false", "200"], + ["false", "200"] + ]); + dispose(); + }); +});