Repository: facebook/react
Pull request: [DevTools] Add structure full stack parsing to DevTools

We'll need complete parsing of stack traces for both owner stacks and async debug info so we need to expand the stack parsing capabilities a bit. This refactors the source location extraction to use some helpers we can use for other things too.

This is a fork of `ReactFlightStackConfigV8` which also supports DevTools requirements like checking both `react_stack_bottom_frame` and `react-stack-bottom-frame` as well as supporting Firefox stacks.

It also supports extracting the first frame of a component stack or the last frame of an owner stack for the source location.


Changed files:
filename: packages/react-devtools-shared/src/__tests__/utils-test.js
patch: @@ -19,8 +19,8 @@ import {
   formatWithStyles,
   gt,
   gte,
-  parseSourceFromComponentStack,
 } from 'react-devtools-shared/src/backend/utils';
+import {extractLocationFromComponentStack} from 'react-devtools-shared/src/backend/utils/parseStackTrace';
 import {
   REACT_SUSPENSE_LIST_TYPE as SuspenseList,
   REACT_STRICT_MODE_TYPE as StrictMode,
@@ -306,20 +306,20 @@ describe('utils', () => {
     });
   });
 
-  describe('parseSourceFromComponentStack', () => {
+  describe('extractLocationFromComponentStack', () => {
     it('should return null if passed empty string', () => {
-      expect(parseSourceFromComponentStack('')).toEqual(null);
+      expect(extractLocationFromComponentStack('')).toEqual(null);
     });
 
     it('should construct the source from the first frame if available', () => {
       expect(
-        parseSourceFromComponentStack(
+        extractLocationFromComponentStack(
           'at l (https://react.dev/_next/static/chunks/main-78a3b4c2aa4e4850.js:1:10389)\n' +
             'at f (https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8519)\n' +
             'at r (https://react.dev/_next/static/chunks/pages/_app-dd0b77ea7bd5b246.js:1:498)\n',
         ),
       ).toEqual([
-        '',
+        'l',
         'https://react.dev/_next/static/chunks/main-78a3b4c2aa4e4850.js',
         1,
         10389,
@@ -328,7 +328,7 @@ describe('utils', () => {
 
     it('should construct the source from highest available frame', () => {
       expect(
-        parseSourceFromComponentStack(
+        extractLocationFromComponentStack(
           '    at Q\n' +
             '    at a\n' +
             '    at m (https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js:5:9236)\n' +
@@ -342,7 +342,7 @@ describe('utils', () => {
             '    at f (https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8519)',
         ),
       ).toEqual([
-        '',
+        'm',
         'https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js',
         5,
         9236,
@@ -351,7 +351,7 @@ describe('utils', () => {
 
     it('should construct the source from frame, which has only url specified', () => {
       expect(
-        parseSourceFromComponentStack(
+        extractLocationFromComponentStack(
           '    at Q\n' +
             '    at a\n' +
             '    at https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js:5:9236\n',
@@ -366,13 +366,13 @@ describe('utils', () => {
 
     it('should parse sourceURL correctly if it includes parentheses', () => {
       expect(
-        parseSourceFromComponentStack(
+        extractLocationFromComponentStack(
           'at HotReload (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:307:11)\n' +
             '    at Router (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js:181:11)\n' +
             '    at ErrorBoundaryHandler (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js:114:9)',
         ),
       ).toEqual([
-        '',
+        'HotReload',
         'webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js',
         307,
         11,
@@ -381,13 +381,13 @@ describe('utils', () => {
 
     it('should support Firefox stack', () => {
       expect(
-        parseSourceFromComponentStack(
+        extractLocationFromComponentStack(
           'tt@https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js:1:165558\n' +
             'f@https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8535\n' +
             'r@https://react.dev/_next/static/chunks/pages/_app-dd0b77ea7bd5b246.js:1:513',
         ),
       ).toEqual([
-        '',
+        'tt',
         'https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js',
         1,
         165558,

filename: packages/react-devtools-shared/src/backend/fiber/renderer.js
patch: @@ -54,10 +54,12 @@ import {
   formatDurationToMicrosecondsGranularity,
   gt,
   gte,
-  parseSourceFromComponentStack,
-  parseSourceFromOwnerStack,
   serializeToString,
 } from 'react-devtools-shared/src/backend/utils';
+import {
+  extractLocationFromComponentStack,
+  extractLocationFromOwnerStack,
+} from 'react-devtools-shared/src/backend/utils/parseStackTrace';
 import {
   cleanForBridge,
   copyWithDelete,
@@ -6340,7 +6342,7 @@ export function attach(
     if (stackFrame === null) {
       return null;
     }
-    const source = parseSourceFromComponentStack(stackFrame);
+    const source = extractLocationFromComponentStack(stackFrame);
     fiberInstance.source = source;
     return source;
   }
@@ -6369,15 +6371,15 @@ export function attach(
     // any intermediate utility functions. This won't point to the top of the component function
     // but it's at least somewhere within it.
     if (isError(unresolvedSource)) {
-      return (instance.source = parseSourceFromOwnerStack(
+      return (instance.source = extractLocationFromOwnerStack(
         (unresolvedSource: any),
       ));
     }
     if (typeof unresolvedSource === 'string') {
       const idx = unresolvedSource.lastIndexOf('\n');
       const lastLine =
         idx === -1 ? unresolvedSource : unresolvedSource.slice(idx + 1);
-      return (instance.source = parseSourceFromComponentStack(lastLine));
+      return (instance.source = extractLocationFromComponentStack(lastLine));
     }
 
     // $FlowFixMe: refined.

filename: packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js
patch: @@ -13,12 +13,9 @@ export function formatOwnerStack(error: Error): string {
   const prevPrepareStackTrace = Error.prepareStackTrace;
   // $FlowFixMe[incompatible-type] It does accept undefined.
   Error.prepareStackTrace = undefined;
-  const stack = error.stack;
+  let stack = error.stack;
   Error.prepareStackTrace = prevPrepareStackTrace;
-  return formatOwnerStackString(stack);
-}
 
-export function formatOwnerStackString(stack: string): string {
   if (stack.startsWith('Error: react-stack-top-frame\n')) {
     // V8's default formatting prefixes with the error message which we
     // don't want/need.

filename: packages/react-devtools-shared/src/backend/utils/index.js
patch: @@ -12,14 +12,11 @@ import {compareVersions} from 'compare-versions';
 import {dehydrate} from 'react-devtools-shared/src/hydration';
 import isArray from 'shared/isArray';
 
-import type {ReactFunctionLocation} from 'shared/ReactTypes';
 import type {DehydratedData} from 'react-devtools-shared/src/frontend/types';
 
 export {default as formatWithStyles} from './formatWithStyles';
 export {default as formatConsoleArguments} from './formatConsoleArguments';
 
-import {formatOwnerStackString} from '../shared/DevToolsOwnerStack';
-
 // TODO: update this to the first React version that has a corresponding DevTools backend
 const FIRST_DEVTOOLS_BACKEND_LOCKSTEP_VER = '999.9.9';
 export function hasAssignedBackend(version?: string): boolean {
@@ -258,186 +255,6 @@ export const isReactNativeEnvironment = (): boolean => {
   return window.document == null;
 };
 
-function extractLocation(url: string): null | {
-  functionName?: string,
-  sourceURL: string,
-  line?: string,
-  column?: string,
-} {
-  if (url.indexOf(':') === -1) {
-    return null;
-  }
-
-  // remove any parentheses from start and end
-  const withoutParentheses = url.replace(/^\(+/, '').replace(/\)+$/, '');
-  const locationParts = /(at )?(.+?)(?::(\d+))?(?::(\d+))?$/.exec(
-    withoutParentheses,
-  );
-
-  if (locationParts == null) {
-    return null;
-  }
-
-  const functionName = ''; // TODO: Parse this in the regexp.
-  const [, , sourceURL, line, column] = locationParts;
-  return {functionName, sourceURL, line, column};
-}
-
-const CHROME_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
-function parseSourceFromChromeStack(
-  stack: string,
-): ReactFunctionLocation | null {
-  const frames = stack.split('\n');
-  // eslint-disable-next-line no-for-of-loops/no-for-of-loops
-  for (const frame of frames) {
-    const sanitizedFrame = frame.trim();
-
-    const locationInParenthesesMatch = sanitizedFrame.match(/ (\(.+\)$)/);
-    const possibleLocation = locationInParenthesesMatch
-      ? locationInParenthesesMatch[1]
-      : sanitizedFrame;
-
-    const location = extractLocation(possibleLocation);
-    // Continue the search until at least sourceURL is found
-    if (location == null) {
-      continue;
-    }
-
-    const {functionName, sourceURL, line = '1', column = '1'} = location;
-
-    return [
-      functionName || '',
-      sourceURL,
-      parseInt(line, 10),
-      parseInt(column, 10),
-    ];
-  }
-
-  return null;
-}
-
-function parseSourceFromFirefoxStack(
-  stack: string,
-): ReactFunctionLocation | null {
-  const frames = stack.split('\n');
-  // eslint-disable-next-line no-for-of-loops/no-for-of-loops
-  for (const frame of frames) {
-    const sanitizedFrame = frame.trim();
-    const frameWithoutFunctionName = sanitizedFrame.replace(
-      /((.*".+"[^@]*)?[^@]*)(?:@)/,
-      '',
-    );
-
-    const location = extractLocation(frameWithoutFunctionName);
-    // Continue the search until at least sourceURL is found
-    if (location == null) {
-      continue;
-    }
-
-    const {functionName, sourceURL, line = '1', column = '1'} = location;
-
-    return [
-      functionName || '',
-      sourceURL,
-      parseInt(line, 10),
-      parseInt(column, 10),
-    ];
-  }
-
-  return null;
-}
-
-export function parseSourceFromComponentStack(
-  componentStack: string,
-): ReactFunctionLocation | null {
-  if (componentStack.match(CHROME_STACK_REGEXP)) {
-    return parseSourceFromChromeStack(componentStack);
-  }
-
-  return parseSourceFromFirefoxStack(componentStack);
-}
-
-let collectedLocation: ReactFunctionLocation | null = null;
-
-function collectStackTrace(
-  error: Error,
-  structuredStackTrace: CallSite[],
-): string {
-  let result: null | ReactFunctionLocation = null;
-  // Collect structured stack traces from the callsites.
-  // We mirror how V8 serializes stack frames and how we later parse them.
-  for (let i = 0; i < structuredStackTrace.length; i++) {
-    const callSite = structuredStackTrace[i];
-    const name = callSite.getFunctionName();
-    if (
-      name != null &&
-      (name.includes('react_stack_bottom_frame') ||
-        name.includes('react-stack-bottom-frame'))
-    ) {
-      // We pick the last frame that matches before the bottom frame since
-      // that will be immediately inside the component as opposed to some helper.
-      // If we don't find a bottom frame then we bail to string parsing.
-      collectedLocation = result;
-      // Skip everything after the bottom frame since it'll be internals.
-      break;
-    } else {
-      const sourceURL = callSite.getScriptNameOrSourceURL();
-      const line =
-        // $FlowFixMe[prop-missing]
-        typeof callSite.getEnclosingLineNumber === 'function'
-          ? (callSite: any).getEnclosingLineNumber()
-          : callSite.getLineNumber();
-      const col =
-        // $FlowFixMe[prop-missing]
-        typeof callSite.getEnclosingColumnNumber === 'function'
-          ? (callSite: any).getEnclosingColumnNumber()
-          : callSite.getColumnNumber();
-      if (!sourceURL || !line || !col) {
-        // Skip eval etc. without source url. They don't have location.
-        continue;
-      }
-      result = [name, sourceURL, line, col];
-    }
-  }
-  // At the same time we generate a string stack trace just in case someone
-  // else reads it.
-  const name = error.name || 'Error';
-  const message = error.message || '';
-  let stack = name + ': ' + message;
-  for (let i = 0; i < structuredStackTrace.length; i++) {
-    stack += '\n    at ' + structuredStackTrace[i].toString();
-  }
-  return stack;
-}
-
-export function parseSourceFromOwnerStack(
-  error: Error,
-): ReactFunctionLocation | null {
-  // First attempt to collected the structured data using prepareStackTrace.
-  collectedLocation = null;
-  const previousPrepare = Error.prepareStackTrace;
-  Error.prepareStackTrace = collectStackTrace;
-  let stack;
-  try {
-    stack = error.stack;
-  } catch (e) {
-    // $FlowFixMe[incompatible-type] It does accept undefined.
-    Error.prepareStackTrace = undefined;
-    stack = error.stack;
-  } finally {
-    Error.prepareStackTrace = previousPrepare;
-  }
-  if (collectedLocation !== null) {
-    return collectedLocation;
-  }
-  if (stack == null) {
-    return null;
-  }
-  // Fallback to parsing the string form.
-  const componentStack = formatOwnerStackString(stack);
-  return parseSourceFromComponentStack(componentStack);
-}
-
 // 0.123456789 => 0.123
 // Expects high-resolution timestamp in milliseconds, like from performance.now()
 // Mainly used for optimizing the size of serialized profiling payload

filename: packages/react-devtools-shared/src/backend/utils/parseStackTrace.js
patch: @@ -0,0 +1,331 @@
+/**
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import type {ReactStackTrace, ReactFunctionLocation} from 'shared/ReactTypes';
+
+function parseStackTraceFromChromeStack(
+  stack: string,
+  skipFrames: number,
+): ReactStackTrace {
+  if (stack.startsWith('Error: react-stack-top-frame\n')) {
+    // V8's default formatting prefixes with the error message which we
+    // don't want/need.
+    stack = stack.slice(29);
+  }
+  let idx = stack.indexOf('react_stack_bottom_frame');
+  if (idx === -1) {
+    idx = stack.indexOf('react-stack-bottom-frame');
+  }
+  if (idx !== -1) {
+    idx = stack.lastIndexOf('\n', idx);
+  }
+  if (idx !== -1) {
+    // Cut off everything after the bottom frame since it'll be internals.
+    stack = stack.slice(0, idx);
+  }
+  const frames = stack.split('\n');
+  const parsedFrames: ReactStackTrace = [];
+  // We skip top frames here since they may or may not be parseable but we
+  // want to skip the same number of frames regardless. I.e. we can't do it
+  // in the caller.
+  for (let i = skipFrames; i < frames.length; i++) {
+    const parsed = chromeFrameRegExp.exec(frames[i]);
+    if (!parsed) {
+      continue;
+    }
+    let name = parsed[1] || '';
+    let isAsync = parsed[8] === 'async ';
+    if (name === '<anonymous>') {
+      name = '';
+    } else if (name.startsWith('async ')) {
+      name = name.slice(5);
+      isAsync = true;
+    }
+    let filename = parsed[2] || parsed[5] || '';
+    if (filename === '<anonymous>') {
+      filename = '';
+    }
+    const line = +(parsed[3] || parsed[6]);
+    const col = +(parsed[4] || parsed[7]);
+    parsedFrames.push([name, filename, line, col, 0, 0, isAsync]);
+  }
+  return parsedFrames;
+}
+
+const firefoxFrameRegExp = /^((?:.*".+")?[^@]*)@(.+):(\d+):(\d+)$/;
+function parseStackTraceFromFirefoxStack(
+  stack: string,
+  skipFrames: number,
+): ReactStackTrace {
+  let idx = stack.indexOf('react_stack_bottom_frame');
+  if (idx === -1) {
+    idx = stack.indexOf('react-stack-bottom-frame');
+  }
+  if (idx !== -1) {
+    idx = stack.lastIndexOf('\n', idx);
+  }
+  if (idx !== -1) {
+    // Cut off everything after the bottom frame since it'll be internals.
+    stack = stack.slice(0, idx);
+  }
+  const frames = stack.split('\n');
+  const parsedFrames: ReactStackTrace = [];
+  // We skip top frames here since they may or may not be parseable but we
+  // want to skip the same number of frames regardless. I.e. we can't do it
+  // in the caller.
+  for (let i = skipFrames; i < frames.length; i++) {
+    const parsed = firefoxFrameRegExp.exec(frames[i]);
+    if (!parsed) {
+      continue;
+    }
+    const name = parsed[1] || '';
+    const filename = parsed[2] || '';
+    const line = +parsed[3];
+    const col = +parsed[4];
+    parsedFrames.push([name, filename, line, col, 0, 0, false]);
+  }
+  return parsedFrames;
+}
+
+const CHROME_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
+export function parseStackTraceFromString(
+  stack: string,
+  skipFrames: number,
+): ReactStackTrace {
+  if (stack.match(CHROME_STACK_REGEXP)) {
+    return parseStackTraceFromChromeStack(stack, skipFrames);
+  }
+  return parseStackTraceFromFirefoxStack(stack, skipFrames);
+}
+
+let framesToSkip: number = 0;
+let collectedStackTrace: null | ReactStackTrace = null;
+
+const identifierRegExp = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;
+
+function getMethodCallName(callSite: CallSite): string {
+  const typeName = callSite.getTypeName();
+  const methodName = callSite.getMethodName();
+  const functionName = callSite.getFunctionName();
+  let result = '';
+  if (functionName) {
+    if (
+      typeName &&
+      identifierRegExp.test(functionName) &&
+      functionName !== typeName
+    ) {
+      result += typeName + '.';
+    }
+    result += functionName;
+    if (
+      methodName &&
+      functionName !== methodName &&
+      !functionName.endsWith('.' + methodName) &&
+      !functionName.endsWith(' ' + methodName)
+    ) {
+      result += ' [as ' + methodName + ']';
+    }
+  } else {
+    if (typeName) {
+      result += typeName + '.';
+    }
+    if (methodName) {
+      result += methodName;
+    } else {
+      result += '<anonymous>';
+    }
+  }
+  return result;
+}
+
+function collectStackTrace(
+  error: Error,
+  structuredStackTrace: CallSite[],
+): string {
+  const result: ReactStackTrace = [];
+  // Collect structured stack traces from the callsites.
+  // We mirror how V8 serializes stack frames and how we later parse them.
+  for (let i = framesToSkip; i < structuredStackTrace.length; i++) {
+    const callSite = structuredStackTrace[i];
+    let name = callSite.getFunctionName() || '<anonymous>';
+    if (
+      name.includes('react_stack_bottom_frame') ||
+      name.includes('react-stack-bottom-frame')
+    ) {
+      // Skip everything after the bottom frame since it'll be internals.
+      break;
+    } else if (callSite.isNative()) {
+      // $FlowFixMe[prop-missing]
+      const isAsync = callSite.isAsync();
+      result.push([name, '', 0, 0, 0, 0, isAsync]);
+    } else {
+      // We encode complex function calls as if they're part of the function
+      // name since we cannot simulate the complex ones and they look the same
+      // as function names in UIs on the client as well as stacks.
+      if (callSite.isConstructor()) {
+        name = 'new ' + name;
+      } else if (!callSite.isToplevel()) {
+        name = getMethodCallName(callSite);
+      }
+      if (name === '<anonymous>') {
+        name = '';
+      }
+      let filename = callSite.getScriptNameOrSourceURL() || '<anonymous>';
+      if (filename === '<anonymous>') {
+        filename = '';
+        if (callSite.isEval()) {
+          const origin = callSite.getEvalOrigin();
+          if (origin) {
+            filename = origin.toString() + ', <anonymous>';
+          }
+        }
+      }
+      const line = callSite.getLineNumber() || 0;
+      const col = callSite.getColumnNumber() || 0;
+      const enclosingLine: number =
+        // $FlowFixMe[prop-missing]
+        typeof callSite.getEnclosingLineNumber === 'function'
+          ? (callSite: any).getEnclosingLineNumber() || 0
+          : 0;
+      const enclosingCol: number =
+        // $FlowFixMe[prop-missing]
+        typeof callSite.getEnclosingColumnNumber === 'function'
+          ? (callSite: any).getEnclosingColumnNumber() || 0
+          : 0;
+      // $FlowFixMe[prop-missing]
+      const isAsync = callSite.isAsync();
+      result.push([
+        name,
+        filename,
+        line,
+        col,
+        enclosingLine,
+        enclosingCol,
+        isAsync,
+      ]);
+    }
+  }
+  collectedStackTrace = result;
+
+  // At the same time we generate a string stack trace just in case someone
+  // else reads it. Ideally, we'd call the previous prepareStackTrace to
+  // ensure it's in the expected format but it's common for that to be
+  // source mapped and since we do a lot of eager parsing of errors, it
+  // would be slow in those environments. We could maybe just rely on those
+  // environments having to disable source mapping globally to speed things up.
+  // For now, we just generate a default V8 formatted stack trace without
+  // source mapping as a fallback.
+  const name = error.name || 'Error';
+  const message = error.message || '';
+  let stack = name + ': ' + message;
+  for (let i = 0; i < structuredStackTrace.length; i++) {
+    stack += '\n    at ' + structuredStackTrace[i].toString();
+  }
+  return stack;
+}
+
+// This matches either of these V8 formats.
+//     at name (filename:0:0)
+//     at filename:0:0
+//     at async filename:0:0
+const chromeFrameRegExp =
+  /^ *at (?:(.+) \((?:(.+):(\d+):(\d+)|\<anonymous\>)\)|(?:async )?(.+):(\d+):(\d+)|\<anonymous\>)$/;
+
+const stackTraceCache: WeakMap<Error, ReactStackTrace> = new WeakMap();
+
+export function parseStackTrace(
+  error: Error,
+  skipFrames: number,
+): ReactStackTrace {
+  // We can only get structured data out of error objects once. So we cache the information
+  // so we can get it again each time. It also helps performance when the same error is
+  // referenced more than once.
+  const existing = stackTraceCache.get(error);
+  if (existing !== undefined) {
+    return existing;
+  }
+  // We override Error.prepareStackTrace with our own version that collects
+  // the structured data. We need more information than the raw stack gives us
+  // and we need to ensure that we don't get the source mapped version.
+  collectedStackTrace = null;
+  framesToSkip = skipFrames;
+  const previousPrepare = Error.prepareStackTrace;
+  Error.prepareStackTrace = collectStackTrace;
+  let stack;
+  try {
+    stack = String(error.stack);
+  } finally {
+    Error.prepareStackTrace = previousPrepare;
+  }
+
+  if (collectedStackTrace !== null) {
+    const result = collectedStackTrace;
+    collectedStackTrace = null;
+    stackTraceCache.set(error, result);
+    return result;
+  }
+
+  // If the stack has already been read, or this is not actually a V8 compatible
+  // engine then we might not get a normalized stack and it might still have been
+  // source mapped. Regardless we try our best to parse it.
+
+  const parsedFrames = parseStackTraceFromString(stack, skipFrames);
+  stackTraceCache.set(error, parsedFrames);
+  return parsedFrames;
+}
+
+export function extractLocationFromOwnerStack(
+  error: Error,
+): ReactFunctionLocation | null {
+  const stackTrace = parseStackTrace(error, 0);
+  const stack = error.stack;
+  if (
+    !stack.includes('react_stack_bottom_frame') &&
+    !stack.includes('react-stack-bottom-frame')
+  ) {
+    // This didn't have a bottom to it, we can't trust it.
+    return null;
+  }
+  // We start from the bottom since that will have the best location for the owner itself.
+  for (let i = stackTrace.length - 1; i >= 0; i--) {
+    const [functionName, fileName, line, col, encLine, encCol] = stackTrace[i];
+    // Take the first match with a colon in the file name.
+    if (fileName.indexOf(':') !== -1) {
+      return [
+        functionName,
+        fileName,
+        // Use enclosing line if available, since that points to the start of the function.
+        encLine || line,
+        encCol || col,
+      ];
+    }
+  }
+  return null;
+}
+
+export function extractLocationFromComponentStack(
+  stack: string,
+): ReactFunctionLocation | null {
+  const stackTrace = parseStackTraceFromString(stack, 0);
+  for (let i = 0; i < stackTrace.length; i++) {
+    const [functionName, fileName, line, col, encLine, encCol] = stackTrace[i];
+    // Take the first match with a colon in the file name.
+    if (fileName.indexOf(':') !== -1) {
+      return [
+        functionName,
+        fileName,
+        // Use enclosing line if available. (Never the case here because we parse from string.)
+        encLine || line,
+        encCol || col,
+      ];
+    }
+  }
+  return null;
+}