[Flight] Allow parens in filenames when parsing stackframes (#30396)
Sebastian Silbermann committed
Jul 24, 2024 at 20:02 UTC
c0b76a68318687bf5260d623c7b70a8156be21d9
2 files changed
+152
-1
packages/react-client/src/__tests__/ReactFlight-test.js
+151
@@ -10,6 +10,8 @@
10
11
'use strict';
12
13
+const path = require('path');
14
+
15
if (typeof Blob === 'undefined') {
16
global.Blob = require('buffer').Blob;
17
}
@@ -41,6 +43,26 @@ function formatV8Stack(stack) {
43
return v8StyleStack;
44
}
45
46
+const repoRoot = path.resolve(__dirname, '../../../../');
47
+function normalizeReactCodeLocInfo(str) {
48
+ const repoRootForRegexp = repoRoot.replace(/\//g, '\\/');
49
+ const repoFileLocMatch = new RegExp(`${repoRootForRegexp}.+?:\\d+:\\d+`, 'g');
50
+ return str && str.replace(repoFileLocMatch, '**');
51
+}
52
+
53
+// If we just use the original Error prototype, Jest will only display the error message if assertions fail.
54
+// But we usually want to also assert on our expando properties or even the stack.
55
+// By hiding the fact from Jest that this is an error, it will show all enumerable properties on mismatch.
56
+
57
+function getErrorForJestMatcher(error) {
58
+ return {
59
+ ...error,
60
+ // non-enumerable properties that are still relevant for testing
61
+ message: error.message,
62
+ stack: normalizeReactCodeLocInfo(error.stack),
63
+ };
64
+}
65
+
66
function normalizeComponentInfo(debugInfo) {
67
if (Array.isArray(debugInfo.stack)) {
68
const {debugTask, debugStack, ...copy} = debugInfo;
@@ -1177,6 +1199,135 @@ describe('ReactFlight', () => {
1199
});
1200
});
1201
1202
+ it('should handle exotic stack frames', async () => {
1203
+ function ServerComponent() {
1204
+ const error = new Error('This is an error');
1205
+ const originalStackLines = error.stack.split('\n');
1206
+ // Fake a stack
1207
+ error.stack = [
1208
+ originalStackLines[0],
1209
+ // original
1210
+ // ' at ServerComponentError (file://~/react/packages/react-client/src/__tests__/ReactFlight-test.js:1166:19)',
1211
+ // nested eval (https://github.com/ChromeDevTools/devtools-frontend/blob/831be28facb4e85de5ee8c1acc4d98dfeda7a73b/test/unittests/front_end/panels/console/ErrorStackParser_test.ts#L198)
1212
+ ' at eval (eval at testFunction (inspected-page.html:29:11), <anonymous>:1:10)',
1213
+ // parens may be added by Webpack when bundle layers are used. They're also valid in directory names.
1214
+ ' at ServerComponentError (file://~/(some)(really)(exotic-directory)/ReactFlight-test.js:1166:19)',
1215
+ // anon function (https://github.com/ChromeDevTools/devtools-frontend/blob/831be28facb4e85de5ee8c1acc4d98dfeda7a73b/test/unittests/front_end/panels/console/ErrorStackParser_test.ts#L115C9-L115C35)
1216
+ ' at file:///testing.js:42:3',
1217
+ // async anon function (https://github.com/ChromeDevTools/devtools-frontend/blob/831be28facb4e85de5ee8c1acc4d98dfeda7a73b/test/unittests/front_end/panels/console/ErrorStackParser_test.ts#L130C9-L130C41)
1218
+ ' at async file:///testing.js:42:3',
1219
+ ...originalStackLines.slice(2),
1220
+ ].join('\n');
1221
+ throw error;
1222
+ }
1223
+
1224
+ const findSourceMapURL = jest.fn(() => null);
1225
+ const errors = [];
1226
+ class MyErrorBoundary extends React.Component {
1227
+ state = {error: null};
1228
+ static getDerivedStateFromError(error) {
1229
+ return {error};
1230
+ }
1231
+ componentDidCatch(error, componentInfo) {
1232
+ errors.push(error);
1233
+ }
1234
+ render() {
1235
+ if (this.state.error) {
1236
+ return null;
1237
+ }
1238
+ return this.props.children;
1239
+ }
1240
+ }
1241
+ const ClientErrorBoundary = clientReference(MyErrorBoundary);
1242
+
1243
+ function App() {
1244
+ return (
1245
+ <ClientErrorBoundary>
1246
+ <ServerComponent />
1247
+ </ClientErrorBoundary>
1248
+ );
1249
+ }
1250
+
1251
+ const transport = ReactNoopFlightServer.render(<App />, {
1252
+ onError(x) {
1253
+ if (__DEV__) {
1254
+ return 'a dev digest';
1255
+ }
1256
+ if (x instanceof Error) {
1257
+ return `digest("${x.message}")`;
1258
+ } else if (Array.isArray(x)) {
1259
+ return `digest([])`;
1260
+ } else if (typeof x === 'object' && x !== null) {
1261
+ return `digest({})`;
1262
+ }
1263
+ return `digest(${String(x)})`;
1264
+ },
1265
+ });
1266
+
1267
+ await act(() => {
1268
+ startTransition(() => {
1269
+ ReactNoop.render(
1270
+ ReactNoopFlightClient.read(transport, {findSourceMapURL}),
1271
+ );
1272
+ });
1273
+ });
1274
+
1275
+ if (__DEV__) {
1276
+ expect({
1277
+ errors: errors.map(getErrorForJestMatcher),
1278
+ findSourceMapURLCalls: findSourceMapURL.mock.calls,
1279
+ }).toEqual({
1280
+ errors: [
1281
+ {
1282
+ message: 'This is an error',
1283
+ stack: gate(flags => flags.enableOwnerStacks)
1284
+ ? expect.stringContaining(
1285
+ 'Error: This is an error\n' +
1286
+ ' at eval (eval at testFunction (eval at createFakeFunction (**), <anonymous>:1:35)\n' +
1287
+ ' at ServerComponentError (file://~/(some)(really)(exotic-directory)/ReactFlight-test.js:1166:19)\n' +
1288
+ ' at (anonymous) (file:///testing.js:42:3)\n' +
1289
+ ' at (anonymous) (file:///testing.js:42:3)\n',
1290
+ )
1291
+ : expect.stringContaining(
1292
+ 'Error: This is an error\n' +
1293
+ ' at eval (eval at testFunction (inspected-page.html:29:11), <anonymous>:1:10)\n' +
1294
+ ' at ServerComponentError (file://~/(some)(really)(exotic-directory)/ReactFlight-test.js:1166:19)\n' +
1295
+ ' at file:///testing.js:42:3\n' +
1296
+ ' at file:///testing.js:42:3',
1297
+ ),
1298
+ digest: 'a dev digest',
1299
+ environmentName: 'Server',
1300
+ },
1301
+ ],
1302
+ findSourceMapURLCalls: gate(flags => flags.enableOwnerStacks)
1303
+ ? [
1304
+ [__filename],
1305
+ [__filename],
1306
+ // TODO: What should we request here? The outer (<anonymous>) or the inner (inspected-page.html)?
1307
+ ['inspected-page.html:29:11), <anonymous>'],
1308
+ ['file://~/(some)(really)(exotic-directory)/ReactFlight-test.js'],
1309
+ ['file:///testing.js'],
1310
+ [__filename],
1311
+ ]
1312
+ : [],
1313
+ });
1314
+ } else {
1315
+ expect(errors.map(getErrorForJestMatcher)).toEqual([
1316
+ {
1317
+ message:
1318
+ 'An error occurred in the Server Components render. The specific message is omitted in production' +
1319
+ ' builds to avoid leaking sensitive details. A digest property is included on this error instance which' +
1320
+ ' may provide additional details about the nature of the error.',
1321
+ stack:
1322
+ 'Error: An error occurred in the Server Components render. The specific message is omitted in production' +
1323
+ ' builds to avoid leaking sensitive details. A digest property is included on this error instance which' +
1324
+ ' may provide additional details about the nature of the error.',
1325
+ digest: 'digest("This is an error")',
1326
+ },
1327
+ ]);
1328
+ }
1329
+ });
1330
+
1331
it('should include server components in warning stacks', async () => {
1332
function Component() {
1333
// Trigger key warning
packages/react-server/src/ReactFlightStackConfigV8.js
+1
-1
@@ -44,7 +44,7 @@ function getStack(error: Error): string {
44
// at filename:0:0
45
// at async filename:0:0
46
const frameRegExp =
47
- /^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|(?:async )?([^\)]+):(\d+):(\d+))$/;
47
+ /^ {3} at (?:(.+) \((.+):(\d+):(\d+)\)|(?:async )?(.+):(\d+):(\d+))$/;
48
49
export function parseStackTrace(
50
error: Error,