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
7 changes: 7 additions & 0 deletions .changeset/fix-memo-pull-under-lane.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@solidjs/signals": patch
---

A memo computes under its own lane posture, never its puller's (#3442).

A combined `isPending(() => [fast(), copy()])` over two async memos, with `copy` a sync memo wrapping the slow one, released the hold as soon as the fast flight landed: `Fast: 1` beside `Slow: 0` with `Pending: false`, then `Slow: 1` a second later. The probe effect carries the companion lane of the pending signals it reads, and its pull of `copy` ran under that lane — where a pending node on no lane serves its committed value instead of throwing — so `copy` published a stale settled value, dropped its pending status, and its readers stopped holding the slow flight. `recompute` now runs a memo plain unless the memo itself is lane-dirty or adopts a lane through its dependencies; effects keep the ambient lane, since their runs are the lane's own view. Both values now reveal together, with the probe reporting pending until they do.
75 changes: 38 additions & 37 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ Sync derivations of transition-held sources are visible through `latest()`/`isPe

A tracked computation served a node's staged `_pendingValue` — a value a live transaction holds — derives from that transaction's world, so its pass enters the transaction and its result is held with it. This is the read twin of the two entries that already existed, `setSignal` on a stamped node and `recompute` of a stamped node, and closes the gap between them: a conditional memo whose branch flipped mainline (`panel = show() ? count() : "hidden"`) started reading a held `count`, was served the staged value (non-stale readers keep speculation), and published a value derived from the held world into the mainline frame — `Panel: 1` beside `Count: 0`. A stale (render) reader is unchanged: the reveal carve-out (A15) serves it the committed value with no entanglement, which is why the same shape written as a plain JSX expression already showed a coherent frame. A probe (`isPending(() => x())`) observes, it does not derive, so it enters nothing (A23).

### A31. A memo computes under its own lane posture, never its puller's

**Status:** **live** 2026-09-14 (#3442) — stated by the fix; the lane-side twin of A29's "a value derived from the held world is that transaction's work"
**Pinned by:** `tests/ispending-combined-atomic-3442.test.ts` (#3442: a combined `isPending` over two async memos, one wrapped by a sync memo, holds both until both land)
**Mechanism (index, 2026-09-14):** `recompute` clears `currentOptimisticLane` for a non-effect node before the lane branches (`recomputeLane(el, true)` for an OPT-dirty node, `recomputeLane(el, false)` adoption through deps) re-establish the memo's own posture; effects keep the ambient lane.

A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself owns — OPT-dirty, or adopted through its dependencies — and never under the lane of whichever reader happened to pull it. Lane posture changes what a read serves: under a lane, a pending node on no lane (or another lane) serves its committed value instead of throwing, and the entanglement gates serve committed values for the lane's own view. Those carve-outs are sound for the lane's effects, whose runs are that view, and unsound for a memo, whose result is cached for everyone. Before: the probe effect of `isPending(() => [fast(), copy()])` carried the companion lane of the pending signals it reads, and its pull of `copy = createMemo(() => slow())` ran under it; `copy` read the in-flight `slow` as its committed `0`, published a clean value, dropped its pending status, and its readers stopped holding `slow` — the transaction settled on `fast`'s landing with `slow` still in flight (`Fast: 1` beside `Slow: 0`, `Pending: false`). Now the pull throws `NotReady` as a plain reader would, `copy` stays pending, and the hold lasts until both flights land. A plain getter in place of `copy` never had the gap: the probe read `slow` directly, and a probe observes without deriving (A23).

## Verdicts — `isPending()` and `latest()`

### A19. `isPending(x)` ≡ the observable value is not final (three causes)
Expand Down
11 changes: 11 additions & 0 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,17 @@ export function recompute(el: Computed<any>, create: boolean = false): void {
// covers creation-time computes and flushes that run inside the window.
const prevLatestRead = latestReadActive;
latestReadActive = false;
// A memo computes under its OWN lane posture, never the puller's (A31,
// #3442): its value is one shared slot every reader sees, so a pull from a
// lane-carrying reader (a probe effect on its companion lane pulling a sync
// memo) must not run it with that lane's read carve-outs — under a lane, a
// pending node on no lane serves its committed value instead of throwing,
// and the memo then published a stale "settled" value, dropped its
// pending status, and stopped holding its transaction. The branches below
// re-establish the posture the memo itself owns (OPT-dirty, or adopted
// through its deps). Effects keep the ambient lane: their runs are the
// lane's own view.
if (!isEffect) currentOptimisticLane = null;
// Lane posture lives with the engine: OPTIMISTIC_DIRTY is only ever set by
// engine-driven paths, and _optimisticNodes is only pushed by
// _optimisticWrite, so the hook is installed whenever either gate holds.
Expand Down
104 changes: 104 additions & 0 deletions packages/signals/tests/ispending-combined-atomic-3442.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
isPending
} from "../src/index.js";

let now = 0;
let timers: { at: number; run: () => void }[] = [];
function delay<T>(ms: number, value?: T): Promise<T> {
return new Promise<T>(r => timers.push({ at: now + ms, run: () => r(value as T) }));
}
async function settle() {
for (let r = 0; r < 3; r++) {
for (let i = 0; i < 10; i++) await Promise.resolve();
flush();
}
}
async function advanceTo(t: number) {
while (true) {
timers.sort((a, b) => a.at - b.at);
const next = timers[0];
if (!next || next.at > t) break;
timers.shift();
now = next.at;
next.run();
await settle();
}
now = t;
await settle();
}
function frames(log: string[], when: number[]): string[] {
const byTime = new Map<number, string[]>();
log.forEach((v, i) => (byTime.get(when[i]) ?? byTime.set(when[i], []).get(when[i])!).push(v));
return [...byTime].map(([t, vs]) => `${t}: ${vs.sort().join(" | ")}`);
}
function text(fn: () => string, log: string[], when: number[]) {
let last: string | undefined;
createRenderEffect(fn, v => {
if (v !== last) {
last = v;
log.push(v);
when.push(now);
}
});
}

async function scenario(variant: "issue" | "no-pending" | "getter") {
now = 0;
timers = [];
const log: string[] = [];
const when: number[] = [];
let setCount!: (v: number) => void;
createRoot(() => {
const [count, sC] = createSignal(0);
setCount = sC;
const slow = createMemo(() => delay(1000, count()));
const fast = createMemo(async () => count());
const copy = variant === "getter" ? () => slow() : createMemo(() => slow());
if (variant !== "no-pending")
text(() => `Pending: ${isPending(() => [fast(), copy()])}`, log, when);
text(() => `Fast: ${fast()}`, log, when);
text(() => `Slow: ${copy()}`, log, when);
});
flush();
await settle();
await advanceTo(2000);
setCount(1);
await settle();
await advanceTo(5000);
return frames(log, when);
}

describe("combined isPending read across two async memos (#3442)", () => {
// The write (2000) starts two flights; `fast` lands in a microtask and is
// held with `slow` (1000ms). The probe effect carries the companion lane of
// the pending signals it reads, and its pull of `copy` (a sync memo over
// `slow`) used to run under that lane, where a pending node on no lane
// serves its committed value instead of throwing: `copy` published a
// stale settled `0`, dropped its pending status, and its readers stopped
// holding `slow` — the hold released with `slow` still in flight
// (`Fast: 1` beside `Slow: 0`, `Pending: false`). A memo now computes
// under its own lane posture only.
const atomic = [
"1000: Fast: 0 | Pending: false | Slow: 0",
"2000: Pending: true",
"3000: Fast: 1 | Pending: false | Slow: 1"
];
it("A31 / #3442 isPending(() => [fast(), copy()]) with copy a sync memo holds both flights", async () => {
expect(await scenario("issue")).toEqual(atomic);
});
it("control: copy is a plain getter", async () => {
expect(await scenario("getter")).toEqual(atomic);
});
it("control: no pending read", async () => {
expect(await scenario("no-pending")).toEqual([
"1000: Fast: 0 | Slow: 0",
"3000: Fast: 1 | Slow: 1"
]);
});
});
3 changes: 3 additions & 0 deletions packages/signals/tests/treeshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ describe("pay-for-use tree-shaking (#2883)", () => {
// recompute's tail keeps an effect's dependency tail while a run is owed
// (`_modified`), and runEffect trims it once the run applies. Measured
// at 23,353 post-change.
// NOTE (2026-09-14, no bump): +12 B for memo lane posture (#3442) — one
// assignment at recompute's head runs a memo plain unless it owns or
// adopts a lane. Measured at 23,365 on top of #3438 (23,353 → 23,365).
expect(minifiedBytes).toBeLessThan(23_400);
});

Expand Down
72 changes: 72 additions & 0 deletions packages/web/test/ispending-combined-atomic-3442.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @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 = <T = void,>(ms: number, value?: T) =>
new Promise<T>(r => setTimeout(r, ms, value as T));

function snapshot(div: HTMLElement) {
return Array.from(div.querySelectorAll("p"))
.map(p => p.textContent)
.join(" | ");
}

// Exact port of https://s.olid.uk/id/P4Zyb5IlR_Gmz8H0dvcBUQ, 1000ms -> 100ms.
function App(props: { variant: "issue" | "no-pending" | "getter" }) {
const [count, setCount] = createSignal(0);
const slow = createMemo(() => delay(100, count()));
const fast = createMemo(async () => count());
const copy = props.variant === "getter" ? () => slow() : createMemo(() => slow());
return (
<>
<button onClick={() => setCount(1)}>Run</button>
{props.variant === "no-pending" ? (
<p>Pending: n/a</p>
) : (
<p>Pending: {String(isPending(() => [fast(), copy()]))}</p>
)}
<p>Fast: {fast()}</p>
<p>Slow: {copy()}</p>
</>
);
}

async function run(variant: "issue" | "no-pending" | "getter") {
const div = document.createElement("div");
document.body.appendChild(div);
const dispose = render(() => <App variant={variant} />, div);
await delay(150);
flush();
const initial = snapshot(div);
div.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const log: string[] = [];
for (let t = 0; t <= 140; t += 20) {
flush();
log.push(`t=${t}: ${snapshot(div)}`);
await delay(20);
}
process.stderr.write(`ISSUE-3442 ${variant}\ninitial: ${initial}\n${log.join("\n")}\n`);
dispose();
div.remove();
expect(initial).toMatch(/Fast: 0 \| Slow: 0$/);
expect(log.at(-1)).toMatch(/Fast: 1 \| Slow: 1$/);
// Fast and Slow must never disagree in a published frame, and while they
// are held the combined probe reports pending.
for (const line of log) {
expect(line, line).not.toMatch(/Fast: 1 \| Slow: 0/);
if (variant !== "no-pending" && / Fast: 0 \| Slow: 0$/.test(line))
expect(line, line).toMatch(/Pending: true/);
}
}

describe("combined isPending read across two async memos (#3442)", () => {
test("issue: isPending(() => [fast(), copy()]) with copy = createMemo(() => slow())", () =>
run("issue"));
test("control: no pending read", () => run("no-pending"));
test("control: copy is a plain getter", () => run("getter"));
});
5 changes: 5 additions & 0 deletions scripts/size/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,9 @@ module.exports = [
// 15636 B against `next`'s 15580 (+56 brotli on +35 B minified — the
// `_modified` gate on recompute's trim and runEffect's trim); see the
// core floor note.
// Memo lane posture (#3442, 2026-09-14): no bump, measured at 15624 B on
// the rebase over #3438 (+12 B minified — one assignment in recompute's
// head; brotli noise absorbs it; see the core floor note).
limit: "15.65 KB",
modifyEsbuildConfig
},
Expand Down Expand Up @@ -749,6 +752,8 @@ module.exports = [
// Effect arm of A30 (#3438, 2026-09-14): 18.85 -> 18.90 KB, measured at
// 18880 B against `next`'s 18816 (+64 brotli on +35 B minified); see the
// core floor note.
// Memo lane posture (#3442, 2026-09-14): no bump, measured at 18854 B on
// the rebase over #3438 (+12 B minified; brotli noise absorbs it).
limit: "18.90 KB",
modifyEsbuildConfig
},
Expand Down
Loading