Skip to content

Commit b15c198

Browse files
authored
[Flight] Normalize Stack Using Fake Evals (#30401)
Stacked on #30400 and #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 #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`.
1 parent 792f192 commit b15c198

File tree

13 files changed

+199
-55
lines changed

13 files changed

+199
-55
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -689,11 +689,21 @@ function createElement(
689689
value: null,
690690
});
691691
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+
}
692702
Object.defineProperty(element, '_debugStack', {
693703
configurable: false,
694704
enumerable: false,
695705
writable: true,
696-
value: stack,
706+
value: normalizedStackTrace,
697707
});
698708

699709
let task: null | ConsoleTask = null;
@@ -724,6 +734,12 @@ function createElement(
724734
writable: true,
725735
value: task,
726736
});
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+
}
727743
}
728744
}
729745

@@ -752,9 +768,9 @@ function createElement(
752768
};
753769
if (enableOwnerStacks) {
754770
// $FlowFixMe[cannot-write]
755-
erroredComponent.stack = element._debugStack;
771+
erroredComponent.debugStack = element._debugStack;
756772
// $FlowFixMe[cannot-write]
757-
erroredComponent.task = element._debugTask;
773+
erroredComponent.debugTask = element._debugTask;
758774
}
759775
erroredChunk._debugInfo = [erroredComponent];
760776
}
@@ -915,9 +931,9 @@ function waitForReference<T>(
915931
};
916932
if (enableOwnerStacks) {
917933
// $FlowFixMe[cannot-write]
918-
erroredComponent.stack = element._debugStack;
934+
erroredComponent.debugStack = element._debugStack;
919935
// $FlowFixMe[cannot-write]
920-
erroredComponent.task = element._debugTask;
936+
erroredComponent.debugTask = element._debugTask;
921937
}
922938
const chunkDebugInfo: ReactDebugInfo =
923939
chunk._debugInfo || (chunk._debugInfo = []);
@@ -2001,16 +2017,23 @@ function initializeFakeTask(
20012017
response: Response,
20022018
debugInfo: ReactComponentInfo | ReactAsyncInfo,
20032019
): null | ConsoleTask {
2004-
if (!supportsCreateTask || typeof debugInfo.stack !== 'string') {
2020+
if (!supportsCreateTask) {
20052021
return null;
20062022
}
20072023
const componentInfo: ReactComponentInfo = (debugInfo: any); // Refined
2008-
const stack: string = debugInfo.stack;
2009-
const cachedEntry = componentInfo.task;
2024+
const cachedEntry = componentInfo.debugTask;
20102025
if (cachedEntry !== undefined) {
20112026
return cachedEntry;
20122027
}
20132028

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+
20142037
const ownerTask =
20152038
componentInfo.owner == null
20162039
? null
@@ -2034,10 +2057,63 @@ function initializeFakeTask(
20342057
componentTask = ownerTask.run(callStack);
20352058
}
20362059
// $FlowFixMe[cannot-write]: We consider this part of initialization.
2037-
componentInfo.task = componentTask;
2060+
componentInfo.debugTask = componentTask;
20382061
return componentTask;
20392062
}
20402063

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+
20412117
function resolveDebugInfo(
20422118
response: Response,
20432119
id: number,
@@ -2054,6 +2130,8 @@ function resolveDebugInfo(
20542130
// render phase so we're not inside a user space stack at this point. If we waited
20552131
// to initialize it when we need it, we might be inside user code.
20562132
initializeFakeTask(response, debugInfo);
2133+
initializeFakeStack(response, debugInfo);
2134+
20572135
const chunk = getChunk(response, id);
20582136
const chunkDebugInfo: ReactDebugInfo =
20592137
chunk._debugInfo || (chunk._debugInfo = []);
@@ -2096,6 +2174,7 @@ function resolveConsoleEntry(
20962174
);
20972175
if (owner != null) {
20982176
const task = initializeFakeTask(response, owner);
2177+
initializeFakeStack(response, owner);
20992178
if (task !== null) {
21002179
task.run(callStack);
21012180
return;

packages/react-client/src/__tests__/ReactFlight-test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ function normalizeCodeLocInfo(str) {
2929

3030
function normalizeComponentInfo(debugInfo) {
3131
if (typeof debugInfo.stack === 'string') {
32-
const {task, ...copy} = debugInfo;
32+
const {debugTask, debugStack, ...copy} = debugInfo;
3333
copy.stack = normalizeCodeLocInfo(debugInfo.stack);
3434
if (debugInfo.owner) {
3535
copy.owner = normalizeComponentInfo(debugInfo.owner);

packages/react-dom/src/__tests__/ReactUpdates-test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1858,14 +1858,14 @@ describe('ReactUpdates', () => {
18581858

18591859
let error = null;
18601860
let ownerStack = null;
1861-
let nativeStack = null;
1861+
let debugStack = null;
18621862
const originalConsoleError = console.error;
18631863
console.error = e => {
18641864
error = e;
18651865
ownerStack = gate(flags => flags.enableOwnerStacks)
18661866
? React.captureOwnerStack()
18671867
: null;
1868-
nativeStack = new Error().stack;
1868+
debugStack = new Error().stack;
18691869
Scheduler.log('stop');
18701870
};
18711871
try {
@@ -1879,7 +1879,7 @@ describe('ReactUpdates', () => {
18791879

18801880
expect(error).toContain('Maximum update depth exceeded');
18811881
// The currently executing effect should be on the native stack
1882-
expect(nativeStack).toContain('at myEffect');
1882+
expect(debugStack).toContain('at myEffect');
18831883
if (gate(flags => flags.enableOwnerStacks)) {
18841884
expect(ownerStack).toContain('at App');
18851885
} else {

packages/react-reconciler/src/ReactChildFiber.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2015,7 +2015,7 @@ function createChildReconciler(
20152015
if (typeof debugInfo[i].stack === 'string') {
20162016
throwFiber._debugOwner = (debugInfo[i]: any);
20172017
if (enableOwnerStacks) {
2018-
throwFiber._debugTask = debugInfo[i].task;
2018+
throwFiber._debugTask = debugInfo[i].debugTask;
20192019
}
20202020
break;
20212021
}

packages/react-reconciler/src/ReactFiberComponentStack.js

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,13 @@ export function getOwnerStackByFiberInDev(workInProgress: Fiber): string {
165165
info += '\n' + debugStack;
166166
}
167167
}
168-
} else if (typeof owner.stack === 'string') {
168+
} else if (owner.debugStack != null) {
169169
// 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;
176171
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);
179175
}
180176
} else {
181177
break;

packages/react-reconciler/src/ReactFiberOwnerStack.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function filterDebugStack(error: Error): string {
3838
// To keep things light we exclude the entire trace in this case.
3939
return '';
4040
}
41-
const frames = stack.split('\n').slice(1);
41+
const frames = stack.split('\n').slice(1); // Pop the JSX frame.
4242
return frames.filter(isNotExternal).join('\n');
4343
}
4444

packages/react-server/src/ReactFizzComponentStack.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,21 +159,32 @@ export function getOwnerStackByComponentStackNodeInDev(
159159
componentStack;
160160

161161
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;
167179
}
168-
owner = owner.owner;
169180
// If we don't actually print the stack if there is no owner of this JSX element.
170181
// In a real app it's typically not useful since the root app is always controlled
171182
// by the framework. These also tend to have noisy stacks because they're not rooted
172183
// in a React render but in some imperative bootstrapping code. It could be useful
173184
// if the element was created in module scope. E.g. hoisted. We could add a a single
174185
// 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;
177188
}
178189
}
179190
return info;

packages/react-server/src/ReactFizzOwnerStack.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function filterDebugStack(error: Error): string {
3838
// To keep things light we exclude the entire trace in this case.
3939
return '';
4040
}
41-
const frames = stack.split('\n').slice(1);
41+
const frames = stack.split('\n').slice(1); // Pop the JSX frame.
4242
return frames.filter(isNotExternal).join('\n');
4343
}
4444

packages/react-server/src/ReactFizzServer.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -859,17 +859,17 @@ function pushServerComponentStack(
859859
if (typeof componentInfo.name !== 'string') {
860860
continue;
861861
}
862-
if (enableOwnerStacks && componentInfo.stack === undefined) {
862+
if (enableOwnerStacks && componentInfo.debugStack === undefined) {
863863
continue;
864864
}
865865
task.componentStack = {
866866
parent: task.componentStack,
867867
type: componentInfo,
868868
owner: componentInfo.owner,
869-
stack: componentInfo.stack,
869+
stack: enableOwnerStacks ? componentInfo.debugStack : null,
870870
};
871871
if (enableOwnerStacks) {
872-
task.debugTask = (componentInfo.task: any);
872+
task.debugTask = (componentInfo.debugTask: any);
873873
}
874874
}
875875
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)