Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-verdict-lane-flush-loop.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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… |
Expand Down
14 changes: 10 additions & 4 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1858,7 +1858,7 @@ export function serve(el: Signal<any> | Computed<any>, c: Computed<any> | null):
stagedScreen(c!)
)
return el._value;
stagedRead(c!);
stagedRead(c!, el);
return el._pendingValue;
}

Expand All @@ -1870,10 +1870,16 @@ export function serve(el: Signal<any> | Computed<any>, c: Computed<any> | 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<any>): 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<any>, el?: Signal<any> | Computed<any>): void {
c._flags |= REACTIVE_STAGED_READ;
if (passLane !== null) stagedReaders.push(c);
if (
passLane !== null &&
!((el as Computed<any> | undefined)?._statusFlags! & STATUS_UNINITIALIZED)
)
stagedReaders.push(c);
}

/** A10 for a staged node: a verdict reader (the pass entered a window) that
Expand Down
105 changes: 105 additions & 0 deletions packages/signals/tests/verdict-mount-loading-flush-loop.test.ts
Original file line number Diff line number Diff line change
@@ -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).
*
* `<Show when={latest(x) > 0}>` (gated through a memo) opens while an action
* holds `x`, and mounts a `<Loading>` 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<void>(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");
});
});
});
Loading