diff --git a/.changeset/fix-verdict-lane-flush-loop.md b/.changeset/fix-verdict-lane-flush-loop.md new file mode 100644 index 000000000..d98d35c32 --- /dev/null +++ b/.changeset/fix-verdict-lane-flush-loop.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Fix an infinite flush loop ("Potential Infinite Loop Detected") when a `latest()`-gated `Show` mounts a fresh `Loading` under an action hold: lane work that reads a node born staged is no longer re-queued to re-derive on a committed value it doesn't have. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index c1d8ab1ed..8883c70e6 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -73,7 +73,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | 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… | | A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:299` | — | 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:93` | 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 issue-3800-repro.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:117` | boundaries.ts×1 action.ts×1 async.ts×1 constants.ts×1 core.ts×13 effect.ts×1 lanes.ts×1 scheduler.ts×4 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 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-mount-nested-memo-3835.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 verdict-mount-first-pass-3851.test.ts×2 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 … | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | boundaries.ts×1 action.ts×1 async.ts×1 constants.ts×1 core.ts×13 effect.ts×1 lanes.ts×1 scheduler.ts×4 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 loading-fallback-in-flush-3540.test.ts×6 loading-on-frame-following-3540.test.ts×1 mount-over-foreign-hold-3761.test.ts×1 optimistic-mount-nested-memo-3835.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 verdict-mount-first-pass-3851.test.ts×2 verdict-mount-loading-flush-loop.test.ts×2 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:255` | 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:129` | 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:137` | 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… | diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 731cc0f6f..cd737912d 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1858,7 +1858,7 @@ export function serve(el: Signal | Computed, c: Computed | null): stagedScreen(c!) ) return el._value; - stagedRead(c!); + stagedRead(c!, el); return el._pendingValue; } @@ -1870,10 +1870,16 @@ export function serve(el: Signal | Computed, c: Computed | null): * the committed world and its lane's runs wait that round, so the held * write never shows through the lane. A verdict lane's work likewise: the * lane holds verdicts, not the frame's other stagings (#3851) — except a - * verdict reader, which answered for itself (the lane seam, lanes.ts). */ -export function stagedRead(c: Computed): void { + * verdict reader, which answered for itself (the lane seam, lanes.ts). Not + * a read of a node born staged: it has no committed value to re-derive on, + * and a re-run would read the same staging and re-queue every round. */ +export function stagedRead(c: Computed, el?: Signal | Computed): void { c._flags |= REACTIVE_STAGED_READ; - if (passLane !== null) stagedReaders.push(c); + if ( + passLane !== null && + !((el as Computed | undefined)?._statusFlags! & STATUS_UNINITIALIZED) + ) + stagedReaders.push(c); } /** A10 for a staged node: a verdict reader (the pass entered a window) that diff --git a/packages/signals/tests/verdict-mount-loading-flush-loop.test.ts b/packages/signals/tests/verdict-mount-loading-flush-loop.test.ts new file mode 100644 index 000000000..54977cde8 --- /dev/null +++ b/packages/signals/tests/verdict-mount-loading-flush-loop.test.ts @@ -0,0 +1,105 @@ +/** + * A verdict reader mounts a fresh Loading under an action hold (semantic + * fuzzer, mount-under-hold seed 3289 cases 119 and 323). + * + * ` 0}>` (gated through a memo) opens while an action + * holds `x`, and mounts a `` whose content reads `x` directly with + * no load of its own. The boundary's output is born into the held frame, so + * it has no committed value; the render effect inserting it is verdict-lane + * work that read its staging. Since #3869 the seam re-queued that effect to + * re-derive on the committed world every round the frame stayed parked, and + * each re-run read the same staging: "Potential Infinite Loop Detected". + */ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + action, + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + latest, + untrack +} from "../src/index.js"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +const resolve = (v: unknown): unknown => { + while (typeof v === "function") v = (v as () => unknown)(); + return v; +}; + +function mount(anchor: boolean) { + const [x, setX] = createSignal(0); + const screen = { x: undefined as number | undefined, slot: undefined as unknown }; + createRoot(() => { + if (anchor) + createRenderEffect(x, v => { + screen.x = v; + }); + const condition = createMemo(() => latest(x) > 0); + const children = createMemo(() => + condition() + ? untrack(() => + createLoadingBoundary( + () => untrack(() => () => `content ${x()}`), + () => "fallback" + ) + ) + : undefined + ); + createRenderEffect( + () => resolve(children()), + v => { + screen.slot = v; + } + ); + }); + flush(); + const committed = () => (anchor ? screen.x : untrack(x)); + let release!: () => void; + const hold = () => + action(function* () { + setX(1); + yield new Promise(r => (release = r)); + })(); + return { screen, committed, hold, release: () => release() }; +} + +describe("a verdict reader mounts a fresh Loading under an action hold", () => { + for (const anchor of [false, true]) + describe(anchor ? "with a reader of x on screen (case 323)" : "case 119", () => { + test("flush settles while held, and the commit reveals the content", async () => { + const m = mount(anchor); + expect(m.screen.slot).toBeUndefined(); + expect(m.committed()).toBe(0); + + const done = m.hold(); + expect(() => flush()).not.toThrow(); + await Promise.resolve(); + expect(() => flush()).not.toThrow(); + expect(m.committed()).toBe(0); + + m.release(); + await done; + flush(); + expect(m.committed()).toBe(1); + expect(m.screen.slot).toBe("content 1"); + }); + + // A29: a fresh, never-shown Loading over held data shows its fallback + // now. Pre-#3869 behavior, still open (MH1): the content shows the held + // value beside the committed `x = 0`. + test.fails("while held, the fresh boundary shows its fallback (A29)", async () => { + const m = mount(anchor); + m.hold(); + flush(); + await Promise.resolve(); + flush(); + expect(m.committed()).toBe(0); + expect(m.screen.slot).toBe("fallback"); + }); + }); +});