[Flight] Parse Stack Trace from Structured CallSite if available (#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.
Sebastian Markbåge committed
May 7, 2025 at 11:43 UTC
0ff1d13b8055c801d8b9b6779958c09fd0dc63e0
2 files changed
+139
-16
packages/react-server/src/ReactFlightServer.js
+17
@@ -152,11 +152,27 @@ function defaultFilterStackFrame(
152
);
153
}
154
155
+// DEV-only cache of parsed and filtered stack frames.
156
+const stackTraceCache: WeakMap<Error, ReactStackTrace> = __DEV__
157
+ ? new WeakMap()
158
+ : (null: any);
159
+
160
function filterStackTrace(
161
request: Request,
162
error: Error,
163
skipFrames: number,
164
): 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
+ }
176
// Since stacks can be quite large and we pass a lot of them, we filter them out eagerly
177
// to save bandwidth even in DEV. We'll also replay these stacks on the client so by
178
// stripping them early we avoid that overhead. Otherwise we'd normally just rely on
@@ -183,6 +199,7 @@ function filterStackTrace(
199
i--;
200
}
201
}
202
+ stackTraceCache.set(error, stack);
203
return stack;
204
}
205
packages/react-server/src/ReactFlightStackConfigV8.js
+122
-16
@@ -9,23 +9,104 @@
9
10
import type {ReactStackTrace} from 'shared/ReactTypes';
11
12
-import DefaultPrepareStackTrace from 'shared/DefaultPrepareStackTrace';
12
+let framesToSkip: number = 0;
13
+let collectedStackTrace: null | ReactStackTrace = null;
14
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
+ }
93
}
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;
110
}
111
112
// This matches either of these V8 formats.
@@ -39,7 +120,32 @@ export function parseStackTrace(
120
error: Error,
121
skipFrames: number,
122
): 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
+
149
if (stack.startsWith('Error: react-stack-top-frame\n')) {
150
// V8's default formatting prefixes with the error message which we
151
// don't want/need.