DevTools: Fixed potential cache miss when insepcting elements (#22472)
Brian Vaughn committed
Sep 30, 2021 at 12:48 UTC
47177247f8c792fbb9a33328c913e246ff139a2d
8 files changed
+89
-8
packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+61
@@ -384,6 +384,67 @@ describe('InspectedElement', () => {
384
`);
385
});
386
387
+ // See github.com/facebook/react/issues/22241#issuecomment-931299972
388
+ it('should properly recover from a cache miss on the frontend', async () => {
389
+ let targetRenderCount = 0;
390
+
391
+ const Wrapper = ({children}) => children;
392
+ const Target = React.memo(props => {
393
+ targetRenderCount++;
394
+ // Even though his hook isn't referenced, it's used to observe backend rendering.
395
+ React.useState(0);
396
+ return null;
397
+ });
398
+
399
+ const container = document.createElement('div');
400
+ await utils.actAsync(() =>
401
+ legacyRender(
402
+ <Wrapper>
403
+ <Target a={1} b="abc" />
404
+ </Wrapper>,
405
+ container,
406
+ ),
407
+ );
408
+
409
+ targetRenderCount = 0;
410
+
411
+ let inspectedElement = await inspectElementAtIndex(1);
412
+ expect(targetRenderCount).toBe(1);
413
+ expect(inspectedElement.props).toMatchInlineSnapshot(`
414
+ Object {
415
+ "a": 1,
416
+ "b": "abc",
417
+ }
418
+ `);
419
+
420
+ const prevInspectedElement = inspectedElement;
421
+
422
+ // This test causes an intermediate error to be logged but we can ignore it.
423
+ console.error = () => {};
424
+
425
+ // Wait for our check-for-updates poll to get the new data.
426
+ jest.runOnlyPendingTimers();
427
+ await Promise.resolve();
428
+
429
+ // Clear the frontend cache to simulate DevTools being closed and re-opened.
430
+ // The backend still thinks the most recently-inspected element is still cached,
431
+ // so the frontend needs to tell it to resend a full value.
432
+ // We can verify this by asserting that the component is re-rendered again.
433
+ testRendererInstance = TestRenderer.create(null, {
434
+ unstable_isConcurrent: true,
435
+ });
436
+
437
+ const {
438
+ clearCacheForTests,
439
+ } = require('react-devtools-shared/src/inspectedElementMutableSource');
440
+ clearCacheForTests();
441
+
442
+ targetRenderCount = 0;
443
+ inspectedElement = await inspectElementAtIndex(1);
444
+ expect(targetRenderCount).toBe(1);
445
+ expect(inspectedElement).toEqual(prevInspectedElement);
446
+ });
447
+
448
it('should temporarily disable console logging when re-running a component to inspect its hooks', async () => {
449
let targetRenderCount = 0;
450
packages/react-devtools-shared/src/backend/agent.js
+3
-1
@@ -72,6 +72,7 @@ type CopyElementParams = {|
72
|};
73
74
type InspectElementParams = {|
75
+ forceFullData: boolean,
76
id: number,
77
path: Array<string | number> | null,
78
rendererID: number,
@@ -346,6 +347,7 @@ export default class Agent extends EventEmitter<{|
347
};
348
349
inspectElement = ({
350
+ forceFullData,
351
id,
352
path,
353
rendererID,
@@ -357,7 +359,7 @@ export default class Agent extends EventEmitter<{|
359
} else {
360
this._bridge.send(
361
'inspectedElement',
360
- renderer.inspectElement(requestID, id, path),
362
+ renderer.inspectElement(requestID, id, path, forceFullData),
363
);
364
365
// When user selects an element, stop trying to restore the selection,
packages/react-devtools-shared/src/backend/legacy/renderer.js
+2
-1
@@ -694,8 +694,9 @@ export function attach(
694
requestID: number,
695
id: number,
696
path: Array<string | number> | null,
697
+ forceFullData: boolean,
698
): InspectedElementPayload {
698
- if (currentlyInspectedElementID !== id) {
699
+ if (forceFullData || currentlyInspectedElementID !== id) {
700
currentlyInspectedElementID = id;
701
currentlyInspectedPaths = {};
702
}
packages/react-devtools-shared/src/backend/renderer.js
+2
-1
@@ -3439,12 +3439,13 @@ export function attach(
3439
requestID: number,
3440
id: number,
3441
path: Array<string | number> | null,
3442
+ forceFullData: boolean,
3443
): InspectedElementPayload {
3444
if (path !== null) {
3445
mergeInspectedPaths(path);
3446
}
3447
3447
- if (isMostRecentlyInspectedElement(id)) {
3448
+ if (isMostRecentlyInspectedElement(id) && !forceFullData) {
3449
if (!hasElementUpdatedSinceLastInspected) {
3450
if (path !== null) {
3451
let secondaryCategory = null;
packages/react-devtools-shared/src/backend/types.js
+1
@@ -339,6 +339,7 @@ export type RendererInterface = {
339
requestID: number,
340
id: number,
341
inspectedPaths: Object,
342
+ forceFullData: boolean,
343
) => InspectedElementPayload,
344
logElementToConsole: (id: number) => void,
345
overrideError: (id: number, forceError: boolean) => void,
packages/react-devtools-shared/src/backendAPI.js
+3
@@ -86,11 +86,13 @@ export function copyInspectedElementPath({
86
87
export function inspectElement({
88
bridge,
89
+ forceFullData,
90
id,
91
path,
92
rendererID,
93
}: {|
94
bridge: FrontendBridge,
95
+ forceFullData: boolean,
96
id: number,
97
path: Array<string | number> | null,
98
rendererID: number,
@@ -103,6 +105,7 @@ export function inspectElement({
105
);
106
107
bridge.send('inspectElement', {
108
+ forceFullData,
109
id,
110
path,
111
rendererID,
packages/react-devtools-shared/src/bridge.js
+1
@@ -138,6 +138,7 @@ type ViewAttributeSourceParams = {|
138
139
type InspectElementParams = {|
140
...ElementAndRendererID,
141
+ forceFullData: boolean,
142
path: Array<number | string> | null,
143
requestID: number,
144
|};
packages/react-devtools-shared/src/inspectedElementMutableSource.js
+16
-5
@@ -62,8 +62,15 @@ export function inspectElement({
62
rendererID: number,
63
|}): Promise<InspectElementReturnType> {
64
const {id} = element;
65
+
66
+ // This could indicate that the DevTools UI has been closed and reopened.
67
+ // The in-memory cache will be clear but the backend still thinks we have cached data.
68
+ // In this case, we need to tell it to resend the full data.
69
+ const forceFullData = !inspectedElementCache.has(id);
70
+
71
return inspectElementAPI({
72
bridge,
73
+ forceFullData,
74
id,
75
path,
76
rendererID,
@@ -74,7 +81,7 @@ export function inspectElement({
81
switch (type) {
82
case 'no-change':
83
// This is a no-op for the purposes of our cache.
77
- inspectedElement = inspectedElementCache.get(element.id);
84
+ inspectedElement = inspectedElementCache.get(id);
85
if (inspectedElement != null) {
86
return [inspectedElement, type];
87
}
@@ -85,7 +92,7 @@ export function inspectElement({
92
case 'not-found':
93
// This is effectively a no-op.
94
// If the Element is still in the Store, we can eagerly remove it from the Map.
88
- inspectedElementCache.remove(element.id);
95
+ inspectedElementCache.remove(id);
96
97
throw Error(`Element "${id}" not found`);
98
@@ -98,7 +105,7 @@ export function inspectElement({
105
fullData.value,
106
);
107
101
- inspectedElementCache.set(element.id, inspectedElement);
108
+ inspectedElementCache.set(id, inspectedElement);
109
110
return [inspectedElement, type];
111
@@ -108,7 +115,7 @@ export function inspectElement({
115
116
// A path has been hydrated.
117
// Merge it with the latest copy we have locally and resolve with the merged value.
111
- inspectedElement = inspectedElementCache.get(element.id) || null;
118
+ inspectedElement = inspectedElementCache.get(id) || null;
119
if (inspectedElement !== null) {
120
// Clone element
121
inspectedElement = {...inspectedElement};
@@ -121,7 +128,7 @@ export function inspectElement({
128
hydrateHelper(value, ((path: any): Path)),
129
);
130
124
- inspectedElementCache.set(element.id, inspectedElement);
131
+ inspectedElementCache.set(id, inspectedElement);
132
133
return [inspectedElement, type];
134
}
@@ -140,3 +147,7 @@ export function inspectElement({
147
throw Error(`Unable to inspect element with id "${id}"`);
148
});
149
}
150
+
151
+export function clearCacheForTests(): void {
152
+ inspectedElementCache.reset();
153
+}