Skip to content

Commit edfe505

Browse files
author
Brian Vaughn
authored
DevTools: Replaced WeakMap with LRU for inspected element cache (#22160)
We use an LRU for this rather than a WeakMap because of how the "no-change" optimization works. When the frontend polls the backend for an update on the element that's currently inspected, the backend will send a "no-change" message if the element hasn't updated (rendered) since the last time it was asked. In thid case, the frontend cache should reuse the previous (cached) value. Using a WeakMap keyed on Element generally works well for this, since Elements are mutable and stable in the Store. This doens't work properly though when component filters are changed, because this will cause the Store to dump all roots and re-initialize the tree (recreating the Element objects). So instead we key on Element ID (which is stable in this case) and use an LRU for eviction.
1 parent bd5bf55 commit edfe505

File tree

4 files changed

+81
-17
lines changed

4 files changed

+81
-17
lines changed

packages/react-devtools-shared/src/__tests__/inspectedElement-test.js

+54
Original file line numberDiff line numberDiff line change
@@ -1895,6 +1895,60 @@ describe('InspectedElement', () => {
18951895
`);
18961896
});
18971897

1898+
// Regression test for github.com/facebook/react/issues/22099
1899+
it('should not error when an unchanged component is re-inspected after component filters changed', async () => {
1900+
const Example = () => <div />;
1901+
1902+
const container = document.createElement('div');
1903+
await utils.actAsync(() => legacyRender(<Example />, container));
1904+
1905+
// Select/inspect element
1906+
let inspectedElement = await inspectElementAtIndex(0);
1907+
expect(inspectedElement).toMatchInlineSnapshot(`
1908+
Object {
1909+
"context": null,
1910+
"events": undefined,
1911+
"hooks": null,
1912+
"id": 2,
1913+
"owners": null,
1914+
"props": Object {},
1915+
"state": null,
1916+
}
1917+
`);
1918+
1919+
await utils.actAsync(async () => {
1920+
// Ignore transient warning this causes
1921+
utils.withErrorsOrWarningsIgnored(['No element found with id'], () => {
1922+
store.componentFilters = [];
1923+
1924+
// Flush events to the renderer.
1925+
jest.runOnlyPendingTimers();
1926+
});
1927+
}, false);
1928+
1929+
// HACK: Recreate TestRenderer instance because we rely on default state values
1930+
// from props like defaultSelectedElementID and it's easier to reset here than
1931+
// to read the TreeDispatcherContext and update the selected ID that way.
1932+
// We're testing the inspected values here, not the context wiring, so that's ok.
1933+
testRendererInstance = TestRenderer.create(null, {
1934+
unstable_isConcurrent: true,
1935+
});
1936+
1937+
// Select/inspect the same element again
1938+
inspectedElement = await inspectElementAtIndex(0);
1939+
expect(inspectedElement).toMatchInlineSnapshot(`
1940+
Object {
1941+
"context": null,
1942+
"events": undefined,
1943+
"hooks": null,
1944+
"id": 2,
1945+
"owners": null,
1946+
"props": Object {},
1947+
"state": null,
1948+
}
1949+
`);
1950+
});
1951+
18981952
describe('$r', () => {
18991953
it('should support function components', async () => {
19001954
const Example = () => {

packages/react-devtools-shared/src/inspectedElementCache.js

+2-3
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ export function inspectElement(
8888
): InspectedElementFrontend | null {
8989
const map = getRecordMap();
9090
let record = map.get(element);
91-
9291
if (!record) {
9392
const callbacks = new Set();
9493
const wakeable: Wakeable = {
@@ -110,7 +109,7 @@ export function inspectElement(
110109
if (rendererID == null) {
111110
const rejectedRecord = ((newRecord: any): RejectedRecord);
112111
rejectedRecord.status = Rejected;
113-
rejectedRecord.value = `Could not inspect element with id "${element.id}"`;
112+
rejectedRecord.value = `Could not inspect element with id "${element.id}". No renderer found.`;
114113

115114
map.set(element, record);
116115

@@ -134,7 +133,7 @@ export function inspectElement(
134133
if (newRecord.status === Pending) {
135134
const rejectedRecord = ((newRecord: any): RejectedRecord);
136135
rejectedRecord.status = Rejected;
137-
rejectedRecord.value = `Could not inspect element with id "${element.id}"`;
136+
rejectedRecord.value = `Could not inspect element with id "${element.id}". Error thrown:\n${error.message}`;
138137
wake();
139138
}
140139
},

packages/react-devtools-shared/src/inspectedElementMutableSource.js

+24-14
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
* @flow
88
*/
99

10+
import LRU from 'lru-cache';
1011
import {
1112
convertInspectedElementBackendToFrontend,
1213
hydrateHelper,
1314
inspectElement as inspectElementAPI,
1415
} from 'react-devtools-shared/src/backendAPI';
1516
import {fillInPath} from 'react-devtools-shared/src/hydration';
1617

18+
import type {LRUCache} from 'react-devtools-shared/src/types';
1719
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
1820
import type {
1921
InspectElementFullData,
@@ -25,15 +27,21 @@ import type {
2527
InspectedElementResponseType,
2628
} from 'react-devtools-shared/src/devtools/views/Components/types';
2729

28-
// Map an Element in the Store to the most recent copy of its inspected data.
29-
// As updates comes from the backend, inspected data is updated.
30-
// Both this map and the inspected objects in it are mutable.
31-
// They should never be read from directly during render;
32-
// Use a Suspense cache to ensure that transitions work correctly and there is no tearing.
33-
const inspectedElementMap: WeakMap<
34-
Element,
30+
// Maps element ID to inspected data.
31+
// We use an LRU for this rather than a WeakMap because of how the "no-change" optimization works.
32+
// When the frontend polls the backend for an update on the element that's currently inspected,
33+
// the backend will send a "no-change" message if the element hasn't updated (rendered) since the last time it was asked.
34+
// In thid case, the frontend cache should reuse the previous (cached) value.
35+
// Using a WeakMap keyed on Element generally works well for this, since Elements are mutable and stable in the Store.
36+
// This doens't work properly though when component filters are changed,
37+
// because this will cause the Store to dump all roots and re-initialize the tree (recreating the Element objects).
38+
// So instead we key on Element ID (which is stable in this case) and use an LRU for eviction.
39+
const inspectedElementCache: LRUCache<
40+
number,
3541
InspectedElementFrontend,
36-
> = new WeakMap();
42+
> = new LRU({
43+
max: 25,
44+
});
3745

3846
type Path = Array<string | number>;
3947

@@ -66,16 +74,18 @@ export function inspectElement({
6674
switch (type) {
6775
case 'no-change':
6876
// This is a no-op for the purposes of our cache.
69-
inspectedElement = inspectedElementMap.get(element);
77+
inspectedElement = inspectedElementCache.get(element.id);
7078
if (inspectedElement != null) {
7179
return [inspectedElement, type];
7280
}
73-
break;
81+
82+
// We should only encounter this case in the event of a bug.
83+
throw Error(`Cached data for element "${id}" not found`);
7484

7585
case 'not-found':
7686
// This is effectively a no-op.
7787
// If the Element is still in the Store, we can eagerly remove it from the Map.
78-
inspectedElementMap.delete(element);
88+
inspectedElementCache.remove(element.id);
7989

8090
throw Error(`Element "${id}" not found`);
8191

@@ -88,7 +98,7 @@ export function inspectElement({
8898
fullData.value,
8999
);
90100

91-
inspectedElementMap.set(element, inspectedElement);
101+
inspectedElementCache.set(element.id, inspectedElement);
92102

93103
return [inspectedElement, type];
94104

@@ -98,7 +108,7 @@ export function inspectElement({
98108

99109
// A path has been hydrated.
100110
// Merge it with the latest copy we have locally and resolve with the merged value.
101-
inspectedElement = inspectedElementMap.get(element) || null;
111+
inspectedElement = inspectedElementCache.get(element.id) || null;
102112
if (inspectedElement !== null) {
103113
// Clone element
104114
inspectedElement = {...inspectedElement};
@@ -111,7 +121,7 @@ export function inspectElement({
111121
hydrateHelper(value, ((path: any): Path)),
112122
);
113123

114-
inspectedElementMap.set(element, inspectedElement);
124+
inspectedElementCache.set(element.id, inspectedElement);
115125

116126
return [inspectedElement, type];
117127
}

packages/react-devtools-shared/src/types.js

+1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export type HookNames = Map<HookSourceLocationKey, HookName>;
8787
export type LRUCache<K, V> = {|
8888
get: (key: K) => V,
8989
has: (key: K) => boolean,
90+
remove: (key: K) => void,
9091
reset: () => void,
9192
set: (key: K, value: V) => void,
9293
|};

0 commit comments

Comments
 (0)