main
js 234 lines 6.72 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 {__DEBUG__} from 'react-devtools-shared/src/constants';
11
12 import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
13 import type {
14 Thenable,
15 FulfilledThenable,
16 RejectedThenable,
17 } from 'shared/ReactTypes';
18 import type {
19 Element,
20 HookNames,
21 } from 'react-devtools-shared/src/frontend/types';
22 import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext';
23
24 import * as React from 'react';
25
26 import {withCallbackPerfMeasurements} from './PerformanceLoggingUtils';
27 import {logEvent} from './Logger';
28
29 const TIMEOUT = 30000;
30 function readRecord<T>(record: Thenable<T>): T | null {
31 if (typeof React.use === 'function') {
32 try {
33 // eslint-disable-next-line react-hooks-published/rules-of-hooks
34 return React.use(record);
35 } catch (x) {
36 if (record.status === 'rejected') {
37 return null;
38 }
39 throw x;
40 }
41 }
42 if (record.status === 'fulfilled') {
43 return record.value;
44 } else if (record.status === 'rejected') {
45 return null;
46 } else {
47 throw record;
48 }
49 }
50
51 type LoadHookNamesFunction = (
52 hookLog: HooksTree,
53 fetchFileWithCaching: FetchFileWithCaching | null,
54 ) => Thenable<HookNames>;
55
56 // This is intentionally a module-level Map, rather than a React-managed one.
57 // Otherwise, refreshing the inspected element cache would also clear this cache.
58 // TODO Rethink this if the React API constraints change.
59 // See https://github.com/reactwg/react-18/discussions/25#discussioncomment-980435
60 let map: WeakMap<Element, Thenable<HookNames>> = new WeakMap();
61
62 export function hasAlreadyLoadedHookNames(element: Element): boolean {
63 const record = map.get(element);
64 return record != null && record.status === 'fulfilled';
65 }
66
67 export function getAlreadyLoadedHookNames(element: Element): HookNames | null {
68 const record = map.get(element);
69 if (record != null && record.status === 'fulfilled') {
70 return record.value;
71 }
72 return null;
73 }
74
75 export function loadHookNames(
76 element: Element,
77 hooksTree: HooksTree,
78 loadHookNamesFunction: LoadHookNamesFunction,
79 fetchFileWithCaching: FetchFileWithCaching | null,
80 ): HookNames | null {
81 let record = map.get(element);
82
83 // $FlowFixMe[constant-condition]
84 if (__DEBUG__) {
85 console.groupCollapsed('loadHookNames() record:');
86 console.log(record);
87 console.groupEnd();
88 }
89
90 if (!record) {
91 const callbacks = new Set<(value: any) => mixed>();
92 const rejectCallbacks = new Set<(reason: mixed) => mixed>();
93 const thenable: Thenable<HookNames> = {
94 status: 'pending',
95 value: null,
96 reason: null,
97 then(callback: (value: any) => mixed, reject: (error: mixed) => mixed) {
98 callbacks.add(callback);
99 rejectCallbacks.add(reject);
100 },
101
102 // Optional property, read by React to name this I/O in async debug info:
103 displayName: `Loading hook names for ${element.displayName || 'Unknown'}`,
104 };
105
106 let timeoutID: $FlowFixMe | null;
107 let didTimeout = false;
108 let status: 'success' | 'error' | 'timeout' | 'unknown' = 'unknown';
109 let resolvedHookNames: HookNames | null = null;
110
111 const wake = () => {
112 if (timeoutID) {
113 clearTimeout(timeoutID);
114 timeoutID = null;
115 }
116
117 // This assumes they won't throw.
118 callbacks.forEach(callback => callback((thenable as any).value));
119 callbacks.clear();
120 rejectCallbacks.clear();
121 };
122 const wakeRejections = () => {
123 if (timeoutID) {
124 clearTimeout(timeoutID);
125 timeoutID = null;
126 }
127 // This assumes they won't throw.
128 rejectCallbacks.forEach(callback => callback((thenable as any).reason));
129 rejectCallbacks.clear();
130 callbacks.clear();
131 };
132
133 const handleLoadComplete = (durationMs: number): void => {
134 // Log duration for parsing hook names
135 logEvent({
136 event_name: 'load-hook-names',
137 event_status: status,
138 duration_ms: durationMs,
139 inspected_element_display_name: element.displayName,
140 inspected_element_number_of_hooks: resolvedHookNames?.size ?? null,
141 });
142 };
143
144 record = thenable;
145
146 withCallbackPerfMeasurements(
147 'loadHookNames',
148 done => {
149 loadHookNamesFunction(hooksTree, fetchFileWithCaching).then(
150 function onSuccess(hookNames) {
151 if (didTimeout) {
152 return;
153 }
154
155 // $FlowFixMe[constant-condition]
156 if (__DEBUG__) {
157 console.log('[hookNamesCache] onSuccess() hookNames:', hookNames);
158 }
159
160 if (hookNames) {
161 const fulfilledThenable: FulfilledThenable<HookNames> =
162 thenable as any;
163 fulfilledThenable.status = 'fulfilled';
164 fulfilledThenable.value = hookNames;
165 status = 'success';
166 resolvedHookNames = hookNames;
167 done();
168 wake();
169 } else {
170 const notFoundThenable: RejectedThenable<HookNames> =
171 thenable as any;
172 notFoundThenable.status = 'rejected';
173 notFoundThenable.reason = null;
174 status = 'error';
175 resolvedHookNames = hookNames;
176 done();
177 wakeRejections();
178 }
179 },
180 function onError(error) {
181 if (didTimeout) {
182 return;
183 }
184
185 // $FlowFixMe[constant-condition]
186 if (__DEBUG__) {
187 console.log('[hookNamesCache] onError()');
188 }
189
190 console.error(error);
191
192 const rejectedThenable: RejectedThenable<HookNames> =
193 thenable as any;
194 rejectedThenable.status = 'rejected';
195 rejectedThenable.reason = null;
196
197 status = 'error';
198 done();
199 wakeRejections();
200 },
201 );
202
203 // Eventually timeout and stop trying to load names.
204 timeoutID = setTimeout(function onTimeout() {
205 // $FlowFixMe[constant-condition]
206 if (__DEBUG__) {
207 console.log('[hookNamesCache] onTimeout()');
208 }
209
210 timeoutID = null;
211
212 didTimeout = true;
213
214 const timedoutThenable: RejectedThenable<HookNames> = thenable as any;
215 timedoutThenable.status = 'rejected';
216 timedoutThenable.reason = null;
217
218 status = 'timeout';
219 done();
220 wakeRejections();
221 }, TIMEOUT);
222 },
223 handleLoadComplete,
224 );
225 map.set(element, record);
226 }
227
228 const response = readRecord(record);
229 return response;
230 }
231
232 export function clearHookNamesCache(): void {
233 map = new WeakMap();
234 }