@samitouri / QOS-React-2 / commits / b15c1983dc

[Flight] Normalize Stack Using Fake Evals (#30401)

Stacked on https://github.com/facebook/react/pull/30400 and https://github.com/facebook/react/pull/30369 Previously we were using fake evals to recreate a stack for console replaying and thrown errors. However, for owner stacks we just used the raw string that came from the server. This means that the format of the owner stack could include different formats. Like Spidermonkey format for the client components and V8 for the server components. This means that this stack can't be parsed natively by the browser like when printing them as error like in https://github.com/facebook/react/pull/30289. Additionally, since there's no source file registered with that name and no source mapping url, it can't be source mapped. Before: <img width="1329" alt="before-firefox" src="https://github.com/user-attachments/assets/cbe03f9c-96ac-48fb-b58f-f3a224a774f4"> Instead, we need to create a fake stack like we do for the other things. That way when it's printed as an Error it gets source mapped. It also means that the format is consistently in the native format of the current browser. After: <img width="753" alt="after-firefox" src="https://github.com/user-attachments/assets/b436f1f5-ca37-4203-b29f-df9828c9fad3"> So this is nice because you can just take the result from `captureOwnerStack()` and append it to an `Error` stack and print it natively. E.g. this is what React DevTools will do. If you want to parse and present it yourself though it's a bit awkward though. The `captureOwnerStack()` API now includes a bunch of `rsc://React/` URLs. These don't really have any direct connection to the source map. Only the browser knows this connection from the eval. You basically have to strip the prefix and then manually pass the remainder to your own `findSourceMapURL`. Another awkward part is that since Safari doesn't support eval sourceURL exposed into `error.stack` - it means that `captureOwnerStack()` get an empty location for server components since the fake eval doesn't work there. That's not a big deal since these stacks are already broken even for client modules for many because the `eval-source-map` strategy in Webpack doesn't work in Safari for this same reason. A lot of this refactoring is just clarifying that there's three kind of ReactComponentInfo fields: - `stack` - The raw stack as described on the original server. - `debugStack` - The Error object containing the stack as represented in the current client as fake evals. - `debugTask` - The same thing as `debugStack` but described in terms of a native `console.createTask`.

Sebastian Markbåge committed Jul 22, 2024 at 11:03 UTC b15c1983dcf96f19400b0ca7337be1e1fb1a8717
13 files changed +199 -55
packages/react-client/src/ReactFlightClient.js
+88 -9
@@ -689,11 +689,21 @@ function createElement(
689 value: null,
690 });
691 if (enableOwnerStacks) {
692 + let normalizedStackTrace: null | Error = null;
693 + if (stack !== null) {
694 + // We create a fake stack and then create an Error object inside of it.
695 + // This means that the stack trace is now normalized into the native format
696 + // of the browser and the stack frames will have been registered with
697 + // source mapping information.
698 + // This can unfortunately happen within a user space callstack which will
699 + // remain on the stack.
700 + normalizedStackTrace = createFakeJSXCallStackInDEV(response, stack);
701 + }
702 Object.defineProperty(element, '_debugStack', {
703 configurable: false,
704 enumerable: false,
705 writable: true,
696 - value: stack,
706 + value: normalizedStackTrace,
707 });
708
709 let task: null | ConsoleTask = null;
@@ -724,6 +734,12 @@ function createElement(
734 writable: true,
735 value: task,
736 });
737 +
738 + // This owner should ideally have already been initialized to avoid getting
739 + // user stack frames on the stack.
740 + if (owner !== null) {
741 + initializeFakeStack(response, owner);
742 + }
743 }
744 }
745
@@ -752,9 +768,9 @@ function createElement(
768 };
769 if (enableOwnerStacks) {
770 // $FlowFixMe[cannot-write]
755 - erroredComponent.stack = element._debugStack;
771 + erroredComponent.debugStack = element._debugStack;
772 // $FlowFixMe[cannot-write]
757 - erroredComponent.task = element._debugTask;
773 + erroredComponent.debugTask = element._debugTask;
774 }
775 erroredChunk._debugInfo = [erroredComponent];
776 }
@@ -915,9 +931,9 @@ function waitForReference<T>(
931 };
932 if (enableOwnerStacks) {
933 // $FlowFixMe[cannot-write]
918 - erroredComponent.stack = element._debugStack;
934 + erroredComponent.debugStack = element._debugStack;
935 // $FlowFixMe[cannot-write]
920 - erroredComponent.task = element._debugTask;
936 + erroredComponent.debugTask = element._debugTask;
937 }
938 const chunkDebugInfo: ReactDebugInfo =
939 chunk._debugInfo || (chunk._debugInfo = []);
@@ -2001,16 +2017,23 @@ function initializeFakeTask(
2017 response: Response,
2018 debugInfo: ReactComponentInfo | ReactAsyncInfo,
2019 ): null | ConsoleTask {
2004 - if (!supportsCreateTask || typeof debugInfo.stack !== 'string') {
2020 + if (!supportsCreateTask) {
2021 return null;
2022 }
2023 const componentInfo: ReactComponentInfo = (debugInfo: any); // Refined
2008 - const stack: string = debugInfo.stack;
2009 - const cachedEntry = componentInfo.task;
2024 + const cachedEntry = componentInfo.debugTask;
2025 if (cachedEntry !== undefined) {
2026 return cachedEntry;
2027 }
2028
2029 + if (typeof debugInfo.stack !== 'string') {
2030 + // If this is an error, we should've really already initialized the task.
2031 + // If it's null, we can't initialize a task.
2032 + return null;
2033 + }
2034 +
2035 + const stack = debugInfo.stack;
2036 +
2037 const ownerTask =
2038 componentInfo.owner == null
2039 ? null
@@ -2034,10 +2057,63 @@ function initializeFakeTask(
2057 componentTask = ownerTask.run(callStack);
2058 }
2059 // $FlowFixMe[cannot-write]: We consider this part of initialization.
2037 - componentInfo.task = componentTask;
2060 + componentInfo.debugTask = componentTask;
2061 return componentTask;
2062 }
2063
2064 +const createFakeJSXCallStack = {
2065 + 'react-stack-bottom-frame': function (
2066 + response: Response,
2067 + stack: string,
2068 + ): Error {
2069 + const callStackForError = buildFakeCallStack(
2070 + response,
2071 + stack,
2072 + fakeJSXCallSite,
2073 + );
2074 + return callStackForError();
2075 + },
2076 +};
2077 +
2078 +const createFakeJSXCallStackInDEV: (
2079 + response: Response,
2080 + stack: string,
2081 +) => Error = __DEV__
2082 + ? // We use this technique to trick minifiers to preserve the function name.
2083 + (createFakeJSXCallStack['react-stack-bottom-frame'].bind(
2084 + createFakeJSXCallStack,
2085 + ): any)
2086 + : (null: any);
2087 +
2088 +/** @noinline */
2089 +function fakeJSXCallSite() {
2090 + // This extra call frame represents the JSX creation function. We always pop this frame
2091 + // off before presenting so it needs to be part of the stack.
2092 + return new Error('react-stack-top-frame');
2093 +}
2094 +
2095 +function initializeFakeStack(
2096 + response: Response,
2097 + debugInfo: ReactComponentInfo | ReactAsyncInfo,
2098 +): void {
2099 + const cachedEntry = debugInfo.debugStack;
2100 + if (cachedEntry !== undefined) {
2101 + return;
2102 + }
2103 + if (typeof debugInfo.stack === 'string') {
2104 + // $FlowFixMe[cannot-write]
2105 + // $FlowFixMe[prop-missing]
2106 + debugInfo.debugStack = createFakeJSXCallStackInDEV(
2107 + response,
2108 + debugInfo.stack,
2109 + );
2110 + }
2111 + if (debugInfo.owner != null) {
2112 + // Initialize any owners not yet initialized.
2113 + initializeFakeStack(response, debugInfo.owner);
2114 + }
2115 +}
2116 +
2117 function resolveDebugInfo(
2118 response: Response,
2119 id: number,
@@ -2054,6 +2130,8 @@ function resolveDebugInfo(
2130 // render phase so we're not inside a user space stack at this point. If we waited
2131 // to initialize it when we need it, we might be inside user code.
2132 initializeFakeTask(response, debugInfo);
2133 + initializeFakeStack(response, debugInfo);
2134 +
2135 const chunk = getChunk(response, id);
2136 const chunkDebugInfo: ReactDebugInfo =
2137 chunk._debugInfo || (chunk._debugInfo = []);
@@ -2096,6 +2174,7 @@ function resolveConsoleEntry(
2174 );
2175 if (owner != null) {
2176 const task = initializeFakeTask(response, owner);
2177 + initializeFakeStack(response, owner);
2178 if (task !== null) {
2179 task.run(callStack);
2180 return;
packages/react-client/src/__tests__/ReactFlight-test.js
+1 -1
@@ -29,7 +29,7 @@ function normalizeCodeLocInfo(str) {
29
30 function normalizeComponentInfo(debugInfo) {
31 if (typeof debugInfo.stack === 'string') {
32 - const {task, ...copy} = debugInfo;
32 + const {debugTask, debugStack, ...copy} = debugInfo;
33 copy.stack = normalizeCodeLocInfo(debugInfo.stack);
34 if (debugInfo.owner) {
35 copy.owner = normalizeComponentInfo(debugInfo.owner);
packages/react-dom/src/__tests__/ReactUpdates-test.js
+3 -3
@@ -1858,14 +1858,14 @@ describe('ReactUpdates', () => {
1858
1859 let error = null;
1860 let ownerStack = null;
1861 - let nativeStack = null;
1861 + let debugStack = null;
1862 const originalConsoleError = console.error;
1863 console.error = e => {
1864 error = e;
1865 ownerStack = gate(flags => flags.enableOwnerStacks)
1866 ? React.captureOwnerStack()
1867 : null;
1868 - nativeStack = new Error().stack;
1868 + debugStack = new Error().stack;
1869 Scheduler.log('stop');
1870 };
1871 try {
@@ -1879,7 +1879,7 @@ describe('ReactUpdates', () => {
1879
1880 expect(error).toContain('Maximum update depth exceeded');
1881 // The currently executing effect should be on the native stack
1882 - expect(nativeStack).toContain('at myEffect');
1882 + expect(debugStack).toContain('at myEffect');
1883 if (gate(flags => flags.enableOwnerStacks)) {
1884 expect(ownerStack).toContain('at App');
1885 } else {
packages/react-reconciler/src/ReactChildFiber.js
+1 -1
@@ -2015,7 +2015,7 @@ function createChildReconciler(
2015 if (typeof debugInfo[i].stack === 'string') {
2016 throwFiber._debugOwner = (debugInfo[i]: any);
2017 if (enableOwnerStacks) {
2018 - throwFiber._debugTask = debugInfo[i].task;
2018 + throwFiber._debugTask = debugInfo[i].debugTask;
2019 }
2020 break;
2021 }
packages/react-reconciler/src/ReactFiberComponentStack.js
+5 -9
@@ -165,17 +165,13 @@ export function getOwnerStackByFiberInDev(workInProgress: Fiber): string {
165 info += '\n' + debugStack;
166 }
167 }
168 - } else if (typeof owner.stack === 'string') {
168 + } else if (owner.debugStack != null) {
169 // Server Component
170 - // The Server Component stack can come from a different VM that formats it different.
171 - // Likely V8. Since Chrome based browsers support createTask which is going to use
172 - // another code path anyway. I.e. this is likely NOT a V8 based browser.
173 - // This will cause some of the stack to have different formatting.
174 - // TODO: Normalize server component stacks to the client formatting.
175 - const ownerStack: string = owner.stack;
170 + const ownerStack: Error = owner.debugStack;
171 owner = owner.owner;
177 - if (owner && ownerStack !== '') {
178 - info += '\n' + ownerStack;
172 + if (owner && ownerStack) {
173 + // TODO: Should we stash this somewhere for caching purposes?
174 + info += '\n' + formatOwnerStack(ownerStack);
175 }
176 } else {
177 break;
packages/react-reconciler/src/ReactFiberOwnerStack.js
+1 -1
@@ -38,7 +38,7 @@ function filterDebugStack(error: Error): string {
38 // To keep things light we exclude the entire trace in this case.
39 return '';
40 }
41 - const frames = stack.split('\n').slice(1);
41 + const frames = stack.split('\n').slice(1); // Pop the JSX frame.
42 return frames.filter(isNotExternal).join('\n');
43 }
44
packages/react-server/src/ReactFizzComponentStack.js
+19 -8
@@ -159,21 +159,32 @@ export function getOwnerStackByComponentStackNodeInDev(
159 componentStack;
160
161 while (owner) {
162 - let debugStack: void | null | string | Error = owner.stack;
163 - if (typeof debugStack !== 'string' && debugStack != null) {
164 - // Stash the formatted stack so that we can avoid redoing the filtering.
165 - // $FlowFixMe[cannot-write]: This has been refined to a ComponentStackNode.
166 - owner.stack = debugStack = formatOwnerStack(debugStack);
162 + let ownerStack: ?string = null;
163 + if (owner.debugStack != null) {
164 + // Server Component
165 + // TODO: Should we stash this somewhere for caching purposes?
166 + ownerStack = formatOwnerStack(owner.debugStack);
167 + owner = owner.owner;
168 + } else if (owner.stack != null) {
169 + // Client Component
170 + const node: ComponentStackNode = (owner: any);
171 + if (typeof owner.stack !== 'string') {
172 + ownerStack = node.stack = formatOwnerStack(owner.stack);
173 + } else {
174 + ownerStack = owner.stack;
175 + }
176 + owner = owner.owner;
177 + } else {
178 + owner = owner.owner;
179 }
168 - owner = owner.owner;
180 // If we don't actually print the stack if there is no owner of this JSX element.
181 // In a real app it's typically not useful since the root app is always controlled
182 // by the framework. These also tend to have noisy stacks because they're not rooted
183 // in a React render but in some imperative bootstrapping code. It could be useful
184 // if the element was created in module scope. E.g. hoisted. We could add a a single
185 // stack frame for context for example but it doesn't say much if that's a wrapper.
175 - if (owner && debugStack) {
176 - info += '\n' + debugStack;
186 + if (owner && ownerStack) {
187 + info += '\n' + ownerStack;
188 }
189 }
190 return info;
packages/react-server/src/ReactFizzOwnerStack.js
+1 -1
@@ -38,7 +38,7 @@ function filterDebugStack(error: Error): string {
38 // To keep things light we exclude the entire trace in this case.
39 return '';
40 }
41 - const frames = stack.split('\n').slice(1);
41 + const frames = stack.split('\n').slice(1); // Pop the JSX frame.
42 return frames.filter(isNotExternal).join('\n');
43 }
44
packages/react-server/src/ReactFizzServer.js
+3 -3
@@ -859,17 +859,17 @@ function pushServerComponentStack(
859 if (typeof componentInfo.name !== 'string') {
860 continue;
861 }
862 - if (enableOwnerStacks && componentInfo.stack === undefined) {
862 + if (enableOwnerStacks && componentInfo.debugStack === undefined) {
863 continue;
864 }
865 task.componentStack = {
866 parent: task.componentStack,
867 type: componentInfo,
868 owner: componentInfo.owner,
869 - stack: componentInfo.stack,
869 + stack: enableOwnerStacks ? componentInfo.debugStack : null,
870 };
871 if (enableOwnerStacks) {
872 - task.debugTask = (componentInfo.task: any);
872 + task.debugTask = (componentInfo.debugTask: any);
873 }
874 }
875 }
packages/react-server/src/ReactFlightOwnerStack.js new
+42
@@ -0,0 +1,42 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +// TODO: Make this configurable on the Request.
11 +const externalRegExp = /\/node\_modules\/| \(node\:| node\:|\(\<anonymous\>\)/;
12 +
13 +function isNotExternal(stackFrame: string): boolean {
14 + return !externalRegExp.test(stackFrame);
15 +}
16 +
17 +function filterDebugStack(error: Error): string {
18 + // Since stacks can be quite large and we pass a lot of them, we filter them out eagerly
19 + // to save bandwidth even in DEV. We'll also replay these stacks on the client so by
20 + // stripping them early we avoid that overhead. Otherwise we'd normally just rely on
21 + // the DevTools or framework's ignore lists to filter them out.
22 + let stack = error.stack;
23 + if (stack.startsWith('Error: react-stack-top-frame\n')) {
24 + // V8's default formatting prefixes with the error message which we
25 + // don't want/need.
26 + stack = stack.slice(29);
27 + }
28 + let idx = stack.indexOf('react-stack-bottom-frame');
29 + if (idx !== -1) {
30 + idx = stack.lastIndexOf('\n', idx);
31 + }
32 + if (idx !== -1) {
33 + // Cut off everything after the bottom frame since it'll be internals.
34 + stack = stack.slice(0, idx);
35 + }
36 + const frames = stack.split('\n').slice(1); // Pop the JSX frame.
37 + return frames.filter(isNotExternal).join('\n');
38 +}
39 +
40 +export function formatOwnerStack(ownerStackTrace: Error): string {
41 + return filterDebugStack(ownerStackTrace);
42 +}
packages/react-server/src/ReactFlightServer.js
+25 -14
@@ -351,7 +351,7 @@ type Task = {
351 thenableState: ThenableState | null,
352 environmentName: string, // DEV-only. Used to track if the environment for this task changed.
353 debugOwner: null | ReactComponentInfo, // DEV-only
354 - debugStack: null | string, // DEV-only
354 + debugStack: null | Error, // DEV-only
355 debugTask: null | ConsoleTask, // DEV-only
356 };
357
@@ -972,7 +972,12 @@ function callWithDebugContextInDEV<A, T>(
972 };
973 if (enableOwnerStacks) {
974 // $FlowFixMe[cannot-write]
975 - componentDebugInfo.stack = task.debugStack;
975 + componentDebugInfo.stack =
976 + task.debugStack === null ? null : filterDebugStack(task.debugStack);
977 + // $FlowFixMe[cannot-write]
978 + componentDebugInfo.debugStack = task.debugStack;
979 + // $FlowFixMe[cannot-write]
980 + componentDebugInfo.debugTask = task.debugTask;
981 }
982 const debugTask = task.debugTask;
983 // We don't need the async component storage context here so we only set the
@@ -1029,7 +1034,12 @@ function renderFunctionComponent<Props>(
1034 }: ReactComponentInfo);
1035 if (enableOwnerStacks) {
1036 // $FlowFixMe[cannot-write]
1032 - componentDebugInfo.stack = task.debugStack;
1037 + componentDebugInfo.stack =
1038 + task.debugStack === null ? null : filterDebugStack(task.debugStack);
1039 + // $FlowFixMe[cannot-write]
1040 + componentDebugInfo.debugStack = task.debugStack;
1041 + // $FlowFixMe[cannot-write]
1042 + componentDebugInfo.debugTask = task.debugTask;
1043 }
1044 // We outline this model eagerly so that we can refer to by reference as an owner.
1045 // If we had a smarter way to dedupe we might not have to do this if there ends up
@@ -1419,7 +1429,7 @@ function renderClientElement(
1429 key,
1430 props,
1431 task.debugOwner,
1422 - task.debugStack,
1432 + task.debugStack === null ? null : filterDebugStack(task.debugStack),
1433 validated,
1434 ]
1435 : [REACT_ELEMENT_TYPE, type, key, props, task.debugOwner]
@@ -1598,7 +1608,7 @@ function createTask(
1608 implicitSlot: boolean,
1609 abortSet: Set<Task>,
1610 debugOwner: null | ReactComponentInfo, // DEV-only
1601 - debugStack: null | string, // DEV-only
1611 + debugStack: null | Error, // DEV-only
1612 debugTask: null | ConsoleTask, // DEV-only
1613 ): Task {
1614 request.pendingChunks++;
@@ -2205,10 +2215,7 @@ function renderModelDestructive(
2215 if (__DEV__) {
2216 task.debugOwner = element._owner;
2217 if (enableOwnerStacks) {
2208 - task.debugStack =
2209 - !element._debugStack || typeof element._debugStack === 'string'
2210 - ? element._debugStack
2211 - : filterDebugStack(element._debugStack);
2218 + task.debugStack = element._debugStack;
2219 task.debugTask = element._debugTask;
2220 }
2221 // TODO: Pop this. Since we currently don't have a point where we can pop the stack
@@ -2507,10 +2514,11 @@ function renderModelDestructive(
2514 if (__DEV__) {
2515 if (
2516 // TODO: We don't currently have a brand check on ReactComponentInfo. Reconsider.
2510 - typeof value.task === 'object' &&
2511 - value.task !== null &&
2512 - // $FlowFixMe[method-unbinding]
2513 - typeof value.task.run === 'function' &&
2517 + ((typeof value.debugTask === 'object' &&
2518 + value.debugTask !== null &&
2519 + // $FlowFixMe[method-unbinding]
2520 + typeof value.debugTask.run === 'function') ||
2521 + value.debugStack instanceof Error) &&
2522 typeof value.name === 'string' &&
2523 typeof value.env === 'string' &&
2524 value.owner !== undefined &&
@@ -2520,7 +2528,10 @@ function renderModelDestructive(
2528 ) {
2529 // This looks like a ReactComponentInfo. We can't serialize the ConsoleTask object so we
2530 // need to omit it before serializing.
2523 - const componentDebugInfo: Omit<ReactComponentInfo, 'task'> = {
2531 + const componentDebugInfo: Omit<
2532 + ReactComponentInfo,
2533 + 'debugTask' | 'debugStack',
2534 + > = {
2535 name: value.name,
2536 env: value.env,
2537 owner: (value: any).owner,
packages/react-server/src/flight/ReactFlightComponentStack.js
+7 -4
@@ -13,6 +13,8 @@ import {describeBuiltInComponentFrame} from 'shared/ReactComponentStackFrame';
13
14 import {enableOwnerStacks} from 'shared/ReactFeatureFlags';
15
16 +import {formatOwnerStack} from '../ReactFlightOwnerStack';
17 +
18 export function getOwnerStackByComponentInfoInDev(
19 componentInfo: ReactComponentInfo,
20 ): string {
@@ -34,12 +36,13 @@ export function getOwnerStackByComponentInfoInDev(
36 let owner: void | null | ReactComponentInfo = componentInfo;
37
38 while (owner) {
37 - if (typeof owner.stack === 'string') {
39 + const ownerStack: ?Error = owner.debugStack;
40 + if (ownerStack != null) {
41 // Server Component
39 - const ownerStack: string = owner.stack;
42 owner = owner.owner;
41 - if (owner && ownerStack !== '') {
42 - info += '\n' + ownerStack;
43 + if (owner) {
44 + // TODO: Should we stash this somewhere for caching purposes?
45 + info += '\n' + formatOwnerStack(ownerStack);
46 }
47 } else {
48 break;
packages/shared/ReactTypes.js
+3 -1
@@ -183,7 +183,9 @@ export type ReactComponentInfo = {
183 +env?: string,
184 +owner?: null | ReactComponentInfo,
185 +stack?: null | string,
186 - +task?: null | ConsoleTask,
186 + // Stashed Data for the Specific Execution Environment. Not part of the transport protocol
187 + +debugStack?: null | Error,
188 + +debugTask?: null | ConsoleTask,
189 };
190
191 export type ReactAsyncInfo = {