Skip to content

Commit 7a7e210

Browse files
committed
fix(signals): unwrap StatusError for nullish rejections (closes #2866)
1 parent a99d6c8 commit 7a7e210

4 files changed

Lines changed: 351 additions & 5 deletions

File tree

packages/solid-signals/src/boundaries.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { recompute } from "./core/core.js";
2-
import type { StatusError } from "./core/error.js";
2+
import { unwrapStatusError } from "./core/error.js";
33
import {
44
cleanup,
55
computed,
@@ -322,7 +322,7 @@ export class CollectionQueue extends Queue {
322322
this._sources.add(source);
323323
if (wasEmpty) setSignal(this._disabled, true);
324324
if (this._collectionType & STATUS_ERROR) {
325-
setSignal(this._error!, (source._error as StatusError)?.cause ?? source._error);
325+
setSignal(this._error!, unwrapStatusError(source._error));
326326
}
327327
}
328328
}

packages/solid-signals/src/core/effect.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from "./constants.js";
1111
import { computed, createEffectNode, recompute, setStrictRead, staleValues } from "./core.js";
1212
import { emitDiagnostic } from "./dev.js";
13-
import { StatusError } from "./error.js";
13+
import { StatusError, unwrapStatusError } from "./error.js";
1414
import {
1515
_hitUnhandledAsync,
1616
GlobalQueue,
@@ -133,8 +133,7 @@ function runEffect(node: Effect<any>): void {
133133
// notifyEffectStatus, and a runner queued by an earlier valueChanged in the
134134
// same flush must not be hijacked by a later-arriving error status.
135135
if (node._statusFlags & STATUS_ERROR && node._type === EFFECT_USER) {
136-
const err =
137-
node._error instanceof StatusError ? (node._error.cause ?? node._error) : node._error;
136+
const err = unwrapStatusError(node._error);
138137
node._prevValue = node._value;
139138
node._modified = false;
140139
try {

packages/solid-signals/src/core/error.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export class StatusError extends Error {
3939
}
4040
}
4141

42+
/** Return the user's error from an internal status wrapper. */
43+
export function unwrapStatusError(error: unknown): unknown {
44+
return error instanceof StatusError ? error.cause : error;
45+
}
46+
4247
export class NoOwnerError extends Error {
4348
constructor() {
4449
super(__DEV__ ? "Context can only be accessed under a reactive root." : "");
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
/**
2+
* Pins the error-identity contract (#2840) for FALSY error values: whatever a
3+
* source throws or rejects with — including `undefined` and `null` — is what
4+
* every documented error surface receives. No internal `StatusError` wrapper,
5+
* no leaked reactive `.source` node.
6+
*
7+
* The falsy hole: `StatusError` always installs `cause` (even `undefined`), so
8+
* unwrap sites written as `wrapper.cause ?? wrapper` fall back to the wrapper
9+
* itself exactly when the user's rejection value is nullish. Affected:
10+
* - createErrorBoundary fallback (boundaries.ts `notify`)
11+
* - createEffect bundle `error` arm + no-handler console.error (effect.ts)
12+
*/
13+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14+
import {
15+
createEffect,
16+
createErrorBoundary,
17+
createLoadingBoundary,
18+
createMemo,
19+
createRenderEffect,
20+
createRoot,
21+
createSignal,
22+
flush
23+
} from "../src/index.js";
24+
25+
function deferred<T = void>() {
26+
let resolveFn!: (value: T) => void;
27+
let rejectFn!: (error?: unknown) => void;
28+
const promise = new Promise<T>((res, rej) => {
29+
resolveFn = res;
30+
rejectFn = rej;
31+
});
32+
return { promise, resolve: resolveFn, reject: rejectFn };
33+
}
34+
35+
const drain = async () => {
36+
for (let i = 0; i < 10; i++) await Promise.resolve();
37+
flush();
38+
};
39+
40+
/** The wrapper leak signature: an Error carrying an internal reactive `.source`. */
41+
function isInternalWrapper(v: unknown): boolean {
42+
return v instanceof Error && "source" in v;
43+
}
44+
45+
describe("createErrorBoundary fallback receives the exact rejection value", () => {
46+
async function rejectWith(rejection: unknown) {
47+
const d = deferred<number>();
48+
let observed: unknown = "NOT_CALLED";
49+
let result: any;
50+
createRoot(() => {
51+
const memo = createMemo(() => d.promise);
52+
const b = createErrorBoundary(
53+
() => memo(),
54+
err => {
55+
observed = err();
56+
return "errored";
57+
}
58+
);
59+
createRenderEffect(
60+
() => (result = b()),
61+
() => {}
62+
);
63+
});
64+
flush();
65+
d.reject(rejection);
66+
await drain();
67+
expect(result).toBe("errored");
68+
return observed;
69+
}
70+
71+
it("Promise.reject(undefined) → err() === undefined", async () => {
72+
const observed = await rejectWith(undefined);
73+
expect(isInternalWrapper(observed)).toBe(false);
74+
expect(observed).toBe(undefined);
75+
});
76+
77+
it("Promise.reject(null) → err() === null", async () => {
78+
const observed = await rejectWith(null);
79+
expect(isInternalWrapper(observed)).toBe(false);
80+
expect(observed).toBe(null);
81+
});
82+
83+
it("Promise.reject(0) → err() === 0", async () => {
84+
expect(await rejectWith(0)).toBe(0);
85+
});
86+
87+
it('Promise.reject("") → err() === ""', async () => {
88+
expect(await rejectWith("")).toBe("");
89+
});
90+
91+
it("Promise.reject(false) → err() === false", async () => {
92+
expect(await rejectWith(false)).toBe(false);
93+
});
94+
95+
it("Promise.reject(NaN) → err() is NaN", async () => {
96+
expect(await rejectWith(NaN)).toBeNaN();
97+
});
98+
99+
it("Promise.reject(Error) → same instance (control)", async () => {
100+
const boom = new Error("boom");
101+
expect(await rejectWith(boom)).toBe(boom);
102+
});
103+
104+
it("Promise.reject(custom class) → instanceof holds (control)", async () => {
105+
class MyError extends Error {}
106+
const boom = new MyError("custom");
107+
const observed = await rejectWith(boom);
108+
expect(observed).toBe(boom);
109+
expect(observed).toBeInstanceOf(MyError);
110+
});
111+
112+
it("Promise.reject(error with its own .cause) → not double-unwrapped", async () => {
113+
const inner = new Error("inner");
114+
const outer = new Error("outer", { cause: inner });
115+
const observed = await rejectWith(outer);
116+
expect(observed).toBe(outer);
117+
expect((observed as Error).cause).toBe(inner);
118+
});
119+
120+
it("Promise.reject(plain object) → same reference", async () => {
121+
const payload = { code: 404 };
122+
expect(await rejectWith(payload)).toBe(payload);
123+
});
124+
125+
it("async iterator throwing undefined → err() === undefined", async () => {
126+
let observed: unknown = "NOT_CALLED";
127+
let result: any;
128+
createRoot(() => {
129+
// eslint-disable-next-line require-yield
130+
const memo = createMemo(() =>
131+
(async function* (): AsyncGenerator<number> {
132+
throw undefined;
133+
})()
134+
);
135+
const b = createErrorBoundary(
136+
() => memo(),
137+
err => {
138+
observed = err();
139+
return "errored";
140+
}
141+
);
142+
createRenderEffect(
143+
() => (result = b()),
144+
() => {}
145+
);
146+
});
147+
flush();
148+
await drain();
149+
expect(result).toBe("errored");
150+
expect(isInternalWrapper(observed)).toBe(false);
151+
expect(observed).toBe(undefined);
152+
});
153+
154+
function throwSync(thrown: unknown) {
155+
let observed: unknown = "NOT_CALLED";
156+
let result: any;
157+
createRoot(() => {
158+
const memo = createMemo(() => {
159+
throw thrown;
160+
});
161+
const b = createErrorBoundary(
162+
() => memo(),
163+
err => {
164+
observed = err();
165+
return "errored";
166+
}
167+
);
168+
createRenderEffect(
169+
() => (result = b()),
170+
() => {}
171+
);
172+
});
173+
flush();
174+
expect(result).toBe("errored");
175+
return observed;
176+
}
177+
178+
it("sync throw undefined in a tracked child → err() === undefined", () => {
179+
const observed = throwSync(undefined);
180+
expect(isInternalWrapper(observed)).toBe(false);
181+
expect(observed).toBe(undefined);
182+
});
183+
184+
it("sync throw null in a tracked child → err() === null", () => {
185+
const observed = throwSync(null);
186+
expect(isInternalWrapper(observed)).toBe(false);
187+
expect(observed).toBe(null);
188+
});
189+
190+
it("Errored > Loading composition: bare rejection reaches err() as undefined", async () => {
191+
const d = deferred<number>();
192+
let observed: unknown = "NOT_CALLED";
193+
let result: any;
194+
createRoot(() => {
195+
const memo = createMemo(() => d.promise);
196+
const b = createErrorBoundary(
197+
() =>
198+
createLoadingBoundary(
199+
() => memo(),
200+
() => "loading"
201+
)(),
202+
err => {
203+
observed = err();
204+
return "errored";
205+
}
206+
);
207+
createRenderEffect(
208+
() => (result = b()),
209+
() => {}
210+
);
211+
});
212+
flush();
213+
expect(result).toBe("loading");
214+
d.reject(undefined);
215+
await drain();
216+
expect(result).toBe("errored");
217+
expect(isInternalWrapper(observed)).toBe(false);
218+
expect(observed).toBe(undefined);
219+
});
220+
221+
it("boundary recovers after a falsy rejection when the source is replaced", async () => {
222+
const d1 = deferred<string>();
223+
const d2 = deferred<string>();
224+
const [gen, setGen] = createSignal(0);
225+
let observed: unknown = "NOT_CALLED";
226+
let result: any;
227+
createRoot(() => {
228+
const memo = createMemo(() => (gen() === 0 ? d1.promise : d2.promise));
229+
const b = createErrorBoundary(
230+
() => memo(),
231+
err => {
232+
observed = err();
233+
return "errored";
234+
}
235+
);
236+
createRenderEffect(
237+
() => (result = b()),
238+
() => {}
239+
);
240+
});
241+
flush();
242+
d1.reject(undefined);
243+
await drain();
244+
expect(result).toBe("errored");
245+
expect(observed).toBe(undefined);
246+
247+
setGen(1);
248+
d2.resolve("recovered");
249+
await drain();
250+
expect(result).toBe("recovered");
251+
});
252+
});
253+
254+
describe("createEffect bundle `error` arm receives the exact error value (#2840)", () => {
255+
let errorSpy!: ReturnType<typeof vi.spyOn>;
256+
257+
beforeEach(() => {
258+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
259+
});
260+
261+
afterEach(() => {
262+
errorSpy.mockRestore();
263+
});
264+
265+
function throwInCompute(thrown: unknown) {
266+
const [armed, setArmed] = createSignal(false);
267+
let received: unknown = "NOT_CALLED";
268+
createRoot(() => {
269+
createEffect(
270+
() => {
271+
if (armed()) throw thrown;
272+
return 0;
273+
},
274+
{
275+
effect: () => {},
276+
error: err => {
277+
received = err;
278+
}
279+
}
280+
);
281+
});
282+
flush();
283+
setArmed(true);
284+
flush();
285+
return received;
286+
}
287+
288+
it("compute-phase throw undefined → handler receives undefined", () => {
289+
const received = throwInCompute(undefined);
290+
expect(isInternalWrapper(received)).toBe(false);
291+
expect(received).toBe(undefined);
292+
});
293+
294+
it("compute-phase throw null → handler receives null", () => {
295+
const received = throwInCompute(null);
296+
expect(isInternalWrapper(received)).toBe(false);
297+
expect(received).toBe(null);
298+
});
299+
300+
it("compute-phase throw 0 → handler receives 0 (control)", () => {
301+
expect(throwInCompute(0)).toBe(0);
302+
});
303+
304+
it("async source rejecting with undefined → handler receives undefined", async () => {
305+
const d = deferred<number>();
306+
let received: unknown = "NOT_CALLED";
307+
createRoot(() => {
308+
const memo = createMemo(() => d.promise);
309+
createEffect(() => memo(), {
310+
effect: () => {},
311+
error: err => {
312+
received = err;
313+
}
314+
});
315+
});
316+
flush();
317+
d.reject(undefined);
318+
await drain();
319+
expect(isInternalWrapper(received)).toBe(false);
320+
expect(received).toBe(undefined);
321+
});
322+
323+
it("no handler: console.error logs the raw undefined, not the wrapper", () => {
324+
const [armed, setArmed] = createSignal(false);
325+
createRoot(() => {
326+
createEffect(
327+
() => {
328+
if (armed()) throw undefined;
329+
return 0;
330+
},
331+
() => {}
332+
);
333+
});
334+
flush();
335+
setArmed(true);
336+
flush();
337+
expect(errorSpy).toHaveBeenCalledTimes(1);
338+
const logged = errorSpy.mock.calls[0][0];
339+
expect(isInternalWrapper(logged)).toBe(false);
340+
expect(logged).toBe(undefined);
341+
});
342+
});

0 commit comments

Comments
 (0)