Repository: facebook/react
Pull request: Update test and stack frame code to support newer V8 stack formats

Different versions of V8 use different Error message formats when script tries to read properties from `null` or `undefined`. Older versions use e.g. "_Cannot read property 'foo' of undefined_". Newer versions use e.g. "_Cannot read properties of undefined (reading 'foo')_".

This PR overrides the built-in `toThrow` matches to detect which message format is used and match appropriately. (This enables our tests to pass on Node 12-16 without forking.)

This PR also updates `describeNativeComponentFrame()` to handle another different between engine versions. Specifically, we detect when a function component with a `displayName` is represented in the component stack as "_\<anonymous\>_" and sub in the display name accordingly.

These could be split into separate PRs, but technically both are required for all tests to pass on Node 16, so...

To test this PR, I ran all tests using node 14.17.0 and 16.9.1.

Resolves #22441

Changed files:
filename: packages/shared/ReactComponentStackFrame.js
patch: @@ -168,7 +168,15 @@ export function describeNativeComponentFrame(
               // The next one that isn't the same should be our match though.
               if (c < 0 || sampleLines[s] !== controlLines[c]) {
                 // V8 adds a "new" prefix for native classes. Let's remove it to make it prettier.
-                const frame = '\n' + sampleLines[s].replace(' at new ', ' at ');
+                let frame = '\n' + sampleLines[s].replace(' at new ', ' at ');
+
+                // If our component frame is labeled "<anonymous>"
+                // but we have a user-provided "displayName"
+                // splice it in to make the stack more readable.
+                if (fn.displayName && frame.includes('<anonymous>')) {
+                  frame = frame.replace('<anonymous>', fn.displayName);
+                }
+
                 if (__DEV__) {
                   if (typeof fn === 'function') {
                     componentFrameCache.set(fn, frame);

filename: scripts/jest/matchers/toThrow.js
patch: @@ -0,0 +1,50 @@
+'use strict';
+
+// V8 uses a different message format when reading properties of null or undefined.
+// Older versions use e.g. "Cannot read property 'world' of undefined"
+// Newer versions use e.g. "Cannot read properties of undefined (reading 'world')"
+// This file overrides the built-in toThrow() matches to handle both cases,
+// enabling the React project to support Node 12-16 witout forking tests.
+
+const toThrowMatchers = require('expect/build/toThrowMatchers').default;
+const builtInToThrow = toThrowMatchers.toThrow;
+
+// Detect the newer stack format:
+let newErrorFormat = false;
+try {
+  null.test();
+} catch (error) {
+  if (error.message.includes('Cannot read properties of null')) {
+    newErrorFormat = true;
+  }
+}
+
+// Detect the message pattern we need to rename:
+const regex = /Cannot read property '([^']+)' of (.+)/;
+
+// Massage strings (written in the older format) to match the newer format
+// if tests are currently running on Node 16+
+function normalizeErrorMessage(message) {
+  if (newErrorFormat) {
+    const match = message.match(regex);
+    if (match) {
+      return `Cannot read properties of ${match[2]} (reading '${match[1]}')`;
+    }
+  }
+
+  return message;
+}
+
+function toThrow(value, expectedValue) {
+  if (typeof expectedValue === 'string') {
+    expectedValue = normalizeErrorMessage(expectedValue);
+  } else if (expectedValue instanceof Error) {
+    expectedValue.message = normalizeErrorMessage(expectedValue.message);
+  }
+
+  return builtInToThrow.call(this, value, expectedValue);
+}
+
+module.exports = {
+  toThrow,
+};

filename: scripts/jest/setupTests.js
patch: @@ -45,8 +45,9 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) {
   }
 
   expect.extend({
-    ...require('./matchers/toWarnDev'),
     ...require('./matchers/reactTestMatchers'),
+    ...require('./matchers/toThrow'),
+    ...require('./matchers/toWarnDev'),
   });
 
   // We have a Babel transform that inserts guards against infinite loops.

filename: scripts/jest/spec-equivalence-reporter/setupTests.js
patch: @@ -46,8 +46,9 @@ global.spyOnProd = function(...args) {
 };
 
 expect.extend({
-  ...require('../matchers/toWarnDev'),
   ...require('../matchers/reactTestMatchers'),
+  ...require('../matchers/toThrow'),
+  ...require('../matchers/toWarnDev'),
 });
 
 beforeEach(() => (numExpectations = 0));