@samitouri / QOS-React / commits / 0ff1d13b80

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