/** * 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 strict-local * @format * @oncall react_native */ import type ReactNativeElement from '../../src/private/webapis/dom/nodes/ReactNativeElement'; import type ReadOnlyElement from '../../src/private/webapis/dom/nodes/ReadOnlyElement'; import type { AppContainerRootViewRef, DebuggingOverlayRef, } from '../ReactNative/AppContainer-dev'; import type {NativeMethods} from '../Renderer/shims/ReactNativeTypes'; import type { InstanceFromReactDevTools, ReactDevToolsAgent, ReactDevToolsAgentEvents, ReactDevToolsGlobalHook, } from '../Types/ReactDevToolsTypes'; import type { ElementRectangle, TraceUpdate, } from './DebuggingOverlayNativeComponent'; import { findNodeHandle, isChildPublicInstance, } from '../ReactNative/RendererProxy'; import processColor from '../StyleSheet/processColor'; // TODO(T171193075): __REACT_DEVTOOLS_GLOBAL_HOOK__ is always injected in dev-bundles, // but it is not mocked in some Jest tests. We should update Jest tests setup, so it would be the same as expected testing environment. const reactDevToolsHook: ?ReactDevToolsGlobalHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; export type DebuggingOverlayRegistrySubscriberProtocol = { rootViewRef: AppContainerRootViewRef, debuggingOverlayRef: DebuggingOverlayRef, }; type ModernNodeUpdate = { id: number, instance: ReactNativeElement, color: string, }; type LegacyNodeUpdate = { id: number, instance: NativeMethods, color: string, }; class DebuggingOverlayRegistry { #registry: Set = new Set(); #reactDevToolsAgent: ReactDevToolsAgent | null = null; constructor() { if (reactDevToolsHook?.reactDevtoolsAgent != null) { this.#onReactDevToolsAgentAttached(reactDevToolsHook.reactDevtoolsAgent); } // There could be cases when frontend is disconnected and then connected again for the same React Native runtime. reactDevToolsHook?.on?.( 'react-devtools', this.#onReactDevToolsAgentAttached, ); } subscribe(subscriber: DebuggingOverlayRegistrySubscriberProtocol) { this.#registry.add(subscriber); } unsubscribe(subscriber: DebuggingOverlayRegistrySubscriberProtocol) { const wasPresent = this.#registry.delete(subscriber); if (!wasPresent) { console.error( '[DebuggingOverlayRegistry] Unexpected argument for unsubscription, which was not previously subscribed:', subscriber, ); } } #onReactDevToolsAgentAttached = (agent: ReactDevToolsAgent): void => { this.#reactDevToolsAgent = agent; agent.addListener('drawTraceUpdates', this.#onDrawTraceUpdates); agent.addListener('showNativeHighlight', this.#onHighlightElements); agent.addListener('hideNativeHighlight', this.#onClearElementsHighlights); }; #getPublicInstanceFromInstance = ( instanceHandle: InstanceFromReactDevTools, ): NativeMethods | null => { // `canonical.publicInstance` => Fabric if (instanceHandle.canonical?.publicInstance != null) { return instanceHandle.canonical?.publicInstance; } // `canonical` => Legacy Fabric if (instanceHandle.canonical != null) { // $FlowFixMe[incompatible-return] return instanceHandle.canonical; } // `instanceHandle` => Legacy renderer if (instanceHandle.measure != null) { // $FlowFixMe[incompatible-return] return instanceHandle; } return null; }; #findLowestParentFromRegistryForInstance( instance: ReactNativeElement, ): ?DebuggingOverlayRegistrySubscriberProtocol { let iterator: ?ReadOnlyElement = instance; while (iterator != null) { for (const subscriber of this.#registry) { if (subscriber.rootViewRef.current === iterator) { return subscriber; } } iterator = iterator.parentElement; } return null; } #findLowestParentFromRegistryForInstanceLegacy( instance: NativeMethods, ): ?DebuggingOverlayRegistrySubscriberProtocol { const candidates: Array = []; for (const subscriber of this.#registry) { if ( subscriber.rootViewRef.current != null && // $FlowFixMe[incompatible-call] There is a lot of stuff to untangle to make types for refs work. isChildPublicInstance(subscriber.rootViewRef.current, instance) ) { candidates.push(subscriber); } } if (candidates.length === 0) { // In some cases, like with LogBox in custom integrations, the whole subtree for specific React root might not have an AppContainer. return null; } if (candidates.length === 1) { return candidates[0]; } // If there are multiple candidates, we need to find the lowest. // Imagine the case when there is a modal on the screen, both of them will have their own AppContainers, // but modal's AppContainer is a child of screen's AppContainer. const candidatesWithNoChildren: Array = []; for (const potentialParent of candidates) { let shouldSkipThisParent = false; if (potentialParent.rootViewRef.current == null) { continue; } for (const potentialChild of candidates) { if (potentialChild === potentialParent) { continue; } if (potentialChild.rootViewRef.current == null) { continue; } if ( isChildPublicInstance( // $FlowFixMe[incompatible-call] There is a lot of stuff to untangle to make types for refs work. potentialParent.rootViewRef.current, // $FlowFixMe[incompatible-call] There is a lot of stuff to untangle to make types for refs work. potentialChild.rootViewRef.current, ) ) { shouldSkipThisParent = true; break; } } if (!shouldSkipThisParent) { candidatesWithNoChildren.push(potentialParent); } } if (candidatesWithNoChildren.length === 0) { console.error( '[DebuggingOverlayRegistry] Unexpected circular relationship between AppContainers', ); return null; } else if (candidatesWithNoChildren.length > 1) { console.error( '[DebuggingOverlayRegistry] Unexpected multiple options for lowest parent AppContainer', ); return null; } return candidatesWithNoChildren[0]; } #onDrawTraceUpdates: ( ...ReactDevToolsAgentEvents['drawTraceUpdates'] ) => void = traceUpdates => { const modernNodesUpdates: Array = []; const legacyNodesUpdates: Array = []; for (const {node, color} of traceUpdates) { const publicInstance = this.#getPublicInstanceFromInstance(node); if (publicInstance == null) { return; } const instanceReactTag = findNodeHandle(node); if (instanceReactTag == null) { return; } // Lazy import to avoid dependency cycle. const ReactNativeElementClass = require('../../src/private/webapis/dom/nodes/ReactNativeElement').default; if (publicInstance instanceof ReactNativeElementClass) { modernNodesUpdates.push({ id: instanceReactTag, instance: publicInstance, color, }); } else { legacyNodesUpdates.push({ id: instanceReactTag, instance: publicInstance, color, }); } } if (modernNodesUpdates.length > 0) { this.#drawTraceUpdatesModern(modernNodesUpdates); } if (legacyNodesUpdates.length > 0) { this.#drawTraceUpdatesLegacy(legacyNodesUpdates); } }; #drawTraceUpdatesModern(updates: Array): void { const parentToTraceUpdatesMap = new Map< DebuggingOverlayRegistrySubscriberProtocol, Array, >(); for (const {id, instance, color} of updates) { const parent = this.#findLowestParentFromRegistryForInstance(instance); if (parent == null) { continue; } let traceUpdatesForParent = parentToTraceUpdatesMap.get(parent); if (traceUpdatesForParent == null) { traceUpdatesForParent = []; parentToTraceUpdatesMap.set(parent, traceUpdatesForParent); } const {x, y, width, height} = instance.getBoundingClientRect(); const rootViewInstance = parent.rootViewRef.current; if (rootViewInstance == null) { continue; } const {x: parentX, y: parentY} = // $FlowFixMe[prop-missing] React Native View is not a descendant of ReactNativeElement yet. We should be able to remove it once Paper is no longer supported. rootViewInstance.getBoundingClientRect(); // DebuggingOverlay will scale to the same size as a Root view. Substract Root view position from the element position // to calculate the element's position relatively to its parent DebuggingOverlay. // We can't call `getBoundingClientRect` on the debuggingOverlayRef, because its a ref for the native component, which doesn't have it, hopefully yet. traceUpdatesForParent.push({ id, rectangle: {x: x - parentX, y: y - parentY, width, height}, color: processColor(color), }); } for (const [parent, traceUpdates] of parentToTraceUpdatesMap.entries()) { const {debuggingOverlayRef} = parent; debuggingOverlayRef.current?.highlightTraceUpdates(traceUpdates); } } // TODO: remove once DOM Node APIs are opt-in by default and Paper is no longer supported. #drawTraceUpdatesLegacy(updates: Array): void { const parentToTraceUpdatesPromisesMap = new Map< DebuggingOverlayRegistrySubscriberProtocol, Array>, >(); for (const {id, instance, color} of updates) { const parent = this.#findLowestParentFromRegistryForInstanceLegacy(instance); if (parent == null) { continue; } let traceUpdatesPromisesForParent = parentToTraceUpdatesPromisesMap.get(parent); if (traceUpdatesPromisesForParent == null) { traceUpdatesPromisesForParent = []; parentToTraceUpdatesPromisesMap.set( parent, traceUpdatesPromisesForParent, ); } const frameToDrawPromise = new Promise((resolve, reject) => { instance.measure((x, y, width, height, left, top) => { // measure can execute callback without any values provided to signal error. if (left == null || top == null || width == null || height == null) { reject('Unexpectedly failed to call measure on an instance.'); } resolve({ id, rectangle: {x: left, y: top, width, height}, color: processColor(color), }); }); }); traceUpdatesPromisesForParent.push(frameToDrawPromise); } for (const [ parent, traceUpdatesPromises, ] of parentToTraceUpdatesPromisesMap.entries()) { Promise.all(traceUpdatesPromises) .then(resolvedTraceUpdates => parent.debuggingOverlayRef.current?.highlightTraceUpdates( resolvedTraceUpdates, ), ) .catch(() => { // noop. For legacy architecture (Paper) this can happen for root views or LogBox button. // LogBox case: it has a separate React root, so `measure` fails. // Calling `console.error` here would trigger rendering a new LogBox button, for which we will call measure again, this is a cycle. // Don't spam the UI with errors for such cases. }); } } #onHighlightElements: ( ...ReactDevToolsAgentEvents['showNativeHighlight'] ) => void = nodes => { // First clear highlights for every container for (const subscriber of this.#registry) { subscriber.debuggingOverlayRef.current?.clearElementsHighlight(); } // Lazy import to avoid dependency cycle. const ReactNativeElementClass = require('../../src/private/webapis/dom/nodes/ReactNativeElement').default; const reactNativeElements: Array = []; const legacyPublicInstances: Array = []; for (const node of nodes) { const publicInstance = this.#getPublicInstanceFromInstance(node); if (publicInstance == null) { continue; } if (publicInstance instanceof ReactNativeElementClass) { reactNativeElements.push(publicInstance); } else { legacyPublicInstances.push(publicInstance); } } if (reactNativeElements.length > 0) { this.#onHighlightElementsModern(reactNativeElements); } if (legacyPublicInstances.length > 0) { this.#onHighlightElementsLegacy(legacyPublicInstances); } }; #onHighlightElementsModern(elements: Array): void { const parentToElementsMap = new Map< DebuggingOverlayRegistrySubscriberProtocol, Array, >(); for (const element of elements) { const parent = this.#findLowestParentFromRegistryForInstance(element); if (parent == null) { continue; } let childElementOfAParent = parentToElementsMap.get(parent); if (childElementOfAParent == null) { childElementOfAParent = []; parentToElementsMap.set(parent, childElementOfAParent); } childElementOfAParent.push(element); } for (const [parent, elementsToHighlight] of parentToElementsMap.entries()) { const rootViewInstance = parent.rootViewRef.current; if (rootViewInstance == null) { return; } const {x: parentX, y: parentY} = // $FlowFixMe[prop-missing] React Native View is not a descendant of ReactNativeElement yet. We should be able to remove it once Paper is no longer supported. rootViewInstance.getBoundingClientRect(); // DebuggingOverlay will scale to the same size as a Root view. Substract Root view position from the element position // to calculate the element's position relatively to its parent DebuggingOverlay. // We can't call `getBoundingClientRect` on the debuggingOverlayRef, because its a ref for the native component, which doesn't have it, hopefully yet. const elementsRectangles = elementsToHighlight.map(element => { const {x, y, width, height} = element.getBoundingClientRect(); return {x: x - parentX, y: y - parentY, width, height}; }); parent.debuggingOverlayRef.current?.highlightElements(elementsRectangles); } } // TODO: remove once DOM Node APIs are opt-in by default and Paper is no longer supported. #onHighlightElementsLegacy(elements: Array): void { const parentToElementsMap = new Map< DebuggingOverlayRegistrySubscriberProtocol, Array, >(); for (const element of elements) { const parent = this.#findLowestParentFromRegistryForInstanceLegacy(element); if (parent == null) { continue; } let childElementOfAParent = parentToElementsMap.get(parent); if (childElementOfAParent == null) { childElementOfAParent = []; parentToElementsMap.set(parent, childElementOfAParent); } childElementOfAParent.push(element); } for (const [parent, elementsToHighlight] of parentToElementsMap.entries()) { const promises = elementsToHighlight.map( element => new Promise((resolve, reject) => { element.measure((x, y, width, height, left, top) => { // measure can execute callback without any values provided to signal error. if ( left == null || top == null || width == null || height == null ) { reject('Unexpectedly failed to call measure on an instance.'); } resolve({x: left, y: top, width, height}); }); }), ); Promise.all(promises) .then(resolvedElementsRectangles => parent.debuggingOverlayRef.current?.highlightElements( resolvedElementsRectangles, ), ) .catch(() => { // noop. For legacy architecture (Paper) this can happen for root views or LogBox button. // LogBox case: it has a separate React root, so `measure` fails. // Calling `console.error` here would trigger rendering a new LogBox button, for which we will call measure again, this is a cycle. // Don't spam the UI with errors for such cases. }); } } #onClearElementsHighlights: ( ...ReactDevToolsAgentEvents['hideNativeHighlight'] ) => void = () => { for (const subscriber of this.#registry) { subscriber.debuggingOverlayRef.current?.clearElementsHighlight(); } }; } const debuggingOverlayRegistryInstance: DebuggingOverlayRegistry = new DebuggingOverlayRegistry(); export default debuggingOverlayRegistryInstance;