@samitouri / QOS-React / commits / 1b7b3592f4

[Fizz] Implement Component Stacks in DEV for warnings (#21610)

* Implement component stacks This uses a reverse linked list in DEV-only to keep track of where we're currently executing. * Fix bug that wasn't picking up the right stack at suspended boundaries This makes it more explicit which stack we pass in to be retained by the task.

Sebastian Markbåge committed Jun 3, 2021 at 16:02 UTC 1b7b3592f4713b3a53709554aefd1d20e9a0a6a5
3 files changed +292 -4
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+112
@@ -1058,6 +1058,118 @@ describe('ReactDOMFizzServer', () => {
1058 );
1059 });
1060
1061 + function normalizeCodeLocInfo(str) {
1062 + return (
1063 + str &&
1064 + str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) {
1065 + return '\n in ' + name + ' (at **)';
1066 + })
1067 + );
1068 + }
1069 +
1070 + // @gate experimental
1071 + it('should include a component stack across suspended boundaries', async () => {
1072 + function B() {
1073 + const children = [readText('Hello'), readText('World')];
1074 + // Intentionally trigger a key warning here.
1075 + return (
1076 + <div>
1077 + {children.map(t => (
1078 + <span>{t}</span>
1079 + ))}
1080 + </div>
1081 + );
1082 + }
1083 + function C() {
1084 + return (
1085 + <inCorrectTag>
1086 + <Text text="Loading" />
1087 + </inCorrectTag>
1088 + );
1089 + }
1090 + function A() {
1091 + return (
1092 + <div>
1093 + <Suspense fallback={<C />}>
1094 + <B />
1095 + </Suspense>
1096 + </div>
1097 + );
1098 + }
1099 +
1100 + // We can't use the toErrorDev helper here because this is an async act.
1101 + const originalConsoleError = console.error;
1102 + const mockError = jest.fn();
1103 + console.error = (...args) => {
1104 + mockError(...args.map(normalizeCodeLocInfo));
1105 + };
1106 +
1107 + try {
1108 + await act(async () => {
1109 + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
1110 + <A />,
1111 + writable,
1112 + );
1113 + startWriting();
1114 + });
1115 +
1116 + expect(getVisibleChildren(container)).toEqual(
1117 + <div>
1118 + <incorrecttag>Loading</incorrecttag>
1119 + </div>,
1120 + );
1121 +
1122 + if (__DEV__) {
1123 + expect(mockError).toHaveBeenCalledWith(
1124 + 'Warning: <%s /> is using incorrect casing. Use PascalCase for React components, or lowercase for HTML elements.%s',
1125 + 'inCorrectTag',
1126 + '\n' +
1127 + ' in inCorrectTag (at **)\n' +
1128 + ' in C (at **)\n' +
1129 + ' in Suspense (at **)\n' +
1130 + ' in div (at **)\n' +
1131 + ' in A (at **)',
1132 + );
1133 + mockError.mockClear();
1134 + } else {
1135 + expect(mockError).not.toHaveBeenCalled();
1136 + }
1137 +
1138 + await act(async () => {
1139 + resolveText('Hello');
1140 + resolveText('World');
1141 + });
1142 +
1143 + if (__DEV__) {
1144 + expect(mockError).toHaveBeenCalledWith(
1145 + 'Warning: Each child in a list should have a unique "key" prop.%s%s' +
1146 + ' See https://reactjs.org/link/warning-keys for more information.%s',
1147 + '\n\nCheck the top-level render call using <div>.',
1148 + '',
1149 + '\n' +
1150 + ' in span (at **)\n' +
1151 + ' in B (at **)\n' +
1152 + ' in Suspense (at **)\n' +
1153 + ' in div (at **)\n' +
1154 + ' in A (at **)',
1155 + );
1156 + } else {
1157 + expect(mockError).not.toHaveBeenCalled();
1158 + }
1159 +
1160 + expect(getVisibleChildren(container)).toEqual(
1161 + <div>
1162 + <div>
1163 + <span>Hello</span>
1164 + <span>World</span>
1165 + </div>
1166 + </div>,
1167 + );
1168 + } finally {
1169 + console.error = originalConsoleError;
1170 + }
1171 + });
1172 +
1173 // @gate experimental
1174 it('should can suspend in a class component with legacy context', async () => {
1175 class TestProvider extends React.Component {
packages/react-server/src/ReactFizzComponentStack.js new
+61
@@ -0,0 +1,61 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +import {
11 + describeBuiltInComponentFrame,
12 + describeFunctionComponentFrame,
13 + describeClassComponentFrame,
14 +} from 'shared/ReactComponentStackFrame';
15 +
16 +// DEV-only reverse linked list representing the current component stack
17 +type BuiltInComponentStackNode = {
18 + tag: 0,
19 + parent: null | ComponentStackNode,
20 + type: string,
21 +};
22 +type FunctionComponentStackNode = {
23 + tag: 1,
24 + parent: null | ComponentStackNode,
25 + type: Function,
26 +};
27 +type ClassComponentStackNode = {
28 + tag: 2,
29 + parent: null | ComponentStackNode,
30 + type: Function,
31 +};
32 +export type ComponentStackNode =
33 + | BuiltInComponentStackNode
34 + | FunctionComponentStackNode
35 + | ClassComponentStackNode;
36 +
37 +export function getStackByComponentStackNode(
38 + componentStack: ComponentStackNode,
39 +): string {
40 + try {
41 + let info = '';
42 + let node = componentStack;
43 + do {
44 + switch (node.tag) {
45 + case 0:
46 + info += describeBuiltInComponentFrame(node.type, null, null);
47 + break;
48 + case 1:
49 + info += describeFunctionComponentFrame(node.type, null, null);
50 + break;
51 + case 2:
52 + info += describeClassComponentFrame(node.type, null, null);
53 + break;
54 + }
55 + node = node.parent;
56 + } while (node);
57 + return info;
58 + } catch (x) {
59 + return '\nError generating stack: ' + x.message + '\n' + x.stack;
60 + }
61 +}
packages/react-server/src/ReactFizzServer.js
+119 -4
@@ -24,6 +24,7 @@ import type {
24 FormatContext,
25 } from './ReactServerFormatConfig';
26 import type {ContextSnapshot} from './ReactFizzNewContext';
27 +import type {ComponentStackNode} from './ReactFizzComponentStack';
28
29 import {
30 scheduleWork,
@@ -77,6 +78,7 @@ import {
78 currentResponseState,
79 setCurrentResponseState,
80 } from './ReactFizzHooks';
81 +import {getStackByComponentStackNode} from './ReactFizzComponentStack';
82
83 import {
84 getIteratorFn,
@@ -110,6 +112,7 @@ import invariant from 'shared/invariant';
112 import isArray from 'shared/isArray';
113
114 const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
115 +const ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
116
117 type LegacyContext = {
118 [key: string]: any,
@@ -135,6 +138,7 @@ type Task = {
138 legacyContext: LegacyContext, // the current legacy context that this task is executing in
139 context: ContextSnapshot, // the current new context that this task is executing in
140 assignID: null | SuspenseBoundaryID, // id to assign to the content
141 + componentStack: null | ComponentStackNode, // DEV-only component stack
142 };
143
144 const PENDING = 0;
@@ -299,7 +303,7 @@ function createTask(
303 } else {
304 blockedBoundary.pendingTasks++;
305 }
302 - const task = {
306 + const task: Task = ({
307 node,
308 ping: () => pingTask(request, task),
309 blockedBoundary,
@@ -308,7 +312,10 @@ function createTask(
312 legacyContext,
313 context,
314 assignID,
311 - };
315 + }: any);
316 + if (__DEV__) {
317 + task.componentStack = null;
318 + }
319 abortSet.add(task);
320 return task;
321 }
@@ -331,6 +338,57 @@ function createPendingSegment(
338 };
339 }
340
341 +// DEV-only global reference to the currently executing task
342 +let currentTaskInDEV: null | Task = null;
343 +function getCurrentStackInDEV(): string {
344 + if (__DEV__) {
345 + if (currentTaskInDEV === null || currentTaskInDEV.componentStack === null) {
346 + return '';
347 + }
348 + return getStackByComponentStackNode(currentTaskInDEV.componentStack);
349 + }
350 + return '';
351 +}
352 +
353 +function pushBuiltInComponentStackInDEV(task: Task, type: string): void {
354 + if (__DEV__) {
355 + task.componentStack = {
356 + tag: 0,
357 + parent: task.componentStack,
358 + type,
359 + };
360 + }
361 +}
362 +function pushFunctionComponentStackInDEV(task: Task, type: Function): void {
363 + if (__DEV__) {
364 + task.componentStack = {
365 + tag: 1,
366 + parent: task.componentStack,
367 + type,
368 + };
369 + }
370 +}
371 +function pushClassComponentStackInDEV(task: Task, type: Function): void {
372 + if (__DEV__) {
373 + task.componentStack = {
374 + tag: 2,
375 + parent: task.componentStack,
376 + type,
377 + };
378 + }
379 +}
380 +function popComponentStackInDEV(task: Task): void {
381 + if (__DEV__) {
382 + if (task.componentStack === null) {
383 + console.error(
384 + 'Unexpectedly popped too many stack frames. This is a bug in React.',
385 + );
386 + } else {
387 + task.componentStack = task.componentStack.parent;
388 + }
389 + }
390 +}
391 +
392 function reportError(request: Request, error: mixed): void {
393 // If this callback errors, we intentionally let that error bubble up to become a fatal error
394 // so that someone fixes the error reporting instead of hiding it.
@@ -351,6 +409,7 @@ function renderSuspenseBoundary(
409 task: Task,
410 props: Object,
411 ): void {
412 + pushBuiltInComponentStackInDEV(task, 'Suspense');
413 const parentBoundary = task.blockedBoundary;
414 const parentSegment = task.blockedSegment;
415
@@ -406,6 +465,7 @@ function renderSuspenseBoundary(
465 // This must have been the last segment we were waiting on. This boundary is now complete.
466 // Therefore we won't need the fallback. We early return so that we don't have to create
467 // the fallback.
468 + popComponentStackInDEV(task);
469 return;
470 }
471 } catch (error) {
@@ -445,9 +505,14 @@ function renderSuspenseBoundary(
505 task.context,
506 null,
507 );
508 + if (__DEV__) {
509 + suspendedFallbackTask.componentStack = task.componentStack;
510 + }
511 // TODO: This should be queued at a separate lower priority queue so that we only work
512 // on preparing fallbacks if we don't have any more main content to task on.
513 request.pingedTasks.push(suspendedFallbackTask);
514 +
515 + popComponentStackInDEV(task);
516 }
517
518 function renderHostElement(
@@ -456,6 +521,7 @@ function renderHostElement(
521 type: string,
522 props: Object,
523 ): void {
524 + pushBuiltInComponentStackInDEV(task, type);
525 const segment = task.blockedSegment;
526 const children = pushStartInstance(
527 segment.chunks,
@@ -476,6 +542,7 @@ function renderHostElement(
542 // the correct context. Therefore this is not in a finally.
543 segment.formatContext = prevContext;
544 pushEndInstance(segment.chunks, type, props);
545 + popComponentStackInDEV(task);
546 }
547
548 function shouldConstruct(Component) {
@@ -564,12 +631,14 @@ function renderClassComponent(
631 Component: any,
632 props: any,
633 ): void {
634 + pushClassComponentStackInDEV(task, Component);
635 const maskedContext = !disableLegacyContext
636 ? getMaskedContext(Component, task.legacyContext)
637 : undefined;
638 const instance = constructClassInstance(Component, props, maskedContext);
639 mountClassInstance(instance, Component, props, maskedContext);
640 finishClassComponent(request, task, instance, Component, props);
641 + popComponentStackInDEV(task);
642 }
643
644 const didWarnAboutBadClass = {};
@@ -594,6 +663,7 @@ function renderIndeterminateComponent(
663 if (!disableLegacyContext) {
664 legacyContext = getMaskedContext(Component, task.legacyContext);
665 }
666 + pushFunctionComponentStackInDEV(task, Component);
667
668 if (__DEV__) {
669 if (
@@ -688,6 +758,7 @@ function renderIndeterminateComponent(
758 // the previous task every again, so we can use the destructive recursive form.
759 renderNodeDestructive(request, task, value);
760 }
761 + popComponentStackInDEV(task);
762 }
763
764 function validateFunctionComponentInDev(Component: any): void {
@@ -768,8 +839,10 @@ function renderForwardRef(
839 props: Object,
840 ref: any,
841 ): void {
842 + pushFunctionComponentStackInDEV(task, type.render);
843 const children = renderWithHooks(request, task, type.render, props, ref);
844 renderNodeDestructive(request, task, children);
845 + popComponentStackInDEV(task);
846 }
847
848 function renderMemo(
@@ -866,11 +939,13 @@ function renderLazyComponent(
939 props: Object,
940 ref: any,
941 ): void {
942 + pushBuiltInComponentStackInDEV(task, 'Lazy');
943 const payload = lazyComponent._payload;
944 const init = lazyComponent._init;
945 const Component = init(payload);
946 const resolvedProps = resolveDefaultProps(Component, props);
873 - return renderElement(request, task, Component, resolvedProps, ref);
947 + renderElement(request, task, Component, resolvedProps, ref);
948 + popComponentStackInDEV(task);
949 }
950
951 function renderElement(
@@ -907,11 +982,17 @@ function renderElement(
982 case REACT_DEBUG_TRACING_MODE_TYPE:
983 case REACT_STRICT_MODE_TYPE:
984 case REACT_PROFILER_TYPE:
910 - case REACT_SUSPENSE_LIST_TYPE: // TODO: SuspenseList should control the boundaries.
985 case REACT_FRAGMENT_TYPE: {
986 renderNodeDestructive(request, task, props.children);
987 return;
988 }
989 + case REACT_SUSPENSE_LIST_TYPE: {
990 + pushBuiltInComponentStackInDEV(task, 'SuspenseList');
991 + // TODO: SuspenseList should control the boundaries.
992 + renderNodeDestructive(request, task, props.children);
993 + popComponentStackInDEV(task);
994 + return;
995 + }
996 case REACT_SCOPE_TYPE: {
997 if (enableScopeAPI) {
998 renderNodeDestructive(request, task, props.children);
@@ -1157,6 +1238,13 @@ function spawnNewSuspendedTask(
1238 task.context,
1239 task.assignID,
1240 );
1241 + if (__DEV__) {
1242 + if (task.componentStack !== null) {
1243 + // We pop one task off the stack because the node that suspended will be tried again,
1244 + // which will add it back onto the stack.
1245 + newTask.componentStack = task.componentStack.parent;
1246 + }
1247 + }
1248 // We've delegated the assignment.
1249 task.assignID = null;
1250 const ping = newTask.ping;
@@ -1174,6 +1262,10 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void {
1262 const previousFormatContext = task.blockedSegment.formatContext;
1263 const previousLegacyContext = task.legacyContext;
1264 const previousContext = task.context;
1265 + let previousComponentStack = null;
1266 + if (__DEV__) {
1267 + previousComponentStack = task.componentStack;
1268 + }
1269 try {
1270 return renderNodeDestructive(request, task, node);
1271 } catch (x) {
@@ -1187,6 +1279,9 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void {
1279 task.context = previousContext;
1280 // Restore all active ReactContexts to what they were before.
1281 switchContext(previousContext);
1282 + if (__DEV__) {
1283 + task.componentStack = previousComponentStack;
1284 + }
1285 } else {
1286 // Restore the context. We assume that this will be restored by the inner
1287 // functions in case nothing throws so we don't use "finally" here.
@@ -1195,6 +1290,9 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void {
1290 task.context = previousContext;
1291 // Restore all active ReactContexts to what they were before.
1292 switchContext(previousContext);
1293 + if (__DEV__) {
1294 + task.componentStack = previousComponentStack;
1295 + }
1296 // We assume that we don't need the correct context.
1297 // Let's terminate the rest of the tree and don't render any siblings.
1298 throw x;
@@ -1360,6 +1458,11 @@ function retryTask(request: Request, task: Task): void {
1458 // We don't restore it after we leave because it's likely that we'll end up
1459 // needing a very similar context soon again.
1460 switchContext(task.context);
1461 + let prevTaskInDEV = null;
1462 + if (__DEV__) {
1463 + prevTaskInDEV = currentTaskInDEV;
1464 + currentTaskInDEV = task;
1465 + }
1466 try {
1467 // We call the destructive form that mutates this task. That way if something
1468 // suspends again, we can reuse the same task instead of spawning a new one.
@@ -1379,6 +1482,10 @@ function retryTask(request: Request, task: Task): void {
1482 segment.status = ERRORED;
1483 erroredTask(request, task.blockedBoundary, segment, x);
1484 }
1485 + } finally {
1486 + if (__DEV__) {
1487 + currentTaskInDEV = prevTaskInDEV;
1488 + }
1489 }
1490 }
1491
@@ -1389,6 +1496,11 @@ export function performWork(request: Request): void {
1496 const prevContext = getActiveContext();
1497 const prevDispatcher = ReactCurrentDispatcher.current;
1498 ReactCurrentDispatcher.current = Dispatcher;
1499 + let prevGetCurrentStackImpl;
1500 + if (__DEV__) {
1501 + prevGetCurrentStackImpl = ReactDebugCurrentFrame.getCurrentStack;
1502 + ReactDebugCurrentFrame.getCurrentStack = getCurrentStackInDEV;
1503 + }
1504 const prevResponseState = currentResponseState;
1505 setCurrentResponseState(request.responseState);
1506 try {
@@ -1408,6 +1520,9 @@ export function performWork(request: Request): void {
1520 } finally {
1521 setCurrentResponseState(prevResponseState);
1522 ReactCurrentDispatcher.current = prevDispatcher;
1523 + if (__DEV__) {
1524 + ReactDebugCurrentFrame.getCurrentStack = prevGetCurrentStackImpl;
1525 + }
1526 if (prevDispatcher === Dispatcher) {
1527 // This means that we were in a reentrant work loop. This could happen
1528 // in a renderer that supports synchronous work like renderToString,