Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DevTools: Replaced WeakMap with LRU for inspected element cache #22160

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1895,6 +1895,60 @@ describe('InspectedElement', () => {
`);
});

// Regression test for github.com/facebook/react/issues/22099
it('should not error when an unchanged component is re-inspected after component filters changed', async () => {
const Example = () => <div />;

const container = document.createElement('div');
await utils.actAsync(() => legacyRender(<Example />, container));

// Select/inspect element
let inspectedElement = await inspectElementAtIndex(0);
expect(inspectedElement).toMatchInlineSnapshot(`
Object {
"context": null,
"events": undefined,
"hooks": null,
"id": 2,
"owners": null,
"props": Object {},
"state": null,
}
`);

await utils.actAsync(async () => {
// Ignore transient warning this causes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be useful to add context to the comment about why it's okay to ignore this warning

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough.

Basically there's a small period of time where (potentially) the element has been removed from the Store but the UI hasn't yet re-rendered with this knowledge and may query the Store for the element.

utils.withErrorsOrWarningsIgnored(['No element found with id'], () => {
store.componentFilters = [];

// Flush events to the renderer.
jest.runOnlyPendingTimers();
});
}, false);

// HACK: Recreate TestRenderer instance because we rely on default state values
// from props like defaultSelectedElementID and it's easier to reset here than
// to read the TreeDispatcherContext and update the selected ID that way.
// We're testing the inspected values here, not the context wiring, so that's ok.
testRendererInstance = TestRenderer.create(null, {
unstable_isConcurrent: true,
});

// Select/inspect the same element again
inspectedElement = await inspectElementAtIndex(0);
expect(inspectedElement).toMatchInlineSnapshot(`
Object {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just cuirious, what part of this snapshot is the relevant assertion here? what was the result before?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can see the same snapshot above.

Really the only thing we need to do is verify that an error isn't thrown (like it was before) but this snapshot assertion goes a step further to verify that we can (re)inspect the same element after filters have changed.

"context": null,
"events": undefined,
"hooks": null,
"id": 2,
"owners": null,
"props": Object {},
"state": null,
}
`);
});

describe('$r', () => {
it('should support function components', async () => {
const Example = () => {
Expand Down
5 changes: 2 additions & 3 deletions packages/react-devtools-shared/src/inspectedElementCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ export function inspectElement(
): InspectedElementFrontend | null {
const map = getRecordMap();
let record = map.get(element);

if (!record) {
const callbacks = new Set();
const wakeable: Wakeable = {
Expand All @@ -110,7 +109,7 @@ export function inspectElement(
if (rendererID == null) {
const rejectedRecord = ((newRecord: any): RejectedRecord);
rejectedRecord.status = Rejected;
rejectedRecord.value = `Could not inspect element with id "${element.id}"`;
rejectedRecord.value = `Could not inspect element with id "${element.id}". No renderer found.`;

map.set(element, record);

Expand All @@ -134,7 +133,7 @@ export function inspectElement(
if (newRecord.status === Pending) {
const rejectedRecord = ((newRecord: any): RejectedRecord);
rejectedRecord.status = Rejected;
rejectedRecord.value = `Could not inspect element with id "${element.id}"`;
rejectedRecord.value = `Could not inspect element with id "${element.id}". Error thrown:\n${error.message}`;
wake();
}
},
Expand Down
38 changes: 24 additions & 14 deletions packages/react-devtools-shared/src/inspectedElementMutableSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
* @flow
*/

import LRU from 'lru-cache';
import {
convertInspectedElementBackendToFrontend,
hydrateHelper,
inspectElement as inspectElementAPI,
} from 'react-devtools-shared/src/backendAPI';
import {fillInPath} from 'react-devtools-shared/src/hydration';

import type {LRUCache} from 'react-devtools-shared/src/types';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type {
InspectElementFullData,
Expand All @@ -25,15 +27,21 @@ import type {
InspectedElementResponseType,
} from 'react-devtools-shared/src/devtools/views/Components/types';

// Map an Element in the Store to the most recent copy of its inspected data.
// As updates comes from the backend, inspected data is updated.
// Both this map and the inspected objects in it are mutable.
// They should never be read from directly during render;
// Use a Suspense cache to ensure that transitions work correctly and there is no tearing.
const inspectedElementMap: WeakMap<
Element,
// Maps element ID to inspected data.
// 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this* case

// 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.
const inspectedElementCache: LRUCache<
number,
InspectedElementFrontend,
> = new WeakMap();
> = new LRU({
max: 25,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why only a max of 25?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arbitrary.

});

type Path = Array<string | number>;

Expand Down Expand Up @@ -66,16 +74,18 @@ export function inspectElement({
switch (type) {
case 'no-change':
// This is a no-op for the purposes of our cache.
inspectedElement = inspectedElementMap.get(element);
inspectedElement = inspectedElementCache.get(element.id);
if (inspectedElement != null) {
return [inspectedElement, type];
}
break;

// We should only encounter this case in the event of a bug.
throw Error(`Cached data for element "${id}" not found`);

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

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

Expand All @@ -88,7 +98,7 @@ export function inspectElement({
fullData.value,
);

inspectedElementMap.set(element, inspectedElement);
inspectedElementCache.set(element.id, inspectedElement);

return [inspectedElement, type];

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

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

inspectedElementMap.set(element, inspectedElement);
inspectedElementCache.set(element.id, inspectedElement);

return [inspectedElement, type];
}
Expand Down
1 change: 1 addition & 0 deletions packages/react-devtools-shared/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export type HookNames = Map<HookSourceLocationKey, HookName>;
export type LRUCache<K, V> = {|
get: (key: K) => V,
has: (key: K) => boolean,
remove: (key: K) => void,
reset: () => void,
set: (key: K, value: V) => void,
|};