@samitouri / QOS-React / commits / 63cefa2957

[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.

Sebastian Markbåge committed Sep 10, 2024 at 10:37 UTC 63cefa2957bbf8c599643d8252f6b8221b399349
3 files changed +185 -5
packages/react-devtools-shared/src/backend/flight/DevToolsComponentInfoStack.js new
+55
@@ -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
@@ -7,21 +7,146 @@
7 * @flow
8 */
9
10 +import type {ReactComponentInfo} from 'shared/ReactTypes';
11 +
12 import type {DevToolsHook, ReactRenderer, RendererInterface} from '../types';
13
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 +
22 import {
23 patchConsoleUsingWindowValues,
24 registerRenderer as registerRendererWithConsole,
25 } from '../console';
26
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 +
34 export function attach(
35 hook: DevToolsHook,
36 rendererID: number,
37 renderer: ReactRenderer,
38 global: Object,
39 ): 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 +
148 patchConsoleUsingWindowValues();
24 - registerRendererWithConsole(); // TODO: Fill in the impl
149 + registerRendererWithConsole(onErrorOrWarning, getComponentStack);
150
151 return {
152 cleanup() {},
packages/react-devtools-shared/src/backend/index.js
+4 -4
@@ -75,7 +75,10 @@ export function initBackend(
75
76 // Inject any not-yet-injected renderers (if we didn't reload-and-profile)
77 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 (
82 // v16-19
83 typeof renderer.findFiberByHostInstance === 'function' ||
84 // v16.8+
@@ -83,9 +86,6 @@ export function initBackend(
86 ) {
87 // react-reconciler v16+
88 rendererInterface = attachFiber(hook, id, renderer, global);
86 - } else if (typeof renderer.getCurrentComponentInfo === 'function') {
87 - // react-flight/client
88 - rendererInterface = attachFlight(hook, id, renderer, global);
89 } else if (renderer.ComponentTree) {
90 // react-dom v15
91 rendererInterface = attachLegacy(hook, id, renderer, global);