[Flight] Add filterStackFrame options to the RSC Server (#30447)
This lets you customize the filter, for example allowing node_modules or filter out additional functions that you don't want to include when sending the stack to the client. Notably this doesn't filter out Server Components out of the parent stack. Those are just like a view of the tree by name. Not virtual stack frames.
Sebastian Markbåge committed
Jul 25, 2024 at 10:50 UTC
4b62400765b6412dd074526687ef447550b687d1
11 files changed
+140
-37
packages/react-client/src/__tests__/ReactFlight-test.js
+54
@@ -3157,4 +3157,58 @@ describe('ReactFlight', () => {
3157
{withoutStack: true},
3158
);
3159
});
3160
+
3161
+ it('can filter out stack frames of a serialized error in dev', async () => {
3162
+ async function bar() {
3163
+ throw new Error('my-error');
3164
+ }
3165
+
3166
+ async function intermediate() {
3167
+ await bar();
3168
+ }
3169
+
3170
+ async function foo() {
3171
+ await intermediate();
3172
+ }
3173
+
3174
+ const rejectedPromise = foo();
3175
+ const transport = ReactNoopFlightServer.render(
3176
+ {model: rejectedPromise},
3177
+ {
3178
+ onError(x) {
3179
+ return `digest("${x.message}")`;
3180
+ },
3181
+ filterStackFrame(url, functionName) {
3182
+ return functionName !== 'intermediate';
3183
+ },
3184
+ },
3185
+ );
3186
+
3187
+ let originalError;
3188
+ try {
3189
+ await rejectedPromise;
3190
+ } catch (x) {
3191
+ originalError = x;
3192
+ }
3193
+
3194
+ const root = await ReactNoopFlightClient.read(transport);
3195
+ let caughtError;
3196
+ try {
3197
+ await root.model;
3198
+ } catch (x) {
3199
+ caughtError = x;
3200
+ }
3201
+ if (__DEV__) {
3202
+ expect(caughtError.message).toBe(originalError.message);
3203
+ expect(normalizeCodeLocInfo(caughtError.stack)).toContain(
3204
+ '\n in bar (at **)' + '\n in foo (at **)',
3205
+ );
3206
+ }
3207
+ expect(normalizeCodeLocInfo(originalError.stack)).toContain(
3208
+ '\n in bar (at **)' +
3209
+ '\n in intermediate (at **)' +
3210
+ '\n in foo (at **)',
3211
+ );
3212
+ expect(caughtError.digest).toBe('digest("my-error")');
3213
+ });
3214
});
packages/react-html/src/ReactHTMLServer.js
+1
@@ -168,6 +168,7 @@ export function renderToMarkup(
168
handleFlightError,
169
options ? options.identifierPrefix : undefined,
170
undefined,
171
+ undefined,
172
'Markup',
173
undefined,
174
);
packages/react-noop-renderer/src/ReactNoopFlightServer.js
+4
-1
@@ -68,6 +68,7 @@ const ReactNoopFlightServer = ReactFlightServer({
68
69
type Options = {
70
environmentName?: string | (() => string),
71
+ filterStackFrame?: (url: string, functionName: string) => boolean,
72
identifierPrefix?: string,
73
onError?: (error: mixed) => void,
74
onPostpone?: (reason: string) => void,
@@ -82,7 +83,9 @@ function render(model: ReactClientValue, options?: Options): Destination {
83
options ? options.onError : undefined,
84
options ? options.identifierPrefix : undefined,
85
options ? options.onPostpone : undefined,
85
- options ? options.environmentName : undefined,
86
+ undefined,
87
+ __DEV__ && options ? options.environmentName : undefined,
88
+ __DEV__ && options ? options.filterStackFrame : undefined,
89
);
90
ReactNoopFlightServer.startWork(request);
91
ReactNoopFlightServer.startFlowing(request, destination);
packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
+3
-1
@@ -66,6 +66,7 @@ function createCancelHandler(request: Request, reason: string) {
66
67
type Options = {
68
environmentName?: string | (() => string),
69
+ filterStackFrame?: (url: string, functionName: string) => boolean,
70
onError?: (error: mixed) => void,
71
onPostpone?: (reason: string) => void,
72
identifierPrefix?: string,
@@ -88,8 +89,9 @@ function renderToPipeableStream(
89
options ? options.onError : undefined,
90
options ? options.identifierPrefix : undefined,
91
options ? options.onPostpone : undefined,
91
- options ? options.environmentName : undefined,
92
options ? options.temporaryReferences : undefined,
93
+ __DEV__ && options ? options.environmentName : undefined,
94
+ __DEV__ && options ? options.filterStackFrame : undefined,
95
);
96
let hasStartedFlowing = false;
97
startWork(request);
packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js
+3
-1
@@ -45,6 +45,7 @@ export type {TemporaryReferenceSet};
45
46
type Options = {
47
environmentName?: string | (() => string),
48
+ filterStackFrame?: (url: string, functionName: string) => boolean,
49
identifierPrefix?: string,
50
signal?: AbortSignal,
51
temporaryReferences?: TemporaryReferenceSet,
@@ -63,8 +64,9 @@ function renderToReadableStream(
64
options ? options.onError : undefined,
65
options ? options.identifierPrefix : undefined,
66
options ? options.onPostpone : undefined,
66
- options ? options.environmentName : undefined,
67
options ? options.temporaryReferences : undefined,
68
+ __DEV__ && options ? options.environmentName : undefined,
69
+ __DEV__ && options ? options.filterStackFrame : undefined,
70
);
71
if (options && options.signal) {
72
const signal = options.signal;
packages/react-server-dom-turbopack/src/ReactFlightDOMServerEdge.js
+3
-1
@@ -45,6 +45,7 @@ export type {TemporaryReferenceSet};
45
46
type Options = {
47
environmentName?: string | (() => string),
48
+ filterStackFrame?: (url: string, functionName: string) => boolean,
49
identifierPrefix?: string,
50
signal?: AbortSignal,
51
temporaryReferences?: TemporaryReferenceSet,
@@ -63,8 +64,9 @@ function renderToReadableStream(
64
options ? options.onError : undefined,
65
options ? options.identifierPrefix : undefined,
66
options ? options.onPostpone : undefined,
66
- options ? options.environmentName : undefined,
67
options ? options.temporaryReferences : undefined,
68
+ __DEV__ && options ? options.environmentName : undefined,
69
+ __DEV__ && options ? options.filterStackFrame : undefined,
70
);
71
if (options && options.signal) {
72
const signal = options.signal;
packages/react-server-dom-turbopack/src/ReactFlightDOMServerNode.js
+3
-1
@@ -67,6 +67,7 @@ function createCancelHandler(request: Request, reason: string) {
67
68
type Options = {
69
environmentName?: string | (() => string),
70
+ filterStackFrame?: (url: string, functionName: string) => boolean,
71
onError?: (error: mixed) => void,
72
onPostpone?: (reason: string) => void,
73
identifierPrefix?: string,
@@ -89,8 +90,9 @@ function renderToPipeableStream(
90
options ? options.onError : undefined,
91
options ? options.identifierPrefix : undefined,
92
options ? options.onPostpone : undefined,
92
- options ? options.environmentName : undefined,
93
options ? options.temporaryReferences : undefined,
94
+ __DEV__ && options ? options.environmentName : undefined,
95
+ __DEV__ && options ? options.filterStackFrame : undefined,
96
);
97
let hasStartedFlowing = false;
98
startWork(request);
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+3
-1
@@ -45,6 +45,7 @@ export type {TemporaryReferenceSet};
45
46
type Options = {
47
environmentName?: string | (() => string),
48
+ filterStackFrame?: (url: string, functionName: string) => boolean,
49
identifierPrefix?: string,
50
signal?: AbortSignal,
51
temporaryReferences?: TemporaryReferenceSet,
@@ -63,8 +64,9 @@ function renderToReadableStream(
64
options ? options.onError : undefined,
65
options ? options.identifierPrefix : undefined,
66
options ? options.onPostpone : undefined,
66
- options ? options.environmentName : undefined,
67
options ? options.temporaryReferences : undefined,
68
+ __DEV__ && options ? options.environmentName : undefined,
69
+ __DEV__ && options ? options.filterStackFrame : undefined,
70
);
71
if (options && options.signal) {
72
const signal = options.signal;
packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+3
-1
@@ -45,6 +45,7 @@ export type {TemporaryReferenceSet};
45
46
type Options = {
47
environmentName?: string | (() => string),
48
+ filterStackFrame?: (url: string, functionName: string) => boolean,
49
identifierPrefix?: string,
50
signal?: AbortSignal,
51
temporaryReferences?: TemporaryReferenceSet,
@@ -63,8 +64,9 @@ function renderToReadableStream(
64
options ? options.onError : undefined,
65
options ? options.identifierPrefix : undefined,
66
options ? options.onPostpone : undefined,
66
- options ? options.environmentName : undefined,
67
options ? options.temporaryReferences : undefined,
68
+ __DEV__ && options ? options.environmentName : undefined,
69
+ __DEV__ && options ? options.filterStackFrame : undefined,
70
);
71
if (options && options.signal) {
72
const signal = options.signal;
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+3
-1
@@ -67,6 +67,7 @@ function createCancelHandler(request: Request, reason: string) {
67
68
type Options = {
69
environmentName?: string | (() => string),
70
+ filterStackFrame?: (url: string, functionName: string) => boolean,
71
onError?: (error: mixed) => void,
72
onPostpone?: (reason: string) => void,
73
identifierPrefix?: string,
@@ -89,8 +90,9 @@ function renderToPipeableStream(
90
options ? options.onError : undefined,
91
options ? options.identifierPrefix : undefined,
92
options ? options.onPostpone : undefined,
92
- options ? options.environmentName : undefined,
93
options ? options.temporaryReferences : undefined,
94
+ __DEV__ && options ? options.environmentName : undefined,
95
+ __DEV__ && options ? options.filterStackFrame : undefined,
96
);
97
let hasStartedFlowing = false;
98
startWork(request);
packages/react-server/src/ReactFlightServer.js
+60
-29
@@ -63,7 +63,6 @@ import type {
63
ReactComponentInfo,
64
ReactAsyncInfo,
65
ReactStackTrace,
66
- ReactCallSite,
66
} from 'shared/ReactTypes';
67
import type {ReactElement} from 'shared/ReactElementType';
68
import type {LazyComponent} from 'react/src/ReactLazy';
@@ -135,32 +134,45 @@ import binaryToComparableString from 'shared/binaryToComparableString';
134
135
import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable';
136
138
-// TODO: Make this configurable on the Request.
139
-const externalRegExp = /\/node\_modules\/|^node\:|^$/;
140
-
141
-function isNotExternal(stackFrame: ReactCallSite): boolean {
142
- const filename = stackFrame[1];
143
- return !externalRegExp.test(filename);
137
+function defaultFilterStackFrame(
138
+ filename: string,
139
+ functionName: string,
140
+): boolean {
141
+ return (
142
+ filename !== '' &&
143
+ !filename.startsWith('node:') &&
144
+ !filename.includes('node_modules')
145
+ );
146
}
147
146
-function filterStackTrace(error: Error, skipFrames: number): ReactStackTrace {
148
+function filterStackTrace(
149
+ request: Request,
150
+ error: Error,
151
+ skipFrames: number,
152
+): ReactStackTrace {
153
// Since stacks can be quite large and we pass a lot of them, we filter them out eagerly
154
// to save bandwidth even in DEV. We'll also replay these stacks on the client so by
155
// stripping them early we avoid that overhead. Otherwise we'd normally just rely on
156
// the DevTools or framework's ignore lists to filter them out.
151
- const stack = parseStackTrace(error, skipFrames).filter(isNotExternal);
157
+ const filterStackFrame = request.filterStackFrame;
158
+ const stack = parseStackTrace(error, skipFrames);
159
for (let i = 0; i < stack.length; i++) {
160
const callsite = stack[i];
154
- const url = callsite[1];
161
+ const functionName = callsite[0];
162
+ let url = callsite[1];
163
if (url.startsWith('rsc://React/')) {
164
// This callsite is a virtual fake callsite that came from another Flight client.
165
// We need to reverse it back into the original location by stripping its prefix
166
// and suffix.
167
const suffixIdx = url.lastIndexOf('?');
168
if (suffixIdx > -1) {
161
- callsite[1] = url.slice(12, suffixIdx);
169
+ url = callsite[1] = url.slice(12, suffixIdx);
170
}
171
}
172
+ if (!filterStackFrame(url, functionName)) {
173
+ stack.splice(i, 1);
174
+ i--;
175
+ }
176
}
177
return stack;
178
}
@@ -188,7 +200,11 @@ function patchConsole(consoleInst: typeof console, methodName: string) {
200
// Extract the stack. Not all console logs print the full stack but they have at
201
// least the line it was called from. We could optimize transfer by keeping just
202
// one stack frame but keeping it simple for now and include all frames.
191
- const stack = filterStackTrace(new Error('react-stack-top-frame'), 1);
203
+ const stack = filterStackTrace(
204
+ request,
205
+ new Error('react-stack-top-frame'),
206
+ 1,
207
+ );
208
request.pendingChunks++;
209
// We don't currently use this id for anything but we emit it so that we can later
210
// refer to previous logs in debug info to associate them with a component.
@@ -360,6 +376,7 @@ export type Request = {
376
onPostpone: (reason: string) => void,
377
// DEV-only
378
environmentName: () => string,
379
+ filterStackFrame: (url: string, functionName: string) => boolean,
380
didWarnForKey: null | WeakSet<ReactComponentInfo>,
381
};
382
@@ -415,8 +432,9 @@ function RequestInstance(
432
onError: void | ((error: mixed) => ?string),
433
identifierPrefix?: string,
434
onPostpone: void | ((reason: string) => void),
418
- environmentName: void | string | (() => string),
435
temporaryReferences: void | TemporaryReferenceSet,
436
+ environmentName: void | string | (() => string), // DEV-only
437
+ filterStackFrame: void | ((url: string, functionName: string) => boolean), // DEV-only
438
) {
439
if (
440
ReactSharedInternals.A !== null &&
@@ -476,6 +494,10 @@ function RequestInstance(
494
: typeof environmentName !== 'function'
495
? () => environmentName
496
: environmentName;
497
+ this.filterStackFrame =
498
+ filterStackFrame === undefined
499
+ ? defaultFilterStackFrame
500
+ : filterStackFrame;
501
this.didWarnForKey = null;
502
}
503
const rootTask = createTask(
@@ -497,8 +519,9 @@ export function createRequest(
519
onError: void | ((error: mixed) => ?string),
520
identifierPrefix?: string,
521
onPostpone: void | ((reason: string) => void),
500
- environmentName: void | string | (() => string),
522
temporaryReferences: void | TemporaryReferenceSet,
523
+ environmentName: void | string | (() => string), // DEV-only
524
+ filterStackFrame: void | ((url: string, functionName: string) => boolean), // DEV-only
525
): Request {
526
// $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors
527
return new RequestInstance(
@@ -507,8 +530,9 @@ export function createRequest(
530
onError,
531
identifierPrefix,
532
onPostpone,
510
- environmentName,
533
temporaryReferences,
534
+ environmentName,
535
+ filterStackFrame,
536
);
537
}
538
@@ -932,6 +956,7 @@ function createLazyWrapperAroundWakeable(wakeable: Wakeable) {
956
}
957
958
function callWithDebugContextInDEV<A, T>(
959
+ request: Request,
960
task: Task,
961
callback: A => T,
962
arg: A,
@@ -947,7 +972,9 @@ function callWithDebugContextInDEV<A, T>(
972
if (enableOwnerStacks) {
973
// $FlowFixMe[cannot-write]
974
componentDebugInfo.stack =
950
- task.debugStack === null ? null : filterStackTrace(task.debugStack, 1);
975
+ task.debugStack === null
976
+ ? null
977
+ : filterStackTrace(request, task.debugStack, 1);
978
// $FlowFixMe[cannot-write]
979
componentDebugInfo.debugStack = task.debugStack;
980
// $FlowFixMe[cannot-write]
@@ -1011,7 +1038,7 @@ function renderFunctionComponent<Props>(
1038
componentDebugInfo.stack =
1039
task.debugStack === null
1040
? null
1014
- : filterStackTrace(task.debugStack, 1);
1041
+ : filterStackTrace(request, task.debugStack, 1);
1042
// $FlowFixMe[cannot-write]
1043
componentDebugInfo.debugStack = task.debugStack;
1044
// $FlowFixMe[cannot-write]
@@ -1142,7 +1169,7 @@ function renderFunctionComponent<Props>(
1169
Object.prototype.toString.call(iterableChild) ===
1170
'[object Generator]';
1171
if (!isGeneratorComponent) {
1145
- callWithDebugContextInDEV(task, () => {
1172
+ callWithDebugContextInDEV(request, task, () => {
1173
console.error(
1174
'Returning an Iterator from a Server Component is not supported ' +
1175
'since it cannot be looped over more than once. ',
@@ -1181,7 +1208,7 @@ function renderFunctionComponent<Props>(
1208
Object.prototype.toString.call(iterableChild) ===
1209
'[object AsyncGenerator]';
1210
if (!isGeneratorComponent) {
1184
- callWithDebugContextInDEV(task, () => {
1211
+ callWithDebugContextInDEV(request, task, () => {
1212
console.error(
1213
'Returning an AsyncIterator from a Server Component is not supported ' +
1214
'since it cannot be looped over more than once. ',
@@ -1437,6 +1464,7 @@ function renderAsyncFragment(
1464
}
1465
1466
function renderClientElement(
1467
+ request: Request,
1468
task: Task,
1469
type: any,
1470
key: null | string,
@@ -1461,7 +1489,7 @@ function renderClientElement(
1489
task.debugOwner,
1490
task.debugStack === null
1491
? null
1464
- : filterStackTrace(task.debugStack, 1),
1492
+ : filterStackTrace(request, task.debugStack, 1),
1493
validated,
1494
]
1495
: [REACT_ELEMENT_TYPE, type, key, props, task.debugOwner]
@@ -1621,7 +1649,7 @@ function renderElement(
1649
// We don't know if the client will support it or not. This might error on the
1650
// client or error during serialization but the stack will point back to the
1651
// server.
1624
- return renderClientElement(task, type, key, props, validated);
1652
+ return renderClientElement(request, task, type, key, props, validated);
1653
}
1654
1655
function pingTask(request: Request, task: Task): void {
@@ -1681,7 +1709,7 @@ function createTask(
1709
) {
1710
// Call with the server component as the currently rendering component
1711
// for context.
1684
- callWithDebugContextInDEV(task, () => {
1712
+ callWithDebugContextInDEV(request, task, () => {
1713
if (objectName(originalValue) !== 'Object') {
1714
const jsxParentType = jsxChildrenParents.get(parent);
1715
if (typeof jsxParentType === 'string') {
@@ -2576,7 +2604,7 @@ function renderModelDestructive(
2604
}
2605
2606
if (objectName(value) !== 'Object') {
2579
- callWithDebugContextInDEV(task, () => {
2607
+ callWithDebugContextInDEV(request, task, () => {
2608
console.error(
2609
'Only plain objects can be passed to Client Components from Server Components. ' +
2610
'%s objects are not supported.%s',
@@ -2585,7 +2613,7 @@ function renderModelDestructive(
2613
);
2614
});
2615
} else if (!isSimpleObject(value)) {
2588
- callWithDebugContextInDEV(task, () => {
2616
+ callWithDebugContextInDEV(request, task, () => {
2617
console.error(
2618
'Only plain objects can be passed to Client Components from Server Components. ' +
2619
'Classes or other objects with methods are not supported.%s',
@@ -2595,7 +2623,7 @@ function renderModelDestructive(
2623
} else if (Object.getOwnPropertySymbols) {
2624
const symbols = Object.getOwnPropertySymbols(value);
2625
if (symbols.length > 0) {
2598
- callWithDebugContextInDEV(task, () => {
2626
+ callWithDebugContextInDEV(request, task, () => {
2627
console.error(
2628
'Only plain objects can be passed to Client Components from Server Components. ' +
2629
'Objects with symbol properties like %s are not supported.%s',
@@ -2774,12 +2802,13 @@ function logPostpone(
2802
requestStorage.run(
2803
undefined,
2804
callWithDebugContextInDEV,
2805
+ request,
2806
task,
2807
onPostpone,
2808
reason,
2809
);
2810
} else {
2782
- callWithDebugContextInDEV(task, onPostpone, reason);
2811
+ callWithDebugContextInDEV(request, task, onPostpone, reason);
2812
}
2813
} else if (supportsRequestStorage) {
2814
// Exit the request context while running callbacks.
@@ -2809,12 +2838,13 @@ function logRecoverableError(
2838
errorDigest = requestStorage.run(
2839
undefined,
2840
callWithDebugContextInDEV,
2841
+ request,
2842
task,
2843
onError,
2844
error,
2845
);
2846
} else {
2817
- errorDigest = callWithDebugContextInDEV(task, onError, error);
2847
+ errorDigest = callWithDebugContextInDEV(request, task, onError, error);
2848
}
2849
} else if (supportsRequestStorage) {
2850
// Exit the request context while running callbacks.
@@ -2860,7 +2890,7 @@ function emitPostponeChunk(
2890
try {
2891
// eslint-disable-next-line react-internal/safe-string-coercion
2892
reason = String(postponeInstance.message);
2863
- stack = filterStackTrace(postponeInstance, 0);
2893
+ stack = filterStackTrace(request, postponeInstance, 0);
2894
} catch (x) {
2895
stack = [];
2896
}
@@ -2888,7 +2918,7 @@ function emitErrorChunk(
2918
if (error instanceof Error) {
2919
// eslint-disable-next-line react-internal/safe-string-coercion
2920
message = String(error.message);
2891
- stack = filterStackTrace(error, 0);
2921
+ stack = filterStackTrace(request, error, 0);
2922
const errorEnv = (error: any).environmentName;
2923
if (typeof errorEnv === 'string') {
2924
// This probably came from another FlightClient as a pass through.
@@ -2905,6 +2935,7 @@ function emitErrorChunk(
2935
}
2936
} catch (x) {
2937
message = 'An error occurred but serializing the error message failed.';
2938
+ stack = [];
2939
}
2940
errorInfo = {digest, message, stack, env};
2941
} else {