/** * 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 */ import type DOMRectReadOnly from 'react-native/src/private/webapis/dom/geometry/DOMRectReadOnly'; import type ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; import IntersectionObserver from 'react-native/src/private/webapis/intersectionobserver/IntersectionObserver'; import MutationObserver from 'react-native/src/private/webapis/mutationobserver/MutationObserver'; export type VisualElement = { time: number, rect: DOMRectReadOnly, }; function debug(...args: $ReadOnlyArray): void { console.debug('VCTrackerExample', args); } export default class VCTracker { _navigationStartTime: number; _intersectionObserver: IntersectionObserver; _mutationObserver: MutationObserver; _registeredCallback: ($ReadOnlyArray) => void; _visualElements: Map = new Map(); _pendingMutations: WeakSet = new WeakSet(); constructor(navigationStartTime: number) { this._navigationStartTime = navigationStartTime; // This should be guaranteed to run before painting RootView in native. this._intersectionObserver = new IntersectionObserver( (entries, observer) => { // This will be executed after mount/paint. for (const entry of entries) { if (this._pendingMutations.has(entry.target)) { this._registerVisualElement(entry.target, { time: entry.time, rect: entry.boundingClientRect, }); this._pendingMutations.delete(entry.target); this._intersectionObserver.unobserve(entry.target); } } }, ); this._mutationObserver = new MutationObserver((entries, observer) => { // This will be executed after layout effects, and before mount/paint. for (const entry of entries) { if (entry.addedNodes) { for (const addedNode of entry.addedNodes) { // To measure paint time for added nodes this._pendingMutations.add(addedNode); if (addedNode instanceof ReactNativeElement) { this._intersectionObserver.observe(addedNode); } } for (const removedNode of entry.removedNodes) { // To measure paint time for added nodes this._pendingMutations.delete(removedNode); if (removedNode instanceof ReactNativeElement) { this._unregisterVisualElement(removedNode); } } } } }); } _registerVisualElement( target: ReadOnlyNode, visualElement: VisualElement, ): void { debug( 'registerVisualElement', (target instanceof ReactNativeElement && target.id) || '', '. Painted in', (visualElement.time - this._navigationStartTime).toFixed(2), 'ms (at ', visualElement.time, '), rect:', visualElement.rect.toJSON(), ); this._visualElements.set(target, visualElement); this._registeredCallback?.([...this._visualElements.values()]); } _unregisterVisualElement(target: ReadOnlyNode): void { this._visualElements.delete(target); this._registeredCallback?.([...this._visualElements.values()]); } onUpdateVisualElements( callback: ($ReadOnlyArray) => void, ): void { this._registeredCallback = callback; } addMutationRoot(rootNode: ReactNativeElement): void { debug('addMutationRoot', rootNode.id); // To observe new nodes added. this._mutationObserver.observe(rootNode, { subtree: true, childList: true, }); this._pendingMutations.add(rootNode); // To measure initial paint. this._intersectionObserver.observe(rootNode); } getVisualElements(): $ReadOnlyArray { return [...this._visualElements.values()]; } disconnect(): void { this._mutationObserver.disconnect(); this._intersectionObserver.disconnect(); } }