diff --git a/packages/react-native/flow/bom.js.flow b/packages/react-native/flow/bom.js.flow index f38641c08022..d6466e09d6f3 100644 --- a/packages/react-native/flow/bom.js.flow +++ b/packages/react-native/flow/bom.js.flow @@ -108,7 +108,7 @@ declare class PerformanceEntry { entryType: string; name: string; startTime: DOMHighResTimeStamp; - toJSON(): string; + toJSON(): {[string]: unknown}; } // https://w3c.github.io/user-timing/#performancemark @@ -127,7 +127,7 @@ declare class PerformanceServerTiming { description: string; duration: DOMHighResTimeStamp; name: string; - toJSON(): string; + toJSON(): {[string]: unknown}; } // https://www.w3.org/TR/resource-timing-2/#sec-performanceresourcetiming @@ -224,7 +224,7 @@ declare class Performance { endMark?: string, ): PerformanceMeasure; now(): DOMHighResTimeStamp; - toJSON(): string; + toJSON(): {[string]: unknown}; } declare var performance: Performance; @@ -327,6 +327,98 @@ declare class DOMRectList { [index: number]: DOMRect; } +declare class MutationRecord { + // Always an empty NodeList for `attributes` and `characterData` mutations. + // React Native currently only supports `childList`, so this contains the + // added nodes for that mutation type. + +addedNodes: NodeList; + // Always `null` in React Native (only `childList` mutations are supported). + +attributeName: null; + // Always `null` in React Native (only `childList` mutations are supported). + +nextSibling: null; + // Always `null` in React Native (only `childList` mutations are supported, + // and `attributeOldValue`/`characterDataOldValue` are not supported). + +oldValue: null; + // Always `null` in React Native (only `childList` mutations are supported). + +previousSibling: null; + // Always an empty NodeList for `attributes` and `characterData` mutations. + // React Native currently only supports `childList`, so this contains the + // removed nodes for that mutation type. + +removedNodes: NodeList; + +target: Node; + // React Native currently only supports `childList` mutations. + +type: 'childList'; +} + +// React Native currently only supports `childList` mutations, so `childList` +// is required and must be `true`. The `attributes`/`attributeFilter`/ +// `attributeOldValue`/`characterData`/`characterDataOldValue` options are not +// supported and will throw if provided. +declare type MutationObserverInit = { + +childList: true, + +subtree?: boolean, + ... +}; + +declare class MutationObserver { + constructor( + callback: ( + arr: Array, + observer: MutationObserver, + ) => unknown, + ): void; + disconnect(): void; + observe(target: Node, options: MutationObserverInit): void; +} + +declare type IntersectionObserverEntry = { + +boundingClientRect: DOMRectReadOnly, + +intersectionRatio: number, + +intersectionRect: DOMRectReadOnly, + +isIntersecting: boolean, + // Always non-null in React Native. + +rootBounds: DOMRectReadOnly, + // React Native-specific extension. Equivalent to `intersectionRatio` but + // computed against the `rnRootThreshold` root-relative thresholds. + +rnRootIntersectionRatio: number, + +target: Element, + +time: DOMHighResTimeStamp, + ... +}; + +declare type IntersectionObserverCallback = ( + entries: Array, + observer: IntersectionObserver, +) => unknown; + +declare type IntersectionObserverOptions = { + root?: Node | null, + rootMargin?: string, + threshold?: number | Array, + // React Native-specific extension. Thresholds expressed as a fraction of the + // root's size (instead of the target's size). + rnRootThreshold?: number | Array, + ... +}; + +// The `delay`, `scrollMargin` and `trackVisibility` options are not supported +// in React Native and will throw if provided. +declare class IntersectionObserver { + constructor( + callback: IntersectionObserverCallback, + options?: IntersectionObserverOptions, + ): void; + disconnect(): void; + observe(target: Element): void; + +root: Element | null; + +rootMargin: string; + // React Native-specific extension. The thresholds expressed as a fraction of + // the root's size (set via the `rnRootThreshold` option). + +rnRootThresholds: ReadonlyArray | null; + +thresholds: ReadonlyArray; + unobserve(target: Element): void; +} + declare class CloseEvent extends Event { code: number; reason: string; diff --git a/packages/rn-tester/js/examples/IntersectionObserver/IntersectionObserverMDNExample.js b/packages/rn-tester/js/examples/IntersectionObserver/IntersectionObserverMDNExample.js index 5673c516830f..557c77a1a909 100644 --- a/packages/rn-tester/js/examples/IntersectionObserver/IntersectionObserverMDNExample.js +++ b/packages/rn-tester/js/examples/IntersectionObserver/IntersectionObserverMDNExample.js @@ -8,8 +8,6 @@ * @format */ -import type IntersectionObserverType from 'react-native/src/private/webapis/intersectionobserver/IntersectionObserver'; - import {RNTesterThemeContext} from '../../components/RNTesterTheme'; import * as React from 'react'; import { @@ -21,8 +19,6 @@ import { } from 'react'; import {Button, ScrollView, StyleSheet, Text, View} from 'react-native'; -declare var IntersectionObserver: Class; - export const name = 'IntersectionObserver MDN Example'; export const title = name; export const description = diff --git a/packages/rn-tester/js/examples/MutationObserver/MutationObserverExample.js b/packages/rn-tester/js/examples/MutationObserver/MutationObserverExample.js new file mode 100644 index 000000000000..c1016b2ea4e8 --- /dev/null +++ b/packages/rn-tester/js/examples/MutationObserver/MutationObserverExample.js @@ -0,0 +1,198 @@ +/** + * 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 {RNTesterThemeContext} from '../../components/RNTesterTheme'; +import * as React from 'react'; +import {type ElementRef, useContext, useEffect, useRef, useState} from 'react'; +import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native'; + +export const name = 'MutationObserver Example'; +export const title = name; +export const description = + '- Tap on elements to append a child.\n- Long tap on elements to remove them.'; + +export function render(): React.Node { + return ; +} + +const nextIdByPrefix: Map = new Map(); +function generateId(prefix: string): string { + let nextId = nextIdByPrefix.get(prefix); + if (nextId == null) { + nextId = 1; + } + nextIdByPrefix.set(prefix, nextId + 1); + return prefix + nextId; +} + +const rootId = generateId('example-item-'); + +function useTemporaryValue(duration: number = 2000): [?T, (?T) => void] { + const [value, setValue] = useState(null); + + useEffect(() => { + const timeoutId = setTimeout(() => { + setValue(null); + }, duration); + return () => clearTimeout(timeoutId); + // we need to set the timer every time the value changes + }, [duration, value]); + + return [value, setValue]; +} + +component MutationObserverExample() { + const parentViewRef = useRef>(null); + const [showExample, setShowExample] = useState(true); + const theme = useContext(RNTesterThemeContext); + const [message, setMessage] = useTemporaryValue(); + + useEffect(() => { + const parentNode = parentViewRef.current; + if (!parentNode) { + return; + } + + const mutationObserver = new MutationObserver(records => { + const messages = []; + records.forEach(record => { + if (record.addedNodes.length > 0) { + console.log( + 'MutationObserverExample: added nodes', + nodeListToString(record.addedNodes), + ); + messages.push(`Added nodes: ${nodeListToString(record.addedNodes)}`); + } + if (record.removedNodes.length > 0) { + console.log( + 'MutationObserverExample: removed nodes', + nodeListToString(record.removedNodes), + ); + messages.push( + `Removed nodes: ${nodeListToString(record.removedNodes)}`, + ); + } + }); + setMessage(messages.join(',\n')); + }); + + // $FlowExpectedError[incompatible-type] + mutationObserver.observe(parentNode, { + subtree: true, + childList: true, + }); + + return () => { + console.log('MutationObserverExample: disconnecting mutation observer'); + mutationObserver.disconnect(); + nextIdByPrefix.clear(); + }; + }, [setMessage]); + + const exampleId = showExample ? rootId : ''; + + return ( + <> + + + {showExample ? ( + setShowExample(false)} + /> + ) : null} + + + + {message} + + + ); +} + +function ExampleItem(props: { + id: string, + label: string, + onRemove?: () => void, +}): React.Node { + const theme = useContext(RNTesterThemeContext); + const [children, setChildren] = useState>( + [], + ); + + return ( + + { + props.onRemove?.(); + }} + onPress={() => { + const id = generateId(props.label + '-'); + setChildren(prevChildren => [ + ...prevChildren, + [ + id, + { + setChildren(prevChildren2 => + prevChildren2.filter(pair => pair[0] !== id), + ); + }} + />, + ], + ]); + }}> + {props.label != null ? ( + + {props.label} + + ) : null} + {children.map(([id, child]) => child)} + + + ); +} + +function nodeListToString(nodeList: NodeList): string { + return [...nodeList] + .map(node => (node instanceof Element && node.id) || '') + .join(', '); +} + +const styles = StyleSheet.create({ + parent: { + flex: 1, + backgroundColor: 'white', + }, + item: { + backgroundColor: 'rgba(0, 0, 0, 0.5)', + flex: 1, + gap: 16, + minHeight: 50, + padding: 40, + }, + label: { + position: 'absolute', + top: 0, + right: 0, + fontSize: 10, + }, + message: { + padding: 10, + }, +}); diff --git a/packages/rn-tester/js/examples/MutationObserver/MutationObserverIndex.js b/packages/rn-tester/js/examples/MutationObserver/MutationObserverIndex.js new file mode 100644 index 000000000000..eb8692dbcb82 --- /dev/null +++ b/packages/rn-tester/js/examples/MutationObserver/MutationObserverIndex.js @@ -0,0 +1,27 @@ +/** + * 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 {RNTesterModuleExample} from '../../types/RNTesterTypes'; + +import * as MutationObserverExample from './MutationObserverExample'; +import * as VisualCompletionExample from './VisualCompletionExample/VisualCompletionExample'; + +export const framework = 'React'; +export const title = 'MutationObserver'; +export const category = 'UI'; +export const documentationURL = + 'https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver'; +export const description = 'API to detect mutations in React Native nodes.'; +export const showIndividualExamples = true; +export const examples: Array = [MutationObserverExample]; + +if (typeof IntersectionObserver !== 'undefined') { + examples.push(VisualCompletionExample); +} diff --git a/packages/rn-tester/js/examples/MutationObserver/VisualCompletionExample/VCOverlayExample.js b/packages/rn-tester/js/examples/MutationObserver/VisualCompletionExample/VCOverlayExample.js new file mode 100644 index 000000000000..5a4e778cfb43 --- /dev/null +++ b/packages/rn-tester/js/examples/MutationObserver/VisualCompletionExample/VCOverlayExample.js @@ -0,0 +1,68 @@ +/** + * 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 VCTracker, {VisualElement} from './VCTrackerExample'; + +import * as React from 'react'; +import {useEffect, useState} from 'react'; +import {Dimensions, StyleSheet, View} from 'react-native'; + +const OVERLAY_SCALE = 0.25; + +export default function VCOverlayExample(props: { + vcTracker: VCTracker, +}): React.Node { + const [visualElements, setVisualElements] = useState< + ReadonlyArray, + >([]); + + useEffect(() => { + setVisualElements(props.vcTracker.getVisualElements()); + props.vcTracker.onUpdateVisualElements(elements => { + setVisualElements(elements); + }); + }, [props.vcTracker]); + + return ( + + {visualElements.map((visualElement, index) => ( + + ))} + + ); +} + +const styles = StyleSheet.create({ + overlay: { + position: 'absolute', + bottom: 60, + right: 10, + width: OVERLAY_SCALE * Dimensions.get('window').width, + height: OVERLAY_SCALE * Dimensions.get('window').height, + backgroundColor: 'gray', + opacity: 0.9, + }, + overlayElement: { + position: 'absolute', + borderWidth: 1, + borderColor: 'black', + }, +}); diff --git a/packages/rn-tester/js/examples/MutationObserver/VisualCompletionExample/VCTrackerExample.js b/packages/rn-tester/js/examples/MutationObserver/VisualCompletionExample/VCTrackerExample.js new file mode 100644 index 000000000000..2d695d8380f3 --- /dev/null +++ b/packages/rn-tester/js/examples/MutationObserver/VisualCompletionExample/VCTrackerExample.js @@ -0,0 +1,120 @@ +/** + * 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 + */ + +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 Element) { + this._intersectionObserver.observe(addedNode); + } + } + for (const removedNode of entry.removedNodes) { + // To measure paint time for added nodes + this._pendingMutations.delete(removedNode); + if (removedNode instanceof Element) { + this._unregisterVisualElement(removedNode); + } + } + } + } + }); + } + + _registerVisualElement(target: Element, visualElement: VisualElement): void { + debug( + 'registerVisualElement', + (target instanceof Element && target.id) || '', + '. Painted in', + (visualElement.time - this._navigationStartTime).toFixed(2), + 'ms (at ', + visualElement.time, + '), rect:', + // $FlowExpectedError[prop-missing] - DOMRectReadOnly isn't typed correctly. + visualElement.rect.toJSON(), + ); + + this._visualElements.set(target, visualElement); + this._registeredCallback?.([...this._visualElements.values()]); + } + + _unregisterVisualElement(target: Element): void { + this._visualElements.delete(target); + this._registeredCallback?.([...this._visualElements.values()]); + } + + onUpdateVisualElements( + callback: (ReadonlyArray) => void, + ): void { + this._registeredCallback = callback; + } + + addMutationRoot(rootNode: Element): 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(); + } +} diff --git a/packages/rn-tester/js/examples/MutationObserver/VisualCompletionExample/VisualCompletionExample.js b/packages/rn-tester/js/examples/MutationObserver/VisualCompletionExample/VisualCompletionExample.js new file mode 100644 index 000000000000..3860e5fcd975 --- /dev/null +++ b/packages/rn-tester/js/examples/MutationObserver/VisualCompletionExample/VisualCompletionExample.js @@ -0,0 +1,187 @@ +/** + * 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 {RNTesterThemeContext} from '../../../components/RNTesterTheme'; +import VCOverlay from './VCOverlayExample'; +import VCTracker from './VCTrackerExample'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; +import {useContext, useEffect} from 'react'; +import { + ActivityIndicator, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; + +export const name = 'Visual Completion Example'; +export const title = name; +export const description = + 'Example of use of MutationObserver and IntersectionObserver together to track rendering performance.'; + +export function render(): React.Node { + // We should use the time of the touch up event that lead to this navigation, + // but we don't have that set up. + const navigationStartTime = performance.now(); + const vcTracker = new VCTracker(navigationStartTime); + return ; +} + +/** + * We are going to track the visual completion of this component, which uses + * suspense and renders a complex tree in multiple steps. + */ +component VisualCompletionExample(vcTracker: VCTracker) { + useEffect(() => { + return () => vcTracker.disconnect(); + }, [vcTracker]); + + return ( + <> + + + + ); +} + +function VisualCompletionExampleScreen(props: { + vcTracker: VCTracker, +}): React.Node { + const theme = useContext(RNTesterThemeContext); + + return ( + { + if (node != null) { + // $FlowExpectedError[incompatible-type] + const element: Element = node; + props.vcTracker.addMutationRoot(element); + } + }}> + + + Title + + + + + + + }> + + + + }> + + + Heading + + + + }> + + + + + + {LONG_TEXT} + + + + + + + + Example copyright footer + + + ); +} + +function ForceSuspense(props: { + queryID: string, + delay: number, + children: React.Node, +}): React.Node { + useForceSuspense(props.queryID, props.delay); + return props.children; +} + +let lastQueryID = 0; +function generateQueryID() { + lastQueryID++; + return 'query-id-' + lastQueryID; +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, + header: { + padding: 10, + backgroundColor: 'gray', + }, + title: { + textAlign: 'center', + fontSize: 20, + }, + body: { + flex: 1, + padding: 10, + }, + heading: { + fontSize: 16, + }, + bodyContent: { + width: 100, + height: 100, + backgroundColor: 'blue', + margin: 50, + }, + footer: { + padding: 10, + backgroundColor: 'gray', + }, +}); + +const store: Map, resolved: boolean}> = + new Map(); + +function useForceSuspense(queryID: string, delay: number): void { + let entry = store.get(queryID); + if (!entry) { + entry = { + resolved: false, + promise: new Promise(resolve => { + setTimeout(() => { + nullthrows(entry).resolved = true; + resolve(); + }, delay); + }), + }; + store.set(queryID, entry); + } + + if (!entry.resolved) { + throw entry.promise; + } +} + +const LONG_TEXT = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas facilisis feugiat ipsum, non placerat nulla. Vestibulum tincidunt eu dui ut bibendum. Cras risus ex, rhoncus auctor velit ut, lobortis convallis turpis. Donec rutrum imperdiet ante, vitae accumsan velit convallis non. Suspendisse feugiat egestas lectus. In eget fringilla ligula, at vehicula orci. Cras laoreet hendrerit urna, sed tincidunt dolor consectetur dapibus.\n'.repeat( + 10, + ); diff --git a/packages/rn-tester/js/utils/RNTesterList.android.js b/packages/rn-tester/js/utils/RNTesterList.android.js index 468bdcfa0f36..e3c5005ea002 100644 --- a/packages/rn-tester/js/utils/RNTesterList.android.js +++ b/packages/rn-tester/js/utils/RNTesterList.android.js @@ -391,6 +391,17 @@ const APIs: Array = ( }, ] : []), + // Basic check to detect the availability of the MutationObserver API. + // $FlowExpectedError[cannot-resolve-name] + ...(typeof MutationObserver === 'function' + ? [ + { + key: 'MutationObserver', + category: 'UI', + module: require('../examples/MutationObserver/MutationObserverIndex'), + }, + ] + : []), // Basic check to detect the availability of the modern Performance API. ...(typeof performance.getEntries === 'function' ? [ diff --git a/packages/rn-tester/js/utils/RNTesterList.ios.js b/packages/rn-tester/js/utils/RNTesterList.ios.js index 18fdc72e7df7..e7ed0a40d775 100644 --- a/packages/rn-tester/js/utils/RNTesterList.ios.js +++ b/packages/rn-tester/js/utils/RNTesterList.ios.js @@ -359,6 +359,17 @@ const APIs: Array = ( }, ] : []), + // Basic check to detect the availability of the MutationObserver API. + // $FlowExpectedError[cannot-resolve-name] + ...(typeof MutationObserver === 'function' + ? [ + { + key: 'MutationObserver', + category: 'UI', + module: require('../examples/MutationObserver/MutationObserverIndex'), + }, + ] + : []), // Basic check to detect the availability of the modern Performance API. ...(typeof performance.getEntries === 'function' ? [