/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @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/frontend/types';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type {
InspectElementError,
InspectElementFullData,
InspectElementHydratedPath,
} from 'react-devtools-shared/src/backend/types';
import UserError from 'react-devtools-shared/src/errors/UserError';
import UnknownHookError from 'react-devtools-shared/src/errors/UnknownHookError';
import type {
Element,
InspectedElement as InspectedElementFrontend,
InspectedElementResponseType,
InspectedElementPath,
} from 'react-devtools-shared/src/frontend/types';
// 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 this 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.
const inspectedElementCache: LRUCache<number, InspectedElementFrontend> =
new LRU({
max: 25,
});
type InspectElementReturnType = [
InspectedElementFrontend,
InspectedElementResponseType,
];
export function inspectElement(
bridge: FrontendBridge,
element: Element,
path: InspectedElementPath | null,
rendererID: number,
shouldListenToPauseEvents: boolean = false,
): Promise<InspectElementReturnType> {
const {id} = element;
// This could indicate that the DevTools UI has been closed and reopened.
// The in-memory cache will be clear but the backend still thinks we have cached data.
// In this case, we need to tell it to resend the full data.
const forceFullData = !inspectedElementCache.has(id);
return inspectElementAPI(
bridge,
forceFullData,
id,
path,
rendererID,
shouldListenToPauseEvents,
).then((data: any) => {
const {type} = data;
let inspectedElement;
switch (type) {
case 'error': {
const {message, stack, errorType} = ((data: any): InspectElementError);
// create a different error class for each error type
// and keep useful information from backend.
let error;
if (errorType === 'user') {
error = new UserError(message);
} else if (errorType === 'unknown-hook') {
error = new UnknownHookError(message);
} else {
error = new Error(message);
}
// The backend's stack (where the error originated) is more meaningful than this stack.
error.stack = stack || error.stack;
throw error;
}
case 'no-change':
// This is a no-op for the purposes of our cache.
inspectedElement = inspectedElementCache.get(id);
if (inspectedElement != null) {
return [inspectedElement, type];
}
// 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.
inspectedElementCache.del(id);
throw Error(`Element "${id}" not found`);
case 'full-data':
const fullData = ((data: any): InspectElementFullData);
// New data has come in.
// We should replace the data in our local mutable copy.
inspectedElement = convertInspectedElementBackendToFrontend(
fullData.value,
);
inspectedElementCache.set(id, inspectedElement);
return [inspectedElement, type];
case 'hydrated-path':
const hydratedPathData = ((data: any): InspectElementHydratedPath);
const {value} = hydratedPathData;
// A path has been hydrated.
// Merge it with the latest copy we have locally and resolve with the merged value.
inspectedElement = inspectedElementCache.get(id) || null;
if (inspectedElement !== null) {
// Clone element
inspectedElement = {...inspectedElement};
// Merge hydrated data
if (path != null) {
fillInPath(
inspectedElement,
value,
path,
hydrateHelper(value, path),
);
}
inspectedElementCache.set(id, inspectedElement);
return [inspectedElement, type];
}
break;
default:
// Should never happen.
if (__DEV__) {
console.error(
`Unexpected inspected element response data: "${type}"`,
);
}
break;
}
throw Error(`Unable to inspect element with id "${id}"`);
});
}
export function clearCacheForTests(): void {
inspectedElementCache.reset();
}