Skip to content

Commit 0ff1d13

Browse files
authored
[Flight] Parse Stack Trace from Structured CallSite if available (facebook#33135)
This is first step to include more enclosing line/column in the parsed data. We install our own `prepareStackTrace` to collect structured callsite data and only fall back to parsing the string if it was already evaluated or if `prepareStackTrace` doesn't work in this environment. We still mirror the default V8 format for encoding the function name part. A lot of this is covered by tests already.
1 parent 53c9f81 commit 0ff1d13

File tree

2 files changed

+139
-16
lines changed

2 files changed

+139
-16
lines changed

packages/react-server/src/ReactFlightServer.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,27 @@ function defaultFilterStackFrame(
152152
);
153153
}
154154

155+
// DEV-only cache of parsed and filtered stack frames.
156+
const stackTraceCache: WeakMap<Error, ReactStackTrace> = __DEV__
157+
? new WeakMap()
158+
: (null: any);
159+
155160
function filterStackTrace(
156161
request: Request,
157162
error: Error,
158163
skipFrames: number,
159164
): ReactStackTrace {
165+
const existing = stackTraceCache.get(error);
166+
if (existing !== undefined) {
167+
// Return a clone because the Flight protocol isn't yet resilient to deduping
168+
// objects in the debug info. TODO: Support deduping stacks.
169+
const clone = existing.slice(0);
170+
for (let i = 0; i < clone.length; i++) {
171+
// $FlowFixMe[invalid-tuple-arity]
172+
clone[i] = clone[i].slice(0);
173+
}
174+
return clone;
175+
}
160176
// Since stacks can be quite large and we pass a lot of them, we filter them out eagerly
161177
// to save bandwidth even in DEV. We'll also replay these stacks on the client so by
162178
// stripping them early we avoid that overhead. Otherwise we'd normally just rely on
@@ -183,6 +199,7 @@ function filterStackTrace(
183199
i--;
184200
}
185201
}
202+
stackTraceCache.set(error, stack);
186203
return stack;
187204
}
188205

packages/react-server/src/ReactFlightStackConfigV8.js

Lines changed: 122 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,104 @@
99

1010
import type {ReactStackTrace} from 'shared/ReactTypes';
1111

12-
import DefaultPrepareStackTrace from 'shared/DefaultPrepareStackTrace';
12+
let framesToSkip: number = 0;
13+
let collectedStackTrace: null | ReactStackTrace = null;
1314

14-
function getStack(error: Error): string {
15-
// We override Error.prepareStackTrace with our own version that normalizes
16-
// the stack to V8 formatting even if the server uses other formatting.
17-
// It also ensures that source maps are NOT applied to this since that can
18-
// be slow we're better off doing that lazily from the client instead of
19-
// eagerly on the server. If the stack has already been read, then we might
20-
// not get a normalized stack and it might still have been source mapped.
21-
const previousPrepare = Error.prepareStackTrace;
22-
Error.prepareStackTrace = DefaultPrepareStackTrace;
23-
try {
24-
// eslint-disable-next-line react-internal/safe-string-coercion
25-
return String(error.stack);
26-
} finally {
27-
Error.prepareStackTrace = previousPrepare;
15+
const identifierRegExp = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;
16+
17+
function getMethodCallName(callSite: CallSite): string {
18+
const typeName = callSite.getTypeName();
19+
const methodName = callSite.getMethodName();
20+
const functionName = callSite.getFunctionName();
21+
let result = '';
22+
if (functionName) {
23+
if (
24+
typeName &&
25+
identifierRegExp.test(functionName) &&
26+
functionName !== typeName
27+
) {
28+
result += typeName + '.';
29+
}
30+
result += functionName;
31+
if (
32+
methodName &&
33+
functionName !== methodName &&
34+
!functionName.endsWith('.' + methodName) &&
35+
!functionName.endsWith(' ' + methodName)
36+
) {
37+
result += ' [as ' + methodName + ']';
38+
}
39+
} else {
40+
if (typeName) {
41+
result += typeName + '.';
42+
}
43+
if (methodName) {
44+
result += methodName;
45+
} else {
46+
result += '<anonymous>';
47+
}
48+
}
49+
return result;
50+
}
51+
52+
function collectStackTrace(
53+
error: Error,
54+
structuredStackTrace: CallSite[],
55+
): string {
56+
const result: ReactStackTrace = [];
57+
// Collect structured stack traces from the callsites.
58+
// We mirror how V8 serializes stack frames and how we later parse them.
59+
for (let i = framesToSkip; i < structuredStackTrace.length; i++) {
60+
const callSite = structuredStackTrace[i];
61+
let name = callSite.getFunctionName() || '<anonymous>';
62+
if (name === 'react-stack-bottom-frame') {
63+
// Skip everything after the bottom frame since it'll be internals.
64+
break;
65+
} else if (callSite.isNative()) {
66+
result.push([name, '', 0, 0]);
67+
} else {
68+
// We encode complex function calls as if they're part of the function
69+
// name since we cannot simulate the complex ones and they look the same
70+
// as function names in UIs on the client as well as stacks.
71+
if (callSite.isConstructor()) {
72+
name = 'new ' + name;
73+
} else if (!callSite.isToplevel()) {
74+
name = getMethodCallName(callSite);
75+
}
76+
if (name === '<anonymous>') {
77+
name = '';
78+
}
79+
let filename = callSite.getScriptNameOrSourceURL() || '<anonymous>';
80+
if (filename === '<anonymous>') {
81+
filename = '';
82+
}
83+
if (callSite.isEval() && !filename) {
84+
const origin = callSite.getEvalOrigin();
85+
if (origin) {
86+
filename = origin.toString() + ', <anonymous>';
87+
}
88+
}
89+
const line = callSite.getLineNumber() || 0;
90+
const col = callSite.getColumnNumber() || 0;
91+
result.push([name, filename, line, col]);
92+
}
2893
}
94+
// At the same time we generate a string stack trace just in case someone
95+
// else reads it. Ideally, we'd call the previous prepareStackTrace to
96+
// ensure it's in the expected format but it's common for that to be
97+
// source mapped and since we do a lot of eager parsing of errors, it
98+
// would be slow in those environments. We could maybe just rely on those
99+
// environments having to disable source mapping globally to speed things up.
100+
// For now, we just generate a default V8 formatted stack trace without
101+
// source mapping as a fallback.
102+
const name = error.name || 'Error';
103+
const message = error.message || '';
104+
let stack = name + ': ' + message;
105+
for (let i = 0; i < structuredStackTrace.length; i++) {
106+
stack += '\n at ' + structuredStackTrace[i].toString();
107+
}
108+
collectedStackTrace = result;
109+
return stack;
29110
}
30111

31112
// This matches either of these V8 formats.
@@ -39,7 +120,32 @@ export function parseStackTrace(
39120
error: Error,
40121
skipFrames: number,
41122
): ReactStackTrace {
42-
let stack = getStack(error);
123+
// We override Error.prepareStackTrace with our own version that collects
124+
// the structured data. We need more information than the raw stack gives us
125+
// and we need to ensure that we don't get the source mapped version.
126+
collectedStackTrace = null;
127+
framesToSkip = skipFrames;
128+
const previousPrepare = Error.prepareStackTrace;
129+
Error.prepareStackTrace = collectStackTrace;
130+
let stack;
131+
try {
132+
// eslint-disable-next-line react-internal/safe-string-coercion
133+
stack = String(error.stack);
134+
} finally {
135+
Error.prepareStackTrace = previousPrepare;
136+
}
137+
138+
if (collectedStackTrace !== null) {
139+
const result = collectedStackTrace;
140+
collectedStackTrace = null;
141+
return result;
142+
}
143+
144+
// If the stack has already been read, or this is not actually a V8 compatible
145+
// engine then we might not get a normalized stack and it might still have been
146+
// source mapped. Regardless we try our best to parse it. This works best if the
147+
// environment just uses default V8 formatting and no source mapping.
148+
43149
if (stack.startsWith('Error: react-stack-top-frame\n')) {
44150
// V8's default formatting prefixes with the error message which we
45151
// don't want/need.

0 commit comments

Comments
 (0)