/** * 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 {__DEBUG__} from 'react-devtools-shared/src/constants'; import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; import type {Thenable, Wakeable} from 'shared/ReactTypes'; import type { Element, HookNames, HookSourceLocationKey, } from 'react-devtools-shared/src/frontend/types'; import type {HookSource} from 'react-debug-tools/src/ReactDebugHooks'; import type {FetchFileWithCaching} from 'react-devtools-shared/src/devtools/views/Components/FetchFileWithCachingContext'; import {withCallbackPerfMeasurements} from './PerformanceLoggingUtils'; import {logEvent} from './Logger'; const TIMEOUT = 30000; const Pending = 0; const Resolved = 1; const Rejected = 2; type PendingRecord = { status: 0, value: Wakeable, }; type ResolvedRecord = { status: 1, value: T, }; type RejectedRecord = { status: 2, value: null, }; type Record = PendingRecord | ResolvedRecord | RejectedRecord; function readRecord(record: Record): ResolvedRecord | RejectedRecord { if (record.status === Resolved) { // This is just a type refinement. return record; } else if (record.status === Rejected) { // This is just a type refinement. return record; } else { throw record.value; } } type LoadHookNamesFunction = ( hookLog: HooksTree, fetchFileWithCaching: FetchFileWithCaching | null, ) => Thenable; // This is intentionally a module-level Map, rather than a React-managed one. // Otherwise, refreshing the inspected element cache would also clear this cache. // TODO Rethink this if the React API constraints change. // See https://github.com/reactwg/react-18/discussions/25#discussioncomment-980435 let map: WeakMap> = new WeakMap(); export function hasAlreadyLoadedHookNames(element: Element): boolean { const record = map.get(element); return record != null && record.status === Resolved; } export function getAlreadyLoadedHookNames(element: Element): HookNames | null { const record = map.get(element); if (record != null && record.status === Resolved) { return record.value; } return null; } export function loadHookNames( element: Element, hooksTree: HooksTree, loadHookNamesFunction: LoadHookNamesFunction, fetchFileWithCaching: FetchFileWithCaching | null, ): HookNames | null { let record = map.get(element); if (__DEBUG__) { console.groupCollapsed('loadHookNames() record:'); console.log(record); console.groupEnd(); } if (!record) { const callbacks = new Set<() => mixed>(); const wakeable: Wakeable = { then(callback: () => mixed) { callbacks.add(callback); }, // Optional property used by Timeline: displayName: `Loading hook names for ${element.displayName || 'Unknown'}`, }; let timeoutID: $FlowFixMe | null; let didTimeout = false; let status = 'unknown'; let resolvedHookNames: HookNames | null = null; const wake = () => { if (timeoutID) { clearTimeout(timeoutID); timeoutID = null; } // This assumes they won't throw. callbacks.forEach(callback => callback()); callbacks.clear(); }; const handleLoadComplete = (durationMs: number): void => { // Log duration for parsing hook names logEvent({ event_name: 'load-hook-names', event_status: status, duration_ms: durationMs, inspected_element_display_name: element.displayName, inspected_element_number_of_hooks: resolvedHookNames?.size ?? null, }); }; const newRecord: Record = (record = { status: Pending, value: wakeable, }); withCallbackPerfMeasurements( 'loadHookNames', done => { loadHookNamesFunction(hooksTree, fetchFileWithCaching).then( function onSuccess(hookNames) { if (didTimeout) { return; } if (__DEBUG__) { console.log('[hookNamesCache] onSuccess() hookNames:', hookNames); } if (hookNames) { const resolvedRecord = ((newRecord: any): ResolvedRecord); resolvedRecord.status = Resolved; resolvedRecord.value = hookNames; } else { const notFoundRecord = ((newRecord: any): RejectedRecord); notFoundRecord.status = Rejected; notFoundRecord.value = null; } status = 'success'; resolvedHookNames = hookNames; done(); wake(); }, function onError(error) { if (didTimeout) { return; } if (__DEBUG__) { console.log('[hookNamesCache] onError()'); } console.error(error); const thrownRecord = ((newRecord: any): RejectedRecord); thrownRecord.status = Rejected; thrownRecord.value = null; status = 'error'; done(); wake(); }, ); // Eventually timeout and stop trying to load names. timeoutID = setTimeout(function onTimeout() { if (__DEBUG__) { console.log('[hookNamesCache] onTimeout()'); } timeoutID = null; didTimeout = true; const timedoutRecord = ((newRecord: any): RejectedRecord); timedoutRecord.status = Rejected; timedoutRecord.value = null; status = 'timeout'; done(); wake(); }, TIMEOUT); }, handleLoadComplete, ); map.set(element, record); } const response = readRecord(record).value; return response; } export function getHookSourceLocationKey({ fileName, lineNumber, columnNumber, }: HookSource): HookSourceLocationKey { if (fileName == null || lineNumber == null || columnNumber == null) { throw Error('Hook source code location not found.'); } return `${fileName}:${lineNumber}:${columnNumber}`; } export function clearHookNamesCache(): void { map = new WeakMap(); }