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/async-landing-deps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@solidjs/signals": patch
---

An async memo's held landing keeps the committed frame's dependencies (#3461).

- `selected = createMemo(async () => (b() ? b() : a()))` with `b` held by a slow flight: selected's held pass read only `b`, and its landing trimmed `a` at once, before the write staged the landed value under the hold. A later mainline `a` write no longer reached selected, so `A: 1` committed beside `Selected: 0` while `B` still read 0. The landing now trims only when it published (A30, the landing twin of a staged sync pass); a transition-held landing leaves the tail for its commit, so the `a` write reaches selected and joins the hold, and the four values reveal as one frame.
36 changes: 18 additions & 18 deletions packages/signals/docs/RULES-INDEX.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions packages/signals/docs/SPEC-ASYNC-SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,15 @@ A resting optimistic node reports pending via exactly the causes a plain async m
### A30. A memo's dependencies are the committed frame's until the frame is replaced

**Status:** **ruled** 2026-09-13 (#3410) — maintainer ruling, Cluster 4 triage; the dependency twin of held children (#3404)
**Pinned by:** `tests/held-conditional-memo.test.ts` (#3410: a memo whose held pass stopped reading an input still follows a mainline write to it); `tests/held-conditional-effect.test.ts` (#3438: an effect whose held pass stopped reading an input, its run stashed, still follows a mainline write to it)
**Mechanism (index, 2026-09-14):** `recompute` trims the previous pass's dependency tail (`trimStaleDeps`) only when the pass published or changed nothing (`_pendingValue === NOT_PENDING`, no `_error`) and, for an effect, owes no run (`_modified` clear); a pass that staged its value leaves the tail linked and `commitPendingNode` trims it after a clean pass (`_error == null`); an effect pass that direct-committed and owes a run leaves it for `runEffect` to trim once the run applies (again after a clean pass). `__OBSERVE__` fan-in counting and the attribution engine's subscription diff walk the validated prefix only.
**Pinned by:** `tests/held-conditional-memo.test.ts` (#3410: a memo whose held pass stopped reading an input still follows a mainline write to it); `tests/held-conditional-effect.test.ts` (#3438: an effect whose held pass stopped reading an input, its run stashed, still follows a mainline write to it); `tests/async-landing-deps-3461.test.ts` (#3461: an async memo whose held flight stopped reading an input, its landing staged, still follows a mainline write to it)
**Mechanism (index, 2026-09-15):** `recompute` trims the previous pass's dependency tail (`trimStaleDeps`) only when the pass published or changed nothing (`_pendingValue === NOT_PENDING`, no `_error`) and, for an effect, owes no run (`_modified` clear); a pass that staged its value leaves the tail linked and `commitPendingNode` trims it after a clean pass (`_error == null`); an effect pass that direct-committed and owes a run leaves it for `runEffect` to trim once the run applies (again after a clean pass). `__OBSERVE__` fan-in counting and the attribution engine's subscription diff walk the validated prefix only. An async landing (`asyncWrite`) follows the same gate (#3461): it trims only when it published (`_pendingValue === NOT_PENDING` after the write, an equal-value or lane landing); a transition-held landing leaves the tail for `commitPendingNode`.

A pass that _staged_ its value has not replaced the committed frame, so the committed value still derives from the previous pass's dependencies and a write to one of them must reach the node — and, through the node's `_transition` stamp, join its hold — exactly as an unconditional read would. Before: `selected = fixed() ? 2 : count()` held on `fixed → true` dropped `count` at its held pass, and a mainline `count` write then published `Count: 1` beside the committed `Selected: 0` / `Fixed: false`. Decided at commit rather than at the pass because a plain flush knows nothing at recompute time: the transaction that ends up holding the pass may open later in the same flush (an async memo downstream pends and the batch is adopted). An errored pass (a throw, NotReady included, a comparator throw) keeps its full list as before, and the commit skips the trim by the same `_error`. Cost on the plain path: none — a pass that publishes trims at its tail as before; only a staged pass moves the trim to the same flush's commit.

An effect's frame is the run its value is applied by, not its value slot (#3438). A plain-flush pass direct-commits `_value` and enqueues the run, but the same flush can still become a hold (the async memo downstream pends, the batch is adopted) and stash that run with the transaction — so the committed frame is still what the _last_ run published, and it still derives from the previous pass's dependencies. Before: `{show() ? count() : "hidden"}` (the compiler's insert effect) held on `show → false` dropped `count` at its pass, and the mainline `count` write then published `Count: 1` beside `Panel: 0` while `Show` still read `true`. Now the pass leaves the tail while a run is owed and `runEffect` trims once it applies. The write reaches the effect; a render effect is a mainline reader of the foreign hold (it is served the committed `show`, A15's stale-reader term), so it re-derives `Panel: 1` beside `Count: 1` in the mainline frame, and the hold's reveal re-derives it to `hidden` — unlike a memo, which is served the staged value and joins the hold (A29). Both frames are coherent; the shapes differ because effects render mainline by design. A pass that changed nothing owes no run and trims at its tail as before.

An async memo's frame is replaced by its landing, not by the pass that registered the flight (#3461). The flight's pass throws `NotReady` and keeps its full list; the landing used to trim unconditionally, before the write, even when `setSignal` then staged the value under a live transaction. Before: `selected = async () => (b() ? b() : a())` held on `b → 1` dropped `a` at its landing, and a mainline `a` write then published `A: 1` beside `Selected: 0` while `B` still read 0. Now the landing trims only when it published; a held landing leaves the tail for its commit, so the `a` write reaches `selected` and joins its hold (its stamp), exactly as the sync memo's staged pass does. Unchanged by design: a landing equal to the committed value published nothing new and trims at once, like a sync pass that changed nothing, so `b() ? 0 : a()` still drops `a` in both shapes.

## Loading window and seeds

### A27. The commit-#0 loading window is loading-class and verdict-quiet
Expand Down
10 changes: 9 additions & 1 deletion packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,6 @@ export function handleAsync<T>(
// old value as pending, a one-frame pulse to direct observers (#3178).
// A truthy capture implies `_x` exists, so the restore writes it directly.
const wasReask = el._x?._reask;
trimStaleDeps(el);
landStatus(el);
if (wasReask) el._x!._reask = true;
const lane = resolveLane(el as any);
Expand Down Expand Up @@ -590,6 +589,15 @@ export function handleAsync<T>(
if (el._pendingValue === NOT_PENDING) {
el._loading = false;
if (wasReask) el._x!._reask = false;
// The landing published: the dependency tail the flight's pass left
// linked goes now (A30, #3410). A transition-held landing has not
// replaced the committed frame — the committed value still derives
// from the previous pass's inputs, and a mainline write to one of them
// must reach this node and join its hold (its stamp) instead of
// publishing beside the stale derivation (#3461: `b() ? b() : a()`
// held on `b` dropped `a` at its landing, and `A: 1` then committed
// beside `Selected: 0`). `commitPendingNode` trims a held landing.
trimStaleDeps(el);
}
settlePendingSource(el);
schedule();
Expand Down
107 changes: 107 additions & 0 deletions packages/signals/tests/async-landing-deps-3461.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { createMemo, createRenderEffect, createRoot, createSignal, flush } from "../src/index.js";

// Manual clock (see async-chain-supersession.test.ts).
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 reset() {
now = 0;
timers = [];
}
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);
}
});
}

// #3461: `selected = async () => (b() ? b() : a())`. `setB(1)` is held by `slow`;
// selected's held pass reads only `b` and its flight lands into the hold. The
// landing used to trim `a` at once, so the later mainline `setA(1)` never
// reached selected: `A: 1` committed beside `Selected: 0` while `B` still read
// 0. A30: the landed value is staged, the committed 0 still derives from `a`.
async function scenario(asyncSelected: boolean) {
reset();
const log: string[] = [];
const when: number[] = [];
let setA!: (v: number) => void;
let setB!: (v: number) => void;
createRoot(() => {
const [a, sA] = createSignal(0);
const [b, sB] = createSignal(0);
setA = sA;
setB = sB;
const slow = createMemo(() => delay(2000, b()));
const selected = asyncSelected
? createMemo(async () => (b() ? b() : a()))
: createMemo(() => (b() ? b() : a()));
text(() => `A: ${a()}`, log, when);
text(() => `B: ${b()}`, log, when);
text(() => `Slow: ${slow()}`, log, when);
text(() => `Selected: ${selected()}`, log, when);
});
flush();
await settle();
await advanceTo(3000);
setB(1);
await settle();
await advanceTo(3500);
setA(1);
await settle();
await advanceTo(6000);
return frames(log, when);
}

describe("async memo landing keeps the committed frame's dependencies", () => {
it("A30 / #3461 a held async landing does not trim the input its committed value derives from", async () => {
// The `a` write (3500) reaches selected through the dependency its
// committed value still has, and joins the hold through selected's
// stamp: one frame when slow lands, never `A: 1` beside `Selected: 0`.
// (The async memo's first landing is held with the mount's slow flight
// and reveals with it at 2000; the sync control publishes it at 0.)
expect(await scenario(true)).toEqual([
"0: A: 0 | B: 0",
"2000: Selected: 0 | Slow: 0",
"5000: A: 1 | B: 1 | Selected: 1 | Slow: 1"
]);
});

it("A30 / #3410 sync control: the same conditional as a plain memo", async () => {
expect(await scenario(false)).toEqual([
"0: A: 0 | B: 0 | Selected: 0",
"2000: Slow: 0",
"5000: A: 1 | B: 1 | Selected: 1 | Slow: 1"
]);
});
});
77 changes: 77 additions & 0 deletions packages/web/test/async-landing-deps-3461.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*/

import { describe, expect, test } from "vitest";
import { createMemo, createSignal, flush } 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 the issue's playground (https://s.olid.uk/id/XOQbd6CFTfy2QhZK4EiQ7g),
// timings scaled 2000/500 -> 200/50.
function App(props: { asyncSelected: boolean }) {
const [a, setA] = createSignal(0);
const [b, setB] = createSignal(0);
const slow = createMemo(() => delay(200, b()));
const selected = props.asyncSelected
? createMemo(async () => (b() ? b() : a()))
: createMemo(() => (b() ? b() : a()));
return (
<>
<button
onClick={async () => {
setB(1);
await delay(50);
setA(1);
}}
>
Run
</button>
<p>A: {a()}</p>
<p>B: {b()}</p>
<p>Slow: {slow()}</p>
<p>Selected: {selected()}</p>
</>
);
}

async function run(asyncSelected: boolean) {
const div = document.createElement("div");
document.body.appendChild(div);
const dispose = render(() => <App asyncSelected={asyncSelected} />, div);
await delay(250);
flush();
expect(snapshot(div)).toBe("A: 0 | B: 0 | Slow: 0 | Selected: 0");

div.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
const log: string[] = [];
for (let t = 0; t <= 300; t += 20) {
flush();
log.push(`t=${t}: ${snapshot(div)}`);
await delay(20);
}
dispose();
div.remove();

expect(log.at(-1)).toBe("t=300: A: 1 | B: 1 | Slow: 1 | Selected: 1");
// While B still reads 0, Selected (b() ? b() : a()) must agree with A: the
// `a` write joins the hold rather than publishing beside the stale derivation.
for (const line of log) {
const m = /A: (\d) \| B: 0 \| Slow: 0 \| Selected: (\d)/.exec(line);
if (m) expect(m[2], line).toBe(m[1]);
}
}

describe("async conditional memo across a held branch change (#3461)", () => {
test("A30 / #3461 `createMemo(async () => b() ? b() : a())`", () => run(true));
test("plain createMemo conditional (control)", () => run(false));
});
7 changes: 6 additions & 1 deletion scripts/size/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,12 @@ module.exports = [
// mainline). +316 B minified in the in-package floor (23,365 -> 23,681).
// Rebased over #3443/#3444 (2026-09-15): measured at 8820 B against
// `next`'s 8708 (+112); 23,419 -> 23,753 minified.
limit: "8.85 KB",
// Async landing keeps the committed frame's deps (A30 landing arm,
// #3461, 2026-09-15): 8.85 -> 8.90 KB, measured at 8851 B against
// `next`'s 8849 (+2, brotli noise for a moved call: asyncWrite's
// trimStaleDeps now runs after the write, only when the landing
// published; 0 B minified in the in-package floor, 23,752 flat).
limit: "8.90 KB",
modifyEsbuildConfig
},
{
Expand Down
Loading