Repository: facebook/react
Pull request: [DevTools] Implement getComponentStack and onErrorOrWarning for replayed Flight logs

This adds owner stacks to replayed Server Component logs in environments that don't support native console.createTask.

<img width="521" alt="Screenshot 2024-09-09 at 8 55 21 PM" src="https://github.com/user-attachments/assets/261cfaee-ea65-4044-abf0-c41abf358fea">

It also tracks the logs in the global componentInfoToComponentLogsMap which lets us associate those logs with Server Components when they later commit into the fiber tree.

<img width="1280" alt="Screenshot 2024-09-09 at 9 31 16 PM" src="https://github.com/user-attachments/assets/436312a6-f9f4-4add-8129-0fb9b9eb18ee">

I tried to create unit tests for this since it's now wired up end-to-end. Unfortunately, the complicated testing set up for Flight requires a complex set of resetting modules which are incompatible with the complicated test setup in getVersionedRenderImplementation for DevTools tests.

Changed files:
filename: packages/react-devtools-shared/src/backend/flight/DevToolsComponentInfoStack.js
patch: @@ -0,0 +1,55 @@
+/**
+ * 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
+ */
+
+// This is a DevTools fork of ReactComponentInfoStack.
+// This fork enables DevTools to use the same "native" component stack format,
+// while still maintaining support for multiple renderer versions
+// (which use different values for ReactTypeOfWork).
+
+import type {ReactComponentInfo} from 'shared/ReactTypes';
+
+import {describeBuiltInComponentFrame} from '../shared/DevToolsComponentStackFrame';
+
+import {formatOwnerStack} from '../shared/DevToolsOwnerStack';
+
+export function getOwnerStackByComponentInfoInDev(
+  componentInfo: ReactComponentInfo,
+): string {
+  try {
+    let info = '';
+
+    // The owner stack of the current component will be where it was created, i.e. inside its owner.
+    // There's no actual name of the currently executing component. Instead, that is available
+    // on the regular stack that's currently executing. However, if there is no owner at all, then
+    // there's no stack frame so we add the name of the root component to the stack to know which
+    // component is currently executing.
+    if (!componentInfo.owner && typeof componentInfo.name === 'string') {
+      return describeBuiltInComponentFrame(componentInfo.name);
+    }
+
+    let owner: void | null | ReactComponentInfo = componentInfo;
+
+    while (owner) {
+      const ownerStack: ?Error = owner.debugStack;
+      if (ownerStack != null) {
+        // Server Component
+        owner = owner.owner;
+        if (owner) {
+          // TODO: Should we stash this somewhere for caching purposes?
+          info += '\n' + formatOwnerStack(ownerStack);
+        }
+      } else {
+        break;
+      }
+    }
+    return info;
+  } catch (x) {
+    return '\nError generating stack: ' + x.message + '\n' + x.stack;
+  }
+}

filename: packages/react-devtools-shared/src/backend/flight/renderer.js
patch: @@ -7,21 +7,146 @@
  * @flow
  */
 
+import type {ReactComponentInfo} from 'shared/ReactTypes';
+
 import type {DevToolsHook, ReactRenderer, RendererInterface} from '../types';
 
+import {getOwnerStackByComponentInfoInDev} from './DevToolsComponentInfoStack';
+
+import {formatOwnerStack} from '../shared/DevToolsOwnerStack';
+
+import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponentLogs';
+
+import {formatConsoleArgumentsToSingleString} from 'react-devtools-shared/src/backend/utils';
+
 import {
   patchConsoleUsingWindowValues,
   registerRenderer as registerRendererWithConsole,
 } from '../console';
 
+function supportsConsoleTasks(componentInfo: ReactComponentInfo): boolean {
+  // If this ReactComponentInfo supports native console.createTask then we are already running
+  // inside a native async stack trace if it's active - meaning the DevTools is open.
+  // Ideally we'd detect if this task was created while the DevTools was open or not.
+  return !!componentInfo.debugTask;
+}
+
 export function attach(
   hook: DevToolsHook,
   rendererID: number,
   renderer: ReactRenderer,
   global: Object,
 ): RendererInterface {
+  const {getCurrentComponentInfo} = renderer;
+
+  function getComponentStack(
+    topFrame: Error,
+  ): null | {enableOwnerStacks: boolean, componentStack: string} {
+    if (getCurrentComponentInfo === undefined) {
+      // Expected this to be part of the renderer. Ignore.
+      return null;
+    }
+    const current = getCurrentComponentInfo();
+    if (current === null) {
+      // Outside of our render scope.
+      return null;
+    }
+
+    if (supportsConsoleTasks(current)) {
+      // This will be handled natively by console.createTask. No need for
+      // DevTools to add it.
+      return null;
+    }
+
+    const enableOwnerStacks = current.debugStack != null;
+    let componentStack = '';
+    if (enableOwnerStacks) {
+      // Prefix the owner stack with the current stack. I.e. what called
+      // console.error. While this will also be part of the native stack,
+      // it is hidden and not presented alongside this argument so we print
+      // them all together.
+      const topStackFrames = formatOwnerStack(topFrame);
+      if (topStackFrames) {
+        componentStack += '\n' + topStackFrames;
+      }
+      componentStack += getOwnerStackByComponentInfoInDev(current);
+    }
+    return {enableOwnerStacks, componentStack};
+  }
+
+  // Called when an error or warning is logged during render, commit, or passive (including unmount functions).
+  function onErrorOrWarning(
+    type: 'error' | 'warn',
+    args: $ReadOnlyArray<any>,
+  ): void {
+    if (getCurrentComponentInfo === undefined) {
+      // Expected this to be part of the renderer. Ignore.
+      return;
+    }
+    const componentInfo = getCurrentComponentInfo();
+    if (componentInfo === null) {
+      // Outside of our render scope.
+      return;
+    }
+
+    if (
+      args.length > 3 &&
+      typeof args[0] === 'string' &&
+      args[0].startsWith('%c%s%c ') &&
+      typeof args[1] === 'string' &&
+      typeof args[2] === 'string' &&
+      typeof args[3] === 'string'
+    ) {
+      // This looks like the badge we prefixed to the log. Our UI doesn't support formatted logs.
+      // We remove the formatting. If the environment of the log is the same as the environment of
+      // the component (the common case) we remove the badge completely otherwise leave it plain
+      const format = args[0].slice(7);
+      const env = args[2].trim();
+      args = args.slice(4);
+      if (env !== componentInfo.env) {
+        args.unshift('[' + env + '] ' + format);
+      } else {
+        args.unshift(format);
+      }
+    }
+
+    // We can't really use this message as a unique key, since we can't distinguish
+    // different objects in this implementation. We have to delegate displaying of the objects
+    // to the environment, the browser console, for example, so this is why this should be kept
+    // as an array of arguments, instead of the plain string.
+    // [Warning: %o, {...}] and [Warning: %o, {...}] will be considered as the same message,
+    // even if objects are different
+    const message = formatConsoleArgumentsToSingleString(...args);
+
+    // Track the warning/error for later.
+    let componentLogsEntry = componentInfoToComponentLogsMap.get(componentInfo);
+    if (componentLogsEntry === undefined) {
+      componentLogsEntry = {
+        errors: new Map(),
+        errorsCount: 0,
+        warnings: new Map(),
+        warningsCount: 0,
+      };
+      componentInfoToComponentLogsMap.set(componentInfo, componentLogsEntry);
+    }
+
+    const messageMap =
+      type === 'error'
+        ? componentLogsEntry.errors
+        : componentLogsEntry.warnings;
+    const count = messageMap.get(message) || 0;
+    messageMap.set(message, count + 1);
+    if (type === 'error') {
+      componentLogsEntry.errorsCount++;
+    } else {
+      componentLogsEntry.warningsCount++;
+    }
+
+    // The changes will be flushed later when we commit this tree to Fiber.
+  }
+
   patchConsoleUsingWindowValues();
-  registerRendererWithConsole(); // TODO: Fill in the impl
+  registerRendererWithConsole(onErrorOrWarning, getComponentStack);
 
   return {
     cleanup() {},

filename: packages/react-devtools-shared/src/backend/index.js
patch: @@ -75,17 +75,17 @@ export function initBackend(
 
     // Inject any not-yet-injected renderers (if we didn't reload-and-profile)
     if (rendererInterface == null) {
-      if (
+      if (typeof renderer.getCurrentComponentInfo === 'function') {
+        // react-flight/client
+        rendererInterface = attachFlight(hook, id, renderer, global);
+      } else if (
         // v16-19
         typeof renderer.findFiberByHostInstance === 'function' ||
         // v16.8+
         renderer.currentDispatcherRef != null
       ) {
         // react-reconciler v16+
         rendererInterface = attachFiber(hook, id, renderer, global);
-      } else if (typeof renderer.getCurrentComponentInfo === 'function') {
-        // react-flight/client
-        rendererInterface = attachFlight(hook, id, renderer, global);
       } else if (renderer.ComponentTree) {
         // react-dom v15
         rendererInterface = attachLegacy(hook, id, renderer, global);