main
js 183 lines 5.9 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and 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 import LRU from 'lru-cache';
11 import {
12 convertInspectedElementBackendToFrontend,
13 hydrateHelper,
14 inspectElement as inspectElementAPI,
15 inspectScreen as inspectScreenAPI,
16 } from 'react-devtools-shared/src/backendAPI';
17 import {fillInPath} from 'react-devtools-shared/src/hydration';
18
19 import type {LRUCache} from 'react-devtools-shared/src/frontend/types';
20 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
21 import type {
22 InspectElementError,
23 InspectElementFullData,
24 InspectElementHydratedPath,
25 } from 'react-devtools-shared/src/backend/types';
26 import UserError from 'react-devtools-shared/src/errors/UserError';
27 import UnknownHookError from 'react-devtools-shared/src/errors/UnknownHookError';
28 import type {
29 Element,
30 InspectedElement as InspectedElementFrontend,
31 InspectedElementResponseType,
32 InspectedElementPath,
33 } from 'react-devtools-shared/src/frontend/types';
34
35 // Maps element ID to inspected data.
36 // We use an LRU for this rather than a WeakMap because of how the "no-change" optimization works.
37 // When the frontend polls the backend for an update on the element that's currently inspected,
38 // the backend will send a "no-change" message if the element hasn't updated (rendered) since the last time it was asked.
39 // In this case, the frontend cache should reuse the previous (cached) value.
40 // Using a WeakMap keyed on Element generally works well for this, since Elements are mutable and stable in the Store.
41 // This doens't work properly though when component filters are changed,
42 // because this will cause the Store to dump all roots and re-initialize the tree (recreating the Element objects).
43 // So instead we key on Element ID (which is stable in this case) and use an LRU for eviction.
44 const inspectedElementCache: LRUCache<number, InspectedElementFrontend> =
45 new LRU({
46 max: 25,
47 });
48
49 type InspectElementReturnType = [
50 InspectedElementFrontend,
51 InspectedElementResponseType,
52 ];
53
54 export function inspectElement(
55 bridge: FrontendBridge,
56 element: Element,
57 path: InspectedElementPath | null,
58 rendererID: number,
59 shouldListenToPauseEvents: boolean = false,
60 ): Promise<InspectElementReturnType> {
61 const {id, parentID} = element;
62
63 // This could indicate that the DevTools UI has been closed and reopened.
64 // The in-memory cache will be clear but the backend still thinks we have cached data.
65 // In this case, we need to tell it to resend the full data.
66 const forceFullData = !inspectedElementCache.has(id);
67 const isRoot = parentID === 0;
68 const promisedElement = isRoot
69 ? inspectScreenAPI(
70 bridge,
71 forceFullData,
72 id,
73 path,
74 shouldListenToPauseEvents,
75 )
76 : inspectElementAPI(
77 bridge,
78 forceFullData,
79 id,
80 path,
81 rendererID,
82 shouldListenToPauseEvents,
83 );
84
85 return promisedElement.then((data: any) => {
86 const {type} = data;
87
88 let inspectedElement;
89 switch (type) {
90 case 'error': {
91 const {message, stack, errorType} = data as any as InspectElementError;
92
93 // create a different error class for each error type
94 // and keep useful information from backend.
95 let error;
96 if (errorType === 'user') {
97 error = new UserError(message);
98 } else if (errorType === 'unknown-hook') {
99 error = new UnknownHookError(message);
100 } else {
101 error = new Error(message);
102 }
103 // The backend's stack (where the error originated) is more meaningful than this stack.
104 error.stack = stack || error.stack;
105
106 throw error;
107 }
108
109 case 'no-change':
110 // This is a no-op for the purposes of our cache.
111 inspectedElement = inspectedElementCache.get(id);
112 if (inspectedElement != null) {
113 return [inspectedElement, type];
114 }
115
116 // We should only encounter this case in the event of a bug.
117 throw Error(`Cached data for element "${id}" not found`);
118
119 case 'not-found':
120 // This is effectively a no-op.
121 // If the Element is still in the Store, we can eagerly remove it from the Map.
122 inspectedElementCache.del(id);
123
124 throw Error(`Element "${id}" not found`);
125
126 case 'full-data':
127 const fullData = data as any as InspectElementFullData;
128
129 // New data has come in.
130 // We should replace the data in our local mutable copy.
131 inspectedElement = convertInspectedElementBackendToFrontend(
132 fullData.value,
133 );
134
135 inspectedElementCache.set(id, inspectedElement);
136
137 return [inspectedElement, type];
138
139 case 'hydrated-path':
140 const hydratedPathData = data as any as InspectElementHydratedPath;
141 const {value} = hydratedPathData;
142
143 // A path has been hydrated.
144 // Merge it with the latest copy we have locally and resolve with the merged value.
145 inspectedElement = inspectedElementCache.get(id) || null;
146 // $FlowFixMe[invalid-compare]
147 if (inspectedElement !== null) {
148 // Clone element
149 inspectedElement = {...inspectedElement};
150
151 // Merge hydrated data
152 if (path != null) {
153 fillInPath(
154 inspectedElement,
155 value,
156 path,
157 hydrateHelper(value, path),
158 );
159 }
160
161 inspectedElementCache.set(id, inspectedElement);
162
163 return [inspectedElement, type];
164 }
165 break;
166
167 default:
168 // Should never happen.
169 if (__DEV__) {
170 console.error(
171 `Unexpected inspected element response data: "${type}"`,
172 );
173 }
174 break;
175 }
176
177 throw Error(`Unable to inspect element with id "${id}"`);
178 });
179 }
180
181 export function clearCacheForTests(): void {
182 inspectedElementCache.reset();
183 }