From 387bd70e49f67d310764dd23a9e6edb93cc00b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Fri, 23 Jun 2023 02:56:04 -0700 Subject: [PATCH] Basic implementation of IntersectionObserver (#37853) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/37853 This adds a basic implementation of `IntersectionObserver`. This will not be available yet and is only compatible with the new React Native architecture. This shouldn't show up in the changelog until we're ready to enable this in some form. Changelog: [Internal] ## Context This implements a basic version of the `IntersectionObserver` API (as defined on the Web) for React Native. The motivation for this is supporting several use cases that are not possible in React Native at the moment, most importantly: * Tracking paint times for elements in the screen. * Tracking precise visibility of elements in the screen outside the context of a `VirtualizedList` (with an even better precision and control). ## Implementation details This API is implemented as a native module that registers a mount hook in Fabric. Whenever there's a mount (an update to the UI of the host platform) we check for intersections in the shadow tree. The shadow tree contains information about the representation of the UI in a given time (including scroll position), which we use as source of truth for this in a cross-platform fashion. We rely on the fact that scroll position is updated regularly in the shadow tree to provide an up-to-date view into the UI. **This implementation is completely cross-platform.** The only platform-specific part is the report of mounts in mount hooks from the host platform to Fabric. This API uses a centralized entity in JS and native to handle registration of observers and dispatch of notifications. The dispatch the notifications for all observers in the same callback so we can easily change the sequencing of events easily (for example, we can change this to use microtasks when they're available in RN). ## Known limitations * Timestamps are generally accurate for paint (as we report mounts right after they happen in the host platform), but **state updates (like scroll) are reported with a slight delay**. * In regular rendering, we first update the shadow tree and then mount it (paint), which is generally precise. In state updates, the UI is updated first and then the shadow tree is updated. In this case, we're not correctly reporting the timestamp of the scroll event (which we should be using) but the timestamp of when the update is processed. We'll fix this in a following diff. * The IntersectionObserver API has a concept of initial notification. This is a mechanism to report the initial state of an observed target. If we start observing a target when it's added to the tree but before it's painted, this initial notification is supposed to provide initial paint time (which is important for performance measurements). This implements some logic to handle that correctly (we check if there is a pending transaction) but it's currently unreliable: * React Native does not currently block paint on microtasks or layout effects, so setting up an observer in these stages could have race conditions with actual mount. If mount happens before the observation is started, the initial notification doesn't report initial time but observation time. If mount happens after, the initial notification should be fine (except in some cases on Android, see the next point). * On Android, we have a push model to send mutations to the host platform, we means we consume transactions after commit, not immediately before mount. This breaks this logic and we need to figure out a solution in a following diff. ---- Reviewed By: sammy-SC, rshest Differential Revision: D45278720 fbshipit-source-id: de350388c6325128f1cf73328779a9d3577a258a --- .../Core/setUpIntersectionObserver.js | 16 ++ .../IntersectionObserver.js | 252 ++++++++++++++++++ .../IntersectionObserverEntry.js | 140 ++++++++++ .../IntersectionObserverManager.js | 221 +++++++++++++++ .../NativeIntersectionObserver.cpp | 118 ++++++++ .../NativeIntersectionObserver.h | 109 ++++++++ .../NativeIntersectionObserver.js | 41 +++ .../__mocks__/NativeIntersectionObserver.js | 158 +++++++++++ .../ReactNative/__mocks__/FabricUIManager.js | 12 +- .../renderer/mounting/MountingCoordinator.cpp | 4 + .../renderer/mounting/MountingCoordinator.h | 8 + .../intersection/IntersectionObserver.cpp | 202 ++++++++++++++ .../intersection/IntersectionObserver.h | 80 ++++++ .../IntersectionObserverManager.cpp | 213 +++++++++++++++ .../IntersectionObserverManager.h | 74 +++++ .../IntersectionObserverState.cpp | 60 +++++ .../intersection/IntersectionObserverState.h | 46 ++++ .../renderer/uimanager/UIManagerBinding.cpp | 4 + .../renderer/uimanager/UIManagerBinding.h | 2 + 19 files changed, 1758 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/Libraries/Core/setUpIntersectionObserver.js create mode 100644 packages/react-native/Libraries/IntersectionObserver/IntersectionObserver.js create mode 100644 packages/react-native/Libraries/IntersectionObserver/IntersectionObserverEntry.js create mode 100644 packages/react-native/Libraries/IntersectionObserver/IntersectionObserverManager.js create mode 100644 packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.cpp create mode 100644 packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.h create mode 100644 packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.js create mode 100644 packages/react-native/Libraries/IntersectionObserver/__mocks__/NativeIntersectionObserver.js create mode 100644 packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.h create mode 100644 packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverManager.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverManager.h create mode 100644 packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverState.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverState.h diff --git a/packages/react-native/Libraries/Core/setUpIntersectionObserver.js b/packages/react-native/Libraries/Core/setUpIntersectionObserver.js new file mode 100644 index 00000000000..84fecbc0b56 --- /dev/null +++ b/packages/react-native/Libraries/Core/setUpIntersectionObserver.js @@ -0,0 +1,16 @@ +/** + * 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 {polyfillGlobal} from '../Utilities/PolyfillFunctions'; + +polyfillGlobal( + 'IntersectionObserver', + () => require('../IntersectionObserver/IntersectionObserver').default, +); diff --git a/packages/react-native/Libraries/IntersectionObserver/IntersectionObserver.js b/packages/react-native/Libraries/IntersectionObserver/IntersectionObserver.js new file mode 100644 index 00000000000..23d2c158569 --- /dev/null +++ b/packages/react-native/Libraries/IntersectionObserver/IntersectionObserver.js @@ -0,0 +1,252 @@ +/** + * 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 + */ + +// flowlint unsafe-getters-setters:off + +import type IntersectionObserverEntry from './IntersectionObserverEntry'; +import type {IntersectionObserverId} from './IntersectionObserverManager'; + +import ReactNativeElement from '../DOM/Nodes/ReactNativeElement'; +import * as IntersectionObserverManager from './IntersectionObserverManager'; + +export type IntersectionObserverCallback = ( + entries: Array, + observer: IntersectionObserver, +) => mixed; + +type IntersectionObserverInit = { + // root?: ReactNativeElement, // This option exists on the Web but it's not currently supported in React Native. + // rootMargin?: string, // This option exists on the Web but it's not currently supported in React Native. + threshold?: number | $ReadOnlyArray, +}; + +/** + * The [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) + * provides a way to asynchronously observe changes in the intersection of a + * target element with an ancestor element or with a top-level document's + * viewport. + * + * The ancestor element or viewport is referred to as the root. + * + * When an `IntersectionObserver` is created, it's configured to watch for given + * ratios of visibility within the root. + * + * The configuration cannot be changed once the `IntersectionObserver` is + * created, so a given observer object is only useful for watching for specific + * changes in degree of visibility; however, you can watch multiple target + * elements with the same observer. + * + * This implementation only supports the `threshold` option at the moment + * (`root` and `rootMargin` are not supported). + */ +export default class IntersectionObserver { + _callback: IntersectionObserverCallback; + _thresholds: $ReadOnlyArray; + _observationTargets: Set = new Set(); + _intersectionObserverId: ?IntersectionObserverId; + + constructor( + callback: IntersectionObserverCallback, + options?: IntersectionObserverInit, + ): void { + if (callback == null) { + throw new TypeError( + "Failed to construct 'IntersectionObserver': 1 argument required, but only 0 present.", + ); + } + + if (typeof callback !== 'function') { + throw new TypeError( + "Failed to construct 'IntersectionObserver': parameter 1 is not of type 'Function'.", + ); + } + + // $FlowExpectedError[prop-missing] it's not typed in React Native but exists on Web. + if (options?.root != null) { + throw new TypeError( + "Failed to construct 'IntersectionObserver': root is not supported", + ); + } + + // $FlowExpectedError[prop-missing] it's not typed in React Native but exists on Web. + if (options?.rootMargin != null) { + throw new TypeError( + "Failed to construct 'IntersectionObserver': rootMargin is not supported", + ); + } + + this._callback = callback; + this._thresholds = normalizeThresholds(options?.threshold); + } + + /** + * The `ReactNativeElement` whose bounds are used as the bounding box when + * testing for intersection. + * If no `root` value was passed to the constructor or its value is `null`, + * the root view is used. + * + * NOTE: This cannot currently be configured and `root` is always `null`. + */ + get root(): ReactNativeElement | null { + return null; + } + + /** + * String with syntax similar to that of the CSS `margin` property. + * Each side of the rectangle represented by `rootMargin` is added to the + * corresponding side in the root element's bounding box before the + * intersection test is performed. + * + * NOTE: This cannot currently be configured and `rootMargin` is always + * `null`. + */ + get rootMargin(): string { + return '0px 0px 0px 0px'; + } + + /** + * A list of thresholds, sorted in increasing numeric order, where each + * threshold is a ratio of intersection area to bounding box area of an + * observed target. + * Notifications for a target are generated when any of the thresholds are + * crossed for that target. + * If no value was passed to the constructor, `0` is used. + */ + get thresholds(): $ReadOnlyArray { + return this._thresholds; + } + + /** + * Adds an element to the set of target elements being watched by the + * `IntersectionObserver`. + * One observer has one set of thresholds and one root, but can watch multiple + * target elements for visibility changes. + * To stop observing the element, call `IntersectionObserver.unobserve()`. + */ + observe(target: ReactNativeElement): void { + if (!(target instanceof ReactNativeElement)) { + throw new TypeError( + "Failed to execute 'observe' on 'IntersectionObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + } + + if (this._observationTargets.has(target)) { + return; + } + + IntersectionObserverManager.observe({ + intersectionObserverId: this._getOrCreateIntersectionObserverId(), + target, + }); + + this._observationTargets.add(target); + } + + /** + * Instructs the `IntersectionObserver` to stop observing the specified target + * element. + */ + unobserve(target: ReactNativeElement): void { + if (!(target instanceof ReactNativeElement)) { + throw new TypeError( + "Failed to execute 'unobserve' on 'IntersectionObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + } + + if (!this._observationTargets.has(target)) { + return; + } + + const intersectionObserverId = this._intersectionObserverId; + if (intersectionObserverId == null) { + // This is unexpected if the target is in `_observationTargets`. + console.error( + "Unexpected state in 'IntersectionObserver': could not find observer ID to unobserve target.", + ); + return; + } + + IntersectionObserverManager.unobserve(intersectionObserverId, target); + this._observationTargets.delete(target); + + if (this._observationTargets.size === 0) { + IntersectionObserverManager.unregisterObserver(intersectionObserverId); + this._intersectionObserverId = null; + } + } + + /** + * Stops watching all of its target elements for visibility changes. + */ + disconnect(): void { + for (const target of this._observationTargets.keys()) { + this.unobserve(target); + } + } + + _getOrCreateIntersectionObserverId(): IntersectionObserverId { + let intersectionObserverId = this._intersectionObserverId; + if (intersectionObserverId == null) { + intersectionObserverId = IntersectionObserverManager.registerObserver( + this, + this._callback, + ); + this._intersectionObserverId = intersectionObserverId; + } + return intersectionObserverId; + } + + // Only for tests + __getObserverID(): ?IntersectionObserverId { + return this._intersectionObserverId; + } +} + +/** + * Converts the user defined `threshold` value into an array of sorted valid + * threshold options for `IntersectionObserver` (double ∈ [0, 1]). + * + * @example + * normalizeThresholds(0.5); // → [0.5] + * normalizeThresholds([1, 0.5, 0]); // → [0, 0.5, 1] + * normalizeThresholds(['1', '0.5', '0']); // → [0, 0.5, 1] + */ +function normalizeThresholds(threshold: mixed): $ReadOnlyArray { + if (Array.isArray(threshold)) { + if (threshold.length > 0) { + return threshold.map(normalizeThresholdValue).sort(); + } else { + return [0]; + } + } + + return [normalizeThresholdValue(threshold)]; +} + +function normalizeThresholdValue(threshold: mixed): number { + if (threshold == null) { + return 0; + } + + const thresholdAsNumber = Number(threshold); + if (!Number.isFinite(thresholdAsNumber)) { + throw new TypeError( + "Failed to read the 'threshold' property from 'IntersectionObserverInit': The provided double value is non-finite.", + ); + } + + if (thresholdAsNumber < 0 || thresholdAsNumber > 1) { + throw new RangeError( + "Failed to construct 'IntersectionObserver': Threshold values must be numbers between 0 and 1", + ); + } + + return thresholdAsNumber; +} diff --git a/packages/react-native/Libraries/IntersectionObserver/IntersectionObserverEntry.js b/packages/react-native/Libraries/IntersectionObserver/IntersectionObserverEntry.js new file mode 100644 index 00000000000..315a53446c6 --- /dev/null +++ b/packages/react-native/Libraries/IntersectionObserver/IntersectionObserverEntry.js @@ -0,0 +1,140 @@ +/** + * 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 + */ + +// flowlint unsafe-getters-setters:off + +import type ReactNativeElement from '../DOM/Nodes/ReactNativeElement'; +import type {InternalInstanceHandle} from '../Renderer/shims/ReactNativeTypes'; +import type {NativeIntersectionObserverEntry} from './NativeIntersectionObserver'; + +import DOMRectReadOnly from '../DOM/Geometry/DOMRectReadOnly'; +import {getPublicInstanceFromInternalInstanceHandle} from '../DOM/Nodes/ReadOnlyNode'; + +/** + * The [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) + * interface of the Intersection Observer API describes the intersection between + * the target element and its root container at a specific moment of transition. + * + * An array of `IntersectionObserverEntry` is delivered to + * `IntersectionObserver` callbacks as the first argument. + */ +export default class IntersectionObserverEntry { + // We lazily compute all the properties from the raw entry provided by the + // native module, so we avoid unnecessary work. + _nativeEntry: NativeIntersectionObserverEntry; + + constructor(nativeEntry: NativeIntersectionObserverEntry) { + this._nativeEntry = nativeEntry; + } + + /** + * Returns the bounds rectangle of the target element as a `DOMRectReadOnly`. + * The bounds are computed as described in the documentation for + * [`Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect). + */ + get boundingClientRect(): DOMRectReadOnly { + const targetRect = this._nativeEntry.targetRect; + return new DOMRectReadOnly( + targetRect[0], + targetRect[1], + targetRect[2], + targetRect[3], + ); + } + + /** + * Returns the ratio of the `intersectionRect` to the `boundingClientRect`. + */ + get intersectionRatio(): number { + const intersectionRect = this.intersectionRect; + const boundingClientRect = this.boundingClientRect; + + if (boundingClientRect.width === 0 || boundingClientRect.height === 0) { + return 0; + } + + return ( + (intersectionRect.width * intersectionRect.height) / + (boundingClientRect.width * boundingClientRect.height) + ); + } + + /** + * Returns a `DOMRectReadOnly` representing the target's visible area. + */ + get intersectionRect(): DOMRectReadOnly { + const intersectionRect = this._nativeEntry.intersectionRect; + + if (intersectionRect == null) { + return new DOMRectReadOnly(); + } + + return new DOMRectReadOnly( + intersectionRect[0], + intersectionRect[1], + intersectionRect[2], + intersectionRect[3], + ); + } + + /** + * A `Boolean` value which is `true` if the target element intersects with the + * intersection observer's root. + * * If this is `true`, then, the `IntersectionObserverEntry` describes a + * transition into a state of intersection. + * * If it's `false`, then you know the transition is from intersecting to + * not-intersecting. + */ + get isIntersecting(): boolean { + return this._nativeEntry.isIntersectingAboveThresholds; + } + + /** + * Returns a `DOMRectReadOnly` for the intersection observer's root. + */ + get rootBounds(): DOMRectReadOnly { + const rootRect = this._nativeEntry.rootRect; + return new DOMRectReadOnly( + rootRect[0], + rootRect[1], + rootRect[2], + rootRect[3], + ); + } + + /** + * The `ReactNativeElement` whose intersection with the root changed. + */ + get target(): ReactNativeElement { + const targetInstanceHandle: InternalInstanceHandle = + // $FlowExpectedError[incompatible-type] native modules don't support using InternalInstanceHandle as a type + this._nativeEntry.targetInstanceHandle; + + const targetElement = + getPublicInstanceFromInternalInstanceHandle(targetInstanceHandle); + + // $FlowExpectedError[incompatible-cast] we know targetElement is a ReactNativeElement, not just a ReadOnlyNode + return (targetElement: ReactNativeElement); + } + + /** + * A `DOMHighResTimeStamp` indicating the time at which the intersection was + * recorded, relative to the `IntersectionObserver`'s time origin. + */ + get time(): DOMHighResTimeStamp { + return this._nativeEntry.time; + } +} + +export function createIntersectionObserverEntry( + entry: NativeIntersectionObserverEntry, +): IntersectionObserverEntry { + return new IntersectionObserverEntry(entry); +} diff --git a/packages/react-native/Libraries/IntersectionObserver/IntersectionObserverManager.js b/packages/react-native/Libraries/IntersectionObserver/IntersectionObserverManager.js new file mode 100644 index 00000000000..c3f88ee7ef1 --- /dev/null +++ b/packages/react-native/Libraries/IntersectionObserver/IntersectionObserverManager.js @@ -0,0 +1,221 @@ +/** + * 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 + */ + +/** + * This module handles the communication between the React Native renderer + * and all the intersection observers that are currently observing any targets. + * + * In order to reduce the communication between native and JavaScript, + * we register a single notication callback in native, and then we handle how + * to notify each entry to the right intersection observer when we receive all + * the notifications together. + */ + +import type ReactNativeElement from '../DOM/Nodes/ReactNativeElement'; +import type IntersectionObserver, { + IntersectionObserverCallback, +} from './IntersectionObserver'; +import type IntersectionObserverEntry from './IntersectionObserverEntry'; + +import {getShadowNode} from '../DOM/Nodes/ReadOnlyNode'; +import * as Systrace from '../Performance/Systrace'; +import warnOnce from '../Utilities/warnOnce'; +import {createIntersectionObserverEntry} from './IntersectionObserverEntry'; +import NativeIntersectionObserver from './NativeIntersectionObserver'; + +export type IntersectionObserverId = number; + +let nextIntersectionObserverId: IntersectionObserverId = 1; +let isConnected: boolean = false; + +const registeredIntersectionObservers: Map< + IntersectionObserverId, + {observer: IntersectionObserver, callback: IntersectionObserverCallback}, +> = new Map(); + +/** + * Registers the given intersection observer and returns a unique ID for it, + * which is required to start observing targets. + */ +export function registerObserver( + observer: IntersectionObserver, + callback: IntersectionObserverCallback, +): IntersectionObserverId { + const intersectionObserverId = nextIntersectionObserverId; + nextIntersectionObserverId++; + registeredIntersectionObservers.set(intersectionObserverId, { + observer, + callback, + }); + return intersectionObserverId; +} + +/** + * Unregisters the given intersection observer. + * This should only be called when an observer is no longer observing any + * targets. + */ +export function unregisterObserver( + intersectionObserverId: IntersectionObserverId, +): void { + const deleted = registeredIntersectionObservers.delete( + intersectionObserverId, + ); + if (deleted && registeredIntersectionObservers.size === 0) { + NativeIntersectionObserver?.disconnect(); + isConnected = false; + } +} + +/** + * Starts observing a target on a specific intersection observer. + * If this is the first target being observed, this also sets up the centralized + * notification callback in native. + */ +export function observe({ + intersectionObserverId, + target, +}: { + intersectionObserverId: IntersectionObserverId, + target: ReactNativeElement, +}): void { + if (NativeIntersectionObserver == null) { + warnNoNativeIntersectionObserver(); + return; + } + + const registeredObserver = registeredIntersectionObservers.get( + intersectionObserverId, + ); + if (registeredObserver == null) { + console.error( + `IntersectionObserverManager: could not start observing target because IntersectionObserver with ID ${intersectionObserverId} was not registered.`, + ); + return; + } + + const targetShadowNode = getShadowNode(target); + if (targetShadowNode == null) { + console.error( + 'IntersectionObserverManager: could not find reference to host node from target', + ); + return; + } + + if (!isConnected) { + NativeIntersectionObserver.connect(notifyIntersectionObservers); + isConnected = true; + } + + return NativeIntersectionObserver.observe({ + intersectionObserverId, + targetShadowNode, + thresholds: registeredObserver.observer.thresholds, + }); +} + +export function unobserve( + intersectionObserverId: number, + target: ReactNativeElement, +): void { + if (NativeIntersectionObserver == null) { + warnNoNativeIntersectionObserver(); + return; + } + + const registeredObserver = registeredIntersectionObservers.get( + intersectionObserverId, + ); + if (registeredObserver == null) { + console.error( + `IntersectionObserverManager: could not stop observing target because IntersectionObserver with ID ${intersectionObserverId} was not registered.`, + ); + return; + } + + const targetShadowNode = getShadowNode(target); + if (targetShadowNode == null) { + console.error( + 'IntersectionObserverManager: could not find reference to host node from target', + ); + return; + } + + NativeIntersectionObserver.unobserve( + intersectionObserverId, + targetShadowNode, + ); +} + +/** + * This function is called from native when there are `IntersectionObserver` + * entries to dispatch. + */ +function notifyIntersectionObservers(): void { + Systrace.beginEvent( + 'IntersectionObserverManager.notifyIntersectionObservers', + ); + try { + doNotifyIntersectionObservers(); + } finally { + Systrace.endEvent(); + } +} + +function doNotifyIntersectionObservers(): void { + if (NativeIntersectionObserver == null) { + warnNoNativeIntersectionObserver(); + return; + } + + const nativeEntries = NativeIntersectionObserver.takeRecords(); + + const entriesByObserver: Map< + IntersectionObserverId, + Array, + > = new Map(); + + for (const nativeEntry of nativeEntries) { + let list = entriesByObserver.get(nativeEntry.intersectionObserverId); + if (list == null) { + list = []; + entriesByObserver.set(nativeEntry.intersectionObserverId, list); + } + list.push(createIntersectionObserverEntry(nativeEntry)); + } + + for (const [ + intersectionObserverId, + entriesForObserver, + ] of entriesByObserver) { + const registeredObserver = registeredIntersectionObservers.get( + intersectionObserverId, + ); + if (!registeredObserver) { + // This could happen if the observer is disconnected between commit + // and mount. In this case, we can just ignore the entries. + return; + } + + const {observer, callback} = registeredObserver; + try { + callback.call(observer, entriesForObserver, observer); + } catch (error) { + console.error(error); + } + } +} + +function warnNoNativeIntersectionObserver() { + warnOnce( + 'missing-native-intersection-observer', + 'Missing native implementation of IntersectionObserver', + ); +} diff --git a/packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.cpp b/packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.cpp new file mode 100644 index 00000000000..94657af8cff --- /dev/null +++ b/packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.cpp @@ -0,0 +1,118 @@ +/* + * 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. + */ + +#include "NativeIntersectionObserver.h" +#include +#include +#include + +#include "Plugins.h" + +std::shared_ptr +NativeIntersectionObserverModuleProvider( + std::shared_ptr jsInvoker) { + return std::make_shared( + std::move(jsInvoker)); +} + +namespace facebook::react { + +NativeIntersectionObserver::NativeIntersectionObserver( + std::shared_ptr jsInvoker) + : NativeIntersectionObserverCxxSpec(std::move(jsInvoker)) {} + +void NativeIntersectionObserver::observe( + jsi::Runtime &runtime, + NativeIntersectionObserverObserveOptions options) { + auto intersectionObserverId = options.intersectionObserverId; + auto shadowNode = + shadowNodeFromValue(runtime, std::move(options.targetShadowNode)); + auto thresholds = options.thresholds; + auto &uiManager = getUIManagerFromRuntime(runtime); + + intersectionObserverManager_.observe( + intersectionObserverId, shadowNode, thresholds, uiManager); +} + +void NativeIntersectionObserver::unobserve( + jsi::Runtime &runtime, + IntersectionObserverObserverId intersectionObserverId, + jsi::Object targetShadowNode) { + auto shadowNode = shadowNodeFromValue(runtime, std::move(targetShadowNode)); + intersectionObserverManager_.unobserve(intersectionObserverId, *shadowNode); +} + +void NativeIntersectionObserver::connect( + jsi::Runtime &runtime, + AsyncCallback<> notifyIntersectionObserversCallback) { + auto &uiManager = getUIManagerFromRuntime(runtime); + intersectionObserverManager_.connect( + uiManager, notifyIntersectionObserversCallback); +} + +void NativeIntersectionObserver::disconnect(jsi::Runtime &runtime) { + auto &uiManager = getUIManagerFromRuntime(runtime); + intersectionObserverManager_.disconnect(uiManager); +} + +std::vector +NativeIntersectionObserver::takeRecords(jsi::Runtime &runtime) { + auto entries = intersectionObserverManager_.takeRecords(); + + std::vector nativeModuleEntries; + nativeModuleEntries.reserve(entries.size()); + + for (auto const &entry : entries) { + nativeModuleEntries.emplace_back( + convertToNativeModuleEntry(entry, runtime)); + } + + return nativeModuleEntries; +} + +NativeIntersectionObserverEntry +NativeIntersectionObserver::convertToNativeModuleEntry( + IntersectionObserverEntry entry, + jsi::Runtime &runtime) { + RectAsTuple targetRect = { + entry.targetRect.origin.x, + entry.targetRect.origin.y, + entry.targetRect.size.width, + entry.targetRect.size.height}; + RectAsTuple rootRect = { + entry.rootRect.origin.x, + entry.rootRect.origin.y, + entry.rootRect.size.width, + entry.rootRect.size.height}; + std::optional intersectionRect; + if (entry.intersectionRect) { + intersectionRect = { + entry.intersectionRect.value().origin.x, + entry.intersectionRect.value().origin.y, + entry.intersectionRect.value().size.width, + entry.intersectionRect.value().size.height}; + } + + NativeIntersectionObserverEntry nativeModuleEntry = { + entry.intersectionObserverId, + (*entry.shadowNode).getInstanceHandle(runtime), + targetRect, + rootRect, + intersectionRect, + entry.isIntersectingAboveThresholds, + entry.time, + }; + + return nativeModuleEntry; +} + +UIManager &NativeIntersectionObserver::getUIManagerFromRuntime( + jsi::Runtime &runtime) { + return UIManagerBinding::getBinding(runtime)->getUIManager(); +} + +} // namespace facebook::react diff --git a/packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.h b/packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.h new file mode 100644 index 00000000000..123e4cf20e6 --- /dev/null +++ b/packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.h @@ -0,0 +1,109 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +using NativeIntersectionObserverIntersectionObserverId = int32_t; +using RectAsTuple = std::tuple; + +using NativeIntersectionObserverObserveOptions = + NativeIntersectionObserverCxxBaseNativeIntersectionObserverObserveOptions< + // intersectionObserverId + NativeIntersectionObserverIntersectionObserverId, + // targetShadowNode + jsi::Object, + // thresholds + std::vector>; + +template <> +struct Bridging + : NativeIntersectionObserverCxxBaseNativeIntersectionObserverObserveOptionsBridging< + // intersectionObserverId + NativeIntersectionObserverIntersectionObserverId, + // targetShadowNode + jsi::Object, + // thresholds + std::vector> {}; + +using NativeIntersectionObserverEntry = + NativeIntersectionObserverCxxBaseNativeIntersectionObserverEntry< + // intersectionObserverId + NativeIntersectionObserverIntersectionObserverId, + // targetInstanceHandle + jsi::Value, + // targetRect + RectAsTuple, + // rootRect + RectAsTuple, + // intersectionRect + std::optional, + // isIntersectingAboveThresholds + bool, + // time + double>; + +template <> +struct Bridging + : NativeIntersectionObserverCxxBaseNativeIntersectionObserverEntryBridging< + // intersectionObserverId + NativeIntersectionObserverIntersectionObserverId, + // targetInstanceHandle + jsi::Value, + // targetRect + RectAsTuple, + // rootRect + RectAsTuple, + // intersectionRect + std::optional, + // isIntersectingAboveThresholds + bool, + // time + double> {}; + +class NativeIntersectionObserver + : public NativeIntersectionObserverCxxSpec, + std::enable_shared_from_this { + public: + NativeIntersectionObserver(std::shared_ptr jsInvoker); + + void observe( + jsi::Runtime &runtime, + NativeIntersectionObserverObserveOptions options); + + void unobserve( + jsi::Runtime &runtime, + IntersectionObserverObserverId intersectionObserverId, + jsi::Object targetShadowNode); + + void connect( + jsi::Runtime &runtime, + AsyncCallback<> notifyIntersectionObserversCallback); + + void disconnect(jsi::Runtime &runtime); + + std::vector takeRecords( + jsi::Runtime &runtime); + + private: + IntersectionObserverManager intersectionObserverManager_{}; + + static UIManager &getUIManagerFromRuntime(jsi::Runtime &runtime); + static NativeIntersectionObserverEntry convertToNativeModuleEntry( + IntersectionObserverEntry entry, + jsi::Runtime &runtime); +}; + +} // namespace facebook::react diff --git a/packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.js b/packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.js new file mode 100644 index 00000000000..ff620ec123a --- /dev/null +++ b/packages/react-native/Libraries/IntersectionObserver/NativeIntersectionObserver.js @@ -0,0 +1,41 @@ +/** + * 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 + * @format + */ + +import type {TurboModule} from '../TurboModule/RCTExport'; + +import * as TurboModuleRegistry from '../TurboModule/TurboModuleRegistry'; + +export type NativeIntersectionObserverEntry = { + intersectionObserverId: number, + targetInstanceHandle: mixed, + targetRect: $ReadOnlyArray, // It's actually a tuple with x, y, width and height + rootRect: $ReadOnlyArray, // It's actually a tuple with x, y, width and height + intersectionRect: ?$ReadOnlyArray, // It's actually a tuple with x, y, width and height + isIntersectingAboveThresholds: boolean, + time: number, +}; + +export type NativeIntersectionObserverObserveOptions = { + intersectionObserverId: number, + targetShadowNode: mixed, + thresholds: $ReadOnlyArray, +}; + +export interface Spec extends TurboModule { + +observe: (options: NativeIntersectionObserverObserveOptions) => void; + +unobserve: (intersectionObserverId: number, targetShadowNode: mixed) => void; + +connect: (notifyIntersectionObserversCallback: () => void) => void; + +disconnect: () => void; + +takeRecords: () => $ReadOnlyArray; +} + +export default (TurboModuleRegistry.get( + 'NativeIntersectionObserverCxx', +): ?Spec); diff --git a/packages/react-native/Libraries/IntersectionObserver/__mocks__/NativeIntersectionObserver.js b/packages/react-native/Libraries/IntersectionObserver/__mocks__/NativeIntersectionObserver.js new file mode 100644 index 00000000000..7bd88d6e1a9 --- /dev/null +++ b/packages/react-native/Libraries/IntersectionObserver/__mocks__/NativeIntersectionObserver.js @@ -0,0 +1,158 @@ +/** + * 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 ReactNativeElement from '../../DOM/Nodes/ReactNativeElement'; +import type IntersectionObserver from '../IntersectionObserver'; +import type { + NativeIntersectionObserverEntry, + NativeIntersectionObserverObserveOptions, + Spec, +} from '../NativeIntersectionObserver'; + +import {getShadowNode} from '../../DOM/Nodes/ReadOnlyNode'; +import {getFabricUIManager} from '../../ReactNative/__mocks__/FabricUIManager'; +import invariant from 'invariant'; +import nullthrows from 'nullthrows'; + +type ObserverState = { + thresholds: $ReadOnlyArray, + intersecting: boolean, + currentThreshold: ?number, +}; + +type Observation = { + ...NativeIntersectionObserverObserveOptions, + state: ObserverState, +}; + +let pendingRecords: Array = []; +let callback: ?() => void; +let observations: Array = []; + +const FabricUIManagerMock = nullthrows(getFabricUIManager()); + +function createRecordFromObservation( + observation: Observation, +): NativeIntersectionObserverEntry { + return { + intersectionObserverId: observation.intersectionObserverId, + targetInstanceHandle: FabricUIManagerMock.__getInstanceHandleFromNode( + // $FlowExpectedError[incompatible-call] + observation.targetShadowNode, + ), + targetRect: observation.state.intersecting ? [0, 0, 1, 1] : [20, 20, 1, 1], + rootRect: [0, 0, 10, 10], + intersectionRect: observation.state.intersecting ? [0, 0, 1, 1] : null, + isIntersectingAboveThresholds: observation.state.intersecting, + time: performance.now(), + }; +} + +function notifyIntersectionObservers(): void { + callback?.(); +} + +const NativeIntersectionObserverMock = { + observe: (options: NativeIntersectionObserverObserveOptions): void => { + invariant( + observations.find( + observation => + observation.intersectionObserverId === + options.intersectionObserverId && + observation.targetShadowNode === options.targetShadowNode, + ) == null, + 'unexpected duplicate call to observe', + ); + const observation = { + ...options, + state: { + thresholds: options.thresholds, + intersecting: false, + currentThreshold: null, + }, + }; + observations.push(observation); + pendingRecords.push(createRecordFromObservation(observation)); + setImmediate(notifyIntersectionObservers); + }, + unobserve: ( + intersectionObserverId: number, + targetShadowNode: mixed, + ): void => { + const observationIndex = observations.findIndex( + observation => + observation.intersectionObserverId === intersectionObserverId && + observation.targetShadowNode === targetShadowNode, + ); + invariant( + observationIndex !== -1, + 'unexpected duplicate call to unobserve', + ); + observations.splice(observationIndex, 1); + }, + connect: (notifyIntersectionObserversCallback?: () => void): void => { + invariant(callback == null, 'unexpected call to connect'); + callback = notifyIntersectionObserversCallback; + }, + disconnect: (): void => { + invariant(callback != null, 'unexpected call to disconnect'); + callback = null; + }, + takeRecords: (): $ReadOnlyArray => { + const currentRecords = pendingRecords; + pendingRecords = []; + return currentRecords; + }, + __forceTransitionForTests: ( + observer: IntersectionObserver, + target: ReactNativeElement, + ) => { + const targetShadowNode = getShadowNode(target); + const observation = observations.find( + obs => + obs.intersectionObserverId === observer.__getObserverID() && + obs.targetShadowNode === targetShadowNode, + ); + invariant( + observation != null, + 'cannot force transition on an unobserved target', + ); + if (observation.state.intersecting) { + observation.state.intersecting = false; + observation.state.currentThreshold = null; + } else { + observation.state.intersecting = true; + observation.state.currentThreshold = observation.thresholds[0]; + } + pendingRecords.push(createRecordFromObservation(observation)); + setImmediate(notifyIntersectionObservers); + }, + __getObservationsForTests: ( + observer: IntersectionObserver, + ): Array<{targetShadowNode: mixed, thresholds: $ReadOnlyArray}> => { + const intersectionObserverId = observer.__getObserverID(); + return observations + .filter( + observation => + observation.intersectionObserverId === intersectionObserverId, + ) + .map(observation => ({ + targetShadowNode: observation.targetShadowNode, + thresholds: observation.thresholds, + })); + }, + __isConnected: (): boolean => { + return callback != null; + }, +}; + +(NativeIntersectionObserverMock: Spec); + +export default NativeIntersectionObserverMock; diff --git a/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js index 0e9a67dccb0..36cbb700fdb 100644 --- a/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js +++ b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js @@ -143,7 +143,11 @@ function hasDisplayNone(node: Node): boolean { return props != null && props.display === 'none'; } -const FabricUIManagerMock: FabricUIManager = { +interface IFabricUIManagerMock extends FabricUIManager { + __getInstanceHandleFromNode(node: Node): InternalInstanceHandle; +} + +const FabricUIManagerMock: IFabricUIManagerMock = { createNode: jest.fn( ( reactTag: number, @@ -458,10 +462,14 @@ const FabricUIManagerMock: FabricUIManager = { return [scrollLeft, scrollTop]; }, ), + + __getInstanceHandleFromNode(node: Node): InternalInstanceHandle { + return fromNode(node).instanceHandle; + }, }; global.nativeFabricUIManager = FabricUIManagerMock; -export function getFabricUIManager(): ?FabricUIManager { +export function getFabricUIManager(): ?IFabricUIManagerMock { return FabricUIManagerMock; } diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/MountingCoordinator.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/MountingCoordinator.cpp index 16626c1eb56..be1132fc180 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/MountingCoordinator.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/MountingCoordinator.cpp @@ -178,6 +178,10 @@ std::optional MountingCoordinator::pullTransaction() return transaction; } +bool MountingCoordinator::hasPendingTransactions() const { + return lastRevision_.has_value(); +} + TelemetryController const &MountingCoordinator::getTelemetryController() const { return telemetryController_; } diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/MountingCoordinator.h b/packages/react-native/ReactCommon/react/renderer/mounting/MountingCoordinator.h index 7ca6aeb5a9b..16d3ca1b777 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/MountingCoordinator.h +++ b/packages/react-native/ReactCommon/react/renderer/mounting/MountingCoordinator.h @@ -58,6 +58,14 @@ class MountingCoordinator final { */ std::optional pullTransaction() const; + /* + * Indicates if there are transactions waiting to be consumed and mounted on + * the host platform. This can be useful to determine if side-effects of + * mounting can be expected after some operations (like IntersectionObserver + * initial paint notifications). + */ + bool hasPendingTransactions() const; + /* * Blocks the current thread until a new mounting transaction is available or * after the specified `timeout` duration. diff --git a/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.cpp b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.cpp new file mode 100644 index 00000000000..85dc0bb3ca6 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.cpp @@ -0,0 +1,202 @@ +/* + * 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. + */ + +#include "IntersectionObserver.h" +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +IntersectionObserver::IntersectionObserver( + IntersectionObserverObserverId intersectionObserverId, + ShadowNode::Shared targetShadowNode, + std::vector thresholds) + : intersectionObserverId_(intersectionObserverId), + targetShadowNode_(std::move(targetShadowNode)), + thresholds_(std::move(thresholds)) {} + +static Rect getRootBoundingRect( + LayoutableShadowNode const &layoutableRootShadowNode) { + auto layoutMetrics = layoutableRootShadowNode.getLayoutMetrics(); + + if (layoutMetrics == EmptyLayoutMetrics || + layoutMetrics.displayType == DisplayType::None) { + return Rect{}; + } + + // Apply the transform to translate the root view to its location in the + // viewport. + return layoutMetrics.frame * layoutableRootShadowNode.getTransform(); +} + +static Rect getTargetBoundingRect( + ShadowNodeFamily::AncestorList const &targetAncestors) { + auto layoutMetrics = LayoutableShadowNode::computeRelativeLayoutMetrics( + targetAncestors, + {/* .includeTransform = */ true, + /* .includeViewportOffset = */ true}); + return layoutMetrics == EmptyLayoutMetrics ? Rect{} : layoutMetrics.frame; +} + +static Rect getClippedTargetBoundingRect( + ShadowNodeFamily::AncestorList const &targetAncestors) { + auto layoutMetrics = LayoutableShadowNode::computeRelativeLayoutMetrics( + targetAncestors, + {/* .includeTransform = */ true, + /* .includeViewportOffset = */ true, + /* .applyParentClipping = */ true}); + + return layoutMetrics == EmptyLayoutMetrics ? Rect{} : layoutMetrics.frame; +} + +// Partially equivalent to +// https://w3c.github.io/IntersectionObserver/#compute-the-intersection +static Rect computeIntersection( + Rect const &rootBoundingRect, + Rect const &targetBoundingRect, + ShadowNodeFamily::AncestorList const &targetAncestors) { + auto absoluteIntersectionRect = + Rect::intersect(rootBoundingRect, targetBoundingRect); + + Float absoluteIntersectionRectArea = absoluteIntersectionRect.size.width * + absoluteIntersectionRect.size.height; + + Float targetBoundingRectArea = + targetBoundingRect.size.width * targetBoundingRect.size.height; + + // Finish early if there is not intersection between the root and the target + // before we do any clipping. + if (absoluteIntersectionRectArea == 0 || targetBoundingRectArea == 0) { + return {}; + } + + // Coordinates of the target after clipping the parts hidden by a parent + // (e.g.: in scroll views, or in views with a parent with overflow: hidden) + auto clippedTargetBoundingRect = + getClippedTargetBoundingRect(targetAncestors); + + return Rect::intersect(rootBoundingRect, clippedTargetBoundingRect); +} + +// Partially equivalent to +// https://w3c.github.io/IntersectionObserver/#update-intersection-observations-algo +std::optional +IntersectionObserver::updateIntersectionObservation( + RootShadowNode const &rootShadowNode, + double mountTime) { + auto const layoutableRootShadowNode = + traitCast(&rootShadowNode); + + react_native_assert( + layoutableRootShadowNode != nullptr && + "RootShadowNode instances must always inherit from LayoutableShadowNode."); + + auto targetAncestors = + targetShadowNode_->getFamily().getAncestors(rootShadowNode); + + // Absolute coordinates of the root + auto rootBoundingRect = getRootBoundingRect(*layoutableRootShadowNode); + + // Absolute coordinates of the target + auto targetBoundingRect = getTargetBoundingRect(targetAncestors); + + auto intersectionRect = computeIntersection( + rootBoundingRect, targetBoundingRect, targetAncestors); + + Float targetBoundingRectArea = + targetBoundingRect.size.width * targetBoundingRect.size.height; + auto intersectionRectArea = + intersectionRect.size.width * intersectionRect.size.height; + + Float intersectionRatio = + targetBoundingRectArea == 0 // prevent division by zero + ? 0 + : intersectionRectArea / targetBoundingRectArea; + + if (intersectionRatio == 0) { + return setNotIntersectingState( + rootBoundingRect, targetBoundingRect, mountTime); + } + + auto highestThresholdCrossed = getHighestThresholdCrossed(intersectionRatio); + if (highestThresholdCrossed == -1) { + return setNotIntersectingState( + rootBoundingRect, targetBoundingRect, mountTime); + } + + return setIntersectingState( + rootBoundingRect, + targetBoundingRect, + intersectionRect, + highestThresholdCrossed, + mountTime); +} + +Float IntersectionObserver::getHighestThresholdCrossed( + Float intersectionRatio) { + Float highestThreshold = -1; + for (auto threshold : thresholds_) { + if (intersectionRatio >= threshold) { + highestThreshold = threshold; + } + } + return highestThreshold; +} + +std::optional +IntersectionObserver::setIntersectingState( + Rect const &rootBoundingRect, + Rect const &targetBoundingRect, + Rect const &intersectionRect, + Float threshold, + double mountTime) { + auto newState = IntersectionObserverState::Intersecting(threshold); + + if (state_ != newState) { + state_ = newState; + IntersectionObserverEntry entry{ + intersectionObserverId_, + targetShadowNode_, + targetBoundingRect, + rootBoundingRect, + intersectionRect, + true, + mountTime, + }; + return std::optional{std::move(entry)}; + } + + return std::nullopt; +} + +std::optional +IntersectionObserver::setNotIntersectingState( + Rect const &rootBoundingRect, + Rect const &targetBoundingRect, + double mountTime) { + if (state_ != IntersectionObserverState::NotIntersecting()) { + state_ = IntersectionObserverState::NotIntersecting(); + IntersectionObserverEntry entry{ + intersectionObserverId_, + targetShadowNode_, + targetBoundingRect, + rootBoundingRect, + std::nullopt, + false, + mountTime, + }; + return std::optional(std::move(entry)); + } + + return std::nullopt; +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.h b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.h new file mode 100644 index 00000000000..5f0371b6b23 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserver.h @@ -0,0 +1,80 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include "IntersectionObserverState.h" + +namespace facebook::react { + +using IntersectionObserverObserverId = int32_t; + +struct IntersectionObserverEntry { + IntersectionObserverObserverId intersectionObserverId; + ShadowNode::Shared shadowNode; + Rect targetRect; + Rect rootRect; + std::optional intersectionRect; + bool isIntersectingAboveThresholds; + // TODO(T156529385) Define `DOMHighResTimeStamp` as an alias for `double` and + // use it here. + double time; +}; + +class IntersectionObserver { + public: + IntersectionObserver( + IntersectionObserverObserverId intersectionObserverId, + ShadowNode::Shared targetShadowNode, + std::vector thresholds); + + // Partially equivalent to + // https://w3c.github.io/IntersectionObserver/#update-intersection-observations-algo + std::optional updateIntersectionObservation( + RootShadowNode const &rootShadowNode, + double mountTime); + + IntersectionObserverObserverId getIntersectionObserverId() const { + return intersectionObserverId_; + } + + ShadowNode const &getTargetShadowNode() const { + return *targetShadowNode_; + } + + std::vector getThresholds() const { + return thresholds_; + } + + private: + Float getHighestThresholdCrossed(Float intersectionRatio); + + std::optional setIntersectingState( + Rect const &rootBoundingRect, + Rect const &targetBoundingRect, + Rect const &intersectionRect, + Float threshold, + double mountTime); + + std::optional setNotIntersectingState( + Rect const &rootBoundingRect, + Rect const &targetBoundingRect, + double mountTime); + + IntersectionObserverObserverId intersectionObserverId_; + ShadowNode::Shared targetShadowNode_; + std::vector thresholds_; + mutable IntersectionObserverState state_ = + IntersectionObserverState::Initial(); +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverManager.cpp b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverManager.cpp new file mode 100644 index 00000000000..7959b9316d2 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverManager.cpp @@ -0,0 +1,213 @@ +/* + * 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. + */ + +#include "IntersectionObserverManager.h" +#include +#include +#include +#include "IntersectionObserver.h" + +namespace facebook::react { + +IntersectionObserverManager::IntersectionObserverManager() = default; + +void IntersectionObserverManager::observe( + IntersectionObserverObserverId intersectionObserverId, + const ShadowNode::Shared &shadowNode, + std::vector thresholds, + UIManager const &uiManager) { + SystraceSection s("IntersectionObserverManager::observe"); + + auto surfaceId = shadowNode->getSurfaceId(); + + // The actual observer lives in the array, so we need to create it there and + // then get a reference. Otherwise we only update its state in a copy. + IntersectionObserver *observer; + + // Register observer + { + std::unique_lock lock(observersMutex_); + + auto &observers = observersBySurfaceId_[surfaceId]; + observers.emplace_back(IntersectionObserver{ + intersectionObserverId, shadowNode, std::move(thresholds)}); + observer = &observers.back(); + } + + // Notification of initial state. + // Ideally, we'd have well defined event loop step to notify observers + // (like on the Web) and we'd send the initial notification there, but as + // we don't have it we have to run this check once and manually dispatch. + auto &shadowTreeRegistry = uiManager.getShadowTreeRegistry(); + MountingCoordinator::Shared mountingCoordinator = nullptr; + RootShadowNode::Shared rootShadowNode = nullptr; + shadowTreeRegistry.visit(surfaceId, [&](ShadowTree const &shadowTree) { + mountingCoordinator = shadowTree.getMountingCoordinator(); + rootShadowNode = shadowTree.getCurrentRevision().rootShadowNode; + }); + auto hasPendingTransactions = mountingCoordinator != nullptr && + mountingCoordinator->hasPendingTransactions(); + + if (!hasPendingTransactions) { + auto entry = observer->updateIntersectionObservation( + *rootShadowNode, JSExecutor::performanceNow()); + if (entry) { + { + std::unique_lock lock(pendingEntriesMutex_); + pendingEntries_.push_back(std::move(entry).value()); + } + notifyObserversIfNecessary(); + } + } +} + +void IntersectionObserverManager::unobserve( + IntersectionObserverObserverId intersectionObserverId, + ShadowNode const &shadowNode) { + SystraceSection s("IntersectionObserverManager::unobserve"); + + std::unique_lock lock(observersMutex_); + + auto surfaceId = shadowNode.getSurfaceId(); + + auto observersIt = observersBySurfaceId_.find(surfaceId); + if (observersIt == observersBySurfaceId_.end()) { + return; + } + + auto &observers = observersIt->second; + + observers.erase( + std::remove_if( + observers.begin(), + observers.end(), + [intersectionObserverId, &shadowNode](auto const &observer) { + return observer.getIntersectionObserverId() == + intersectionObserverId && + ShadowNode::sameFamily( + observer.getTargetShadowNode(), shadowNode); + }), + observers.end()); + + if (observers.empty()) { + observersBySurfaceId_.erase(surfaceId); + } +} + +void IntersectionObserverManager::connect( + UIManager &uiManager, + std::function notifyIntersectionObserversCallback) { + SystraceSection s("IntersectionObserverManager::connect"); + notifyIntersectionObserversCallback_ = + std::move(notifyIntersectionObserversCallback); + + // Fail-safe in case the caller doesn't guarantee consistency. + if (mountHookRegistered_) { + return; + } + + uiManager.registerMountHook(*this); + mountHookRegistered_ = true; +} + +void IntersectionObserverManager::disconnect(UIManager &uiManager) { + SystraceSection s("IntersectionObserverManager::disconnect"); + + // Fail-safe in case the caller doesn't guarantee consistency. + if (!mountHookRegistered_) { + return; + } + + uiManager.unregisterMountHook(*this); + mountHookRegistered_ = false; + notifyIntersectionObserversCallback_ = nullptr; +} + +std::vector +IntersectionObserverManager::takeRecords() { + std::unique_lock lock(pendingEntriesMutex_); + + notifiedIntersectionObservers_ = false; + + std::vector entries; + pendingEntries_.swap(entries); + return entries; +} + +void IntersectionObserverManager::shadowTreeDidMount( + RootShadowNode::Shared const &rootShadowNode, + double mountTime) noexcept { + updateIntersectionObservations(*rootShadowNode, mountTime); +} + +void IntersectionObserverManager::updateIntersectionObservations( + RootShadowNode const &rootShadowNode, + double mountTime) { + SystraceSection s( + "IntersectionObserverManager::updateIntersectionObservations"); + + std::vector entries; + + // Run intersection observations + { + std::shared_lock lock(observersMutex_); + + auto surfaceId = rootShadowNode.getSurfaceId(); + + auto observersIt = observersBySurfaceId_.find(surfaceId); + if (observersIt == observersBySurfaceId_.end()) { + return; + } + + auto &observers = observersIt->second; + for (auto &observer : observers) { + auto entry = + observer.updateIntersectionObservation(rootShadowNode, mountTime); + if (entry) { + entries.push_back(std::move(entry).value()); + } + } + } + + { + std::unique_lock lock(pendingEntriesMutex_); + pendingEntries_.insert( + pendingEntries_.end(), entries.begin(), entries.end()); + } + + notifyObserversIfNecessary(); +} + +/** + * This method allows us to avoid scheduling multiple calls to notify observers + * in the JS thread. We schedule one and skip subsequent ones (we just append + * the entries to the pending list and wait for the scheduled task to consume + * all of them). + */ +void IntersectionObserverManager::notifyObserversIfNecessary() { + bool dispatchNotification = false; + + { + std::unique_lock lock(pendingEntriesMutex_); + + if (!pendingEntries_.empty() && !notifiedIntersectionObservers_) { + notifiedIntersectionObservers_ = true; + dispatchNotification = true; + } + } + + if (dispatchNotification) { + notifyObservers(); + } +} + +void IntersectionObserverManager::notifyObservers() { + SystraceSection s("IntersectionObserverManager::notifyObservers"); + notifyIntersectionObserversCallback_(); +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverManager.h b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverManager.h new file mode 100644 index 00000000000..4691febc752 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverManager.h @@ -0,0 +1,74 @@ +/* + * 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. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include "IntersectionObserver.h" + +namespace facebook::react { + +class IntersectionObserverManager final : public UIManagerMountHook { + public: + IntersectionObserverManager(); + + void observe( + IntersectionObserverObserverId intersectionObserverId, + const ShadowNode::Shared &shadowNode, + std::vector thresholds, + UIManager const &uiManager); + + void unobserve( + IntersectionObserverObserverId intersectionObserverId, + ShadowNode const &shadowNode); + + void connect( + UIManager &uiManager, + std::function notifyIntersectionObserversCallback); + + void disconnect(UIManager &uiManager); + + std::vector takeRecords(); + +#pragma mark - UIManagerMountHook + + void shadowTreeDidMount( + RootShadowNode::Shared const &rootShadowNode, + double mountTime) noexcept override; + + private: + mutable std::unordered_map> + observersBySurfaceId_; + mutable std::shared_mutex observersMutex_; + + mutable std::function notifyIntersectionObserversCallback_; + + mutable std::vector pendingEntries_; + mutable std::mutex pendingEntriesMutex_; + + mutable bool notifiedIntersectionObservers_{}; + mutable bool mountHookRegistered_{}; + + void notifyObserversIfNecessary(); + void notifyObservers(); + + // Equivalent to + // https://w3c.github.io/IntersectionObserver/#update-intersection-observations-algo + void updateIntersectionObservations( + RootShadowNode const &rootShadowNode, + double mountTime); + + IntersectionObserver const &getRegisteredIntersectionObserver( + SurfaceId surfaceId, + IntersectionObserverObserverId observerId) const; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverState.cpp b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverState.cpp new file mode 100644 index 00000000000..79feead1cac --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverState.cpp @@ -0,0 +1,60 @@ +/* + * 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. + */ + +#include "IntersectionObserverState.h" + +namespace facebook::react { + +IntersectionObserverState IntersectionObserverState::Initial() { + static const IntersectionObserverState state = + IntersectionObserverState(IntersectionObserverStateType::Initial); + return state; +} + +IntersectionObserverState IntersectionObserverState::NotIntersecting() { + static const IntersectionObserverState state = + IntersectionObserverState(IntersectionObserverStateType::NotIntersecting); + return state; +} + +IntersectionObserverState IntersectionObserverState::Intersecting( + Float threshold) { + return IntersectionObserverState( + IntersectionObserverStateType::Intersecting, threshold); +} + +IntersectionObserverState::IntersectionObserverState( + IntersectionObserverStateType state) + : state_(state) {} + +IntersectionObserverState::IntersectionObserverState( + IntersectionObserverStateType state, + Float threshold) + : state_(state), threshold_(threshold) {} + +bool IntersectionObserverState::isIntersecting() const { + return state_ == IntersectionObserverStateType::Intersecting; +} + +bool IntersectionObserverState::operator==( + const IntersectionObserverState &other) const { + if (state_ != other.state_) { + return false; + } + + if (state_ != IntersectionObserverStateType::Intersecting) { + return true; + } + + return threshold_ == other.threshold_; +} + +bool IntersectionObserverState::operator!=( + const IntersectionObserverState &other) const { + return !(*this == other); +} +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverState.h b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverState.h new file mode 100644 index 00000000000..67ae4e98c93 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/intersection/IntersectionObserverState.h @@ -0,0 +1,46 @@ +/* + * 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. + */ + +#pragma once + +#include + +namespace { +enum class IntersectionObserverStateType { + Initial, + NotIntersecting, + Intersecting, +}; +} + +namespace facebook::react { + +class IntersectionObserverState { + public: + static IntersectionObserverState Initial(); + static IntersectionObserverState NotIntersecting(); + static IntersectionObserverState Intersecting(Float threshold); + + bool isIntersecting() const; + + bool operator==(const IntersectionObserverState &other) const; + bool operator!=(const IntersectionObserverState &other) const; + + private: + explicit IntersectionObserverState(IntersectionObserverStateType state); + IntersectionObserverState( + IntersectionObserverStateType state, + Float threshold); + + IntersectionObserverStateType state_; + + // This value is only relevant if the state is + // IntersectionObserverStateType::Intersecting. + Float threshold_{}; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index 9d8daa8fd4d..ef4100ef6c4 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp @@ -1179,4 +1179,8 @@ jsi::Value UIManagerBinding::get( return jsi::Value::undefined(); } +UIManager &UIManagerBinding::getUIManager() { + return *uiManager_; +} + } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.h b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.h index 0be85f6dc88..b2180ea4ede 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.h +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.h @@ -68,6 +68,8 @@ class UIManagerBinding : public jsi::HostObject { */ jsi::Value get(jsi::Runtime &runtime, jsi::PropNameID const &name) override; + UIManager &getUIManager(); + private: std::shared_ptr uiManager_; std::unique_ptr eventHandler_;