diff --git a/packages/react-native/Libraries/Debugging/DebuggingOverlayRegistry.js b/packages/react-native/Libraries/Debugging/DebuggingOverlayRegistry.js index 67e9fbe4af1..bfd89b1dc8e 100644 --- a/packages/react-native/Libraries/Debugging/DebuggingOverlayRegistry.js +++ b/packages/react-native/Libraries/Debugging/DebuggingOverlayRegistry.js @@ -22,7 +22,10 @@ import type { ReactDevToolsAgentEvents, ReactDevToolsGlobalHook, } from '../Types/ReactDevToolsTypes'; -import type {TraceUpdate} from './DebuggingOverlayNativeComponent'; +import type { + ElementRectangle, + TraceUpdate, +} from './DebuggingOverlayNativeComponent'; import { findNodeHandle, @@ -90,9 +93,9 @@ class DebuggingOverlayRegistry { agent.addListener('hideNativeHighlight', this.#onClearElementsHighlights); }; - #getPublicInstanceFromInstance( + #getPublicInstanceFromInstance = ( instanceHandle: InstanceFromReactDevTools, - ): NativeMethods | null { + ): NativeMethods | null => { // `canonical.publicInstance` => Fabric if (instanceHandle.canonical?.publicInstance != null) { return instanceHandle.canonical?.publicInstance; @@ -111,7 +114,7 @@ class DebuggingOverlayRegistry { } return null; - } + }; #findLowestParentFromRegistryForInstance( instance: ReactNativeElement, @@ -360,34 +363,63 @@ class DebuggingOverlayRegistry { #onHighlightElements: ( ...ReactDevToolsAgentEvents['showNativeHighlight'] - ) => void = node => { + ) => void = nodes => { // First clear highlights for every container for (const subscriber of this.#registry) { subscriber.debuggingOverlayRef.current?.clearElementsHighlight(); } - const publicInstance = this.#getPublicInstanceFromInstance(node); - if (publicInstance == null) { - return; - } - // Lazy import to avoid dependency cycle. const ReactNativeElementClass = require('../DOM/Nodes/ReactNativeElement').default; - if (publicInstance instanceof ReactNativeElementClass) { - this.#onHighlightElementsModern(publicInstance); - } else { - this.#onHighlightElementsLegacy(publicInstance); + + 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(publicInstance: ReactNativeElement): void { - const {x, y, width, height} = publicInstance.getBoundingClientRect(); + #onHighlightElementsModern(elements: Array): void { + const parentToElementsMap = new Map< + DebuggingOverlayRegistrySubscriberProtocol, + Array, + >(); - const parent = - this.#findLowestParentFromRegistryForInstance(publicInstance); + for (const element of elements) { + const parent = this.#findLowestParentFromRegistryForInstance(element); + if (parent == null) { + continue; + } - if (parent) { + 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; @@ -400,28 +432,70 @@ class DebuggingOverlayRegistry { // 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. - parent.debuggingOverlayRef.current?.highlightElements([ - {x: x - parentX, y: y - parentY, width, height}, - ]); + 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(publicInstance: NativeMethods): void { - const container = - this.#findLowestParentFromRegistryForInstanceLegacy(publicInstance); + #onHighlightElementsLegacy(elements: Array): void { + const parentToElementsMap = new Map< + DebuggingOverlayRegistrySubscriberProtocol, + Array, + >(); - if (container != null) { - publicInstance.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) { - return; - } + for (const element of elements) { + const parent = + this.#findLowestParentFromRegistryForInstanceLegacy(element); + if (parent == null) { + continue; + } - container.debuggingOverlayRef.current?.highlightElements([ - {x: left, y: top, width, height}, - ]); - }); + 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. + }); } } diff --git a/packages/react-native/Libraries/Types/ReactDevToolsTypes.js b/packages/react-native/Libraries/Types/ReactDevToolsTypes.js index 6252c69f205..9772b01c4bd 100644 --- a/packages/react-native/Libraries/Types/ReactDevToolsTypes.js +++ b/packages/react-native/Libraries/Types/ReactDevToolsTypes.js @@ -29,7 +29,7 @@ export type ReactDevToolsAgentEvents = { drawTraceUpdates: [Array<{node: InstanceFromReactDevTools, color: string}>], disableTraceUpdates: [], - showNativeHighlight: [node: InstanceFromReactDevTools], + showNativeHighlight: [nodes: Array], hideNativeHighlight: [], shutdown: [], startInspectingNative: [], diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index 43014acb422..26de514b788 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -9482,7 +9482,7 @@ export type InstanceFromReactDevTools = export type ReactDevToolsAgentEvents = { drawTraceUpdates: [Array<{ node: InstanceFromReactDevTools, color: string }>], disableTraceUpdates: [], - showNativeHighlight: [node: InstanceFromReactDevTools], + showNativeHighlight: [nodes: Array], hideNativeHighlight: [], shutdown: [], startInspectingNative: [], diff --git a/packages/react-native/package.json b/packages/react-native/package.json index c64a7facc1e..fd065b4eb88 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -122,7 +122,7 @@ "nullthrows": "^1.1.1", "pretty-format": "^26.5.2", "promise": "^8.3.0", - "react-devtools-core": "^4.27.7", + "react-devtools-core": "^5.0.0", "react-refresh": "^0.14.0", "react-shallow-renderer": "^16.15.0", "regenerator-runtime": "^0.13.2", diff --git a/yarn.lock b/yarn.lock index a09ab149b18..8115ae98f4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8100,10 +8100,10 @@ range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -react-devtools-core@^4.27.7: - version "4.27.7" - resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.27.7.tgz#458a6541483078d60a036c75bf88f54c478086ec" - integrity sha512-12N0HrhCPbD76Z7SkyJdGdXdPGouUsgV6tlEsbSpAnLDO06tjXZP+irht4wPdYwJAJRQ85DxL48eQoz7UmrSuQ== +react-devtools-core@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-5.0.0.tgz#50b04a4dbfa62badbe4d86529e9478c396988b31" + integrity sha512-SAAMLacNDfFjMJjmbXURNWtrTyARi9xTqGkY48Btw5cIWlr1wgxfWYZKxoUZav1qqmhbpgTzSmmF+cpMHGHY3A== dependencies: shell-quote "^1.6.1" ws "^7"