main
js 294 lines 7.83 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 * as React from 'react';
11
12 import {
13 unstable_getCacheForType as getCacheForType,
14 startTransition,
15 } from 'react';
16 import Store from 'react-devtools-shared/src/devtools/store';
17 import {inspectElement as inspectElementMutableSource} from 'react-devtools-shared/src/inspectedElementMutableSource';
18 import ElementPollingCancellationError from 'react-devtools-shared/src//errors/ElementPollingCancellationError';
19
20 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
21 import type {
22 Thenable,
23 FulfilledThenable,
24 RejectedThenable,
25 } from 'shared/ReactTypes';
26 import type {
27 Element,
28 InspectedElement as InspectedElementFrontend,
29 InspectedElementResponseType,
30 InspectedElementPath,
31 } from 'react-devtools-shared/src/frontend/types';
32
33 function readRecord<T>(record: Thenable<T>): T {
34 if (typeof React.use === 'function') {
35 // eslint-disable-next-line react-hooks-published/rules-of-hooks
36 return React.use(record);
37 }
38 if (record.status === 'fulfilled') {
39 return record.value;
40 } else if (record.status === 'rejected') {
41 throw record.reason;
42 } else {
43 throw record;
44 }
45 }
46
47 type InspectedElementMap = WeakMap<Element, Thenable<InspectedElementFrontend>>;
48 type CacheSeedKey = () => InspectedElementMap;
49
50 function createMap(): InspectedElementMap {
51 return new WeakMap();
52 }
53
54 function getRecordMap(): WeakMap<Element, Thenable<InspectedElementFrontend>> {
55 return getCacheForType(createMap);
56 }
57
58 function createCacheSeed(
59 element: Element,
60 inspectedElement: InspectedElementFrontend,
61 ): [CacheSeedKey, InspectedElementMap] {
62 const thenable: FulfilledThenable<InspectedElementFrontend> = {
63 then(callback: (value: any) => mixed, reject: (error: mixed) => mixed) {
64 callback(thenable.value);
65 },
66 status: 'fulfilled',
67 value: inspectedElement,
68 };
69 const map = createMap();
70 map.set(element, thenable);
71 return [createMap, map];
72 }
73
74 /**
75 * Fetches element props and state from the backend for inspection.
76 * This method should be called during render; it will suspend if data has not yet been fetched.
77 */
78 export function inspectElement(
79 element: Element,
80 path: InspectedElementPath | null,
81 store: Store,
82 bridge: FrontendBridge,
83 ): InspectedElementFrontend | null {
84 const map = getRecordMap();
85 let record = map.get(element);
86 if (!record) {
87 const callbacks = new Set<(value: any) => mixed>();
88 const rejectCallbacks = new Set<(reason: mixed) => mixed>();
89 const thenable: Thenable<InspectedElementFrontend> = {
90 status: 'pending',
91 value: null,
92 reason: null,
93 then(callback: (value: any) => mixed, reject: (error: mixed) => mixed) {
94 callbacks.add(callback);
95 rejectCallbacks.add(reject);
96 },
97
98 // Optional property, read by React to name this I/O in async debug info:
99 displayName: `Inspecting ${element.displayName || 'Unknown'}`,
100 };
101
102 const wake = () => {
103 // This assumes they won't throw.
104 callbacks.forEach(callback => callback((thenable as any).value));
105 callbacks.clear();
106 rejectCallbacks.clear();
107 };
108 const wakeRejections = () => {
109 // This assumes they won't throw.
110 rejectCallbacks.forEach(callback => callback((thenable as any).reason));
111 rejectCallbacks.clear();
112 callbacks.clear();
113 };
114 record = thenable;
115
116 const rendererID = store.getRendererIDForElement(element.id);
117 if (rendererID == null) {
118 const rejectedThenable: RejectedThenable<InspectedElementFrontend> =
119 thenable as any;
120 rejectedThenable.status = 'rejected';
121 rejectedThenable.reason = new Error(
122 `Could not inspect element with id "${element.id}". No renderer found.`,
123 );
124
125 map.set(element, record);
126
127 return null;
128 }
129
130 inspectElementMutableSource(bridge, element, path, rendererID).then(
131 ([inspectedElement]: [
132 InspectedElementFrontend,
133 InspectedElementResponseType,
134 ]) => {
135 const fulfilledThenable: FulfilledThenable<InspectedElementFrontend> =
136 thenable as any;
137 fulfilledThenable.status = 'fulfilled';
138 fulfilledThenable.value = inspectedElement;
139 wake();
140 },
141
142 error => {
143 console.error(error);
144
145 const rejectedThenable: RejectedThenable<InspectedElementFrontend> =
146 thenable as any;
147 rejectedThenable.status = 'rejected';
148 rejectedThenable.reason = error;
149
150 wakeRejections();
151 },
152 );
153
154 map.set(element, record);
155 }
156
157 const response = readRecord(record);
158 return response;
159 }
160
161 type RefreshFunction = (
162 seedKey: CacheSeedKey,
163 cacheMap: InspectedElementMap,
164 ) => void;
165
166 /**
167 * Asks the backend for updated props and state from an expected element.
168 * This method should never be called during render; call it from an effect or event handler.
169 * This method will schedule an update if updated information is returned.
170 */
171 export function checkForUpdate({
172 bridge,
173 element,
174 refresh,
175 store,
176 }: {
177 bridge: FrontendBridge,
178 element: Element,
179 refresh: RefreshFunction,
180 store: Store,
181 }): void | Promise<void> {
182 const {id} = element;
183 const rendererID = store.getRendererIDForElement(id);
184
185 if (rendererID == null) {
186 return;
187 }
188
189 return inspectElementMutableSource(
190 bridge,
191 element,
192 null,
193 rendererID,
194 true,
195 ).then(
196 ([inspectedElement, responseType]: [
197 InspectedElementFrontend,
198 InspectedElementResponseType,
199 ]) => {
200 if (responseType === 'full-data') {
201 startTransition(() => {
202 const [key, value] = createCacheSeed(element, inspectedElement);
203 refresh(key, value);
204 });
205 }
206 },
207 );
208 }
209
210 function createPromiseWhichResolvesInOneSecond() {
211 return new Promise(resolve => setTimeout(resolve, 1000));
212 }
213
214 type PollingStatus = 'idle' | 'running' | 'paused' | 'aborted';
215
216 export function startElementUpdatesPolling({
217 bridge,
218 element,
219 refresh,
220 store,
221 }: {
222 bridge: FrontendBridge,
223 element: Element,
224 refresh: RefreshFunction,
225 store: Store,
226 }): {abort: () => void, pause: () => void, resume: () => void} {
227 let status: PollingStatus = 'idle';
228
229 function abort() {
230 status = 'aborted';
231 }
232
233 function resume() {
234 if (status === 'running' || status === 'aborted') {
235 return;
236 }
237
238 status = 'idle';
239 poll();
240 }
241
242 function pause() {
243 if (status === 'paused' || status === 'aborted') {
244 return;
245 }
246
247 status = 'paused';
248 }
249
250 function poll(): Promise<void> {
251 status = 'running';
252
253 return Promise.allSettled([
254 checkForUpdate({bridge, element, refresh, store}),
255 createPromiseWhichResolvesInOneSecond(),
256 ])
257 .then(([{status: updateStatus, reason}]) => {
258 // There isn't much to do about errors in this case,
259 // but we should at least log them, so they aren't silent.
260 // Log only if polling is still active, we can't handle the case when
261 // request was sent, and then bridge was remounted (for example, when user did navigate to a new page),
262 // but at least we can mark that polling was aborted
263 if (updateStatus === 'rejected' && status !== 'aborted') {
264 // This is expected Promise rejection, no need to log it
265 if (reason instanceof ElementPollingCancellationError) {
266 return;
267 }
268
269 console.error(reason);
270 }
271 })
272 .finally(() => {
273 const shouldContinuePolling =
274 status !== 'aborted' && status !== 'paused';
275
276 status = 'idle';
277
278 if (shouldContinuePolling) {
279 return poll();
280 }
281 });
282 }
283
284 poll();
285
286 return {abort, resume, pause};
287 }
288
289 export function clearCacheBecauseOfError(refresh: RefreshFunction): void {
290 startTransition(() => {
291 const map = createMap();
292 refresh(createMap, map);
293 });
294 }