Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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/staged-error-reaches-derivations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

A memo's error staged with a hold now reaches the memos and derived stores that derive from it: `latest()` of them throws the proposed error and `isPending()` of them is `true` until the hold commits, as for the memo itself (D-41).
44 changes: 41 additions & 3 deletions packages/signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,39 @@ export function stageError(el: Computed<any>, error: any, inPass?: boolean): boo
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; the
// verdict readers the walk wakes need a flush (#4106).
// verdict readers the walk wakes need a flush (#4106); the derivations it
// reaches carry the staging (`settlePendingSource`).
settlePendingSource(el);
schedule();
return true;
}

/** D-41: a derivation of a staged error carries it, staged with the same
* frame — marked, not re-run, as a published error marks it (a re-run would
* replace the frame its content owns) — and its own verdict readers
* re-derive (the settle walk). Only a verdict reads a derivation's staging
* before its commit, so only the verdict engine marks. Not a boundary's
* output, an async memo, an effect or a boundary's tree (`EFFECT_TRACKED <<
* 23` spans CONFIG_FRAME and every effect type), nor one already carrying a
* staged error: the commit's publish reaches them. A pending mark supersedes
* the staging (`propagateStatus`). */
function stageMark(node: Computed<any>, el: Computed<any>): void {
if (
!(
node._config &
(CONFIG_REDERIVE | CONFIG_STAGED_ERROR | CONFIG_ASYNC | (EFFECT_TRACKED << 23))
)
) {
node._pendingValue = el._pendingValue;
node._config |= CONFIG_STAGED_ERROR;
queuePendingNode(node);
// A derived store's readers are its family's (store/projection.ts): they
// re-derive from the staging, as from the derive's status (#4189).
(node as any).fam?.st(STATUS_ERROR);
settlePendingSource(node);
}
}

export function settleErroredDependents(el: Computed<any>, error: any): void {
let scheduled = false;
const visited = new NewSet<Computed<any>>();
Expand Down Expand Up @@ -318,7 +345,13 @@ export function settlePendingSource(el: Computed<any>, source: Computed<any> = e
// equal to the committed value notifies nobody — the source settling
// is its verdict changing: it runs again. (Verdict readers exist only
// with the verdict engine installed.)
return holdWorkHook && node._config & CONFIG_VERDICT && enqueueSub(node);
// D-41: a derivation of a staged error carries it (`stageMark`).
return (
holdWorkHook &&
(node._config & CONFIG_VERDICT
? enqueueSub(node)
: el._config & CONFIG_STAGED_ERROR && stageMark(node, el))
);
visited.add(node);
node._time = clock;
const remaining = node._x?._pendingSources?.values().next().value;
Expand Down Expand Up @@ -1006,9 +1039,14 @@ export function propagateStatus(el: Computed<any>, status: number, error: any):
// it is served the committed value it already derived from (A10) and
// asks that question again. It waits on the source like any derivation
// (F-9), and asks its next question once, at the landing (#4397).
// A node carrying a staged error re-derives on a pending mark: its
// source asks again, and the staging it carried is not the answer
// (#4109, `stageMark`).
if (
sub._config & CONFIG_REDERIVE ||
(holdWorkHook && sub._config & CONFIG_VERDICT && !(pendingSource && holdWorkHook(sub, el))) ||
(holdWorkHook &&
(sub._config & CONFIG_VERDICT || (pendingSource && sub._config & CONFIG_STAGED_ERROR)) &&
!(pendingSource && holdWorkHook(sub, el))) ||
(status === STATUS_PENDING && link._gen !== sub._depGen)
) {
enqueueSub(sub);
Expand Down
156 changes: 156 additions & 0 deletions packages/signals/tests/staged-error-derivation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* D-41: "A derivation that reads a staged error throws it, as it would read
* a staged value: its own pass errors and stages with the same frame." Found
* by the async-gen rerun on next 5e68925c (E2, E3): a memo whose held pass
* staged an error did not reach its derivations — an identity memo over it
* kept the old value for `latest()` and read `isPending() === false` for the
* rest of the hold, while the memo itself threw the proposed error from
* `latest()` and read pending (D-22, D-23 iii). A staged error now marks its
* derivations staged with it, as a published error marks them errored.
*/
import { afterEach, expect, it } from "vitest";
import {
action,
createEffect,
createErrorBoundary,
createMemo,
createRenderEffect,
createRoot,
createSignal,
flush,
isPending,
latest,
resetErrorHalt
} from "../src/index.js";

afterEach(() => resetErrorHalt());

const tick = async () => {
await new Promise(r => setTimeout(r, 0));
flush();
};
const attempt = (fn: () => unknown) => {
try {
return fn();
} catch (e: any) {
return `throws ${e?.cause?.message ?? e?.message}`;
}
};

for (const identity of [false, true])
it(`latest() of a derivation throws the error a held pass staged${identity ? " (identity memo)" : ""}`, async () => {
const [s, setS] = createSignal(2);
let m!: () => number;
let screen: unknown;
let release!: () => void;
const dispose = createRoot(d => {
const m0 = createMemo(() => {
const v = s() + 1;
if (v % 4 === 1) throw new Error(`m0:${v}`);
return v;
});
m = identity ? createMemo(() => m0()) : m0;
const shown = createErrorBoundary(
() => latest(m),
e => `E:${(e() as Error).message}`
);
createRenderEffect(shown, v => void (screen = v));
return d;
});
flush();
expect(screen).toBe(3);
const act = action(function* () {
setS(0);
yield new Promise<void>(r => (release = r));
});
act();
await tick();
// The hold is open: the screen's value is committed, the proposal is
// the error.
expect([attempt(() => latest(m)), isPending(m), attempt(() => m())]).toEqual([
"throws m0:1",
true,
3
]);
release();
await tick();
await tick();
expect([attempt(() => m()), isPending(m)]).toEqual(["throws m0:1", false]);
dispose();
});

it("isPending of a derivation over an errored memo whose held re-run errors again is true", async () => {
// m0 = s + 1 (throws at 2 mod 4), m1 = m0 + m0 + 1 (throws at 3 mod 4):
// errored at s = 0 (m1:3) and again at s = 2 (m1:7).
const [s, setS] = createSignal(0);
let m1!: () => number;
let id!: () => number;
const seen: boolean[] = [];
const dispose = createRoot(d => {
const m0 = createMemo(() => {
const v = s() + 1;
if (v % 4 === 2) throw new Error(`m0:${v}`);
return v;
});
m1 = createMemo(() => {
const v = m0() + m0() + 1;
if (v % 4 === 3) throw new Error(`m1:${v}`);
return v;
});
id = createMemo(() => m1());
createRenderEffect(
() => isPending(id),
v => void seen.push(v)
);
return d;
});
flush();
const act = action(function* () {
setS(2);
yield new Promise(() => {});
});
act();
await tick();
expect([isPending(m1), isPending(id), seen.at(-1)]).toEqual([true, true, true]);
dispose();
});

it("a derivation's staged error is superseded when its source re-runs to a pending value in the same flush (#4109)", async () => {
const log: unknown[] = [];
const [s, setS] = createSignal(0);
const [fix, setFix] = createSignal(false, { ownedWrite: true });
let m!: () => number;
const dispose = createRoot(d => {
const m0 = createMemo(() => {
const v = s();
if (v === 1 && !fix()) throw new Error("boom");
return v === 0 ? 0 : Promise.resolve(v * 10);
});
m = createMemo(() => m0());
// Taller than `m0`: seeing s === 1 it writes `fix`, so `m0` re-runs in
// the same flush, after its throw was staged and marked onto `m`.
const a = createMemo(() => s() + 1);
const b = createMemo(() => {
const x = a();
if (x === 2) setFix(true);
return x;
});
createEffect(m, {
effect: v => void log.push(["value", v]),
error: e => void log.push(["error", (e as any)?.cause?.message ?? (e as any)?.message])
});
createRenderEffect(b, () => {});
return d;
});
flush();
setS(1);
flush();
await tick();
await tick();
expect(log).toEqual([
["value", 0],
["value", 10]
]);
expect(m()).toBe(10);
dispose();
});
66 changes: 66 additions & 0 deletions packages/web/test/async-gen/e2-staged-error-derivation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* @vitest-environment jsdom
*/
// async-gen rerun on next 5e68925c, seed 3289 (E2): reading every memo
// through an identity memo shows the same screens (D-41: a derivation of a
// staged error carries it, staged with the same frame).
// - The action makes m0 throw: `latest(m0)` throws the proposed error, so the
// leaf errors to [ROOT-E] (D-22); through the identity memo it showed the
// old `lm0:3`.
// - An identity memo over an errored m1 whose held re-run errors again:
// `isPending` is true for both (D-23 iii); through the identity it was false.
import { expect, test } from "vitest";
import { runScenario } from "./run.js";

const latestThrows = {
signals: [1, 2],
memos: [{ async: false, deps: [{ src: { k: "s", i: 1 }, how: "plain" }], poison: 1 }],
tree: {
t: "show",
when: { how: "plain", src: { k: "s", i: 1 } },
child: {
t: "leaf",
text: [],
attrs: [{ how: "latest", src: { k: "m", i: 0 } }],
grouped: true
},
fallback: false
},
steps: [{ t: "action", s: 1, v: 0 }, { t: "task" }]
} as any;

const pendingOverError = {
signals: [0],
memos: [
{ async: false, deps: [{ src: { k: "s", i: 0 }, how: "plain" }], poison: 2 },
{
async: false,
deps: [
{ src: { k: "m", i: 0 }, how: "plain" },
{ src: { k: "m", i: 0 }, how: "plain" }
],
poison: 3
}
],
tree: {
t: "leaf",
text: [],
attrs: [{ how: "pending", src: { k: "m", i: 1 } }],
grouped: true
},
steps: [{ t: "action", s: 0, v: 2 }, { t: "task" }]
} as any;

test("minimized: latest() of a memo the action makes throw errors through an identity memo too", async () => {
const direct = await runScenario(latestThrows, {});
const identity = await runScenario(latestThrows, { identity: true });
expect(identity.screens).toEqual(direct.screens);
expect(identity.screens.at(-1)).toBe("[ROOT-E]");
});

test("minimized: isPending of an identity memo over a held re-run's new error is true", async () => {
const direct = await runScenario(pendingOverError, {});
const identity = await runScenario(pendingOverError, { identity: true });
expect(identity.screens).toEqual(direct.screens);
expect(identity.screens.at(-1)).toBe("<span data-a0=pm1:true ></span>");
});
62 changes: 62 additions & 0 deletions packages/web/test/async-gen/e3-store-staged-error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @vitest-environment jsdom
*/
// async-gen rerun on rc.20, E3 (a derived store `createStore(d => { d.v = m() })`
// reads like `m`): an action makes the derive's source stage an error. The
// staged error reaches a memo's derivations (D-41), but a derive was skipped:
// its leaves' readers learn its status through its family, and nothing told
// the family. `latest(store.v)` kept the old value where `latest(m)` throws,
// and `isPending` over the store read false where over a memo it reads true.
import { expect, test } from "vitest";
import { runScenario } from "./run.js";

const cases: Record<string, any> = {
// [s0=1 s1=2 m0=memo(s1 !%4=1)] <Show when=s1()><span fx[latest(m0)]></span></Show> :: A(s1=0) t
"latest of the store throws the staged error": {
signals: [1, 2],
memos: [{ async: false, deps: [{ src: { k: "s", i: 1 }, how: "plain" }], poison: 1 }],
tree: {
t: "show",
when: { how: "plain", src: { k: "s", i: 1 } },
child: {
t: "leaf",
text: [],
attrs: [{ how: "latest", src: { k: "m", i: 0 } }],
grouped: true
},
fallback: false
},
steps: [{ t: "action", s: 1, v: 0 }, { t: "task" }]
},
// [s0=0 m0=memo(s0 !%4=2) m1=memo(m0+m0 !%4=3)] <span fx[isPending(m1)]></span> :: A(s0=2) t
"isPending of a store over the staged error is true": {
signals: [0],
memos: [
{ async: false, deps: [{ src: { k: "s", i: 0 }, how: "plain" }], poison: 2 },
{
async: false,
deps: [
{ src: { k: "m", i: 0 }, how: "plain" },
{ src: { k: "m", i: 0 }, how: "plain" }
],
poison: 3
}
],
tree: {
t: "leaf",
text: [],
attrs: [{ how: "pending", src: { k: "m", i: 1 } }],
grouped: true
},
steps: [{ t: "action", s: 0, v: 2 }, { t: "task" }]
}
};

for (const [name, sc] of Object.entries(cases))
test(`E3: ${name}`, async () => {
const memo = await runScenario(sc, {});
const store = await runScenario(sc, { store: true });
expect(store.screens).toEqual(memo.screens);
expect(store.final).toBe(memo.final);
expect(store.findings).toEqual([]);
});
2 changes: 1 addition & 1 deletion scripts/size/floor-caps.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"page: live server components (base + live/GET + action + isPending/latest)": {
"cap": "38.85 KB",
"minified": 117376
"minified": 117514
},
"server: floor (getRequestEvent + isServer)": {
"cap": "1.29 KB",
Expand Down
Loading
Loading