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/latest-derived-held-error-4106.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A memo over `latest(source)` now sees a held rejection at the same point it sees a held value (#4106). Such a memo is display-ahead (F-6): when a held flight on `source` resolves, the memo re-runs in the hold's verdict lane and its new value shows at once, while a sibling still holds the frame. A rejection did not reach it the same way. `stageError` woke the memo but scheduled no flush, so the memo kept its old value until the reveal. After the memo did re-run, its lane pass errored, but `latest(memo)` and `memo()` kept serving the lane value from before the error, while the memo's effect had already received the error. Now `stageError` schedules the flush, and a read of an errored lane member that is not a written guess throws its error. `latest(memo)`, `memo()` and the effect's error arm now show `boom` before the reveal, just as they show `2` when the source resolves.
4 changes: 3 additions & 1 deletion packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,10 @@ export function stageError(el: Computed<any>, error: any, inPass?: boolean): boo
setPendingError(el);
el._pendingValue = error instanceof StatusError ? error : new StatusError(el, error);
el._config |= CONFIG_STAGED | CONFIG_STAGED_ERROR;
// Readers parked on the flight re-run and derive from the staging.
// Readers parked on the flight re-run and derive from the staging; the
// verdict readers the walk wakes need a flush (#4106).
settlePendingSource(el);
schedule();
return true;
}

Expand Down
5 changes: 4 additions & 1 deletion packages/signals/src/core/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
REACTIVE_RECOMPUTING_DEPS,
REACTIVE_SCREEN_READ,
REACTIVE_STAGED_READ,
STATUS_ERROR,
STATUS_PENDING,
STATUS_UNINITIALIZED
} from "./constants.js";
Expand Down Expand Up @@ -553,9 +554,11 @@ function dissolveLane(l: Transaction, into: Transaction | null, except?: Signal<
export function laneRead(c: Computed<any> | null, el: Signal<any> | Computed<any>): unknown {
const guess = el._config & CONFIG_GUESS;
if (c !== null && c._config & CONFIG_AUTHORITATIVE) return GlobalQueue._truth!(el);
const status = (el as Computed<any>)._statusFlags;
// An errored member's lane value predates its error; the error is the answer (#4106).
if (!guess && status & STATUS_ERROR) return NOT_PENDING;
if (c === null) return laneValueOf(el);
const l = txOf(el);
const status = (el as Computed<any>)._statusFlags;
if (!tracking || c._config & CONFIG_CHILDREN_FORBIDDEN) {
// An untracked read inside lane work derives from the lane like a
// tracked one, minus the subscription — `untrack` is about
Expand Down
5 changes: 4 additions & 1 deletion packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,10 @@ function verdictValue(el: Signal<any> | Computed<any>, c: Computed<any> | null):
probeFound = true;
if (tracked && flushTransaction !== null) verdictRead(c!, flushTransaction);
}
if (el._config & CONFIG_OVERRIDE) {
if (
el._config & CONFIG_OVERRIDE &&
!(owner._statusFlags & STATUS_ERROR && !(el._config & CONFIG_GUESS))
) {
const l = txOf(el);
if (tracked) verdictRead(c!, l._parent ?? l);
const flying = (owner._statusFlags & STATUS_PENDING) !== 0 && !quietPending(owner);
Expand Down
161 changes: 161 additions & 0 deletions packages/signals/tests/latest-derived-held-error-4106.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* #4106: a memo over `latest(source)` is display-ahead (F-6, D-22) — a held
* landing on `source` reaches it before the frame reveals. A rejection is an
* answer like a value (D-41), so it reaches the derivation at the same point:
* `latest(derived)`, the plain read and the effect's error arm see it while
* the sibling still holds the frame, as they see a fulfilled value.
*/
import { expect, it } from "vitest";
import {
createEffect,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
latest
} from "../src/index.js";

function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const tick = async () => {
await new Promise(r => setTimeout(r, 0));
flush();
};
const read = (fn: () => unknown) => {
try {
return fn();
} catch (e) {
return e instanceof Error ? e.message : e;
}
};

async function scene() {
let request = deferred<number>();
let sibling = deferred<number>();
const [round, setRound] = createSignal(1);
const log: unknown[] = [];
const screen = { round: 0 };
let source!: () => number;
let derived!: () => number;
createRoot(() => {
source = createMemo(() => {
round();
return request.promise;
});
derived = createMemo(() => latest(source));
const slow = createMemo(() => {
round();
return sibling.promise;
});
createRenderEffect(
() => [round(), slow()],
([r]) => void (screen.round = r),
{ schedule: true }
);
createEffect(derived, {
effect: v => void log.push(v),
error: e => void log.push((e as Error).message)
});
});
request.resolve(1);
sibling.resolve(1);
await tick();
expect(log).toEqual([1]);
expect(screen.round).toBe(1);
request = deferred<number>();
sibling = deferred<number>();
setRound(2);
flush();
return {
log,
screen,
request: () => request,
sibling: () => sibling,
// [latest(source), latest(derived), derived(), source()], read unowned
reads: () => [
read(() => latest(source)),
read(() => latest(derived)),
read(derived),
read(source)
]
};
}

it("a held value reaches a derivation of latest before the reveal", async () => {
const s = await scene();
s.request().resolve(2);
await tick();
expect(s.screen.round).toBe(1);
expect(s.reads()).toEqual([2, 2, 2, 1]);
expect(s.log).toEqual([1, 2]);
s.sibling().resolve(2);
await tick();
expect(s.screen.round).toBe(2);
expect(s.reads()).toEqual([2, 2, 2, 2]);
expect(s.log).toEqual([1, 2]);
});

it("a held error reaches a derivation of latest at the same point as a value", async () => {
const s = await scene();
s.request().reject(new Error("boom"));
await tick();
expect(s.screen.round).toBe(1);
expect(s.reads()).toEqual(["boom", "boom", "boom", 1]);
expect(s.log).toEqual([1, "boom"]);
s.sibling().resolve(2);
await tick();
expect(s.screen.round).toBe(2);
expect(s.reads()).toEqual(["boom", "boom", "boom", "boom"]);
expect(s.log).toEqual([1, "boom"]);
});

it("a tracked latest of the derivation sees the held error when it would see a value", async () => {
for (const reject of [false, true]) {
let request = deferred<number>();
let sibling = deferred<number>();
const [round, setRound] = createSignal(1);
const log: unknown[] = [];
let outer!: () => number;
createRoot(() => {
const source = createMemo(() => {
round();
return request.promise;
});
const derived = createMemo(() => latest(source));
outer = createMemo(() => latest(derived));
const slow = createMemo(() => {
round();
return sibling.promise;
});
createRenderEffect(slow, () => {}, { schedule: true });
createEffect(outer, {
effect: v => void log.push(v),
error: e => void log.push((e as Error).message)
});
});
request.resolve(1);
sibling.resolve(1);
await tick();
request = deferred<number>();
sibling = deferred<number>();
setRound(2);
flush();
if (reject) request.reject(new Error("boom"));
else request.resolve(2);
await tick();
const answer = reject ? "boom" : 2;
expect([read(outer), read(() => latest(outer))]).toEqual([answer, answer]);
expect(log).toEqual([1, answer]);
sibling.resolve(2);
await tick();
expect(log).toEqual([1, answer]);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* #4106 follow-up: a derivation inside an optimistic guess lane that is not
* itself a written guess has no override of its own to show. When a mainline
* source it reads errors, the error is its answer (D-41: "a derivation that
* reads a staged error throws it, as it would read a staged value"), so its
* reads throw the error its effect receives. Serving the lane's pre-error
* value to reads while the effect gets the error would tear (F-1).
*/
import { expect, it } from "vitest";
import {
action,
createEffect,
createMemo,
createOptimistic,
createRoot,
createSignal,
flush,
latest
} from "../src/index.js";

const tick = async () => {
await new Promise(r => setTimeout(r, 0));
flush();
};
const read = (fn: () => unknown) => {
try {
return fn();
} catch (e) {
return e instanceof Error ? e.message : e;
}
};

it("a guess-lane derivation's reads throw the mainline error its effect receives", async () => {
const [fail, setFail] = createSignal(false);
const log: unknown[] = [];
let derived!: () => number;
let guess!: (v: number) => void;
createRoot(() => {
const [count, setCount] = createOptimistic(10);
guess = v => setCount(v);
const mainline = createMemo(() => {
if (fail()) throw new Error("boom");
return 1;
});
derived = createMemo(() => count() + mainline());
createEffect(derived, {
effect: v => void log.push(v),
error: e => void log.push((e as Error).message)
});
});
flush();
expect(log).toEqual([11]);

let release!: () => void;
const save = action(function* () {
guess(20);
yield new Promise<void>(r => (release = r));
});
const done = save();
flush();
expect(read(derived)).toBe(21);

setFail(true);
flush();
expect(log.at(-1)).toBe("boom");
expect(read(derived)).toBe("boom");
expect(read(() => latest(derived))).toBe("boom");

release();
await done;
await tick();
expect(read(derived)).toBe("boom");
});
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": 102629
},
"page: live server components (base + live/GET + action + isPending/latest)": {
"cap": "37.58 KB",
"minified": 115982
"cap": "37.65 KB",
"minified": 116033
},
"server: floor (getRequestEvent + isServer)": {
"cap": "1.34 KB",
Expand Down
23 changes: 19 additions & 4 deletions scripts/size/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -2583,8 +2583,9 @@ module.exports = [
// Lower only: cap at measured + 10 B rounded up to 0.01 KB; recorded
// minified never raised.
// mapArray window-staged commit (perf, #4114, 2026-10-16, merged over next @ dbc5d0005 after #4104's ratchet and #4108's observe raises): 29.91 -> 30.09 KB, measured at 30,075 B brotli / 94,579 B minified (+165 B brotli over the cap, +646 B minified over the recorded size): updateKeyedMap stages only the changed window, commits its bookkeeping arrays in place and takes a Map-free pure-removal path. Local measurement; CI's bytes re-base this cap.
limit: "30.09 KB",
capMinified: 94579,
// A held error reaches a derivation of `latest` (#4113, #4106, 2026-10-17, merged over next @ 01270641): 30.09 -> 30.14 KB, measured locally at 30,125 B brotli / 94,611 B minified (+32 B minified over the recorded size): `laneRead` and `verdictValue` hand a held error to an override/derived read the way they hand a value, and `stageError` schedules the verdict wake. Cap at measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer (2026-10-17).
limit: "30.14 KB",
capMinified: 94611,
alias
},
{
Expand Down Expand Up @@ -5213,6 +5214,13 @@ module.exports = [
// in a region mounts under the owner that placed it (the regions tier's
// placement), and a fill's two claim windows share one claim owner. Cap
// (floor-caps.json) at measured + 10 B rounded up to 0.01 KB.
// Size-Exception (a held error reaches a derivation of `latest`, #4113,
// #4106, 2026-10-17, merged over next @ fb498491): 37.58 -> 37.65 KB,
// measured locally at 37,634 B brotli / 116,033 B minified (+51 B
// minified): `laneRead` and `verdictValue` hand a held error to an
// override/derived read the way they hand a value, and `stageError`
// schedules the verdict wake. Cap at measured + 10 B rounded up to
// 0.01 KB. Accepted by the maintainer (2026-10-17).
limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"],
capMinified:
floorMinified["page: live server components (base + live/GET + action + isPending/latest)"],
Expand Down Expand Up @@ -5748,8 +5756,15 @@ module.exports = [
// measured locally (linux) at 47,001 B (141,204 B minified). Lower only:
// cap at measured + 10 B rounded up to 0.01 KB; recorded minified never
// raised.
limit: "47.02 KB",
capMinified: 141204,
// A held error reaches a derivation of `latest` (#4113, #4106, 2026-10-17,
// merged over next @ 7010268d): 47.02 -> 47.05 KB, measured locally at
// 47,040 B brotli / 141,255 B minified (+51 B minified): `laneRead` and
// `verdictValue` hand a held error to an override/derived read the way
// they hand a value, and `stageError` schedules the verdict wake. Cap at
// measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer
// (2026-10-17).
limit: "47.05 KB",
capMinified: 141255,
alias: pageAlias,
conditions: solidConditions,
compile: { hydratable: true }
Expand Down
Loading