Skip to content

Commit

Permalink
[DevTools] Implement getComponentStack and onErrorOrWarning for repla…
Browse files Browse the repository at this point in the history
…yed Flight logs (#30930)

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.
  • Loading branch information
sebmarkbage authored Sep 10, 2024
1 parent d160aa0 commit 63cefa2
Show file tree
Hide file tree
Showing 3 changed files with 185 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -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;
}
}
127 changes: 126 additions & 1 deletion packages/react-devtools-shared/src/backend/flight/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {},
Expand Down
8 changes: 4 additions & 4 deletions packages/react-devtools-shared/src/backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit 63cefa2

Please sign in to comment.