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