DevTools should properly report re-renders due to (use)context changes (#22746)
Note that this only fixes things for newer versions of React (e.g. 18 alpha). Older versions will remain broken because there's not a good way to read the most recent context value for a location in the tree after render has completed. This is because React maintains a stack of context values during render, but by the time DevTools is called– render has finished and the stack is empty.
Brian Vaughn committed
Nov 11, 2021 at 14:22 UTC
56efc65e5857abe22062c9506207abc35c9532d1
2 files changed
+162
-1
packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js
new
+151
@@ -0,0 +1,151 @@
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
+describe('Profiler change descriptions', () => {
11
+ let React;
12
+ let legacyRender;
13
+ let store: Store;
14
+ let utils;
15
+
16
+ beforeEach(() => {
17
+ utils = require('./utils');
18
+ utils.beforeEachProfiling();
19
+
20
+ legacyRender = utils.legacyRender;
21
+
22
+ store = global.store;
23
+ store.collapseNodesByDefault = false;
24
+ store.recordChangeDescriptions = true;
25
+
26
+ React = require('react');
27
+ });
28
+
29
+ it('should identify useContext as the cause for a re-render', () => {
30
+ const Context = React.createContext(0);
31
+
32
+ function Child() {
33
+ const context = React.useContext(Context);
34
+ return context;
35
+ }
36
+
37
+ function areEqual() {
38
+ return true;
39
+ }
40
+
41
+ const MemoizedChild = React.memo(Child, areEqual);
42
+ const ForwardRefChild = React.forwardRef(function RefForwardingComponent(
43
+ props,
44
+ ref,
45
+ ) {
46
+ return <Child />;
47
+ });
48
+
49
+ let forceUpdate = null;
50
+
51
+ const App = function App() {
52
+ const [val, dispatch] = React.useReducer(x => x + 1, 0);
53
+
54
+ forceUpdate = dispatch;
55
+
56
+ return (
57
+ <Context.Provider value={val}>
58
+ <Child />
59
+ <MemoizedChild />
60
+ <ForwardRefChild />
61
+ </Context.Provider>
62
+ );
63
+ };
64
+
65
+ const container = document.createElement('div');
66
+
67
+ utils.act(() => store.profilerStore.startProfiling());
68
+ utils.act(() => legacyRender(<App />, container));
69
+ utils.act(() => forceUpdate());
70
+ utils.act(() => store.profilerStore.stopProfiling());
71
+
72
+ const rootID = store.roots[0];
73
+ const commitData = store.profilerStore.getCommitData(rootID, 1);
74
+
75
+ expect(store).toMatchInlineSnapshot(`
76
+ [root]
77
+ ▾ <App>
78
+ ▾ <Context.Provider>
79
+ <Child>
80
+ ▾ <Child> [Memo]
81
+ <Child>
82
+ ▾ <RefForwardingComponent> [ForwardRef]
83
+ <Child>
84
+ `);
85
+
86
+ let element = store.getElementAtIndex(2);
87
+ expect(element.displayName).toBe('Child');
88
+ expect(element.hocDisplayNames).toBeNull();
89
+ expect(commitData.changeDescriptions.get(element.id))
90
+ .toMatchInlineSnapshot(`
91
+ Object {
92
+ "context": true,
93
+ "didHooksChange": false,
94
+ "hooks": null,
95
+ "isFirstMount": false,
96
+ "props": Array [],
97
+ "state": null,
98
+ }
99
+ `);
100
+
101
+ element = store.getElementAtIndex(3);
102
+ expect(element.displayName).toBe('Child');
103
+ expect(element.hocDisplayNames).toEqual(['Memo']);
104
+ expect(commitData.changeDescriptions.get(element.id)).toBeUndefined();
105
+
106
+ element = store.getElementAtIndex(4);
107
+ expect(element.displayName).toBe('Child');
108
+ expect(element.hocDisplayNames).toBeNull();
109
+ expect(commitData.changeDescriptions.get(element.id))
110
+ .toMatchInlineSnapshot(`
111
+ Object {
112
+ "context": true,
113
+ "didHooksChange": false,
114
+ "hooks": null,
115
+ "isFirstMount": false,
116
+ "props": Array [],
117
+ "state": null,
118
+ }
119
+ `);
120
+
121
+ element = store.getElementAtIndex(5);
122
+ expect(element.displayName).toBe('RefForwardingComponent');
123
+ expect(element.hocDisplayNames).toEqual(['ForwardRef']);
124
+ expect(commitData.changeDescriptions.get(element.id))
125
+ .toMatchInlineSnapshot(`
126
+ Object {
127
+ "context": null,
128
+ "didHooksChange": false,
129
+ "hooks": null,
130
+ "isFirstMount": false,
131
+ "props": Array [],
132
+ "state": null,
133
+ }
134
+ `);
135
+
136
+ element = store.getElementAtIndex(6);
137
+ expect(element.displayName).toBe('Child');
138
+ expect(element.hocDisplayNames).toBeNull();
139
+ expect(commitData.changeDescriptions.get(element.id))
140
+ .toMatchInlineSnapshot(`
141
+ Object {
142
+ "context": true,
143
+ "didHooksChange": false,
144
+ "hooks": null,
145
+ "isFirstMount": false,
146
+ "props": Array [],
147
+ "state": null,
148
+ }
149
+ `);
150
+ });
151
+});
packages/react-devtools-shared/src/backend/renderer.js
+11
-1
@@ -1253,8 +1253,10 @@ export function attach(
1253
1254
function updateContextsForFiber(fiber: Fiber) {
1255
switch (getElementTypeForFiber(fiber)) {
1256
- case ElementTypeFunction:
1256
case ElementTypeClass:
1257
+ case ElementTypeForwardRef:
1258
+ case ElementTypeFunction:
1259
+ case ElementTypeMemo:
1260
if (idToContextsMap !== null) {
1261
const id = getFiberIDThrows(fiber);
1262
const contexts = getContextsForFiber(fiber);
@@ -1292,7 +1294,9 @@ export function attach(
1294
}
1295
}
1296
return [legacyContext, modernContext];
1297
+ case ElementTypeForwardRef:
1298
case ElementTypeFunction:
1299
+ case ElementTypeMemo:
1300
const dependencies = fiber.dependencies;
1301
if (dependencies && dependencies.firstContext) {
1302
modernContext = dependencies.firstContext;
@@ -1341,12 +1345,18 @@ export function attach(
1345
}
1346
}
1347
break;
1348
+ case ElementTypeForwardRef:
1349
case ElementTypeFunction:
1350
+ case ElementTypeMemo:
1351
if (nextModernContext !== NO_CONTEXT) {
1352
let prevContext = prevModernContext;
1353
let nextContext = nextModernContext;
1354
1355
while (prevContext && nextContext) {
1356
+ // Note this only works for versions of React that support this key (e.v. 18+)
1357
+ // For older versions, there's no good way to read the current context value after render has completed.
1358
+ // This is because React maintains a stack of context values during render,
1359
+ // but by the time DevTools is called, render has finished and the stack is empty.
1360
if (!is(prevContext.memoizedValue, nextContext.memoizedValue)) {
1361
return true;
1362
}