514 lines
17 KiB
JavaScript
514 lines
17 KiB
JavaScript
|
/**
|
||
|
* 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<DebuggingOverlayRegistrySubscriberProtocol> = 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<DebuggingOverlayRegistrySubscriberProtocol> = [];
|
||
|
|
||
|
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<DebuggingOverlayRegistrySubscriberProtocol> =
|
||
|
[];
|
||
|
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<ModernNodeUpdate> = [];
|
||
|
const legacyNodesUpdates: Array<LegacyNodeUpdate> = [];
|
||
|
|
||
|
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<ModernNodeUpdate>): void {
|
||
|
const parentToTraceUpdatesMap = new Map<
|
||
|
DebuggingOverlayRegistrySubscriberProtocol,
|
||
|
Array<TraceUpdate>,
|
||
|
>();
|
||
|
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<LegacyNodeUpdate>): void {
|
||
|
const parentToTraceUpdatesPromisesMap = new Map<
|
||
|
DebuggingOverlayRegistrySubscriberProtocol,
|
||
|
Array<Promise<TraceUpdate>>,
|
||
|
>();
|
||
|
|
||
|
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<TraceUpdate>((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<ReactNativeElement> = [];
|
||
|
const legacyPublicInstances: Array<NativeMethods> = [];
|
||
|
|
||
|
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<ReactNativeElement>): void {
|
||
|
const parentToElementsMap = new Map<
|
||
|
DebuggingOverlayRegistrySubscriberProtocol,
|
||
|
Array<ReactNativeElement>,
|
||
|
>();
|
||
|
|
||
|
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<NativeMethods>): void {
|
||
|
const parentToElementsMap = new Map<
|
||
|
DebuggingOverlayRegistrySubscriberProtocol,
|
||
|
Array<NativeMethods>,
|
||
|
>();
|
||
|
|
||
|
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<ElementRectangle>((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;
|