@samitouri / QOS-React / commits / 4b62400765

[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 {