-
-
Notifications
You must be signed in to change notification settings - Fork 629
fix: preserve causes when wrapping render errors #3230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
05a58f3
e5d2867
a4d091c
7956b17
f13cda5
8f80a19
0edc319
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -24,8 +24,37 @@ export function createResultObject( | |||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function isCrossRealmError(e: unknown): e is { message?: unknown } { | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor edge case: if a cross-realm error has no string Not a blocker — the original value is still preserved as
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This check correctly handles cross-realm errors but will also match any plain object where a user has set Extremely unlikely in SSR in practice, but a belt-and-suspenders guard could be:
Suggested change
The explicit
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The name A name like
Suggested change
|
||||||||||||||
| return typeof e === 'object' && e !== null && Object.prototype.toString.call(e) === '[object Error]'; | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If this edge case is worth guarding, you could tighten the check:
Suggested change
Alternatively, a comment acknowledging the limitation is fine since it's a very unlikely throw value in practice. |
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function stringifyThrownValue(e: unknown): string { | ||||||||||||||
| if (isCrossRealmError(e)) { | ||||||||||||||
| return typeof e.message === 'string' ? e.message : Object.prototype.toString.call(e); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (typeof e === 'object' && e !== null) { | ||||||||||||||
| try { | ||||||||||||||
| // JSON.stringify can return undefined without throwing, for example when toJSON returns undefined. | ||||||||||||||
| return JSON.stringify(e) ?? Object.prototype.toString.call(e); | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment correctly calls out the it('wraps objects whose toJSON returns undefined', () => {
const obj = { toJSON: () => undefined };
const error = convertToError(obj);
expect(error.message).toBe('[object Object]');
expect((error as ErrorWithCause).cause).toBe(obj);
}); |
||||||||||||||
| } catch { | ||||||||||||||
| return Object.prototype.toString.call(e); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return String(e); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export function convertToError(e: unknown): Error { | ||||||||||||||
| return e instanceof Error ? e : new Error(String(e)); | ||||||||||||||
| if (e instanceof Error) { | ||||||||||||||
| return e; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const message = stringifyThrownValue(e); | ||||||||||||||
| // tsconfig uses es2020 libs, which do not type Error.cause even though supported runtimes provide it. | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The cast is fine for now, but this can be replaced once the root tsconfig moves to |
||||||||||||||
| const error = new Error(message) as Error & { cause?: unknown }; | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The cast works, but a cleaner long-term option is to add a global type augmentation rather than casting at each use site. Alternatively, if there's appetite to bump |
||||||||||||||
| error.cause = e; | ||||||||||||||
| return error; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export function validateComponent(componentObj: RegisteredComponent, componentName: string) { | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,90 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Script } from 'node:vm'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { convertToError } from '../src/serverRenderUtils.ts'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type ErrorWithCause = Error & { cause?: unknown }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe('serverRenderUtils', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The cast
Suggested change
Then each assertion becomes |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe('convertToError', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('returns Error instances unchanged', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const error = new Error('Already an error'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider adding a test for built-in it('returns Error subclass instances unchanged', () => {
const typeError = new TypeError('wrong type');
expect(convertToError(typeError)).toBe(typeError);
}); |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(convertToError(error)).toBe(error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('wraps plain object thrown values with a readable message while preserving the original cause', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const thrownValue = { message: 'plain object failure' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const error = convertToError(thrownValue); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error).toBeInstanceOf(Error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This assertion locks in the fact that throwing a plain object yields the unhelpful message // e.g.
function safeStringify(e: unknown): string {
if (typeof e === 'object' && e !== null) {
try { return JSON.stringify(e); } catch { /* circular */ }
}
return String(e);
}Not required for this PR — the message is unchanged from before — but since the PR's stated goal is better downstream debugging, a more readable message would complement the |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error.message).toBe('{"message":"plain object failure"}'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect((error as ErrorWithCause).cause).toBe(thrownValue); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('wraps a thrown string with the string as the message and cause', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const error = convertToError('something went wrong'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error).toBeInstanceOf(Error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error.message).toBe('something went wrong'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect((error as ErrorWithCause).cause).toBe('something went wrong'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('wraps a thrown number', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const error = convertToError(42); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error).toBeInstanceOf(Error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error.message).toBe('42'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect((error as ErrorWithCause).cause).toBe(42); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('wraps null thrown values', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const error = convertToError(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error).toBeInstanceOf(Error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error.message).toBe('null'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect((error as ErrorWithCause).cause).toBeNull(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('wraps undefined thrown values', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const error = convertToError(undefined); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error).toBeInstanceOf(Error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error.message).toBe('undefined'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect((error as ErrorWithCause).cause).toBeUndefined(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('wraps circular-reference objects without throwing', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const circular: Record<string, unknown> = {}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| circular.self = circular; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const error = convertToError(circular); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error).toBeInstanceOf(Error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error.message).toBe('[object Object]'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect((error as ErrorWithCause).cause).toBe(circular); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('wraps errors thrown from another JavaScript realm with their original message', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test covers the happy-path of the cross-realm branch, but the branch inside it('wraps a cross-realm error whose message is non-string using the toString tag', () => {
const thrownValue: unknown = new Script('const e = new Error(); e.message = 42; e').runInNewContext();
const error = convertToError(thrownValue);
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('[object Error]');
expect((error as ErrorWithCause).cause).toBe(thrownValue);
}); |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const thrownValue: unknown = new Script('new Error("cross-realm failure")').runInNewContext(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const error = convertToError(thrownValue); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error).toBeInstanceOf(Error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error.message).toBe('cross-realm failure'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect((error as ErrorWithCause).cause).toBe(thrownValue); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('wraps cross-realm errors with non-string messages using the error tag', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const thrownValue: unknown = new Script( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'const error = new Error(); error.message = 42; error', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ).runInNewContext(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const error = convertToError(thrownValue); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error).toBeInstanceOf(Error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(error.message).toBe('[object Error]'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect((error as ErrorWithCause).cause).toBe(thrownValue); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The two existing cases are good, but a few more would meaningfully increase coverage of real-world throws:
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good suite overall. Two thrown values that are easy to miss and worth adding:
Both are real-world cases (e.g. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
Object.prototype.toString.callhere is intentionally defensive because the error may be created inside the VM context (a different JavaScript realm), makinginstanceof Errorunreliable. The approach is correct — just leaving a note so it's clear this isn't accidental verbosity.