Skip to content

Commit 63cefa2

Browse files
authored
[DevTools] Implement getComponentStack and onErrorOrWarning for replayed 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.
1 parent d160aa0 commit 63cefa2

File tree

3 files changed

+185
-5
lines changed

3 files changed

+185
-5
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
// This is a DevTools fork of ReactComponentInfoStack.
11+
// This fork enables DevTools to use the same "native" component stack format,
12+
// while still maintaining support for multiple renderer versions
13+
// (which use different values for ReactTypeOfWork).
14+
15+
import type {ReactComponentInfo} from 'shared/ReactTypes';
16+
17+
import {describeBuiltInComponentFrame} from '../shared/DevToolsComponentStackFrame';
18+
19+
import {formatOwnerStack} from '../shared/DevToolsOwnerStack';
20+
21+
export function getOwnerStackByComponentInfoInDev(
22+
componentInfo: ReactComponentInfo,
23+
): string {
24+
try {
25+
let info = '';
26+
27+
// The owner stack of the current component will be where it was created, i.e. inside its owner.
28+
// There's no actual name of the currently executing component. Instead, that is available
29+
// on the regular stack that's currently executing. However, if there is no owner at all, then
30+
// there's no stack frame so we add the name of the root component to the stack to know which
31+
// component is currently executing.
32+
if (!componentInfo.owner && typeof componentInfo.name === 'string') {
33+
return describeBuiltInComponentFrame(componentInfo.name);
34+
}
35+
36+
let owner: void | null | ReactComponentInfo = componentInfo;
37+
38+
while (owner) {
39+
const ownerStack: ?Error = owner.debugStack;
40+
if (ownerStack != null) {
41+
// Server Component
42+
owner = owner.owner;
43+
if (owner) {
44+
// TODO: Should we stash this somewhere for caching purposes?
45+
info += '\n' + formatOwnerStack(ownerStack);
46+
}
47+
} else {
48+
break;
49+
}
50+
}
51+
return info;
52+
} catch (x) {
53+
return '\nError generating stack: ' + x.message + '\n' + x.stack;
54+
}
55+
}

packages/react-devtools-shared/src/backend/flight/renderer.js

+126-1
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,146 @@
77
* @flow
88
*/
99

10+
import type {ReactComponentInfo} from 'shared/ReactTypes';
11+
1012
import type {DevToolsHook, ReactRenderer, RendererInterface} from '../types';
1113

14+
import {getOwnerStackByComponentInfoInDev} from './DevToolsComponentInfoStack';
15+
16+
import {formatOwnerStack} from '../shared/DevToolsOwnerStack';
17+
18+
import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponentLogs';
19+
20+
import {formatConsoleArgumentsToSingleString} from 'react-devtools-shared/src/backend/utils';
21+
1222
import {
1323
patchConsoleUsingWindowValues,
1424
registerRenderer as registerRendererWithConsole,
1525
} from '../console';
1626

27+
function supportsConsoleTasks(componentInfo: ReactComponentInfo): boolean {
28+
// If this ReactComponentInfo supports native console.createTask then we are already running
29+
// inside a native async stack trace if it's active - meaning the DevTools is open.
30+
// Ideally we'd detect if this task was created while the DevTools was open or not.
31+
return !!componentInfo.debugTask;
32+
}
33+
1734
export function attach(
1835
hook: DevToolsHook,
1936
rendererID: number,
2037
renderer: ReactRenderer,
2138
global: Object,
2239
): RendererInterface {
40+
const {getCurrentComponentInfo} = renderer;
41+
42+
function getComponentStack(
43+
topFrame: Error,
44+
): null | {enableOwnerStacks: boolean, componentStack: string} {
45+
if (getCurrentComponentInfo === undefined) {
46+
// Expected this to be part of the renderer. Ignore.
47+
return null;
48+
}
49+
const current = getCurrentComponentInfo();
50+
if (current === null) {
51+
// Outside of our render scope.
52+
return null;
53+
}
54+
55+
if (supportsConsoleTasks(current)) {
56+
// This will be handled natively by console.createTask. No need for
57+
// DevTools to add it.
58+
return null;
59+
}
60+
61+
const enableOwnerStacks = current.debugStack != null;
62+
let componentStack = '';
63+
if (enableOwnerStacks) {
64+
// Prefix the owner stack with the current stack. I.e. what called
65+
// console.error. While this will also be part of the native stack,
66+
// it is hidden and not presented alongside this argument so we print
67+
// them all together.
68+
const topStackFrames = formatOwnerStack(topFrame);
69+
if (topStackFrames) {
70+
componentStack += '\n' + topStackFrames;
71+
}
72+
componentStack += getOwnerStackByComponentInfoInDev(current);
73+
}
74+
return {enableOwnerStacks, componentStack};
75+
}
76+
77+
// Called when an error or warning is logged during render, commit, or passive (including unmount functions).
78+
function onErrorOrWarning(
79+
type: 'error' | 'warn',
80+
args: $ReadOnlyArray<any>,
81+
): void {
82+
if (getCurrentComponentInfo === undefined) {
83+
// Expected this to be part of the renderer. Ignore.
84+
return;
85+
}
86+
const componentInfo = getCurrentComponentInfo();
87+
if (componentInfo === null) {
88+
// Outside of our render scope.
89+
return;
90+
}
91+
92+
if (
93+
args.length > 3 &&
94+
typeof args[0] === 'string' &&
95+
args[0].startsWith('%c%s%c ') &&
96+
typeof args[1] === 'string' &&
97+
typeof args[2] === 'string' &&
98+
typeof args[3] === 'string'
99+
) {
100+
// This looks like the badge we prefixed to the log. Our UI doesn't support formatted logs.
101+
// We remove the formatting. If the environment of the log is the same as the environment of
102+
// the component (the common case) we remove the badge completely otherwise leave it plain
103+
const format = args[0].slice(7);
104+
const env = args[2].trim();
105+
args = args.slice(4);
106+
if (env !== componentInfo.env) {
107+
args.unshift('[' + env + '] ' + format);
108+
} else {
109+
args.unshift(format);
110+
}
111+
}
112+
113+
// We can't really use this message as a unique key, since we can't distinguish
114+
// different objects in this implementation. We have to delegate displaying of the objects
115+
// to the environment, the browser console, for example, so this is why this should be kept
116+
// as an array of arguments, instead of the plain string.
117+
// [Warning: %o, {...}] and [Warning: %o, {...}] will be considered as the same message,
118+
// even if objects are different
119+
const message = formatConsoleArgumentsToSingleString(...args);
120+
121+
// Track the warning/error for later.
122+
let componentLogsEntry = componentInfoToComponentLogsMap.get(componentInfo);
123+
if (componentLogsEntry === undefined) {
124+
componentLogsEntry = {
125+
errors: new Map(),
126+
errorsCount: 0,
127+
warnings: new Map(),
128+
warningsCount: 0,
129+
};
130+
componentInfoToComponentLogsMap.set(componentInfo, componentLogsEntry);
131+
}
132+
133+
const messageMap =
134+
type === 'error'
135+
? componentLogsEntry.errors
136+
: componentLogsEntry.warnings;
137+
const count = messageMap.get(message) || 0;
138+
messageMap.set(message, count + 1);
139+
if (type === 'error') {
140+
componentLogsEntry.errorsCount++;
141+
} else {
142+
componentLogsEntry.warningsCount++;
143+
}
144+
145+
// The changes will be flushed later when we commit this tree to Fiber.
146+
}
147+
23148
patchConsoleUsingWindowValues();
24-
registerRendererWithConsole(); // TODO: Fill in the impl
149+
registerRendererWithConsole(onErrorOrWarning, getComponentStack);
25150

26151
return {
27152
cleanup() {},

packages/react-devtools-shared/src/backend/index.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,17 @@ export function initBackend(
7575

7676
// Inject any not-yet-injected renderers (if we didn't reload-and-profile)
7777
if (rendererInterface == null) {
78-
if (
78+
if (typeof renderer.getCurrentComponentInfo === 'function') {
79+
// react-flight/client
80+
rendererInterface = attachFlight(hook, id, renderer, global);
81+
} else if (
7982
// v16-19
8083
typeof renderer.findFiberByHostInstance === 'function' ||
8184
// v16.8+
8285
renderer.currentDispatcherRef != null
8386
) {
8487
// react-reconciler v16+
8588
rendererInterface = attachFiber(hook, id, renderer, global);
86-
} else if (typeof renderer.getCurrentComponentInfo === 'function') {
87-
// react-flight/client
88-
rendererInterface = attachFlight(hook, id, renderer, global);
8989
} else if (renderer.ComponentTree) {
9090
// react-dom v15
9191
rendererInterface = attachLegacy(hook, id, renderer, global);

0 commit comments

Comments
 (0)