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
8 changes: 8 additions & 0 deletions .changeset/ispending-async-derivation-lane-4677.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@solidjs/signals": patch
---

An async memo that reads `isPending(x)` no longer shows its verdict beside an `isPending(x)` binding that disagrees with it, and nothing derived from the write it reads ahead of shows before that write does (#4677).

- When the memo's lane landed in the same seam as the write it reads ahead of, the landing voided the binding's lane run, so the memo's `pending: true` showed while the binding stayed `false`. The lane now reveals whole (F-6), and the landing's re-derivation follows next round.
- When the lane landed first, the write's landing re-derived the memo onto a new flight and held its own runs for it, but that flush's runs of effects deriving plainly from the landing still ran: `details: Item 1` showed beside `id: 0`. Those runs now wait with the landing's held runs (F-1, F-7); what derives from a verdict reader still shows now (F-6, D-25).
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul
| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:199` | — | 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… |
| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | — | 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 … |
| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:215` | — | 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:251` | async.ts×2 constants.ts×1 core.ts×4 effect.ts×1 lanes.ts×6 scheduler.ts×18 seat.ts×1 verdict.ts×5 store.ts×1 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 hold-recovers-landing-reruns-4469.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 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 latest-memo-inline-parity.test.ts×1 latest-stale-branch-hold-4661.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 optimistic-lane-seam-loop-4643.test.ts×1 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 show-stale-child-in-action-4137.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 tracked-effect-staged-dep-4606-4607.test.ts×1 treeshake.test.ts×4 unread-derive-midflight-mount-4491.test.ts×1 verdict-memo-flight-no-reask.test.ts×1 verdict-memo-held-branch-4544.test.ts×1 verdict-probe-committed-4648.test.ts×2 verdict-shortcut-pending-source-liveness.test.ts×1 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 … |
| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:251` | async.ts×2 constants.ts×1 core.ts×4 effect.ts×1 lanes.ts×7 scheduler.ts×18 seat.ts×1 verdict.ts×5 store.ts×1 | async-chain-supersession.test.ts×1 boundary-output-frame-reader.test.ts×1 create-deferred-3710.test.ts×2 first-observer-stale-reader.test.ts×1 fuzz-findings-l2.test.ts×17 held-restore.test.ts×1 hold-recovers-landing-reruns-4469.test.ts×1 l2-contract.test.ts×7 l2-fuzz-existing-rules.test.ts×2 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 latest-memo-inline-parity.test.ts×1 latest-stale-branch-hold-4661.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×1 loading-fallback-in-flush-3540.test.ts×4 mount-over-foreign-hold-3761.test.ts×2 optimistic-lane-seam-loop-4643.test.ts×1 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 show-stale-child-in-action-4137.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 tracked-effect-staged-dep-4606-4607.test.ts×1 treeshake.test.ts×4 unread-derive-midflight-mount-4491.test.ts×1 verdict-memo-flight-no-reask.test.ts×1 verdict-memo-held-branch-4544.test.ts×1 verdict-probe-committed-4648.test.ts×2 verdict-shortcut-pending-source-liveness.test.ts×1 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:223` | scheduler.ts×1 | caught-reactive-read-diagnostic.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 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:71` | async.ts×3 constants.ts×1 lanes.ts×7 scheduler.ts×2 seat.ts×1 verdict.ts×1 map.ts×1 optimistic.ts×2 store.ts×3 | create-deferred-3710.test.ts×1 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:81` | 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 l2-fuzz-existing-rules.test.ts×2 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×5 flight-owned-transaction.test.ts×1 lane-authority-twins.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×17 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… |
Expand Down
50 changes: 48 additions & 2 deletions packages/signals/src/core/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
setPassLane,
txOf,
type Transaction,
globalQueue,
installLaneHooks,
armGuessing
} from "./scheduler.js";
Expand Down Expand Up @@ -880,7 +881,17 @@ function laneSeam(l: Transaction, leaked: boolean): void {
}
l._held = false;
l._shown = true;
if (reruns(l)) revealing.push(resolveTx(l._parent!));
const p = resolveTx(l._parent!),
v = p._reruns.filter(r => (r as any)._modified && r._x?._transaction === l);
if (reruns(l)) revealing.push(p);
// The parent lands this seam and re-derives readers whose runs the lane
// stashed, voiding them (`reruns`: they are for the frame being replaced).
// Beside other runs of the lane they show first: the lane's readers show
// its answer together (F-6), and the re-derivation follows next round
// (#4677: `pending: true` on an async memo over `isPending(id)` beside a
// binding still `false`). Restoring a run `reruns` skipped changes nothing.
else if (l._queues[0].length > v.length)
l._queues[0].unshift(() => v.forEach(r => ((r as any)._modified = true)));
for (let i = 0; i < l._nodes.length; i++) {
const n = l._nodes[i] as Computed<any>;
// (Not once a pass since has notified them — D-37: a lane does not
Expand Down Expand Up @@ -1074,6 +1085,41 @@ function heldBy(t: Transaction, r: Computed<any>): boolean {
}
return true;
}
/** The effects that derive plainly from a landing this round that
* re-derives a stale reader (A15) reading it as committed: its held runs
* wait for the re-derivation (`land`), and these runs are that frame too —
* they wait with them (verdict.ts `attachWaits`; F-1, F-7, #4677). The walk
* stops at verdict readers: what derives from a display-ahead read shows
* now (F-6, D-25, #4398). Dropped at the end of the round's effect phase. */
export let waiting: Set<unknown> | null = null;
/** A transaction lands: its lanes end, nested first (`endLanes`). */
function landing(u: Transaction): void {
walk(u);
endLanes(u);
}
/** Read before the lanes end: a lane's runs are its reveal's (F-6). */
function walk(u: Transaction): void {
// As `reruns` judges it: a stale-reader memo re-derives onto a question of
// its own, and the landing's held runs wait for a re-derivation.
if (
!u._reruns.some(r => !(r as any)._type && r._flags & REACTIVE_FRAME_READ) ||
!u._reruns.some(
r =>
(r._flags & REACTIVE_FRAME_READ || r._config & CONFIG_VERDICT) &&
((r as any)._type || r._config & CONFIG_HELD)
)
)
return;
if (!waiting) ((waiting = new Set()), globalQueue._queues[1].push(() => (waiting = null)));
const q: any[] = u._nodes.filter(n => !(n._config & CONFIG_VERDICT));
for (let n; (n = q.pop()); )
for (let s = n._subs; s; s = s._nextSub) {
const c: any = s._sub;
if (waiting.has(c) || c._config & CONFIG_VERDICT || c._x?._transaction?._lane) continue;
waiting.add(c);
if (!c._type) q.push(c);
}
}
/** `r`'s pass read a node a transaction holds (#4439). */
function readsHeld(r: Computed<any>): boolean {
for (let d = r._deps; d; d = d._nextDep) if (d._dep._config & CONFIG_HELD) return true;
Expand Down Expand Up @@ -1260,7 +1306,7 @@ installLaneHooks(
applyGuessesAt,
laneSeams,
() => lanes.length !== 0 && laneCorrections(),
endLanes,
landing,
lanesBlocked,
adoptParked,
laneServed
Expand Down
12 changes: 11 additions & 1 deletion packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import { settlePendingSource } from "./async.js";
import { forgetThrownRead, warnStrictReadUntracked } from "./dev.js";
import { NotReadyError } from "./error.js";
import { link } from "./graph.js";
import { ahead, display, laneValueOf, verdictLane } from "./lanes.js";
import { ahead, display, laneValueOf, verdictLane, waiting } from "./lanes.js";
import { deleteFromHeap, enqueueSub } from "./heap.js";
import {
blocked,
Expand Down Expand Up @@ -843,6 +843,16 @@ function valueInserts(v: unknown): boolean {
* a value it reads) holds such a hole waits for that hold — the attach runs
* at the landing with its content, never as an empty shell now. */
function attachWaits(node: Computed<any>): boolean {
// A run of the frame a landing holds for its re-derivation (lanes.ts
// `waiting`) runs next round, with the landing's held runs.
if (waiting?.has(node))
return (
globalQueue.enqueue(
(node as any)._type,
((node as any)._boundRunEffect ??= GlobalQueue._runEffect.bind(null, node))
),
true
);
if (
(node as any)._type !== EFFECT_RENDER ||
!(node._config & CONFIG_VERDICT || node._x?._transaction?._lane) ||
Expand Down
144 changes: 144 additions & 0 deletions packages/signals/tests/ispending-async-derivation-4677.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// #4677: an async memo that reads `id()` and `isPending(id)` is a verdict
// reader (D-25: the probe decides the pass's posture), so its async opens a
// lane of its own (F-6). While its flight is up, the verdict binding and
// everything rendered from it wait together; at the lane's landing they
// reveal together, ahead of the parent when the lane lands first. The parent
// (the write to `id`) waits for the lane's flight, and when its landing has
// the badge re-derive onto a new flight, the landing's other runs park with
// that flight and show with it (U-5, D-9). F-1 holds throughout: no
// derivation ahead of its input, no verdict-derived value beside a verdict
// that disagrees with it.
//
// Two orders:
// 1. The issue's repro: details (the parent's own async) lands before the
// badge's lane flight. The lane lands in the same seam the parent commits
// in, whose landing voided the verdict binding's lane run: `pending: true`
// showed on the badge while the binding stayed `false` (the lane split:
// F-6, F-1). The lane reveals whole (`Pending true` beside the badge's
// `true`), then everything at the badge's re-derived landing (lanes.ts
// `laneSeam`).
// 2. The badge's lane flight lands first (it reveals whole); then details
// lands, the parent commits and the badge re-derives onto a new flight.
// The landing flush's own run (details) showed `Item 1` beside `id: 0`
// (F-1, F-7) while the held runs parked with the re-derivation (U-5). The
// flush's runs of effects that derive plainly from the landing wait with
// the held runs (lanes.ts `landing`); what derives from a verdict reader
// still shows now (#4398, `latest-first-one-pass-4402.test.ts`).
import { describe, expect, it } from "vitest";
import {
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
isPending
} from "../src/index.js";

const tick = async () => {
for (let i = 0; i < 8; i++) await Promise.resolve();
flush();
};

function setup(between = false) {
const [id, setId] = createSignal(0);
const details: Array<() => void> = [];
const badges: Array<() => void> = [];
const screen = { id: "", details: "", pending: "", badge: "" };
const frames: string[] = [];
const dispose = createRoot(dispose => {
const detailsMemo = createMemo(() => {
const i = id();
if (i === 0) return "Item 0";
return new Promise<string>(r => details.push(() => r(`Item ${i}`)));
});
const badge = createMemo(() => {
const i = id();
const p = isPending(id);
const text = `ID: ${i}, pending: ${p}`;
if (i === 0 && !p) return text;
return new Promise<string>(r => badges.push(() => r(text)));
});
const bind = (k: keyof typeof screen, fn: () => unknown) =>
createRenderEffect(fn, v => {
screen[k] = String(v);
});
bind("id", id);
// A plain memo between the landed node and its binding: still the
// landing's frame.
bind("details", between ? createMemo(() => `${detailsMemo()}`) : detailsMemo);
bind("pending", () => isPending(id));
bind("badge", badge);
return dispose;
});
flush();
const frame = () =>
`id ${screen.id} | ${screen.details} | pending ${screen.pending} | ${screen.badge}`;
const snap = () => frames.push(frame());
return { setId, details, badges, frames, snap, dispose };
}

describe("#4677 isPending beside an async memo derived from it", () => {
it("baseline: the badge's lane flight lands first and reveals whole", async () => {
const t = setup();
t.snap();
t.setId(1);
flush();
t.snap();
t.badges.shift()!();
await tick();
t.snap();
expect(t.frames).toEqual([
"id 0 | Item 0 | pending false | ID: 0, pending: false",
"id 0 | Item 0 | pending false | ID: 0, pending: false",
"id 0 | Item 0 | pending true | ID: 0, pending: true"
]);
t.dispose();
});

it("the issue's order: details lands, then the lane — the lane reveals whole", async () => {
const t = setup();
t.setId(1);
flush();
t.details.shift()!();
await tick();
t.snap();
t.badges.shift()!();
await tick();
t.snap();
t.badges.shift()!();
await tick();
t.snap();
expect(t.frames).toEqual([
// details landed; the parent waits for the lane's flight (F-6)
"id 0 | Item 0 | pending false | ID: 0, pending: false",
// the lane lands: the verdict and its derivation reveal together
"id 0 | Item 0 | pending true | ID: 0, pending: true",
// the badge's re-derivation lands with the parent's frame (U-5)
"id 1 | Item 1 | pending false | ID: 1, pending: false"
]);
t.dispose();
});

it.each([false, true])(
"the lane first, then details: no derivation shows ahead of `id` (memo between: %s)",
async between => {
const t = setup(between);
t.setId(1);
flush();
t.badges.shift()!();
await tick();
t.details.shift()!();
await tick();
t.snap();
t.badges.shift()!();
await tick();
t.snap();
expect(t.frames).toEqual([
// the parent's landing parks with the badge's re-derivation (U-5)
"id 0 | Item 0 | pending true | ID: 0, pending: true",
"id 1 | Item 1 | pending false | ID: 1, pending: false"
]);
t.dispose();
}
);
});
4 changes: 2 additions & 2 deletions scripts/size/floor-caps.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"minified": 102212
},
"page: live server components (base + live/GET + action + isPending/latest)": {
"cap": "40.14 KB",
"minified": 120619
"cap": "40.30 KB",
"minified": 121153
},
"server: floor (getRequestEvent + isServer)": {
"cap": "1.29 KB",
Expand Down
Loading
Loading