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_;