Skip to content

Commit c0b76a6

Browse files
authored
[Flight] Allow parens in filenames when parsing stackframes (facebook#30396)
1 parent 5b37af7 commit c0b76a6

File tree

2 files changed

+152
-1
lines changed

2 files changed

+152
-1
lines changed

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

+151
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
'use strict';
1212

13+
const path = require('path');
14+
1315
if (typeof Blob === 'undefined') {
1416
global.Blob = require('buffer').Blob;
1517
}
@@ -41,6 +43,26 @@ function formatV8Stack(stack) {
4143
return v8StyleStack;
4244
}
4345

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+
4466
function normalizeComponentInfo(debugInfo) {
4567
if (Array.isArray(debugInfo.stack)) {
4668
const {debugTask, debugStack, ...copy} = debugInfo;
@@ -1177,6 +1199,135 @@ describe('ReactFlight', () => {
11771199
});
11781200
});
11791201

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+
11801331
it('should include server components in warning stacks', async () => {
11811332
function Component() {
11821333
// Trigger key warning

packages/react-server/src/ReactFlightStackConfigV8.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function getStack(error: Error): string {
4444
// at filename:0:0
4545
// at async filename:0:0
4646
const frameRegExp =
47-
/^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|(?:async )?([^\)]+):(\d+):(\d+))$/;
47+
/^ {3} at (?:(.+) \((.+):(\d+):(\d+)\)|(?:async )?(.+):(\d+):(\d+))$/;
4848

4949
export function parseStackTrace(
5050
error: Error,

0 commit comments

Comments
 (0)