diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index 57ec592fd570..29847d028597 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -352,6 +352,13 @@ let reactMutationObserverNativeModule = RNTarget( dependencies: [.reactNativeDependencies, .reactCxxReact, .reactFabric, .reactTurboModuleBridging, .reactTurboModuleCore, .yoga] ) +/// React-resizeobservernativemodule.podspec +let reactResizeObserverNativeModule = RNTarget( + name: .reactResizeObserverNativeModule, + path: "ReactCommon/react/nativemodule/resizeobserver", + dependencies: [.reactNativeDependencies, .reactCxxReact, .reactFabric, .reactTurboModuleBridging, .reactTurboModuleCore, .reactGraphics, .reactGraphicsApple, .reactRuntimeScheduler, .yoga] +) + /// React-viewtransitionnativemodule.podspec let reactViewTransitionNativeModule = RNTarget( name: .reactViewTransitionNativeModule, @@ -476,7 +483,7 @@ let reactFabric = RNTarget( "scheduler/tests", ], dependencies: [.reactNativeDependencies, .reactJsiExecutor, .rctTypesafety, .reactTurboModuleCore, .jsi, .logger, .reactDebug, .reactFeatureFlags, .reactUtils, .reactRuntimeScheduler, .reactCxxReact, .reactRendererDebug, .reactGraphics, .yoga, .reactJsInspectorTracing], - sources: ["animated", "animationbackend", "animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/view/platform/cxx", "components/scrollview", "components/scrollview/platform/cxx", "components/scrollview/platform/ios", "components/legacyviewmanagerinterop", "components/legacyviewmanagerinterop/platform/ios", "dom", "scheduler", "mounting", "observers/events", "observers/intersection", "observers/mutation", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency", "viewtransition"] + sources: ["animated", "animationbackend", "animations", "attributedstring", "core", "componentregistry", "componentregistry/native", "components/root", "components/view", "components/view/platform/cxx", "components/scrollview", "components/scrollview/platform/cxx", "components/scrollview/platform/ios", "components/legacyviewmanagerinterop", "components/legacyviewmanagerinterop/platform/ios", "dom", "scheduler", "mounting", "observers/events", "observers/intersection", "observers/mutation", "observers/resize", "telemetry", "consistency", "leakchecker", "uimanager", "uimanager/consistency", "viewtransition"] ) let reactFabricInputAccessory = RNTarget( @@ -720,6 +727,7 @@ let targets = [ reactWebPerformanceNativeModule, reactIntersectionObserverNativeModule, reactMutationObserverNativeModule, + reactResizeObserverNativeModule, reactViewTransitionNativeModule, reactFeatureflagsNativemodule, reactNativeModuleDom, @@ -912,6 +920,7 @@ extension String { static let reactWebPerformanceNativeModule = "React-webperformancenativemodule" static let reactIntersectionObserverNativeModule = "React-intersectionobservernativemodule" static let reactMutationObserverNativeModule = "React-mutationobservernativemodule" + static let reactResizeObserverNativeModule = "React-resizeobservernativemodule" static let reactViewTransitionNativeModule = "React-viewtransitionnativemodule" static let reactFeatureflagsNativemodule = "React-featureflagsnativemodule" static let reactNativeModuleDom = "React-domnativemodule" diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 1596d8eb2a46..170f093bbdd9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<2fc347cdb33327437d29e5fd91e24011>> + * @generated SignedSource<<077468a30d16df93a0e61fd24ebba831>> */ /** @@ -288,6 +288,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun enablePropsUpdateReconciliationAndroid(): Boolean = accessor.enablePropsUpdateReconciliationAndroid() + /** + * Enables the ResizeObserver Web API in React Native. + */ + @JvmStatic + public fun enableResizeObserverByDefault(): Boolean = accessor.enableResizeObserverByDefault() + /** * When enabled, RuntimeScheduler_Modern clears pending tasks and rendering updates before handling an error. */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index 59d53089af06..a03f2ad7ea08 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<3a37118e535c901ca606aeef0d6add1e>> */ /** @@ -63,6 +63,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var enableNativeCSSParsingCache: Boolean? = null private var enablePreparedTextLayoutCache: Boolean? = null private var enablePropsUpdateReconciliationAndroidCache: Boolean? = null + private var enableResizeObserverByDefaultCache: Boolean? = null private var enableRuntimeSchedulerQueueClearingOnErrorCache: Boolean? = null private var enableSchedulerDelegateInvalidationCache: Boolean? = null private var enableSwiftUIBasedFiltersCache: Boolean? = null @@ -495,6 +496,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun enableResizeObserverByDefault(): Boolean { + var cached = enableResizeObserverByDefaultCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.enableResizeObserverByDefault() + enableResizeObserverByDefaultCache = cached + } + return cached + } + override fun enableRuntimeSchedulerQueueClearingOnError(): Boolean { var cached = enableRuntimeSchedulerQueueClearingOnErrorCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index fbbef2ca6587..7e1977b81fe4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<1ef72233f02973021b83bd2e2aa1f69b>> + * @generated SignedSource<> */ /** @@ -114,6 +114,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun enablePropsUpdateReconciliationAndroid(): Boolean + @DoNotStrip @JvmStatic public external fun enableResizeObserverByDefault(): Boolean + @DoNotStrip @JvmStatic public external fun enableRuntimeSchedulerQueueClearingOnError(): Boolean @DoNotStrip @JvmStatic public external fun enableSchedulerDelegateInvalidation(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index b9cd0522e87f..d8ee929060a1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<3b1eca96cc82c17fadc0ab9ee428b780>> */ /** @@ -109,6 +109,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun enablePropsUpdateReconciliationAndroid(): Boolean = false + override fun enableResizeObserverByDefault(): Boolean = false + override fun enableRuntimeSchedulerQueueClearingOnError(): Boolean = false override fun enableSchedulerDelegateInvalidation(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index 4ed61aa19114..26f707ff7f34 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<9ae32c46a5a6310ef96eb91c9ea5b12e>> + * @generated SignedSource<<4885541294d35171c8d1b876ebc1e706>> */ /** @@ -67,6 +67,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var enableNativeCSSParsingCache: Boolean? = null private var enablePreparedTextLayoutCache: Boolean? = null private var enablePropsUpdateReconciliationAndroidCache: Boolean? = null + private var enableResizeObserverByDefaultCache: Boolean? = null private var enableRuntimeSchedulerQueueClearingOnErrorCache: Boolean? = null private var enableSchedulerDelegateInvalidationCache: Boolean? = null private var enableSwiftUIBasedFiltersCache: Boolean? = null @@ -542,6 +543,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun enableResizeObserverByDefault(): Boolean { + var cached = enableResizeObserverByDefaultCache + if (cached == null) { + cached = currentProvider.enableResizeObserverByDefault() + accessedFeatureFlags.add("enableResizeObserverByDefault") + enableResizeObserverByDefaultCache = cached + } + return cached + } + override fun enableRuntimeSchedulerQueueClearingOnError(): Boolean { var cached = enableRuntimeSchedulerQueueClearingOnErrorCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index 1b52bd34d580..3e2c7d3e1e16 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<1a1d47f2d85404c776e55db40f7dbc6e>> + * @generated SignedSource<<6a5878b6269cd8ca9f763a1262518957>> */ /** @@ -109,6 +109,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun enablePropsUpdateReconciliationAndroid(): Boolean + @DoNotStrip public fun enableResizeObserverByDefault(): Boolean + @DoNotStrip public fun enableRuntimeSchedulerQueueClearingOnError(): Boolean @DoNotStrip public fun enableSchedulerDelegateInvalidation(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt index 00bbc586170d..3af99f9c7d50 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt @@ -118,6 +118,7 @@ add_react_common_subdir(react/renderer/leakchecker) add_react_common_subdir(react/renderer/observers/events) add_react_common_subdir(react/renderer/observers/intersection) add_react_common_subdir(react/renderer/observers/mutation) +add_react_common_subdir(react/renderer/observers/resize) add_react_common_subdir(react/renderer/textlayoutmanager) add_react_common_subdir(react/utils) add_react_common_subdir(react/bridging) @@ -130,6 +131,7 @@ add_react_common_subdir(react/nativemodule/microtasks) add_react_common_subdir(react/nativemodule/idlecallbacks) add_react_common_subdir(react/nativemodule/intersectionobserver) add_react_common_subdir(react/nativemodule/mutationobserver) +add_react_common_subdir(react/nativemodule/resizeobserver) add_react_common_subdir(react/nativemodule/viewtransition) add_react_common_subdir(react/nativemodule/webperformance) add_react_common_subdir(react/networking) @@ -202,6 +204,7 @@ add_library(reactnative $ $ $ + $ $ $ $ @@ -226,6 +229,7 @@ add_library(reactnative $ $ $ + $ $ $ $ @@ -302,6 +306,7 @@ target_include_directories(reactnative $ $ $ + $ $ $ $ @@ -328,6 +333,7 @@ target_include_directories(reactnative $ $ $ + $ $ $ $ diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp index bd0bdc7ac32b..2d7a919850ed 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<28de1e205f30135e96d5cb94a902faec>> + * @generated SignedSource<<8f62bacdc7d44c6c7583e87bd3f9a6a9>> */ /** @@ -297,6 +297,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool enableResizeObserverByDefault() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enableResizeObserverByDefault"); + return method(javaProvider_); + } + bool enableRuntimeSchedulerQueueClearingOnError() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enableRuntimeSchedulerQueueClearingOnError"); @@ -780,6 +786,11 @@ bool JReactNativeFeatureFlagsCxxInterop::enablePropsUpdateReconciliationAndroid( return ReactNativeFeatureFlags::enablePropsUpdateReconciliationAndroid(); } +bool JReactNativeFeatureFlagsCxxInterop::enableResizeObserverByDefault( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::enableResizeObserverByDefault(); +} + bool JReactNativeFeatureFlagsCxxInterop::enableRuntimeSchedulerQueueClearingOnError( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::enableRuntimeSchedulerQueueClearingOnError(); @@ -1160,6 +1171,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "enablePropsUpdateReconciliationAndroid", JReactNativeFeatureFlagsCxxInterop::enablePropsUpdateReconciliationAndroid), + makeNativeMethod( + "enableResizeObserverByDefault", + JReactNativeFeatureFlagsCxxInterop::enableResizeObserverByDefault), makeNativeMethod( "enableRuntimeSchedulerQueueClearingOnError", JReactNativeFeatureFlagsCxxInterop::enableRuntimeSchedulerQueueClearingOnError), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index a787b50e7c9c..7eb9478934cc 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<535898dd9498c65f30d56122c06b408b>> + * @generated SignedSource<<25a4d67d5f41cecdb3194eac15d91ddb>> */ /** @@ -159,6 +159,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool enablePropsUpdateReconciliationAndroid( facebook::jni::alias_ref); + static bool enableResizeObserverByDefault( + facebook::jni::alias_ref); + static bool enableRuntimeSchedulerQueueClearingOnError( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactCommon/React-Fabric.podspec b/packages/react-native/ReactCommon/React-Fabric.podspec index 183c039786eb..29f225f8b9e6 100644 --- a/packages/react-native/ReactCommon/React-Fabric.podspec +++ b/packages/react-native/ReactCommon/React-Fabric.podspec @@ -200,6 +200,12 @@ Pod::Spec.new do |s| sss.exclude_files = "react/renderer/observers/mutation/tests" sss.header_dir = "react/renderer/observers/mutation" end + + ss.subspec "resize" do |sss| + sss.source_files = podspec_sources("react/renderer/observers/resize/**/*.{m,mm,cpp,h}", "react/renderer/observers/resize/**/*.h") + sss.exclude_files = "react/renderer/observers/resize/tests" + sss.header_dir = "react/renderer/observers/resize" + end end s.subspec "telemetry" do |ss| diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index 2fa66b894d23..50303193fe36 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<8fd664cb106945e5c6e436a0cf36120d>> + * @generated SignedSource<> */ /** @@ -198,6 +198,10 @@ bool ReactNativeFeatureFlags::enablePropsUpdateReconciliationAndroid() { return getAccessor().enablePropsUpdateReconciliationAndroid(); } +bool ReactNativeFeatureFlags::enableResizeObserverByDefault() { + return getAccessor().enableResizeObserverByDefault(); +} + bool ReactNativeFeatureFlags::enableRuntimeSchedulerQueueClearingOnError() { return getAccessor().enableRuntimeSchedulerQueueClearingOnError(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index 648d7bf4a0c3..838e44ec5688 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<196ba25b807fd064ca6150c7d2cd741c>> + * @generated SignedSource<<2c96707eeaf5b5c0164a6c5f4fda3cd8>> */ /** @@ -254,6 +254,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool enablePropsUpdateReconciliationAndroid(); + /** + * Enables the ResizeObserver Web API in React Native. + */ + RN_EXPORT static bool enableResizeObserverByDefault(); + /** * When enabled, RuntimeScheduler_Modern clears pending tasks and rendering updates before handling an error. */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index 213255b954b8..776f00dc6c11 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<0500b2c3cb7e5f22e7344c7f5a2e6ca4>> + * @generated SignedSource<> */ /** @@ -803,6 +803,24 @@ bool ReactNativeFeatureFlagsAccessor::enablePropsUpdateReconciliationAndroid() { return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::enableResizeObserverByDefault() { + auto flagValue = enableResizeObserverByDefault_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(43, "enableResizeObserverByDefault"); + + flagValue = currentProvider_->enableResizeObserverByDefault(); + enableResizeObserverByDefault_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::enableRuntimeSchedulerQueueClearingOnError() { auto flagValue = enableRuntimeSchedulerQueueClearingOnError_.load(); @@ -812,7 +830,7 @@ bool ReactNativeFeatureFlagsAccessor::enableRuntimeSchedulerQueueClearingOnError // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(43, "enableRuntimeSchedulerQueueClearingOnError"); + markFlagAsAccessed(44, "enableRuntimeSchedulerQueueClearingOnError"); flagValue = currentProvider_->enableRuntimeSchedulerQueueClearingOnError(); enableRuntimeSchedulerQueueClearingOnError_ = flagValue; @@ -830,7 +848,7 @@ bool ReactNativeFeatureFlagsAccessor::enableSchedulerDelegateInvalidation() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(44, "enableSchedulerDelegateInvalidation"); + markFlagAsAccessed(45, "enableSchedulerDelegateInvalidation"); flagValue = currentProvider_->enableSchedulerDelegateInvalidation(); enableSchedulerDelegateInvalidation_ = flagValue; @@ -848,7 +866,7 @@ bool ReactNativeFeatureFlagsAccessor::enableSwiftUIBasedFilters() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(45, "enableSwiftUIBasedFilters"); + markFlagAsAccessed(46, "enableSwiftUIBasedFilters"); flagValue = currentProvider_->enableSwiftUIBasedFilters(); enableSwiftUIBasedFilters_ = flagValue; @@ -866,7 +884,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewCulling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(46, "enableViewCulling"); + markFlagAsAccessed(47, "enableViewCulling"); flagValue = currentProvider_->enableViewCulling(); enableViewCulling_ = flagValue; @@ -884,7 +902,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecycling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(47, "enableViewRecycling"); + markFlagAsAccessed(48, "enableViewRecycling"); flagValue = currentProvider_->enableViewRecycling(); enableViewRecycling_ = flagValue; @@ -902,7 +920,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForImage() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(48, "enableViewRecyclingForImage"); + markFlagAsAccessed(49, "enableViewRecyclingForImage"); flagValue = currentProvider_->enableViewRecyclingForImage(); enableViewRecyclingForImage_ = flagValue; @@ -920,7 +938,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForScrollView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(49, "enableViewRecyclingForScrollView"); + markFlagAsAccessed(50, "enableViewRecyclingForScrollView"); flagValue = currentProvider_->enableViewRecyclingForScrollView(); enableViewRecyclingForScrollView_ = flagValue; @@ -938,7 +956,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForText() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(50, "enableViewRecyclingForText"); + markFlagAsAccessed(51, "enableViewRecyclingForText"); flagValue = currentProvider_->enableViewRecyclingForText(); enableViewRecyclingForText_ = flagValue; @@ -956,7 +974,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(51, "enableViewRecyclingForView"); + markFlagAsAccessed(52, "enableViewRecyclingForView"); flagValue = currentProvider_->enableViewRecyclingForView(); enableViewRecyclingForView_ = flagValue; @@ -974,7 +992,7 @@ bool ReactNativeFeatureFlagsAccessor::enableVirtualViewContainerStateExperimenta // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(52, "enableVirtualViewContainerStateExperimental"); + markFlagAsAccessed(53, "enableVirtualViewContainerStateExperimental"); flagValue = currentProvider_->enableVirtualViewContainerStateExperimental(); enableVirtualViewContainerStateExperimental_ = flagValue; @@ -992,7 +1010,7 @@ bool ReactNativeFeatureFlagsAccessor::fixDifferentiatorParentTagForUnflattenCase // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(53, "fixDifferentiatorParentTagForUnflattenCase"); + markFlagAsAccessed(54, "fixDifferentiatorParentTagForUnflattenCase"); flagValue = currentProvider_->fixDifferentiatorParentTagForUnflattenCase(); fixDifferentiatorParentTagForUnflattenCase_ = flagValue; @@ -1010,7 +1028,7 @@ bool ReactNativeFeatureFlagsAccessor::fixMappingOfEventPrioritiesBetweenFabricAn // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(54, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); + markFlagAsAccessed(55, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); flagValue = currentProvider_->fixMappingOfEventPrioritiesBetweenFabricAndReact(); fixMappingOfEventPrioritiesBetweenFabricAndReact_ = flagValue; @@ -1028,7 +1046,7 @@ bool ReactNativeFeatureFlagsAccessor::fixYogaFlexBasisFitContentInMainAxis() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(55, "fixYogaFlexBasisFitContentInMainAxis"); + markFlagAsAccessed(56, "fixYogaFlexBasisFitContentInMainAxis"); flagValue = currentProvider_->fixYogaFlexBasisFitContentInMainAxis(); fixYogaFlexBasisFitContentInMainAxis_ = flagValue; @@ -1046,7 +1064,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxAssertSingleHostState() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(56, "fuseboxAssertSingleHostState"); + markFlagAsAccessed(57, "fuseboxAssertSingleHostState"); flagValue = currentProvider_->fuseboxAssertSingleHostState(); fuseboxAssertSingleHostState_ = flagValue; @@ -1064,7 +1082,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxEnabledRelease() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(57, "fuseboxEnabledRelease"); + markFlagAsAccessed(58, "fuseboxEnabledRelease"); flagValue = currentProvider_->fuseboxEnabledRelease(); fuseboxEnabledRelease_ = flagValue; @@ -1082,7 +1100,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxFrameRecordingEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(58, "fuseboxFrameRecordingEnabled"); + markFlagAsAccessed(59, "fuseboxFrameRecordingEnabled"); flagValue = currentProvider_->fuseboxFrameRecordingEnabled(); fuseboxFrameRecordingEnabled_ = flagValue; @@ -1100,7 +1118,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxScreenshotCaptureEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(59, "fuseboxScreenshotCaptureEnabled"); + markFlagAsAccessed(60, "fuseboxScreenshotCaptureEnabled"); flagValue = currentProvider_->fuseboxScreenshotCaptureEnabled(); fuseboxScreenshotCaptureEnabled_ = flagValue; @@ -1118,7 +1136,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxWebSocketEventsEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(60, "fuseboxWebSocketEventsEnabled"); + markFlagAsAccessed(61, "fuseboxWebSocketEventsEnabled"); flagValue = currentProvider_->fuseboxWebSocketEventsEnabled(); fuseboxWebSocketEventsEnabled_ = flagValue; @@ -1136,7 +1154,7 @@ bool ReactNativeFeatureFlagsAccessor::optimizedAnimatedPropUpdates() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(61, "optimizedAnimatedPropUpdates"); + markFlagAsAccessed(62, "optimizedAnimatedPropUpdates"); flagValue = currentProvider_->optimizedAnimatedPropUpdates(); optimizedAnimatedPropUpdates_ = flagValue; @@ -1154,7 +1172,7 @@ bool ReactNativeFeatureFlagsAccessor::overrideBySynchronousMountPropsAtMountingA // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(62, "overrideBySynchronousMountPropsAtMountingAndroid"); + markFlagAsAccessed(63, "overrideBySynchronousMountPropsAtMountingAndroid"); flagValue = currentProvider_->overrideBySynchronousMountPropsAtMountingAndroid(); overrideBySynchronousMountPropsAtMountingAndroid_ = flagValue; @@ -1172,7 +1190,7 @@ bool ReactNativeFeatureFlagsAccessor::perfIssuesEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(63, "perfIssuesEnabled"); + markFlagAsAccessed(64, "perfIssuesEnabled"); flagValue = currentProvider_->perfIssuesEnabled(); perfIssuesEnabled_ = flagValue; @@ -1190,7 +1208,7 @@ bool ReactNativeFeatureFlagsAccessor::perfMonitorV2Enabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(64, "perfMonitorV2Enabled"); + markFlagAsAccessed(65, "perfMonitorV2Enabled"); flagValue = currentProvider_->perfMonitorV2Enabled(); perfMonitorV2Enabled_ = flagValue; @@ -1208,7 +1226,7 @@ double ReactNativeFeatureFlagsAccessor::preparedTextCacheSize() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(65, "preparedTextCacheSize"); + markFlagAsAccessed(66, "preparedTextCacheSize"); flagValue = currentProvider_->preparedTextCacheSize(); preparedTextCacheSize_ = flagValue; @@ -1226,7 +1244,7 @@ bool ReactNativeFeatureFlagsAccessor::preventShadowTreeCommitExhaustion() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(66, "preventShadowTreeCommitExhaustion"); + markFlagAsAccessed(67, "preventShadowTreeCommitExhaustion"); flagValue = currentProvider_->preventShadowTreeCommitExhaustion(); preventShadowTreeCommitExhaustion_ = flagValue; @@ -1244,7 +1262,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2Android() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(67, "redBoxV2Android"); + markFlagAsAccessed(68, "redBoxV2Android"); flagValue = currentProvider_->redBoxV2Android(); redBoxV2Android_ = flagValue; @@ -1262,7 +1280,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2IOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(68, "redBoxV2IOS"); + markFlagAsAccessed(69, "redBoxV2IOS"); flagValue = currentProvider_->redBoxV2IOS(); redBoxV2IOS_ = flagValue; @@ -1280,7 +1298,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldPressibilityUseW3CPointerEventsForHo // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(69, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(70, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1298,7 +1316,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldTriggerResponderTransferOnScrollAndr // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(70, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(71, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1316,7 +1334,7 @@ bool ReactNativeFeatureFlagsAccessor::skipActivityIdentityAssertionOnHostPause() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(71, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(72, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1334,7 +1352,7 @@ bool ReactNativeFeatureFlagsAccessor::syncAndroidClipBoundsWithOverflow() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(72, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(73, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1352,7 +1370,7 @@ bool ReactNativeFeatureFlagsAccessor::traceTurboModulePromiseRejectionsOnAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(73, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(74, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1370,7 +1388,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommit( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(74, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(75, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1388,7 +1406,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommitT // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(75, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(76, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1406,7 +1424,7 @@ bool ReactNativeFeatureFlagsAccessor::useAlwaysAvailableJSErrorHandling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(76, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(77, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1424,7 +1442,7 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(77, "useFabricInterop"); + markFlagAsAccessed(78, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1442,7 +1460,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(78, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(79, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1460,7 +1478,7 @@ bool ReactNativeFeatureFlagsAccessor::useNestedScrollViewAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(79, "useNestedScrollViewAndroid"); + markFlagAsAccessed(80, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1478,7 +1496,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(80, "useSharedAnimatedBackend"); + markFlagAsAccessed(81, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1496,7 +1514,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(81, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(82, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1514,7 +1532,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(82, "useTurboModuleInterop"); + markFlagAsAccessed(83, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1532,7 +1550,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(83, "viewCullingOutsetRatio"); + markFlagAsAccessed(84, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1550,7 +1568,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(84, "viewTransitionEnabled"); + markFlagAsAccessed(85, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1568,7 +1586,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionUseHardwareBitmapAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(86, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1586,7 +1604,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(86, "virtualViewPrerenderRatio"); + markFlagAsAccessed(87, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index 76686e57e2aa..9094946fa7d4 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<88dd99a5753390988c878519a0f468c6>> + * @generated SignedSource<<6d643872eb3952a9333071ca73e0d4ba>> */ /** @@ -75,6 +75,7 @@ class ReactNativeFeatureFlagsAccessor { bool enableNativeCSSParsing(); bool enablePreparedTextLayout(); bool enablePropsUpdateReconciliationAndroid(); + bool enableResizeObserverByDefault(); bool enableRuntimeSchedulerQueueClearingOnError(); bool enableSchedulerDelegateInvalidation(); bool enableSwiftUIBasedFilters(); @@ -130,7 +131,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 87> accessedFeatureFlags_; + std::array, 88> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -175,6 +176,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> enableNativeCSSParsing_; std::atomic> enablePreparedTextLayout_; std::atomic> enablePropsUpdateReconciliationAndroid_; + std::atomic> enableResizeObserverByDefault_; std::atomic> enableRuntimeSchedulerQueueClearingOnError_; std::atomic> enableSchedulerDelegateInvalidation_; std::atomic> enableSwiftUIBasedFilters_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index c3d560395212..03411cd8354c 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<9809c179e61abe55f544d6c8227a5c01>> + * @generated SignedSource<<406567571a6782277bb5b3852eac0b11>> */ /** @@ -199,6 +199,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool enableResizeObserverByDefault() override { + return false; + } + bool enableRuntimeSchedulerQueueClearingOnError() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 4a3915b7b4c6..062aeb455bfd 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> */ /** @@ -432,6 +432,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::enablePropsUpdateReconciliationAndroid(); } + bool enableResizeObserverByDefault() override { + auto value = values_["enableResizeObserverByDefault"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::enableResizeObserverByDefault(); + } + bool enableRuntimeSchedulerQueueClearingOnError() override { auto value = values_["enableRuntimeSchedulerQueueClearingOnError"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index 88c71ced84fb..4090fb2d3a91 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<2c49f7d9235fdb157fa1291cf4ee4c52>> + * @generated SignedSource<<781d139a0632a92e7074f99d6937d67c>> */ /** @@ -68,6 +68,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool enableNativeCSSParsing() = 0; virtual bool enablePreparedTextLayout() = 0; virtual bool enablePropsUpdateReconciliationAndroid() = 0; + virtual bool enableResizeObserverByDefault() = 0; virtual bool enableRuntimeSchedulerQueueClearingOnError() = 0; virtual bool enableSchedulerDelegateInvalidation() = 0; virtual bool enableSwiftUIBasedFilters() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/defaults/CMakeLists.txt b/packages/react-native/ReactCommon/react/nativemodule/defaults/CMakeLists.txt index 8e583a48d748..77820e7020be 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/defaults/CMakeLists.txt +++ b/packages/react-native/ReactCommon/react/nativemodule/defaults/CMakeLists.txt @@ -23,6 +23,7 @@ target_link_libraries(react_nativemodule_defaults react_nativemodule_idlecallbacks react_nativemodule_intersectionobserver react_nativemodule_mutationobserver + react_nativemodule_resizeobserver react_nativemodule_viewtransition react_nativemodule_webperformance react_renderer_animated diff --git a/packages/react-native/ReactCommon/react/nativemodule/defaults/DefaultTurboModules.cpp b/packages/react-native/ReactCommon/react/nativemodule/defaults/DefaultTurboModules.cpp index ddc0242c3e66..a5c8e23f02f7 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/defaults/DefaultTurboModules.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/defaults/DefaultTurboModules.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -58,6 +59,12 @@ namespace facebook::react { } } + if (ReactNativeFeatureFlags::enableResizeObserverByDefault()) { + if (name == NativeResizeObserver::kModuleName) { + return std::make_shared(jsInvoker); + } + } + if (ReactNativeFeatureFlags::viewTransitionEnabled()) { if (name == NativeViewTransition::kModuleName) { return std::make_shared(jsInvoker); diff --git a/packages/react-native/ReactCommon/react/nativemodule/defaults/React-defaultsnativemodule.podspec b/packages/react-native/ReactCommon/react/nativemodule/defaults/React-defaultsnativemodule.podspec index e68eb7c530a4..9a0f25c036ba 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/defaults/React-defaultsnativemodule.podspec +++ b/packages/react-native/ReactCommon/react/nativemodule/defaults/React-defaultsnativemodule.podspec @@ -54,6 +54,7 @@ Pod::Spec.new do |s| s.dependency "React-idlecallbacksnativemodule" s.dependency "React-intersectionobservernativemodule" s.dependency "React-mutationobservernativemodule" + s.dependency "React-resizeobservernativemodule" s.dependency "React-viewtransitionnativemodule" s.dependency "React-webperformancenativemodule" s.dependency "React-Fabric/animated" diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index 1e615d440c6d..ccf4a377ad03 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<9c49f2c73941bd43c3a2ab68eb561b6e>> + * @generated SignedSource<> */ /** @@ -259,6 +259,11 @@ bool NativeReactNativeFeatureFlags::enablePropsUpdateReconciliationAndroid( return ReactNativeFeatureFlags::enablePropsUpdateReconciliationAndroid(); } +bool NativeReactNativeFeatureFlags::enableResizeObserverByDefault( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::enableResizeObserverByDefault(); +} + bool NativeReactNativeFeatureFlags::enableRuntimeSchedulerQueueClearingOnError( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::enableRuntimeSchedulerQueueClearingOnError(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index e96aad170678..dd15e6e470fb 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<741340cf9c128417368324bb80501f18>> + * @generated SignedSource<> */ /** @@ -122,6 +122,8 @@ class NativeReactNativeFeatureFlags bool enablePropsUpdateReconciliationAndroid(jsi::Runtime& runtime); + bool enableResizeObserverByDefault(jsi::Runtime& runtime); + bool enableRuntimeSchedulerQueueClearingOnError(jsi::Runtime& runtime); bool enableSchedulerDelegateInvalidation(jsi::Runtime& runtime); diff --git a/packages/react-native/ReactCommon/react/nativemodule/intersectionobserver/NativeIntersectionObserver.cpp b/packages/react-native/ReactCommon/react/nativemodule/intersectionobserver/NativeIntersectionObserver.cpp index 6b79c9530957..9cd19c5ae076 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/intersectionobserver/NativeIntersectionObserver.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/intersectionobserver/NativeIntersectionObserver.cpp @@ -24,27 +24,6 @@ NativeIntersectionObserverModuleProvider( namespace facebook::react { -namespace { - -jsi::Object tokenFromShadowNodeFamily( - jsi::Runtime& runtime, - ShadowNodeFamily::Shared shadowNodeFamily) { - jsi::Object obj(runtime); - // Need to const_cast since JSI only allows non-const pointees - obj.setNativeState( - runtime, - std::const_pointer_cast(std::move(shadowNodeFamily))); - return obj; -} - -ShadowNodeFamily::Shared shadowNodeFamilyFromToken( - jsi::Runtime& runtime, - jsi::Object token) { - return token.getNativeState(runtime); -} - -} // namespace - NativeIntersectionObserver::NativeIntersectionObserver( std::shared_ptr jsInvoker) : NativeIntersectionObserverCxxSpec(std::move(jsInvoker)) {} diff --git a/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/CMakeLists.txt b/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/CMakeLists.txt new file mode 100644 index 000000000000..c3e707910408 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/CMakeLists.txt @@ -0,0 +1,28 @@ +# 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. + +cmake_minimum_required(VERSION 3.13) +set(CMAKE_VERBOSE_MAKEFILE on) + +include(${REACT_COMMON_DIR}/cmake-utils/react-native-flags.cmake) + +file(GLOB react_nativemodule_resizeobserver_SRC CONFIGURE_DEPENDS *.cpp) +add_library(react_nativemodule_resizeobserver OBJECT ${react_nativemodule_resizeobserver_SRC}) + +target_include_directories(react_nativemodule_resizeobserver PUBLIC ${REACT_COMMON_DIR}) + +target_link_libraries(react_nativemodule_resizeobserver + react_codegen_rncore + react_cxxreact + react_renderer_bridging + react_renderer_core + react_renderer_graphics + react_renderer_observers_resize + react_renderer_runtimescheduler + react_renderer_uimanager + rrc_view +) +target_compile_reactnative_options(react_nativemodule_resizeobserver PRIVATE) +target_compile_options(react_nativemodule_resizeobserver PRIVATE -Wpedantic -Wno-deprecated-declarations) diff --git a/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/NativeResizeObserver.cpp b/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/NativeResizeObserver.cpp new file mode 100644 index 000000000000..28405721f764 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/NativeResizeObserver.cpp @@ -0,0 +1,143 @@ +/* + * 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 "NativeResizeObserver.h" +#include +#include +#include +#include + +#ifdef RN_DISABLE_OSS_PLUGIN_HEADER +#include "Plugins.h" +#endif + +std::shared_ptr +NativeResizeObserverModuleProvider( + std::shared_ptr jsInvoker) { + return std::make_shared( + std::move(jsInvoker)); +} + +namespace facebook::react { + +NativeResizeObserver::NativeResizeObserver( + std::shared_ptr jsInvoker) + : NativeResizeObserverCxxSpec(std::move(jsInvoker)) {} + +jsi::Object NativeResizeObserver::observe( + jsi::Runtime& runtime, + NativeResizeObserverObserveOptions options) { + auto resizeObserverId = options.resizeObserverId; + auto shadowNode = options.targetShadowNode; + auto shadowNodeFamily = shadowNode->getFamilyShared(); + auto boxOptions = boxOptionsFromOptionalString(options.box); + auto& uiManager = getUIManagerFromRuntime(runtime); + + resizeObserverManager_.observe( + resizeObserverId, shadowNodeFamily, boxOptions, uiManager); + + return tokenFromShadowNodeFamily(runtime, shadowNodeFamily); +} + +void NativeResizeObserver::unobserve( + jsi::Runtime& runtime, + NativeResizeObserverResizeObserverId resizeObserverId, + jsi::Object targetToken) { + auto shadowNodeFamily = + shadowNodeFamilyFromToken(runtime, std::move(targetToken)); + resizeObserverManager_.unobserve(resizeObserverId, shadowNodeFamily); +} + +void NativeResizeObserver::connect( + jsi::Runtime& runtime, + NativeResizeObserverNotifyCallback notifyResizeObserversFunction) { + auto& uiManager = getUIManagerFromRuntime(runtime); + + // `SyncCallback` is move-only, so share it: the manager copies the callable + // before invoking it, so that a `disconnect()` from within a callback can't + // destroy it mid-call. It also already holds the runtime it was created with, + // hence the unused parameter. + auto callback = std::make_shared( + std::move(notifyResizeObserversFunction)); + + resizeObserverManager_.connect( + *RuntimeSchedulerBinding::getBinding(runtime)->getRuntimeScheduler(), + uiManager, + [callback](jsi::Runtime& /*runtime*/, bool hasResizeLoopError) { + (*callback)(hasResizeLoopError); + }); +} + +void NativeResizeObserver::disconnect(jsi::Runtime& runtime) { + auto& uiManager = getUIManagerFromRuntime(runtime); + resizeObserverManager_.disconnect( + *RuntimeSchedulerBinding::getBinding(runtime)->getRuntimeScheduler(), + uiManager); +} + +std::vector NativeResizeObserver::takeRecords( + jsi::Runtime& runtime) { + auto entries = resizeObserverManager_.takeRecords(); + + std::vector nativeModuleEntries; + nativeModuleEntries.reserve(entries.size()); + + for (const auto& entry : entries) { + nativeModuleEntries.emplace_back( + convertToNativeModuleEntry(entry, runtime)); + } + + return nativeModuleEntries; +} + +NativeResizeObserverEntry NativeResizeObserver::convertToNativeModuleEntry( + const ResizeObserverEntry& entry, + jsi::Runtime& runtime) { + auto contentRect = RectAsTuple{ + entry.contentRect.origin.x, + entry.contentRect.origin.y, + entry.contentRect.size.width, + entry.contentRect.size.height}; + auto borderBoxSize = + SizeAsTuple{entry.borderBoxSize.width, entry.borderBoxSize.height}; + auto contentBoxSize = + SizeAsTuple{entry.contentBoxSize.width, entry.contentBoxSize.height}; + auto devicePixelContentBoxSize = SizeAsTuple{ + entry.devicePixelContentBoxSize.width, + entry.devicePixelContentBoxSize.height}; + auto nativeModuleEntry = NativeResizeObserverEntry{ + entry.resizeObserverId, + (*entry.shadowNodeFamily).getInstanceHandle(runtime), + contentRect, + borderBoxSize, + contentBoxSize, + devicePixelContentBoxSize}; + + return nativeModuleEntry; +} + +UIManager& NativeResizeObserver::getUIManagerFromRuntime( + jsi::Runtime& runtime) { + return UIManagerBinding::getBinding(runtime)->getUIManager(); +} + +ResizeObserverBoxOptions NativeResizeObserver::boxOptionsFromOptionalString( + const std::optional& box) { + if (box.has_value()) { + if (box.value() == "border-box") { + return ResizeObserverBoxOptions::BorderBox; + } + if (box.value() == "device-pixel-content-box") { + return ResizeObserverBoxOptions::DevicePixelContentBox; + } + } + + // "content-box" is the default value. + return ResizeObserverBoxOptions::ContentBox; +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/NativeResizeObserver.h b/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/NativeResizeObserver.h new file mode 100644 index 000000000000..e4907a0e27f8 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/NativeResizeObserver.h @@ -0,0 +1,95 @@ +/* + * 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 + +#if __has_include("FBReactNativeSpecJSI.h") // CocoaPod headers on Apple +#include "FBReactNativeSpecJSI.h" +#else +#include +#endif +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +using NativeResizeObserverResizeObserverId = ResizeObserverObserverId; +using RectAsTuple = std::tuple; +using SizeAsTuple = std::tuple; +using NativeResizeObserverNotifyCallback = SyncCallback; + +using NativeResizeObserverObserveOptions = + NativeResizeObserverNativeResizeObserverObserveOptions< + // resizeObserverId + NativeResizeObserverResizeObserverId, + // targetShadowNode + std::shared_ptr, + // box + std::optional>; + +template <> +struct Bridging + : NativeResizeObserverNativeResizeObserverObserveOptionsBridging< + NativeResizeObserverObserveOptions> {}; + +using NativeResizeObserverEntry = NativeResizeObserverNativeResizeObserverEntry< + // resizeObserverId + NativeResizeObserverResizeObserverId, + // targetInstanceHandle + jsi::Value, + // contentRect + RectAsTuple, + // borderBoxSize + SizeAsTuple, + // contentBoxSize + SizeAsTuple, + // devicePixelContentBoxSize + SizeAsTuple>; + +template <> +struct Bridging + : NativeResizeObserverNativeResizeObserverEntryBridging< + NativeResizeObserverEntry> {}; + +class NativeResizeObserver + : public NativeResizeObserverCxxSpec { + public: + NativeResizeObserver(std::shared_ptr jsInvoker); + + jsi::Object observe( + jsi::Runtime& runtime, + NativeResizeObserverObserveOptions options); + + void unobserve( + jsi::Runtime& runtime, + NativeResizeObserverResizeObserverId resizeObserverId, + jsi::Object targetToken); + + void connect( + jsi::Runtime& runtime, + NativeResizeObserverNotifyCallback notifyResizeObserversFunction); + + void disconnect(jsi::Runtime& runtime); + + std::vector takeRecords(jsi::Runtime& runtime); + + private: + ResizeObserverManager resizeObserverManager_{}; + + static UIManager& getUIManagerFromRuntime(jsi::Runtime& runtime); + static ResizeObserverBoxOptions boxOptionsFromOptionalString( + const std::optional& box); + static NativeResizeObserverEntry convertToNativeModuleEntry( + const ResizeObserverEntry& entry, + jsi::Runtime& runtime); +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/React-resizeobservernativemodule.podspec b/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/React-resizeobservernativemodule.podspec new file mode 100644 index 000000000000..f9747f00b107 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/React-resizeobservernativemodule.podspec @@ -0,0 +1,68 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "..", "..", "..", "..", "package.json"))) +version = package['version'] + +source = { :git => 'https://github.com/facebook/react-native.git' } +if version == '1000.0.0' + # This is an unpublished version, use the latest commit hash of the react-native repo, which we're presumably in. + source[:commit] = `git rev-parse HEAD`.strip if system("git rev-parse --git-dir > /dev/null 2>&1") +else + source[:tag] = "v#{version}" +end + +header_search_paths = [ + "\"$(PODS_ROOT)/Headers/Private/Yoga\"", +] + +if ENV['USE_FRAMEWORKS'] + header_search_paths << "\"$(PODS_TARGET_SRCROOT)/../../..\"" # this is needed to allow the module access its own files +end + +Pod::Spec.new do |s| + s.name = "React-resizeobservernativemodule" + s.version = version + s.summary = "React Native resize observer native module" + s.homepage = "https://reactnative.dev/" + s.license = package["license"] + s.author = "Meta Platforms, Inc. and its affiliates" + s.platforms = min_supported_versions + s.source = source + s.source_files = podspec_sources("*.{cpp,h}", "*.h") + s.header_dir = "react/nativemodule/resizeobserver" + s.pod_target_xcconfig = { "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), + "HEADER_SEARCH_PATHS" => header_search_paths.join(' '), + "OTHER_CFLAGS" => "$(inherited)", + "DEFINES_MODULE" => "YES" } + + if ENV['USE_FRAMEWORKS'] + s.module_name = "resizeobservernativemodule" + s.header_mappings_dir = "../.." + end + + s.dependency "Yoga" + s.dependency "React-jsi" + s.dependency "React-jsiexecutor" + s.dependency "React-cxxreact" + + depend_on_js_engine(s) + add_rn_third_party_dependencies(s) + add_rncore_dependency(s) + + s.dependency "ReactCommon/turbomodule/core" + s.dependency "React-bridging" + + s.dependency "React-Fabric" + s.dependency "React-Fabric/bridging" + s.dependency "React-Fabric/observers/resize" + s.dependency "React-runtimescheduler" + add_dependency(s, "React-RCTFBReactNativeSpec") + add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"]) + add_dependency(s, "React-graphics", :additional_framework_paths => ["react/renderer/graphics/platform/ios"]) + +end diff --git a/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/__docs__/README.md b/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/__docs__/README.md new file mode 100644 index 000000000000..dcd11199be2b --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/resizeobserver/__docs__/README.md @@ -0,0 +1,13 @@ +# ResizeObserver + +[🏠 Home](../../../../../../../__docs__/README.md) + +This directory contains the native module used by the +[ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) +in React Native. + +## 🔗 Relationship with other systems + +### Part of + +- [ResizeObserver API](../../../../../src/private/webapis/resizeobserver/__docs__/README.md) diff --git a/packages/react-native/ReactCommon/react/renderer/observers/resize/CMakeLists.txt b/packages/react-native/ReactCommon/react/renderer/observers/resize/CMakeLists.txt new file mode 100644 index 000000000000..84949939fecd --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/resize/CMakeLists.txt @@ -0,0 +1,26 @@ +# 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. + +cmake_minimum_required(VERSION 3.13) +set(CMAKE_VERBOSE_MAKEFILE on) + +include(${REACT_COMMON_DIR}/cmake-utils/react-native-flags.cmake) + +file(GLOB react_renderer_observers_resize_SRC CONFIGURE_DEPENDS *.cpp) +add_library(react_renderer_observers_resize OBJECT ${react_renderer_observers_resize_SRC}) + +target_include_directories(react_renderer_observers_resize PUBLIC ${REACT_COMMON_DIR}) + +target_link_libraries(react_renderer_observers_resize + react_cxxreact + react_bridging + react_renderer_core + react_renderer_graphics + react_renderer_mounting + react_renderer_runtimescheduler + react_renderer_uimanager +) +target_compile_reactnative_options(react_renderer_observers_resize PRIVATE) +target_compile_options(react_renderer_observers_resize PRIVATE -Wpedantic) diff --git a/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserver.cpp b/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserver.cpp new file mode 100644 index 000000000000..4d35656411b8 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserver.cpp @@ -0,0 +1,231 @@ +/* + * 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 "ResizeObserver.h" +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +namespace { + +const ShadowNode* getTargetShadowNode( + const ShadowNodeFamily::AncestorList& ancestors) { + if (ancestors.empty()) { + return nullptr; + } + + const auto& parentChildPair = ancestors.back(); + return parentChildPair.first.get() + .getChildren() + .at(parentChildPair.second) + .get(); +} + +bool isHidden(const ShadowNode& shadowNode) { + // `display: 'none'` sets the Hidden trait when props commit, possibly before + // layout updates `displayType`. + if (shadowNode.getTraits().check(ShadowNodeTraits::Trait::Hidden)) { + return true; + } + + if (const auto* layoutableShadowNode = + dynamic_cast(&shadowNode)) { + return layoutableShadowNode->getLayoutMetrics().displayType == + DisplayType::None; + } + + return false; +} + +// Descendants of a `display: none` node are not laid out, so their own layout +// metrics are not a size we can report. +bool hasHiddenAncestor(const ShadowNodeFamily::AncestorList& ancestors) { + return std::any_of( + ancestors.begin(), ancestors.end(), [](const auto& parentChildPair) { + return isHidden(parentChildPair.first.get()); + }); +} + +Size getObservedSize( + ResizeObserverBoxOptions boxOptions, + Size borderBoxSize, + Size contentBoxSize, + Size devicePixelContentBoxSize) { + if (boxOptions == ResizeObserverBoxOptions::ContentBox) { + return contentBoxSize; + } + if (boxOptions == ResizeObserverBoxOptions::DevicePixelContentBox) { + return devicePixelContentBoxSize; + } + return borderBoxSize; +} + +ResizeObserverEntry makeResizeObserverEntry( + ResizeObserverObserverId resizeObserverId, + const ShadowNodeFamily::Shared& targetShadowNodeFamily, + Size borderBoxSize, + Size contentBoxSize, + Size devicePixelContentBoxSize, + Rect contentRect) { + return ResizeObserverEntry{ + resizeObserverId, + targetShadowNodeFamily, + contentRect, + borderBoxSize, + contentBoxSize, + devicePixelContentBoxSize}; +} + +} // namespace + +ResizeObserver::ResizeObserver( + ResizeObserverObserverId resizeObserverId, + ShadowNodeFamily::Shared targetShadowNodeFamily, + ResizeObserverBoxOptions boxOptions, + uint64_t observationSequence) + : resizeObserverId_{resizeObserverId}, + targetShadowNodeFamily_{std::move(targetShadowNodeFamily)}, + boxOptions_{boxOptions}, + observationSequence_{observationSequence} {} + +ResizeObservationResult ResizeObserver::computeActiveObservation( + const RootShadowNode& rootShadowNode) const { + auto ancestors = targetShadowNodeFamily_->getAncestors(rootShadowNode); + const auto* targetShadowNode = + ancestors.empty() ? nullptr : getTargetShadowNode(ancestors); + + // Spec "calculate depth for node": number of nodes on the parent-traversal + // path from the target to the root (inclusive). `ancestors` excludes the + // target, so add one. + ResizeObservationResult result{.targetDepth = ancestors.size() + 1}; + + if (targetShadowNode == nullptr) { + // Target left the tree. Per spec, removal fires one final 0x0 entry. Keep + // `lastReportedSize_` at 0x0 so we don't re-deliver, and mark detached to + // stop re-checking until it's reinserted (via the dirty-family path). + // Every box is 0x0, so the observed box is too, whichever was requested. + auto zeroSize = Size{0, 0}; + auto observedSize = zeroSize; + + result.detached = true; + + const auto alreadyDelivered = lastReportedSize_.has_value() && + lastReportedSize_.value() == observedSize; + if (alreadyDelivered) { + return result; + } + + result.observedSize = observedSize; + result.entry = makeResizeObserverEntry( + resizeObserverId_, + targetShadowNodeFamily_, + zeroSize, + zeroSize, + zeroSize, + Rect{.origin = {0, 0}, .size = zeroSize}); + return result; + } + + const auto isInitialDelivery = !lastReportedSize_.has_value(); + + // Per spec, `display: none` reports zero-sized boxes. Use the Hidden trait + // instead of possibly-stale layout metrics and check ancestors. + if (isHidden(*targetShadowNode) || hasHiddenAncestor(ancestors)) { + auto zeroSize = Size{0, 0}; + auto zeroContentRect = Rect{.origin = {0, 0}, .size = zeroSize}; + auto observedSize = zeroSize; + + if (!isInitialDelivery && lastReportedSize_.value() == observedSize) { + return result; + } + + result.observedSize = observedSize; + result.entry = makeResizeObserverEntry( + resizeObserverId_, + targetShadowNodeFamily_, + zeroSize, + zeroSize, + zeroSize, + zeroContentRect); + return result; + } + + // Only the target's size matters here, never its position, so we read its own + // layout metrics instead of computing viewport-relative ones. + const auto* layoutableShadowNode = + dynamic_cast(targetShadowNode); + + // Not layoutable, so there is no size to report. Observed targets are always + // host components, so this is not expected to happen. + if (layoutableShadowNode == nullptr) { + return result; + } + + auto layoutMetrics = layoutableShadowNode->getLayoutMetrics(); + auto borderBoxSize = layoutMetrics.frame.size; + + // RN's `contentInsets` is border + padding per side, matching the Web + // content-box. Clamp to zero: a frame can be smaller than its insets, but a + // content box is never negative. + auto contentFrame = layoutMetrics.getContentFrame(); + auto contentBoxSize = Size{ + .width = std::max(Float{0}, contentFrame.size.width), + .height = std::max(Float{0}, contentFrame.size.height)}; + + // Per spec, `contentRect`'s origin is the offset of the content box from + // the padding box (i.e. the paddings only, excluding borders). + // https://w3c.github.io/csswg-drafts/resize-observer/#dom-resizeobserverentry-contentrect + auto contentRect = Rect{ + .origin = + {.x = layoutMetrics.contentInsets.left - + layoutMetrics.borderWidth.left, + .y = + layoutMetrics.contentInsets.top - layoutMetrics.borderWidth.top}, + .size = contentBoxSize}; + + // Per spec the device-pixel-content-box holds integers. Round each axis + // (best-effort: we have no pixel-snapped origin or sibling to align to). + auto devicePixelContentBoxSize = Size{ + std::round(contentBoxSize.width * layoutMetrics.pointScaleFactor), + std::round(contentBoxSize.height * layoutMetrics.pointScaleFactor)}; + + auto observedSize = getObservedSize( + boxOptions_, borderBoxSize, contentBoxSize, devicePixelContentBoxSize); + + // Skip when the observed box is unchanged. The first delivery always runs + // (including 0x0 content-box), matching browser behavior on `observe()`. + if (!isInitialDelivery && lastReportedSize_.value() == observedSize) { + return result; + } + + result.observedSize = observedSize; + result.entry = makeResizeObserverEntry( + resizeObserverId_, + targetShadowNodeFamily_, + borderBoxSize, + contentBoxSize, + devicePixelContentBoxSize, + contentRect); + return result; +} + +void ResizeObserver::markAsReported(const ResizeObservationResult& result) { + // Only broadcast results carry a size to report; marking anything else + // would regress `lastReportedSize_` to 0x0. + react_native_assert(result.entry.has_value()); + + lastReportedSize_ = result.observedSize; + detached_ = result.detached; +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserver.h b/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserver.h new file mode 100644 index 000000000000..4c449cb0f5f5 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserver.h @@ -0,0 +1,105 @@ +/* + * 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 +#include + +namespace facebook::react { + +using ResizeObserverObserverId = int32_t; + +// Corresponds to the `box` option of `ResizeObserver#observe`. +// https://w3c.github.io/csswg-drafts/resize-observer/#resize-observer-box-options +enum class ResizeObserverBoxOptions { + ContentBox, + BorderBox, + DevicePixelContentBox +}; + +struct ResizeObserverEntry { + ResizeObserverObserverId resizeObserverId; + ShadowNodeFamily::Shared shadowNodeFamily; + Rect contentRect; + Size borderBoxSize; + Size contentBoxSize; + Size devicePixelContentBoxSize; +}; + +struct ResizeObservationResult { + // Set when the observed box changed (i.e. the observation is active). + std::optional entry; + // The size to store in `lastReportedSize_` when this is broadcast. + Size observedSize{}; + // Spec "calculate depth for node": nodes on the path from target to root. + size_t targetDepth{0}; + // Whether the target was found outside the tree. + bool detached{false}; +}; + +class ResizeObserver { + public: + ResizeObserver( + ResizeObserverObserverId resizeObserverId, + ShadowNodeFamily::Shared targetShadowNodeFamily, + ResizeObserverBoxOptions boxOptions, + uint64_t observationSequence); + + // Computes whether the observation is active and builds a pending entry. It + // must not change any state that decides future deliveries: a skipped + // observation has to stay active for a later round. + ResizeObservationResult computeActiveObservation( + const RootShadowNode& rootShadowNode) const; + + // Applies the state the spec assigns during broadcast. + void markAsReported(const ResizeObservationResult& result); + + ResizeObserverObserverId getResizeObserverId() const { + return resizeObserverId_; + } + + ShadowNodeFamily::Shared getTargetShadowNodeFamily() const { + return targetShadowNodeFamily_; + } + + // Monotonic order of `observe()` registration for this observation. + uint64_t getObservationSequence() const { + return observationSequence_; + } + + // Still awaiting its first delivery (just registered, or target removed). + bool needsInitialDeliveryCheck() const { + return !lastReportedSize_.has_value(); + } + + // Whether the target left the tree and its final 0x0 entry was already + // delivered; such observations stay quiet until reinserted (dirty path). + bool hasDeliveredDetachedState() const { + return detached_ && lastReportedSize_.has_value(); + } + + private: + ResizeObserverObserverId resizeObserverId_; + ShadowNodeFamily::Shared targetShadowNodeFamily_; + ResizeObserverBoxOptions boxOptions_; + uint64_t observationSequence_; + + // Last delivered observed-box size; empty until the first entry. + std::optional lastReportedSize_; + + // True once the detached target's final 0x0 entry was delivered; cleared as + // soon as the target is found attached again. + bool detached_{false}; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserverManager.cpp b/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserverManager.cpp new file mode 100644 index 000000000000..43b3835f5473 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserverManager.cpp @@ -0,0 +1,476 @@ +/* + * 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 "ResizeObserverManager.h" +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +namespace { + +constexpr size_t kMaxResizeObservationLoopIterations = 100; + +} // namespace + +ResizeObserverManager::ResizeObserverManager() = default; + +void ResizeObserverManager::observe( + ResizeObserverObserverId resizeObserverId, + const ShadowNodeFamily::Shared& shadowNodeFamily, + ResizeObserverBoxOptions boxOptions, + UIManager& /*uiManager*/) { + TraceSection s{"ResizeObserverManager::observe"}; + + auto surfaceId = shadowNodeFamily->getSurfaceId(); + + // Per spec, new targets are delivered on the next "update the rendering" + // step (`runResizeObservations`), not here. That step runs at the end of + // every task, so the first delivery is still prompt. + { + std::unique_lock lock{observersMutex_}; + + auto& observers = observersBySurfaceId_[surfaceId]; + + observers.emplace_back( + std::make_unique( + resizeObserverId, + shadowNodeFamily, + boxOptions, + nextObservationSequence_++)); + } + + surfaceIdsWithPendingInitialDelivery_.insert(surfaceId); +} + +void ResizeObserverManager::unobserve( + ResizeObserverObserverId resizeObserverId, + const ShadowNodeFamily::Shared& shadowNodeFamily) { + TraceSection s{"ResizeObserverManager::unobserve"}; + + auto surfaceId = shadowNodeFamily->getSurfaceId(); + + // If another observer still targets this family, keep its dirty state. + auto familyStillObserved = false; + { + std::unique_lock lock{observersMutex_}; + + auto observersIt = observersBySurfaceId_.find(surfaceId); + if (observersIt == observersBySurfaceId_.end()) { + return; + } + + auto& observers = observersIt->second; + + observers.erase( + std::remove_if( + observers.begin(), + observers.end(), + [resizeObserverId, &shadowNodeFamily](const auto& observer) { + return observer->getResizeObserverId() == resizeObserverId && + observer->getTargetShadowNodeFamily() == shadowNodeFamily; + }), + observers.end()); + + for (const auto& observer : observers) { + if (observer->getTargetShadowNodeFamily() == shadowNodeFamily) { + familyStillObserved = true; + break; + } + } + + if (observers.empty()) { + observersBySurfaceId_.erase(surfaceId); + surfaceIdsWithPendingInitialDelivery_.erase(surfaceId); + } + } + + // Clean up the removed observation to avoid a wasted pass, a stale entry, or + // a dangling family pointer. Only clear the dirty family if no other + // observer targets it. + if (!familyStillObserved) { + std::unique_lock lock{dirtyFamiliesMutex_}; + auto dirtyFamiliesIt = dirtyFamiliesBySurfaceId_.find(surfaceId); + if (dirtyFamiliesIt != dirtyFamiliesBySurfaceId_.end()) { + dirtyFamiliesIt->second.erase(shadowNodeFamily.get()); + if (dirtyFamiliesIt->second.empty()) { + dirtyFamiliesBySurfaceId_.erase(dirtyFamiliesIt); + } + } + } + + { + std::unique_lock lock{pendingEntriesMutex_}; + pendingEntries_.erase( + std::remove_if( + pendingEntries_.begin(), + pendingEntries_.end(), + [resizeObserverId, &shadowNodeFamily](const auto& entry) { + return entry.resizeObserverId == resizeObserverId && + entry.shadowNodeFamily == shadowNodeFamily; + }), + pendingEntries_.end()); + } +} + +void ResizeObserverManager::connect( + RuntimeScheduler& runtimeScheduler, + UIManager& uiManager, + std::function notifyResizeObserversFunction) { + TraceSection s{"ResizeObserverManager::connect"}; + + // Fail-safe in case the caller doesn't guarantee consistency. + if (commitHookRegistered_) { + return; + } + + notifyResizeObserversFunction_ = std::move(notifyResizeObserversFunction); + + runtimeScheduler.setResizeObserverDelegate(this); + uiManager.registerCommitHook(*this); + shadowTreeRegistry_ = &uiManager.getShadowTreeRegistry(); + commitHookRegistered_ = true; +} + +void ResizeObserverManager::disconnect( + RuntimeScheduler& runtimeScheduler, + UIManager& uiManager) { + TraceSection s{"ResizeObserverManager::disconnect"}; + + // Fail-safe in case the caller doesn't guarantee consistency. + if (!commitHookRegistered_) { + return; + } + + runtimeScheduler.setResizeObserverDelegate(nullptr); + uiManager.unregisterCommitHook(*this); + shadowTreeRegistry_ = nullptr; + + // May run from inside `broadcastActiveResizeObservations` (a callback can + // disconnect the last observer), which copies the callable before invoking + // it, so clearing it here can't destroy the function we're executing. + notifyResizeObserversFunction_ = nullptr; + commitHookRegistered_ = false; + + // Nothing can consume any of this anymore; don't leak it into a later + // `connect`. + { + std::unique_lock lock{observersMutex_}; + observersBySurfaceId_.clear(); + } + { + std::unique_lock lock{dirtyFamiliesMutex_}; + dirtyFamiliesBySurfaceId_.clear(); + committedSurfaceIds_.clear(); + } + surfaceIdsWithPendingInitialDelivery_.clear(); + + { + std::unique_lock lock{pendingEntriesMutex_}; + pendingEntries_.clear(); + } +} + +std::vector ResizeObserverManager::takeRecords() { + std::unique_lock lock{pendingEntriesMutex_}; + + std::vector entries; + pendingEntries_.swap(entries); + return entries; +} + +#pragma mark - UIManagerCommitHook + +void ResizeObserverManager::commitHookWasRegistered( + const UIManager& uiManager) noexcept {} +void ResizeObserverManager::commitHookWasUnregistered( + const UIManager& uiManager) noexcept {} + +void ResizeObserverManager::shadowTreeDidCommit( + const ShadowTree& shadowTree, + const RootShadowNode::Shared& /*rootShadowNode*/, + const std::vector& + affectedLayoutableNodes) noexcept { + TraceSection s{"ResizeObserverManager::shadowTreeDidCommit"}; + + // Runs on the commit hook (any thread), so it only collects which observed + // targets went dirty; it must not compute observations or notify JS. + // `runResizeObservations` (JS thread) does that against the latest tree. + auto surfaceId = shadowTree.getSurfaceId(); + + std::unordered_set observedFamilies; + { + std::unique_lock lock{observersMutex_}; + + auto observersIt = observersBySurfaceId_.find(surfaceId); + if (observersIt == observersBySurfaceId_.end()) { + // No observers for this surface. + return; + } + + observedFamilies.reserve(observersIt->second.size()); + for (auto& observer : observersIt->second) { + observedFamilies.insert(observer->getTargetShadowNodeFamily().get()); + } + } + + std::unordered_set newlyDirtyFamilies; + for (const auto* node : affectedLayoutableNodes) { + const auto* family = &node->getFamily(); + if (observedFamilies.contains(family)) { + newlyDirtyFamilies.insert(family); + } + } + + std::unique_lock lock{dirtyFamiliesMutex_}; + // Record the commit even with no dirty family, so `runResizeObservations` + // re-checks for removed targets (they aren't in `affectedLayoutableNodes`). + committedSurfaceIds_.insert(surfaceId); + if (!newlyDirtyFamilies.empty()) { + auto& dirtyFamilies = dirtyFamiliesBySurfaceId_[surfaceId]; + dirtyFamilies.insert(newlyDirtyFamilies.begin(), newlyDirtyFamilies.end()); + } +} + +#pragma mark - RuntimeSchedulerResizeObserverDelegate + +void ResizeObserverManager::runResizeObservations(jsi::Runtime& runtime) { + TraceSection s{"ResizeObserverManager::runResizeObservations"}; + + // The broadcast below runs JS, which may commit synchronously and re-enter + // this step. Never start a nested pass: the families it dirties stay queued + // for the next gather iteration below, not a nested `runResizeObservations`. + if (isRunningResizeObservations_) { + return; + } + isRunningResizeObservations_ = true; + OnScopeExit resetIsRunningResizeObservations{ + [&]() { isRunningResizeObservations_ = false; }}; + + size_t depth{0}; + size_t iteration{0}; + auto hasSkippedObservations = false; + + // Bounded by construction: `depth` strictly increases each round, so this + // runs at most as many times as the tree is deep. The hard cap turns a + // future bug into a log line instead of a frozen app. + while (true) { + if (++iteration > kMaxResizeObservationLoopIterations) { + LOG(WARNING) + << "ResizeObserverManager: resize observation loop hit iteration cap"; + // Treat as undelivered notifications so JS reports the spec loop error. + hasSkippedObservations = true; + break; + } + + auto gathered = gatherActiveResizeObservations(depth); + hasSkippedObservations = + hasSkippedObservations || gathered.hasSkippedObservations; + + if (gathered.activeObservations.empty()) { + break; + } + + auto shallowest = + broadcastActiveResizeObservations(runtime, gathered.activeObservations); + if (!shallowest.has_value()) { + break; + } + + depth = shallowest.value(); + } + + // https://w3c.github.io/csswg-drafts/resize-observer/#deliver-resize-loop-error + if (hasSkippedObservations) { + notifyResizeObservers(runtime, true); + } +} + +#pragma mark - Private methods + +ResizeObserverManager::GatherResult +ResizeObserverManager::gatherActiveResizeObservations(size_t depth) { + TraceSection s{"ResizeObserverManager::gatherActiveResizeObservations"}; + + // Drain what the commit hook collected since the last gather. This must run + // every loop iteration so a callback's synchronous commit is visible to the + // next round. + std::unordered_map> + dirtyFamiliesBySurfaceId; + std::unordered_set committedSurfaceIds; + { + std::unique_lock lock{dirtyFamiliesMutex_}; + dirtyFamiliesBySurfaceId.swap(dirtyFamiliesBySurfaceId_); + committedSurfaceIds.swap(committedSurfaceIds_); + } + + // A surface is relevant if it committed (layout changes or removals) or has + // an observer awaiting first delivery. Sourcing the latter from the pending + // set lets a quiet tick skip scanning all observers. + std::unordered_set candidateSurfaceIds{committedSurfaceIds}; + candidateSurfaceIds.insert( + surfaceIdsWithPendingInitialDelivery_.begin(), + surfaceIdsWithPendingInitialDelivery_.end()); + + GatherResult gatherResult; + + // Null once `disconnect` ran; nothing left to observe against. + if (candidateSurfaceIds.empty() || shadowTreeRegistry_ == nullptr) { + return gatherResult; + } + + for (auto surfaceId : candidateSurfaceIds) { + auto rootShadowNode = std::shared_ptr{}; + shadowTreeRegistry_->visit(surfaceId, [&](const auto& shadowTree) { + rootShadowNode = shadowTree.getCurrentRevision().rootShadowNode; + }); + if (rootShadowNode == nullptr) { + // The surface was stopped, so its families are dead and can never report + // again. Drop the bookkeeping instead of rescanning it on every tick. + std::unique_lock lock{observersMutex_}; + observersBySurfaceId_.erase(surfaceId); + surfaceIdsWithPendingInitialDelivery_.erase(surfaceId); + continue; + } + + auto dirtyFamiliesIt = dirtyFamiliesBySurfaceId.find(surfaceId); + const auto* dirtyFamilies = + dirtyFamiliesIt != dirtyFamiliesBySurfaceId.end() + ? &dirtyFamiliesIt->second + : nullptr; + + auto surfaceCommitted = committedSurfaceIds.contains(surfaceId); + + std::unique_lock lock{observersMutex_}; + + auto observersIt = observersBySurfaceId_.find(surfaceId); + if (observersIt == observersBySurfaceId_.end()) { + continue; + } + + // Whether an observer here still awaits first delivery after this pass + // (e.g. reset because it has no reportable size), so the surface stays + // pending. + auto stillNeedsInitialDelivery = false; + + for (auto& observer : observersIt->second) { + // Identity comparison only; see `dirtyFamiliesBySurfaceId_`. + auto wasAffected = dirtyFamilies != nullptr && + dirtyFamilies->contains(observer->getTargetShadowNodeFamily().get()); + + // Re-check delivered observations on any commit to catch targets that + // left the tree (removals aren't dirty families) and send their final + // 0x0. Skip ones already settled as detached - reinsertion comes via the + // dirty path. + auto maybeDetached = surfaceCommitted && + !observer->needsInitialDeliveryCheck() && + !observer->hasDeliveredDetachedState(); + + // Recompute only observers that were affected, still need first + // delivery, or may have just detached. + if (!wasAffected && !observer->needsInitialDeliveryCheck() && + !maybeDetached) { + continue; + } + + auto result = observer->computeActiveObservation(*rootShadowNode); + if (!result.entry.has_value()) { + stillNeedsInitialDelivery = + stillNeedsInitialDelivery || observer->needsInitialDeliveryCheck(); + continue; + } + + if (result.targetDepth > depth) { + gatherResult.activeObservations.push_back( + ActiveObservation{ + .observer = observer.get(), .result = std::move(result)}); + } else { + // Too shallow for this round. It keeps its state, so it stays active + // and is delivered the next time this surface is gathered. + gatherResult.hasSkippedObservations = true; + } + + stillNeedsInitialDelivery = + stillNeedsInitialDelivery || observer->needsInitialDeliveryCheck(); + } + + if (stillNeedsInitialDelivery) { + surfaceIdsWithPendingInitialDelivery_.insert(surfaceId); + } else { + surfaceIdsWithPendingInitialDelivery_.erase(surfaceId); + } + } + + return gatherResult; +} + +std::optional ResizeObserverManager::broadcastActiveResizeObservations( + jsi::Runtime& runtime, + std::vector& active) { + TraceSection s{"ResizeObserverManager::broadcastActiveResizeObservations"}; + + if (active.empty() || notifyResizeObserversFunction_ == nullptr) { + return std::nullopt; + } + + // Deliver in spec order: observers by registration, then targets by + // `observe()` order. + std::sort(active.begin(), active.end(), [](const auto& a, const auto& b) { + if (a.observer->getResizeObserverId() != + b.observer->getResizeObserverId()) { + return a.observer->getResizeObserverId() < + b.observer->getResizeObserverId(); + } + return a.observer->getObservationSequence() < + b.observer->getObservationSequence(); + }); + + std::optional shallowestDepth{}; + + { + std::unique_lock lock{pendingEntriesMutex_}; + pendingEntries_.reserve(pendingEntries_.size() + active.size()); + for (auto& observation : active) { + observation.observer->markAsReported(observation.result); + shallowestDepth = shallowestDepth.has_value() + ? std::min(shallowestDepth.value(), observation.result.targetDepth) + : observation.result.targetDepth; + pendingEntries_.push_back(std::move(*observation.result.entry)); + } + } + + if (!notifyResizeObservers(runtime, false)) { + return std::nullopt; + } + + return shallowestDepth; +} + +bool ResizeObserverManager::notifyResizeObservers( + jsi::Runtime& runtime, + bool hasResizeLoopError) { + // Copy the callable: the notification runs JS, and a callback may + // `disconnect()` the last observer, which clears the member while we are + // still executing it. + auto notifyResizeObserversFunction = notifyResizeObserversFunction_; + if (notifyResizeObserversFunction == nullptr) { + return false; + } + + // No lock may be held here. JS pulls the entries with `takeRecords` and the + // callbacks it then invokes can re-enter `observe`/`unobserve`/`disconnect`, + // or commit synchronously (which re-enters the commit hook on this thread). + notifyResizeObserversFunction(runtime, hasResizeLoopError); + return true; +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserverManager.h b/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserverManager.h new file mode 100644 index 000000000000..bb45cb4b6947 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/resize/ResizeObserverManager.h @@ -0,0 +1,146 @@ +/* + * 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ResizeObserver.h" + +namespace facebook::react { + +class ResizeObserverManager final + : public UIManagerCommitHook, + public RuntimeSchedulerResizeObserverDelegate { + public: + ResizeObserverManager(); + + void observe( + ResizeObserverObserverId resizeObserverId, + const ShadowNodeFamily::Shared& shadowNodeFamily, + ResizeObserverBoxOptions boxOptions, + UIManager& uiManager); + + void unobserve( + ResizeObserverObserverId resizeObserverId, + const ShadowNodeFamily::Shared& shadowNodeFamily); + + void connect( + RuntimeScheduler& runtimeScheduler, + UIManager& uiManager, + std::function notifyResizeObserversFunction); + + void disconnect(RuntimeScheduler& runtimeScheduler, UIManager& uiManager); + + std::vector takeRecords(); + +#pragma mark - RuntimeSchedulerResizeObserverDelegate + + void runResizeObservations(jsi::Runtime& runtime) override; + +#pragma mark - UIManagerCommitHook + + void commitHookWasRegistered(const UIManager& uiManager) noexcept override; + void commitHookWasUnregistered(const UIManager& uiManager) noexcept override; + + void shadowTreeDidCommit( + const ShadowTree& shadowTree, + const RootShadowNode::Shared& rootShadowNode, + const std::vector& + affectedLayoutableNodes) noexcept override; + + private: + struct ActiveObservation { + // Owned by `observersBySurfaceId_`. Safe to dereference for the whole + // broadcast because every `markAsReported` runs before any JS does, so a + // callback cannot free these while they are still in use. + ResizeObserver* observer; + ResizeObservationResult result; + }; + + struct GatherResult { + std::vector activeObservations; + // An active observation was too shallow for the round's depth, so it was + // not delivered and stays active for a later pass. + bool hasSkippedObservations{false}; + }; + + mutable std:: + unordered_map>> + observersBySurfaceId_; + mutable std::mutex observersMutex_; + + // Monotonic counter assigned at `observe()` so deliveries can preserve + // observation registration order. + uint64_t nextObservationSequence_{0}; + + // Families that went dirty since `runResizeObservations` last drained this + // map. Written by the commit hook (any thread), drained on the JS thread. + // Raw pointers, compared by identity only (never dereferenced): a family is + // only present while a live observer holds its `ShadowNodeFamily::Shared`, + // and `unobserve` erases it once no observer targets it. + std::unordered_map> + dirtyFamiliesBySurfaceId_; + + // Surfaces with an observer awaiting its first delivery (just observed, or + // reset because it has no reportable size). Lets `runResizeObservations` + // find them without scanning all observers each tick. JS-thread-only, so no + // mutex. + std::unordered_set surfaceIdsWithPendingInitialDelivery_; + + // Surfaces that committed since the last `runResizeObservations` and have an + // observer. Used to detect targets that left the tree (removals don't appear + // in `affectedLayoutableNodes`). Guarded by `dirtyFamiliesMutex_`. + std::unordered_set committedSurfaceIds_; + std::mutex dirtyFamiliesMutex_; + + std::function notifyResizeObserversFunction_; + bool commitHookRegistered_{}; + + // Set for the duration of `runResizeObservations`. The broadcast runs JS, + // which may commit synchronously; that must not start a nested pass. + // JS-thread-only, so no mutex. + bool isRunningResizeObservations_{false}; + + // This is only accessed from the JS thread at the end of the event loop + // tick, so it is safe to retain it as a raw pointer. + // We need to retain it here because the RuntimeScheduler does not provide + // it when calling `runResizeObservations`. + const ShadowTreeRegistry* shadowTreeRegistry_{nullptr}; + + mutable std::vector pendingEntries_; + mutable std::mutex pendingEntriesMutex_; + + // https://w3c.github.io/csswg-drafts/resize-observer/#gather-active-observations-h + GatherResult gatherActiveResizeObservations(size_t depth); + + // https://w3c.github.io/csswg-drafts/resize-observer/#broadcast-active-resize-observations + // Returns the shallowest broadcast target depth, or std::nullopt if nothing + // was delivered. + std::optional broadcastActiveResizeObservations( + jsi::Runtime& runtime, + std::vector& active); + + // Calls into JS. Returns false if there is nothing connected to notify. + bool notifyResizeObservers(jsi::Runtime& runtime, bool hasResizeLoopError); +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/observers/resize/__docs__/README.md b/packages/react-native/ReactCommon/react/renderer/observers/resize/__docs__/README.md new file mode 100644 index 000000000000..eb9332910a34 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/observers/resize/__docs__/README.md @@ -0,0 +1,13 @@ +# ResizeObserver + +[🏠 Home](../../../../../../../../__docs__/README.md) + +This directory contains the C++ implementation of the +[ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) +in React Native. + +## 🔗 Relationship with other systems + +### Part of + +- [ResizeObserver API](../../../../../../src/private/webapis/resizeobserver/__docs__/README.md) diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.cpp index c9df8025a1fb..3c560e9529e9 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.cpp +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.cpp @@ -153,4 +153,10 @@ void RuntimeScheduler::setIntersectionObserverDelegate( intersectionObserverDelegate); } +void RuntimeScheduler::setResizeObserverDelegate( + RuntimeSchedulerResizeObserverDelegate* resizeObserverDelegate) { + return runtimeSchedulerImpl_->setResizeObserverDelegate( + resizeObserverDelegate); +} + } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.h b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.h index 922e1c7701a2..ecd45d075144 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.h +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler.h @@ -16,6 +16,7 @@ #include #include "RuntimeSchedulerEventTimingDelegate.h" #include "RuntimeSchedulerIntersectionObserverDelegate.h" +#include "RuntimeSchedulerResizeObserverDelegate.h" namespace facebook::react { @@ -55,6 +56,8 @@ class RuntimeSchedulerBase : public facebook::hermes::IEventLoopControl { virtual void setEventTimingDelegate(RuntimeSchedulerEventTimingDelegate *eventTimingDelegate) = 0; virtual void setIntersectionObserverDelegate( RuntimeSchedulerIntersectionObserverDelegate *intersectionObserverDelegate) = 0; + virtual void setResizeObserverDelegate( + RuntimeSchedulerResizeObserverDelegate* resizeObserverDelegate) = 0; }; // This is a proxy for RuntimeScheduler implementation, which will be selected @@ -167,6 +170,9 @@ class RuntimeScheduler final : public RuntimeSchedulerBase { void setIntersectionObserverDelegate( RuntimeSchedulerIntersectionObserverDelegate *intersectionObserverDelegate) override; + void setResizeObserverDelegate( + RuntimeSchedulerResizeObserverDelegate* resizeObserverDelegate) override; + private: // Actual implementation, stored as a unique pointer to simplify memory // management. diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeSchedulerResizeObserverDelegate.h b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeSchedulerResizeObserverDelegate.h new file mode 100644 index 000000000000..f9aea843f812 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeSchedulerResizeObserverDelegate.h @@ -0,0 +1,21 @@ +/* + * 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 facebook::react { + +class RuntimeSchedulerResizeObserverDelegate { + public: + virtual ~RuntimeSchedulerResizeObserverDelegate() = default; + + virtual void runResizeObservations(jsi::Runtime& runtime) = 0; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.cpp index d55b3eab268f..90057c69124b 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.cpp +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.cpp @@ -222,6 +222,11 @@ void RuntimeScheduler_Legacy::setIntersectionObserverDelegate( // No-op in the legacy scheduler } +void RuntimeScheduler_Legacy::setResizeObserverDelegate( + RuntimeSchedulerResizeObserverDelegate* /*resizeObserverDelegate*/) { + // No-op in the legacy scheduler +} + #pragma mark - Private void RuntimeScheduler_Legacy::scheduleWorkLoopIfNecessary() { diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.h b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.h index 7306df99b973..7ccb35ceb583 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.h +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Legacy.h @@ -133,6 +133,9 @@ class RuntimeScheduler_Legacy final : public RuntimeSchedulerBase { void setIntersectionObserverDelegate( RuntimeSchedulerIntersectionObserverDelegate *intersectionObserverDelegate) override; + void setResizeObserverDelegate( + RuntimeSchedulerResizeObserverDelegate* resizeObserverDelegate) override; + private: std::priority_queue, std::vector>, TaskPriorityComparer> taskQueue_; diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp index 5a7e69db108f..073fbf1b6cb8 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp @@ -232,6 +232,11 @@ void RuntimeScheduler_Modern::setIntersectionObserverDelegate( intersectionObserverDelegate_ = intersectionObserverDelegate; } +void RuntimeScheduler_Modern::setResizeObserverDelegate( + RuntimeSchedulerResizeObserverDelegate* resizeObserverDelegate) { + resizeObserverDelegate_ = resizeObserverDelegate; +} + #pragma mark - Private void RuntimeScheduler_Modern::scheduleTask(std::shared_ptr task) { @@ -336,7 +341,7 @@ void RuntimeScheduler_Modern::runEventLoopTick( reportLongTasks(task, taskStartTime, taskEndTime); // "Update the rendering" step. - updateRendering(taskEndTime); + updateRendering(runtime, taskEndTime); currentTask_ = nullptr; } @@ -346,7 +351,9 @@ void RuntimeScheduler_Modern::runEventLoopTick( * event loop. See * https://html.spec.whatwg.org/multipage/webappapis.html#update-the-rendering. */ -void RuntimeScheduler_Modern::updateRendering(HighResTimeStamp taskEndTime) { +void RuntimeScheduler_Modern::updateRendering( + jsi::Runtime& runtime, + HighResTimeStamp taskEndTime) { TraceSection s("RuntimeScheduler::updateRendering"); // This is the integration of the Event Timing API in the Event Loop. @@ -357,6 +364,26 @@ void RuntimeScheduler_Modern::updateRendering(HighResTimeStamp taskEndTime) { taskEndTime, surfaceIdsWithPendingRenderingUpdates_); } + // This is the integration of the Resize Observer API in the Event Loop. + // See + // https://w3c.github.io/csswg-drafts/resize-observer/#broadcast-resize-notifications-h + // The delegate runs the spec's depth-bounded gather/broadcast loop + // internally; this call site stays a single invocation per tick. + if (resizeObserverDelegate_ != nullptr) { + // This delivers the observations to JS synchronously, so it is outside the + // error boundary of `executeTask`. Handle errors the same way here, so a + // throwing observer callback can't abort the remaining steps below. + try { + resizeObserverDelegate_->runResizeObservations(runtime); + } catch (jsi::JSError& error) { + handleTaskError(runtime, error); + } catch (std::exception& ex) { + jsi::JSError error( + runtime, std::string("Non-js exception: ") + ex.what()); + handleTaskError(runtime, error); + } + } + // This is the integration of the Intersection Observer API in the Event Loop. // See if (intersectionObserverDelegate_ != nullptr) { diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.h b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.h index 624676b22755..7df347aefe04 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.h +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.h @@ -151,6 +151,9 @@ class RuntimeScheduler_Modern final : public RuntimeSchedulerBase { void setIntersectionObserverDelegate( RuntimeSchedulerIntersectionObserverDelegate *intersectionObserverDelegate) override; + void setResizeObserverDelegate( + RuntimeSchedulerResizeObserverDelegate* resizeObserverDelegate) override; + private: /// Monotonic counter handing out IDs for IEventLoopControl task queue /// sources. @@ -191,7 +194,7 @@ class RuntimeScheduler_Modern final : public RuntimeSchedulerBase { void executeTask(jsi::Runtime &runtime, Task &task, bool didUserCallbackTimeout); - void updateRendering(HighResTimeStamp taskEndTime); + void updateRendering(jsi::Runtime &runtime, HighResTimeStamp taskEndTime); void handleTaskError(jsi::Runtime &runtime, jsi::JSError &error); @@ -225,6 +228,7 @@ class RuntimeScheduler_Modern final : public RuntimeSchedulerBase { PerformanceEntryReporter *performanceEntryReporter_{nullptr}; RuntimeSchedulerIntersectionObserverDelegate *intersectionObserverDelegate_{nullptr}; + RuntimeSchedulerResizeObserverDelegate* resizeObserverDelegate_{nullptr}; RuntimeSchedulerTaskErrorHandler onTaskError_; }; diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/__docs__/README.md b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/__docs__/README.md index 994fd9085057..9555b32a89f9 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/__docs__/README.md +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/__docs__/README.md @@ -157,14 +157,31 @@ behavior as on the Web. #### 4. Update the rendering -The last step is to check if the previous work produced any rendering updates -(transactions produced by commits in React, or view commands being dispatched). -If that is the case, it notifies the host platform that it should apply the -necessary mutations to reach that state. - -In the future, this step could be extended to do more of the work that browsers -do. For example, it could run resize observations and run resize observer -callbacks, or update animations and run animation frame callbacks. +The last step applies work that browsers perform after task and microtask +execution, before the next task is selected (see +[HTML — update the rendering](https://html.spec.whatwg.org/multipage/webappapis.html#update-the-rendering)). + +In [`RuntimeScheduler_Modern`](../RuntimeScheduler_Modern.h), this step +currently: + +1. Dispatches pending Event Timing entries (when enabled). +2. Runs + [Resize Observer](https://drafts.csswg.org/resize-observer/#broadcast-resize-notifications-h) + observations via + `RuntimeSchedulerResizeObserverDelegate::runResizeObservations()`. See the + [ResizeObserver API](../../../../../src/private/webapis/resizeobserver/__docs__/README.md). +3. Updates [Intersection Observer](https://www.w3.org/TR/intersection-observer/) + observations via `RuntimeSchedulerIntersectionObserverDelegate`. +4. Drains pending rendering updates (commits, view commands) so the host + platform can apply mutations. + +Observer steps run before the rendering-update queue is drained. Resize and +intersection observer integration is only implemented in +`RuntimeScheduler_Modern`; `RuntimeScheduler_Legacy` treats the corresponding +delegates as no-ops. + +Additional browser work in this phase (e.g. animation frame callbacks) may be +added in the future. ### Synchronous execution, events and rendering @@ -205,6 +222,11 @@ processing of those tasks within the event loop. - [MutationObserver](../../../../../src/private/webapis/mutationobserver/__docs__/README.md) uses the event loop to schedule mutation observer callbacks as microtasks. +- [ResizeObserver](../../../../../src/private/webapis/resizeobserver/__docs__/README.md) + uses the event loop to run resize observations during the "update the + rendering" step. +- [IntersectionObserver](../../../../../src/private/webapis/intersectionobserver/__docs__/README.md) + uses the event loop to update intersection observations during the same step. - `Scheduler` integrates with the event loop to register reporters (for `PerformanceObserver`) and schedules the specific work to be done as part of the rendering updates. diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp index 081a511cc550..8f458876f2a2 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp @@ -13,10 +13,14 @@ #include #include #include +#include +#include #include +#include #include #include #include +#include #include #include "StubClock.h" @@ -1454,12 +1458,433 @@ TEST_P(RuntimeSchedulerTest, reportsLongTasksWithYielding) { entry); } +/* + * Stub delegates for the "update the rendering" step, used by the resize + * observer tests below. + * + * Records every delivery of resize observations, together with the runtime it + * was given, so tests can assert *which* runtime reached the delegate (resize + * observations are broadcast to JS synchronously, so it has to be the runtime + * the task ran with) and whether deliveries ever nest. + */ +class StubResizeObserverDelegate + : public RuntimeSchedulerResizeObserverDelegate { + public: + void runResizeObservations(jsi::Runtime& runtime) override { + callCount++; + lastRuntime = &runtime; + + concurrentInvocationCount++; + maxConcurrentInvocationCount = + std::max(maxConcurrentInvocationCount, concurrentInvocationCount); + // Decrement even if the hook throws, so `maxConcurrentInvocationCount` + // stays meaningful in the error tests. + OnScopeExit onExit([this]() { concurrentInvocationCount--; }); + + if (onRunResizeObservations != nullptr) { + onRunResizeObservations(runtime); + } + } + + uint callCount{0}; + jsi::Runtime* lastRuntime{nullptr}; + uint concurrentInvocationCount{0}; + uint maxConcurrentInvocationCount{0}; + std::function onRunResizeObservations{nullptr}; +}; + +class StubEventTimingDelegate : public RuntimeSchedulerEventTimingDelegate { + public: + void dispatchPendingEventTimingEntries( + HighResTimeStamp /*taskEndTime*/, + const std::unordered_set& + /*surfaceIdsWithPendingRenderingUpdates*/) override { + callCount++; + if (onDispatchPendingEventTimingEntries != nullptr) { + onDispatchPendingEventTimingEntries(); + } + } + + uint callCount{0}; + std::function onDispatchPendingEventTimingEntries{nullptr}; +}; + +class StubIntersectionObserverDelegate + : public RuntimeSchedulerIntersectionObserverDelegate { + public: + void updateIntersectionObservations( + const std::unordered_set& + /*surfaceIdsWithPendingRenderingUpdates*/) override { + callCount++; + if (onUpdateIntersectionObservations != nullptr) { + onUpdateIntersectionObservations(); + } + } + + uint callCount{0}; + std::function onUpdateIntersectionObservations{nullptr}; +}; + +TEST_P(RuntimeSchedulerTest, resizeObservationsReceiveTheRuntimeOfTheTask) { + // Only for event loop + if (!GetParam()) { + return; + } + + StubResizeObserverDelegate resizeObserverDelegate; + runtimeScheduler_->setResizeObserverDelegate(&resizeObserverDelegate); + + jsi::Runtime* runtimeFromTask = nullptr; + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [&](jsi::Runtime& runtime) { + runtimeFromTask = &runtime; + // Resize observations are part of the "update the rendering" step, + // which runs after the task. + EXPECT_EQ(resizeObserverDelegate.callCount, 0); + }); + + EXPECT_EQ(resizeObserverDelegate.callCount, 0); + + stubQueue_->tick(); + + EXPECT_EQ(resizeObserverDelegate.callCount, 1); + EXPECT_EQ(runtimeFromTask, static_cast(runtime_.get())); + // The crux of synchronous delivery: the delegate gets the very same runtime + // instance the task ran with, so it can call into JS right away. + EXPECT_EQ(resizeObserverDelegate.lastRuntime, runtimeFromTask); +} + +TEST_P(RuntimeSchedulerTest, resizeObservationsRunSynchronouslyWithinTheTick) { + // Only for event loop + if (!GetParam()) { + return; + } + + StubResizeObserverDelegate resizeObserverDelegate; + runtimeScheduler_->setResizeObserverDelegate(&resizeObserverDelegate); + + bool didRunTask = false; + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, + [&](jsi::Runtime& /*runtime*/) { didRunTask = true; }); + + EXPECT_EQ(stubQueue_->size(), 1); + + stubQueue_->tick(); + + // Already delivered when the tick returns. + EXPECT_TRUE(didRunTask); + EXPECT_EQ(resizeObserverDelegate.callCount, 1); + // And not deferred to a task on the JS queue: nothing is left to run. + EXPECT_EQ(stubQueue_->size(), 0); + + stubQueue_->flush(); + + EXPECT_EQ(resizeObserverDelegate.callCount, 1); +} + +TEST_P(RuntimeSchedulerTest, updateRenderingStepRunsDelegatesInOrder) { + // Only for event loop + if (!GetParam()) { + return; + } + + uint nextOperationPosition = 1; + + uint taskPosition = 0; + uint eventTimingPosition = 0; + uint resizeObservationsPosition = 0; + uint intersectionObservationsPosition = 0; + uint updateRenderingPosition = 0; + + StubEventTimingDelegate eventTimingDelegate; + eventTimingDelegate.onDispatchPendingEventTimingEntries = [&]() { + eventTimingPosition = nextOperationPosition; + nextOperationPosition++; + }; + + StubResizeObserverDelegate resizeObserverDelegate; + resizeObserverDelegate.onRunResizeObservations = + [&](jsi::Runtime& /*runtime*/) { + resizeObservationsPosition = nextOperationPosition; + nextOperationPosition++; + }; + + StubIntersectionObserverDelegate intersectionObserverDelegate; + intersectionObserverDelegate.onUpdateIntersectionObservations = [&]() { + intersectionObservationsPosition = nextOperationPosition; + nextOperationPosition++; + }; + + runtimeScheduler_->setEventTimingDelegate(&eventTimingDelegate); + runtimeScheduler_->setResizeObserverDelegate(&resizeObserverDelegate); + runtimeScheduler_->setIntersectionObserverDelegate( + &intersectionObserverDelegate); + + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [&](jsi::Runtime& /*runtime*/) { + taskPosition = nextOperationPosition; + nextOperationPosition++; + + runtimeScheduler_->scheduleRenderingUpdate(0, [&]() { + updateRenderingPosition = nextOperationPosition; + nextOperationPosition++; + }); + }); + + stubQueue_->tick(); + + EXPECT_EQ(taskPosition, 1); + EXPECT_EQ(eventTimingPosition, 2); + EXPECT_EQ(resizeObservationsPosition, 3); + EXPECT_EQ(intersectionObservationsPosition, 4); + EXPECT_EQ(updateRenderingPosition, 5); +} + +TEST_P(RuntimeSchedulerTest, resizeObservationsRunWithoutRenderingUpdates) { + // Only for event loop + if (!GetParam()) { + return; + } + + StubResizeObserverDelegate resizeObserverDelegate; + runtimeScheduler_->setResizeObserverDelegate(&resizeObserverDelegate); + + // No task below schedules a rendering update for any surface. Resize + // observations still run on every tick, which is what makes the initial + // delivery in `ResizeObserverManager::observe` prompt. + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [](jsi::Runtime& /*runtime*/) {}); + + stubQueue_->tick(); + + EXPECT_EQ(resizeObserverDelegate.callCount, 1); + EXPECT_EQ(stubQueue_->size(), 0); + + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [](jsi::Runtime& /*runtime*/) {}); + + stubQueue_->tick(); + + EXPECT_EQ(resizeObserverDelegate.callCount, 2); + EXPECT_EQ(stubQueue_->size(), 0); +} + +TEST_P(RuntimeSchedulerTest, resizeObservationsStopAfterDelegateIsUnset) { + // Only for event loop + if (!GetParam()) { + return; + } + + StubResizeObserverDelegate resizeObserverDelegate; + runtimeScheduler_->setResizeObserverDelegate(&resizeObserverDelegate); + + bool didRunFirstTask = false; + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, + [&](jsi::Runtime& /*runtime*/) { didRunFirstTask = true; }); + + stubQueue_->tick(); + + EXPECT_TRUE(didRunFirstTask); + EXPECT_EQ(resizeObserverDelegate.callCount, 1); + + // This is what happens when the last observer disconnects (or the surface + // tears down): the scheduler is left without a delegate. + runtimeScheduler_->setResizeObserverDelegate(nullptr); + + bool didRunSecondTask = false; + bool didRunRenderingUpdate = false; + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [&](jsi::Runtime& /*runtime*/) { + didRunSecondTask = true; + runtimeScheduler_->scheduleRenderingUpdate( + 0, [&]() { didRunRenderingUpdate = true; }); + }); + + stubQueue_->tick(); + + EXPECT_TRUE(didRunSecondTask); + // The rest of the "update the rendering" step is unaffected. + EXPECT_TRUE(didRunRenderingUpdate); + EXPECT_EQ(resizeObserverDelegate.callCount, 1); + EXPECT_EQ(stubQueue_->size(), 0); +} + +TEST_P(RuntimeSchedulerTest, resizeObservationsCanUnsetTheDelegateFromWithin) { + // Only for event loop + if (!GetParam()) { + return; + } + + StubResizeObserverDelegate resizeObserverDelegate; + StubIntersectionObserverDelegate intersectionObserverDelegate; + + // A resize callback can disconnect the last observer, which clears the + // delegate from inside the delivery itself. + resizeObserverDelegate.onRunResizeObservations = + [&](jsi::Runtime& /*runtime*/) { + runtimeScheduler_->setResizeObserverDelegate(nullptr); + }; + + runtimeScheduler_->setResizeObserverDelegate(&resizeObserverDelegate); + runtimeScheduler_->setIntersectionObserverDelegate( + &intersectionObserverDelegate); + + bool didRunRenderingUpdate = false; + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [&](jsi::Runtime& /*runtime*/) { + runtimeScheduler_->scheduleRenderingUpdate( + 0, [&]() { didRunRenderingUpdate = true; }); + }); + + stubQueue_->tick(); + + EXPECT_EQ(resizeObserverDelegate.callCount, 1); + // The rest of the step still runs after the delegate went away. + EXPECT_EQ(intersectionObserverDelegate.callCount, 1); + EXPECT_TRUE(didRunRenderingUpdate); + + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [](jsi::Runtime& /*runtime*/) {}); + + stubQueue_->tick(); + + EXPECT_EQ(resizeObserverDelegate.callCount, 1); + EXPECT_EQ(intersectionObserverDelegate.callCount, 2); + EXPECT_EQ(stubQueue_->size(), 0); +} + +TEST_P(RuntimeSchedulerTest, resizeObservationsSchedulingWorkDoesNotReenter) { + // Only for event loop + if (!GetParam()) { + return; + } + + StubResizeObserverDelegate resizeObserverDelegate; + runtimeScheduler_->setResizeObserverDelegate(&resizeObserverDelegate); + + uint nextOperationPosition = 1; + + uint firstResizeObservationsPosition = 0; + uint renderingUpdateFromObservationsPosition = 0; + uint taskFromObservationsPosition = 0; + uint secondResizeObservationsPosition = 0; + + // Simulates a JS resize callback that commits and schedules more work. + resizeObserverDelegate.onRunResizeObservations = + [&](jsi::Runtime& /*runtime*/) { + EXPECT_EQ(resizeObserverDelegate.concurrentInvocationCount, 1); + + if (resizeObserverDelegate.callCount > 1) { + secondResizeObservationsPosition = nextOperationPosition; + nextOperationPosition++; + return; + } + + firstResizeObservationsPosition = nextOperationPosition; + nextOperationPosition++; + + runtimeScheduler_->scheduleRenderingUpdate(0, [&]() { + renderingUpdateFromObservationsPosition = nextOperationPosition; + nextOperationPosition++; + }); + + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [&](jsi::Runtime& /*runtime*/) { + taskFromObservationsPosition = nextOperationPosition; + nextOperationPosition++; + }); + }; + + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [](jsi::Runtime& /*runtime*/) {}); + + stubQueue_->tick(); + + // The task scheduled from the observations runs as another tick of the event + // loop that is already running, so it neither hangs nor needs another trip + // through the JS queue. + EXPECT_EQ(resizeObserverDelegate.callCount, 2); + EXPECT_EQ(resizeObserverDelegate.maxConcurrentInvocationCount, 1); + EXPECT_EQ(stubQueue_->size(), 0); + + EXPECT_EQ(firstResizeObservationsPosition, 1); + // A rendering update scheduled from the observations is flushed by the same + // "update the rendering" step, because it runs before the pending updates. + EXPECT_EQ(renderingUpdateFromObservationsPosition, 2); + // The task, on the other hand, is a new tick: it runs after the current + // "update the rendering" step is done, and gets its own observations + // afterwards. Not nested in the first one. + EXPECT_EQ(taskFromObservationsPosition, 3); + EXPECT_EQ(secondResizeObservationsPosition, 4); +} + +TEST_P(RuntimeSchedulerTest, errorInResizeObservationsDoesNotStopUpdate) { + // Only for event loop + if (!GetParam()) { + return; + } + + StubResizeObserverDelegate resizeObserverDelegate; + resizeObserverDelegate.onRunResizeObservations = [](jsi::Runtime& runtime) { + throw jsi::JSError(runtime, "Test error"); + }; + + StubIntersectionObserverDelegate intersectionObserverDelegate; + + runtimeScheduler_->setResizeObserverDelegate(&resizeObserverDelegate); + runtimeScheduler_->setIntersectionObserverDelegate( + &intersectionObserverDelegate); + + bool didRunRenderingUpdate = false; + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [&](jsi::Runtime& /*runtime*/) { + runtimeScheduler_->scheduleRenderingUpdate( + 0, [&]() { didRunRenderingUpdate = true; }); + }); + + stubQueue_->tick(); + + // Delivering observations to JS synchronously happens outside the error + // boundary of the task, so the step handles the error itself and carries on. + EXPECT_EQ(stubErrorUtils_->getReportFatalCallCount(), 1); + EXPECT_EQ(resizeObserverDelegate.callCount, 1); + EXPECT_EQ(intersectionObserverDelegate.callCount, 1); + EXPECT_TRUE(didRunRenderingUpdate); + EXPECT_EQ(stubQueue_->size(), 0); +} + +TEST_P(RuntimeSchedulerTest, resizeObserverDelegateIsIgnoredInLegacyScheduler) { + // Only for the legacy scheduler, which has no "update the rendering" step. + if (GetParam()) { + return; + } + + StubResizeObserverDelegate resizeObserverDelegate; + runtimeScheduler_->setResizeObserverDelegate(&resizeObserverDelegate); + + bool didRunTask = false; + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, + [&](jsi::Runtime& /*runtime*/) { didRunTask = true; }); + + stubQueue_->tick(); + + EXPECT_TRUE(didRunTask); + EXPECT_EQ(resizeObserverDelegate.callCount, 0); + EXPECT_EQ(stubQueue_->size(), 0); +} + +#ifdef RCT_REMOVE_LEGACY_ARCH INSTANTIATE_TEST_SUITE_P( UseModernRuntimeScheduler, RuntimeSchedulerTest, -#ifdef RCT_REMOVE_LEGACY_ARCH testing::Values(true)); #else +INSTANTIATE_TEST_SUITE_P( + UseModernRuntimeScheduler, + RuntimeSchedulerTest, testing::Values(false, true)); #endif diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/primitives.h b/packages/react-native/ReactCommon/react/renderer/uimanager/primitives.h index ae798d078ebf..aee8f596dc68 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/primitives.h +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/primitives.h @@ -49,6 +49,21 @@ inline static jsi::Value valueFromShadowNode( return obj; } +inline static jsi::Object tokenFromShadowNodeFamily( + jsi::Runtime &runtime, + ShadowNodeFamily::Shared shadowNodeFamily) +{ + jsi::Object obj(runtime); + // Need to const_cast since JSI only allows non-const pointees + obj.setNativeState(runtime, std::const_pointer_cast(std::move(shadowNodeFamily))); + return obj; +} + +inline static ShadowNodeFamily::Shared shadowNodeFamilyFromToken(jsi::Runtime &runtime, jsi::Object token) +{ + return token.getNativeState(runtime); +} + // TODO: once we no longer need to mutate the return value (appendChildToSet) // make this a SharedListOfShared inline static std::shared_ptr>> shadowNodeListFromValue( diff --git a/packages/react-native/ReactCxxPlatform/react/runtime/CMakeLists.txt b/packages/react-native/ReactCxxPlatform/react/runtime/CMakeLists.txt index 01bf276bb49d..088c1016c4d1 100644 --- a/packages/react-native/ReactCxxPlatform/react/runtime/CMakeLists.txt +++ b/packages/react-native/ReactCxxPlatform/react/runtime/CMakeLists.txt @@ -41,6 +41,7 @@ target_link_libraries(react_cxx_platform_react_runtime react_nativemodule_defaults react_nativemodule_intersectionobserver react_nativemodule_mutationobserver + react_nativemodule_resizeobserver react_nativemodule_webperformance react_renderer_graphics react_renderer_runtimescheduler diff --git a/packages/react-native/ReactCxxPlatform/react/runtime/ReactCxxTurboModuleProvider.cpp b/packages/react-native/ReactCxxPlatform/react/runtime/ReactCxxTurboModuleProvider.cpp index ab2af66e29e6..04630f69b6a5 100644 --- a/packages/react-native/ReactCxxPlatform/react/runtime/ReactCxxTurboModuleProvider.cpp +++ b/packages/react-native/ReactCxxPlatform/react/runtime/ReactCxxTurboModuleProvider.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -95,6 +96,8 @@ std::shared_ptr ReactCxxTurboModuleProvider::operator()( return std::make_shared(jsInvoker_); } else if (name == NativeMutationObserver::kModuleName) { return std::make_shared(jsInvoker_); + } else if (name == NativeResizeObserver::kModuleName) { + return std::make_shared(jsInvoker_); } else if (name == NetworkingModule::kModuleName) { return std::make_shared(jsInvoker_, httpClientFactory_); } else if (name == LogBoxModule::kModuleName) { diff --git a/packages/react-native/flow/bom.js.flow b/packages/react-native/flow/bom.js.flow index a7045edfb507..ffac69042d75 100644 --- a/packages/react-native/flow/bom.js.flow +++ b/packages/react-native/flow/bom.js.flow @@ -418,6 +418,36 @@ declare class IntersectionObserver { unobserve(target: Element): void; } +declare class ResizeObserverSize { + readonly blockSize: number; + readonly inlineSize: number; +} + +declare class ResizeObserverEntry { + readonly borderBoxSize: ReadonlyArray; + readonly contentBoxSize: ReadonlyArray; + readonly contentRect: DOMRectReadOnly; + readonly devicePixelContentBoxSize: ReadonlyArray; + readonly target: Element; +} + +declare type ResizeObserverCallback = ( + entries: ReadonlyArray, + observer: ResizeObserver, +) => unknown; + +declare type ResizeObserverOptions = { + box?: 'content-box' | 'border-box' | 'device-pixel-content-box', + ... +}; + +declare class ResizeObserver { + constructor(callback: ResizeObserverCallback): void; + disconnect(): void; + observe(target: Element, options?: ResizeObserverOptions): void; + unobserve(target: Element): void; +} + declare class CloseEvent extends Event { code: number; reason: string; diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 1a0b3f931cbd..d9619a6f2cd1 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -507,6 +507,15 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enableResizeObserverByDefault: { + defaultValue: false, + metadata: { + description: 'Enables the ResizeObserver Web API in React Native.', + expectedReleaseValue: true, + purpose: 'release', + }, + ossReleaseStage: 'none', + }, enableRuntimeSchedulerQueueClearingOnError: { defaultValue: false, metadata: { diff --git a/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json index 9221b6945ba3..3ffc1ce900e2 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json +++ b/packages/react-native/scripts/ios-prebuild/headers-include-baseline.json @@ -1,6 +1,7 @@ [ "notShipped react/nativemodule/intersectionobserver/NativeIntersectionObserver.h -> react/renderer/observers/intersection/IntersectionObserverManager.h", "notShipped react/nativemodule/mutationobserver/NativeMutationObserver.h -> react/renderer/observers/mutation/MutationObserverManager.h", + "notShipped react/nativemodule/resizeobserver/NativeResizeObserver.h -> react/renderer/observers/resize/ResizeObserverManager.h", "notShipped react/renderer/animated/InterpolationAnimatedNode.h -> react/renderer/animated/internal/primitives.h", "notShipped react/renderer/animated/NativeAnimatedNodesManager.h -> react/renderer/animated/event_drivers/EventAnimationDriver.h", "notShipped react/renderer/animated/PropsAnimatedNode.h -> react/renderer/animated/internal/primitives.h", @@ -20,6 +21,7 @@ "quotedNotShipped react/nativemodule/intersectionobserver/NativeIntersectionObserver.h -> \"FBReactNativeSpecJSI.h\"", "quotedNotShipped react/nativemodule/microtasks/NativeMicrotasks.h -> \"FBReactNativeSpecJSI.h\"", "quotedNotShipped react/nativemodule/mutationobserver/NativeMutationObserver.h -> \"FBReactNativeSpecJSI.h\"", + "quotedNotShipped react/nativemodule/resizeobserver/NativeResizeObserver.h -> \"FBReactNativeSpecJSI.h\"", "quotedNotShipped react/nativemodule/viewtransition/NativeViewTransition.h -> \"FBReactNativeSpecJSI.h\"", "quotedNotShipped react/nativemodule/webperformance/NativePerformance.h -> \"FBReactNativeSpecJSI.h\"", "quotedNotShipped react/nativemodule/webperformance/NativePerformance.h -> \"rncoreJSI.h\"", diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index 3b6d3142b38e..1f684afd7105 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -187,6 +187,7 @@ def use_react_native! ( pod 'React-idlecallbacksnativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/idlecallbacks" pod 'React-intersectionobservernativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/intersectionobserver" pod 'React-mutationobservernativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/mutationobserver" + pod 'React-resizeobservernativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/resizeobserver" pod 'React-viewtransitionnativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/viewtransition" pod 'React-webperformancenativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/webperformance" pod 'React-domnativemodule', :path => "#{prefix}/ReactCommon/react/nativemodule/dom" diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 2bd10e7eef76..8a7a87dc1670 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<57ec5f47418b3283caa251114ed07b3b>> + * @generated SignedSource<<726e358c1ce5c148231ebab34e9d1b3c>> * @flow strict * @noformat */ @@ -91,6 +91,7 @@ export type ReactNativeFeatureFlags = Readonly<{ enableNativeCSSParsing: Getter, enablePreparedTextLayout: Getter, enablePropsUpdateReconciliationAndroid: Getter, + enableResizeObserverByDefault: Getter, enableRuntimeSchedulerQueueClearingOnError: Getter, enableSchedulerDelegateInvalidation: Getter, enableSwiftUIBasedFilters: Getter, @@ -378,6 +379,10 @@ export const enablePreparedTextLayout: Getter = createNativeFlagGetter( * When enabled, Android will receive prop updates based on the differences between the last rendered shadow node and the last committed shadow node. */ export const enablePropsUpdateReconciliationAndroid: Getter = createNativeFlagGetter('enablePropsUpdateReconciliationAndroid', false); +/** + * Enables the ResizeObserver Web API in React Native. + */ +export const enableResizeObserverByDefault: Getter = createNativeFlagGetter('enableResizeObserverByDefault', false); /** * When enabled, RuntimeScheduler_Modern clears pending tasks and rendering updates before handling an error. */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index 300b339d27b6..05cebf05f87d 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<721fb68d3038841ecd6bbaabafccd5cc>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -68,6 +68,7 @@ export interface Spec extends TurboModule { readonly enableNativeCSSParsing?: () => boolean; readonly enablePreparedTextLayout?: () => boolean; readonly enablePropsUpdateReconciliationAndroid?: () => boolean; + readonly enableResizeObserverByDefault?: () => boolean; readonly enableRuntimeSchedulerQueueClearingOnError?: () => boolean; readonly enableSchedulerDelegateInvalidation?: () => boolean; readonly enableSwiftUIBasedFilters?: () => boolean; diff --git a/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-ResizeObserver-itest.js b/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-ResizeObserver-itest.js new file mode 100644 index 000000000000..99cf805d12a3 --- /dev/null +++ b/packages/react-native/src/private/setup/__tests__/setUpDefaultReactNativeEnvironment-Globals-ResizeObserver-itest.js @@ -0,0 +1,53 @@ +/** + * 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. + * + * @fantom_flags enableResizeObserverByDefault:* + * @flow strict-local + * @format + * @oncall react_native + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; + +declare var ResizeObserverEntry: unknown; +declare var ResizeObserverSize: unknown; + +// TODO: Merge into `setUpDefaultReactNativeEnvironment-Globals-itest.js` once +// the `enableResizeObserverByDefault` feature flag is cleaned up and the +// ResizeObserver globals are exposed unconditionally. +describe('setUpDefaultReactNativeEnvironment (ResizeObserver globals)', () => { + if (ReactNativeFeatureFlags.enableResizeObserverByDefault()) { + describe('when enableResizeObserverByDefault is enabled', () => { + it('should provide ResizeObserver', () => { + expect(typeof ResizeObserver).toBe('function'); + }); + + it('should provide ResizeObserverEntry', () => { + expect(typeof ResizeObserverEntry).toBe('function'); + }); + + it('should provide ResizeObserverSize', () => { + expect(typeof ResizeObserverSize).toBe('function'); + }); + }); + } else { + describe('when enableResizeObserverByDefault is disabled', () => { + it('should not provide ResizeObserver', () => { + expect(typeof ResizeObserver).toBe('undefined'); + }); + + it('should not provide ResizeObserverEntry', () => { + expect(typeof ResizeObserverEntry).toBe('undefined'); + }); + + it('should not provide ResizeObserverSize', () => { + expect(typeof ResizeObserverSize).toBe('undefined'); + }); + }); + } +}); diff --git a/packages/react-native/src/private/setup/setUpDefaultReactNativeEnvironment.js b/packages/react-native/src/private/setup/setUpDefaultReactNativeEnvironment.js index 6bd27c4cc3d1..b05bd70cb88c 100644 --- a/packages/react-native/src/private/setup/setUpDefaultReactNativeEnvironment.js +++ b/packages/react-native/src/private/setup/setUpDefaultReactNativeEnvironment.js @@ -53,4 +53,10 @@ export default function setUpDefaultReactNativeEnvironment( ) { require('./setUpMutationObserver').default(); } + + if ( + require('../../../src/private/featureflags/ReactNativeFeatureFlags').enableResizeObserverByDefault() + ) { + require('./setUpResizeObserver').default(); + } } diff --git a/packages/react-native/src/private/setup/setUpResizeObserver.js b/packages/react-native/src/private/setup/setUpResizeObserver.js new file mode 100644 index 000000000000..17086f2ca8e2 --- /dev/null +++ b/packages/react-native/src/private/setup/setUpResizeObserver.js @@ -0,0 +1,40 @@ +/** + * 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 '../../../Libraries/Utilities/PolyfillFunctions'; + +let initialized = false; + +export default function setUpResizeObserver() { + if (initialized) { + return; + } + + initialized = true; + + polyfillGlobal( + 'ResizeObserver', + () => require('../webapis/resizeobserver/ResizeObserver').default, + ); + + polyfillGlobal( + 'ResizeObserverEntry', + () => + require('../webapis/resizeobserver/ResizeObserverEntry') + .ResizeObserverEntry_public, + ); + + polyfillGlobal( + 'ResizeObserverSize', + () => + require('../webapis/resizeobserver/ResizeObserverSize') + .ResizeObserverSize_public, + ); +} diff --git a/packages/react-native/src/private/webapis/resizeobserver/ResizeObserver.js b/packages/react-native/src/private/webapis/resizeobserver/ResizeObserver.js new file mode 100644 index 000000000000..d00f60594eb8 --- /dev/null +++ b/packages/react-native/src/private/webapis/resizeobserver/ResizeObserver.js @@ -0,0 +1,207 @@ +/** + * 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 {ResizeObserverId} from './internals/ResizeObserverManager'; +import type ResizeObserverEntry from './ResizeObserverEntry'; + +import ReactNativeElement from '../dom/nodes/ReactNativeElement'; +import {setPlatformObject} from '../webidl/PlatformObjects'; +import * as ResizeObserverManager from './internals/ResizeObserverManager'; + +export type ResizeObserverCallback = ( + entries: ReadonlyArray, + observer: ResizeObserver, +) => unknown; + +/** + * Corresponds to the `box` option of `ResizeObserver#observe`. + * https://drafts.csswg.org/resize-observer/#resize-observer-box-options + */ +export type ResizeObserverBoxOptions = + 'content-box' | 'border-box' | 'device-pixel-content-box'; + +export interface ResizeObserverOptions { + box?: ResizeObserverBoxOptions; +} + +/** + * The [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) + * interface of the Resize Observer API reports changes to the dimensions of + * an element's content or border box. + * + * `ResizeObserver` avoids infinite callback loops and cyclic dependencies + * that are often created when resizing via a callback function, as it only + * observes block and inline sizes on the content box or border box, as + * opposed to observing changes in pixels. + * + * You can add/remove multiple target elements to/from a single + * `ResizeObserver` instance. + */ +export default class ResizeObserver { + _callback: ResizeObserverCallback; + _observationTargets: Map = + new Map(); + _resizeObserverId: ?ResizeObserverId; + + constructor(callback: ResizeObserverCallback): void { + if (callback == null) { + throw new TypeError( + "Failed to construct 'ResizeObserver': 1 argument required, but only 0 present.", + ); + } + + if (typeof callback !== 'function') { + throw new TypeError( + "Failed to construct 'ResizeObserver': parameter 1 is not of type 'Function'.", + ); + } + + this._callback = callback; + } + + /** + * Starts observing the specified `ReactNativeElement`. + * One observer has one set of observation targets, and each target can be + * observed using either the target's content box or its border box (as + * specified by the `box` option). + * To stop observing an element, call `ResizeObserver.unobserve()`. + */ + observe(target: ReactNativeElement, options?: ResizeObserverOptions): void { + if (target == null) { + throw new TypeError( + "Failed to execute 'observe' on 'ResizeObserver': parameter 1 is null or undefined.", + ); + } + + if (!(target instanceof ReactNativeElement)) { + throw new TypeError( + "Failed to execute 'observe' on 'ResizeObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + } + + const box = normalizeBoxOption(options?.box); + + if (this._observationTargets.has(target)) { + // Re-observing a target already observed with the SAME box is a no-op + // and does not re-deliver. This matches observed browser behavior. + const previousBox = this._observationTargets.get(target); + if (previousBox === box) { + return; + } + + ResizeObserverManager.unobserve( + this._getOrCreateResizeObserverId(), + target, + ); + this._observationTargets.delete(target); + } + + const resizeObserverId = this._getOrCreateResizeObserverId(); + const didObserve = ResizeObserverManager.observe({ + resizeObserverId, + target, + box, + }); + + if (didObserve) { + this._observationTargets.set(target, box); + } else if (this._observationTargets.size === 0) { + // Target couldn't be observed (e.g. disconnected). Don't record it so a + // later `observe` can retry, and release the registration since nothing + // is observed. + ResizeObserverManager.unregisterObserver(resizeObserverId); + this._resizeObserverId = null; + } + } + + /** + * Ends the observing of the specified `ReactNativeElement`. + */ + unobserve(target: ReactNativeElement): void { + if (!(target instanceof ReactNativeElement)) { + throw new TypeError( + "Failed to execute 'unobserve' on 'ResizeObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + } + + if (!this._observationTargets.has(target)) { + return; + } + + const resizeObserverId = this._resizeObserverId; + if (resizeObserverId == null) { + // This is unexpected if the target is in `_observationTargets`. + console.error( + "Unexpected state in 'ResizeObserver': could not find observer ID to unobserve target.", + ); + return; + } + + ResizeObserverManager.unobserve(resizeObserverId, target); + this._observationTargets.delete(target); + + if (this._observationTargets.size === 0) { + ResizeObserverManager.unregisterObserver(resizeObserverId); + this._resizeObserverId = null; + } + } + + /** + * Unobserves all observed `ReactNativeElement` targets. + */ + disconnect(): void { + for (const target of this._observationTargets.keys()) { + this.unobserve(target); + } + } + + _getOrCreateResizeObserverId(): ResizeObserverId { + let resizeObserverId = this._resizeObserverId; + if (resizeObserverId == null) { + resizeObserverId = ResizeObserverManager.registerObserver( + this, + this._callback, + ); + this._resizeObserverId = resizeObserverId; + } + return resizeObserverId; + } + + // Only for tests + __getObserverID(): ?ResizeObserverId { + return this._resizeObserverId; + } +} + +setPlatformObject(ResizeObserver); + +function normalizeBoxOption( + box: ?ResizeObserverBoxOptions, +): ResizeObserverBoxOptions { + if (box == null) { + return 'content-box'; + } + + if ( + box !== 'content-box' && + box !== 'border-box' && + box !== 'device-pixel-content-box' + ) { + throw new TypeError( + `Failed to execute 'observe' on 'ResizeObserver': Failed to read the 'box' property from 'ResizeObserverOptions': The provided value '${String( + box, + )}' is not a valid enum value of type ResizeObserverBoxOptions.`, + ); + } + + return box; +} diff --git a/packages/react-native/src/private/webapis/resizeobserver/ResizeObserverEntry.js b/packages/react-native/src/private/webapis/resizeobserver/ResizeObserverEntry.js new file mode 100644 index 000000000000..ee38952fa988 --- /dev/null +++ b/packages/react-native/src/private/webapis/resizeobserver/ResizeObserverEntry.js @@ -0,0 +1,146 @@ +/** + * 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 {NativeResizeObserverEntry} from './specs/NativeResizeObserver'; + +import DOMRectReadOnly from '../geometry/DOMRectReadOnly'; +import {setPlatformObject} from '../webidl/PlatformObjects'; +import ResizeObserverSize from './ResizeObserverSize'; + +/** + * The [`ResizeObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) + * interface of the Resize Observer API represents the object passed to the + * `ResizeObserver()` callback function, which allows access to the new + * dimensions of the observed target element after its size has changed. + */ +export default class ResizeObserverEntry { + // We lazily compute all the properties from the raw entry provided by the + // native module, so we avoid unnecessary work. + _nativeEntry: NativeResizeObserverEntry; + // There are cases where this cannot be safely derived from the instance + // handle in the native entry (when the target is detached), so we need to + // keep a reference to it directly. + _target: ReactNativeElement; + _contentRect: ?DOMRectReadOnly; + _borderBoxSize: ?ReadonlyArray; + _contentBoxSize: ?ReadonlyArray; + _devicePixelContentBoxSize: ?ReadonlyArray; + + constructor( + nativeEntry: NativeResizeObserverEntry, + target: ReactNativeElement, + ) { + this._nativeEntry = nativeEntry; + this._target = target; + } + + /** + * The `ReactNativeElement` whose size has changed. + */ + get target(): ReactNativeElement { + return this._target; + } + + /** + * Returns a `DOMRectReadOnly` object which, prior to Firefox 57, contained + * the new size of the observed element. + * + * In React Native, this is the content rect of the observed target (the + * bounds of its content box). + */ + get contentRect(): DOMRectReadOnly { + if (this._contentRect == null) { + const contentRect = this._nativeEntry.contentRect; + this._contentRect = new DOMRectReadOnly( + contentRect[0], + contentRect[1], + contentRect[2], + contentRect[3], + ); + } + return this._contentRect; + } + + /** + * An array containing the new border box size of the observed element. + * + * Note: React Native does not support fragments, so this array will + * always contain a single value. + */ + get borderBoxSize(): ReadonlyArray { + if (this._borderBoxSize == null) { + const borderBoxSize = this._nativeEntry.borderBoxSize; + this._borderBoxSize = Object.freeze([ + new ResizeObserverSize(borderBoxSize[0], borderBoxSize[1]), + ]); + } + return this._borderBoxSize; + } + + /** + * An array containing the new content box size of the observed element. + * + * Note: React Native does not support fragments, so this array will + * always contain a single value. + */ + get contentBoxSize(): ReadonlyArray { + if (this._contentBoxSize == null) { + const contentBoxSize = this._nativeEntry.contentBoxSize; + this._contentBoxSize = Object.freeze([ + new ResizeObserverSize(contentBoxSize[0], contentBoxSize[1]), + ]); + } + return this._contentBoxSize; + } + + /** + * An array containing the new device pixel content box size of the observed element. + * + * Note: React Native does not support fragments, so this array will + * always contain a single value. + */ + get devicePixelContentBoxSize(): ReadonlyArray { + if (this._devicePixelContentBoxSize == null) { + const devicePixelContentBoxSize = + this._nativeEntry.devicePixelContentBoxSize; + this._devicePixelContentBoxSize = Object.freeze([ + new ResizeObserverSize( + devicePixelContentBoxSize[0], + devicePixelContentBoxSize[1], + ), + ]); + } + return this._devicePixelContentBoxSize; + } +} + +setPlatformObject(ResizeObserverEntry); + +export function createResizeObserverEntry( + entry: NativeResizeObserverEntry, + target: ReactNativeElement, +): ResizeObserverEntry { + return new ResizeObserverEntry(entry, target); +} + +export const ResizeObserverEntry_public: typeof ResizeObserverEntry = + /* eslint-disable no-shadow */ + // $FlowExpectedError[incompatible-type] + function ResizeObserverEntry() { + throw new TypeError( + "Failed to construct 'ResizeObserverEntry': Illegal constructor", + ); + }; + +// $FlowExpectedError[prop-missing] +ResizeObserverEntry_public.prototype = ResizeObserverEntry.prototype; diff --git a/packages/react-native/src/private/webapis/resizeobserver/ResizeObserverSize.js b/packages/react-native/src/private/webapis/resizeobserver/ResizeObserverSize.js new file mode 100644 index 000000000000..b38a6c36a510 --- /dev/null +++ b/packages/react-native/src/private/webapis/resizeobserver/ResizeObserverSize.js @@ -0,0 +1,70 @@ +/** + * 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 {setPlatformObject} from '../webidl/PlatformObjects'; + +/** + * The [`ResizeObserverSize`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverSize) + * interface of the Resize Observer API is used to store the block and inline + * sizes of a box as separate properties. + * + * It is returned by the `borderBoxSize`, `contentBoxSize` and + * `devicePixelContentBoxSize` properties of `ResizeObserverEntry`. + */ +export default class ResizeObserverSize { + _inlineSize: number; + _blockSize: number; + + constructor(inlineSize: number, blockSize: number) { + this._inlineSize = inlineSize; + this._blockSize = blockSize; + } + + /** + * The length of the observed box in the inline dimension. For boxes with a + * horizontal writing-mode, this is the horizontal dimension, or width; if + * the writing-mode is vertical, this is the vertical dimension, or height. + * + * React Native assumes a horizontal writing-mode, so this is the width. + */ + get inlineSize(): number { + return this._inlineSize; + } + + /** + * The length of the observed box in the block dimension. For boxes with a + * horizontal writing-mode, this is the vertical dimension, or height; if the + * writing-mode is vertical, this is the horizontal dimension, or width. + * + * React Native assumes a horizontal writing-mode, so this is the height. + */ + get blockSize(): number { + return this._blockSize; + } +} + +setPlatformObject(ResizeObserverSize); + +// `ResizeObserverSize` is not constructible from user code on the Web (its IDL +// declares no constructor). We expose this wrapper as the global instead of the +// class above, which we still use internally to build entries. +export const ResizeObserverSize_public: typeof ResizeObserverSize = + /* eslint-disable no-shadow */ + // $FlowExpectedError[incompatible-type] + function ResizeObserverSize() { + throw new TypeError( + "Failed to construct 'ResizeObserverSize': Illegal constructor", + ); + }; + +// $FlowExpectedError[prop-missing] +ResizeObserverSize_public.prototype = ResizeObserverSize.prototype; diff --git a/packages/react-native/src/private/webapis/resizeobserver/__docs__/README.md b/packages/react-native/src/private/webapis/resizeobserver/__docs__/README.md new file mode 100644 index 000000000000..097fc5cd569f --- /dev/null +++ b/packages/react-native/src/private/webapis/resizeobserver/__docs__/README.md @@ -0,0 +1,58 @@ +# ResizeObserver + +[🏠 Home](../../../../../../../__docs__/README.md) + +This directory contains the React Native implementation of the +[ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver). + +## 🚀 Usage + +`ResizeObserver` is meant to be used from JavaScript, exposed as a global class. + +## 📐 Design + +This is the high-level design of the ResizeObserver API: + +![ResizeObserver architecture design](./architecture.excalidraw.png) + +The global `ResizeObserver` class is defined in JavaScript and it does its setup +using a native module. + +In native, it relies on commit hooks from `UIManager` to learn which nodes had +layout updates after each commit (`shadowTreeDidCommit` with the affected layout +nodes). The commit hook records which observed targets may have resized. A +dedicated step in the Event Loop (as specified in the Web spec, via +`RuntimeSchedulerResizeObserverDelegate`) then runs resize observations against +the latest committed tree and notifies JavaScript when there are pending +entries. JavaScript pulls those entries via `takeRecords` and dispatches them to +the right observers. + +Unlike layout events (`onLayout`), `ResizeObserver` lets callers choose which +box to observe (`content-box`, `border-box`, or `device-pixel-content-box`) and +delivers sizes for those boxes in the notification. Notifications are delivered +from the «update the rendering» step of the Event Loop (via +`RuntimeSchedulerResizeObserverDelegate`) — after the task that committed the +layout change and that task's microtask checkpoint, not synchronously when +layout is computed. + +## 🔗 Relationship with other systems + +### Part of this + +- [NativeResizeObserver C++ TurboModule](../../../../../ReactCommon/react/nativemodule/resizeobserver/__docs__/README.md). +- [C++ implementation](../../../../../ReactCommon/react/renderer/observers/resize/__docs__/README.md). + +### Used by this + +- This relies on `ShadowTree` commit hooks provided by `UIManager`, including + the list of nodes whose layout changed after each commit. +- It uses the C++ TurboModule infra for communication between JavaScript and + native. +- It uses the + [`Event Loop`](../../../../../ReactCommon/react/renderer/runtimescheduler/__docs__/README.md) + to run resize observations as a dedicated step. + +### Uses this + +- This is an API meant to be used by end users. It is not used directly by any + other parts of the platform. diff --git a/packages/react-native/src/private/webapis/resizeobserver/__docs__/architecture.excalidraw.png b/packages/react-native/src/private/webapis/resizeobserver/__docs__/architecture.excalidraw.png new file mode 100644 index 000000000000..bb7c301778f1 Binary files /dev/null and b/packages/react-native/src/private/webapis/resizeobserver/__docs__/architecture.excalidraw.png differ diff --git a/packages/react-native/src/private/webapis/resizeobserver/__tests__/ResizeObserver-itest.js b/packages/react-native/src/private/webapis/resizeobserver/__tests__/ResizeObserver-itest.js new file mode 100644 index 000000000000..6d269b70d3ba --- /dev/null +++ b/packages/react-native/src/private/webapis/resizeobserver/__tests__/ResizeObserver-itest.js @@ -0,0 +1,2897 @@ +/** + * 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. + * + * @fantom_flags enableResizeObserverByDefault:true + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; +import type ResizeObserverType from 'react-native/src/private/webapis/resizeobserver/ResizeObserver'; +import type ResizeObserverEntryType from 'react-native/src/private/webapis/resizeobserver/ResizeObserverEntry'; +import type ResizeObserverSizeType from 'react-native/src/private/webapis/resizeobserver/ResizeObserverSize'; + +import ensureInstance from '../../../__tests__/utilities/ensureInstance'; +import {createShadowNodeReferenceCountingRef} from '../../../__tests__/utilities/ShadowNodeReferenceCounter'; +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {createRef} from 'react'; +import {View} from 'react-native'; +import setUpResizeObserver from 'react-native/src/private/setup/setUpResizeObserver'; +import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; +import DOMRectReadOnly from 'react-native/src/private/webapis/geometry/DOMRectReadOnly'; + +declare const ResizeObserver: Class; +declare const ResizeObserverEntry: Class; +declare const ResizeObserverSize: Class; + +type ResizeObserverMockCallback = JestMockFn< + [ReadonlyArray, ResizeObserver], + unknown, +>; + +setUpResizeObserver(); + +function ensureReactNativeElement(value: unknown): ReactNativeElement { + return ensureInstance(value, ReactNativeElement); +} + +function expectEntrySizes( + entry: ResizeObserverEntry, + expected: { + contentWidth: number, + contentHeight: number, + borderWidth: number, + borderHeight: number, + contentX?: number, + contentY?: number, + devicePixelWidth?: number, + devicePixelHeight?: number, + }, +): void { + expect(entry.contentRect).toBeInstanceOf(DOMRectReadOnly); + expect(entry.contentRect.x).toBe(expected.contentX ?? 0); + expect(entry.contentRect.y).toBe(expected.contentY ?? 0); + expect(entry.contentRect.width).toBe(expected.contentWidth); + expect(entry.contentRect.height).toBe(expected.contentHeight); + + expect(entry.contentBoxSize).toHaveLength(1); + expect(entry.contentBoxSize[0].inlineSize).toBe(expected.contentWidth); + expect(entry.contentBoxSize[0].blockSize).toBe(expected.contentHeight); + + expect(entry.borderBoxSize).toHaveLength(1); + expect(entry.borderBoxSize[0].inlineSize).toBe(expected.borderWidth); + expect(entry.borderBoxSize[0].blockSize).toBe(expected.borderHeight); + + if (expected.devicePixelWidth != null && expected.devicePixelHeight != null) { + expect(entry.devicePixelContentBoxSize).toHaveLength(1); + expect(entry.devicePixelContentBoxSize[0].inlineSize).toBe( + expected.devicePixelWidth, + ); + expect(entry.devicePixelContentBoxSize[0].blockSize).toBe( + expected.devicePixelHeight, + ); + } +} + +describe('ResizeObserver', () => { + let observer: ResizeObserver; + + afterEach(() => { + Fantom.runTask(() => { + if (observer != null) { + observer.disconnect(); + } + }); + }); + + describe('constructor(callback)', () => { + it('should throw if `callback` is not provided', () => { + expect(() => { + // $FlowExpectedError[incompatible-type] + return new ResizeObserver(); + }).toThrow( + "Failed to construct 'ResizeObserver': 1 argument required, but only 0 present.", + ); + }); + + it('should throw if `callback` is not a function', () => { + expect(() => { + // $FlowExpectedError[incompatible-type] + return new ResizeObserver('not a function!'); + }).toThrow( + "Failed to construct 'ResizeObserver': parameter 1 is not of type 'Function'.", + ); + }); + }); + + describe('observe(target, options)', () => { + it('should throw if `target` is null or undefined', () => { + observer = new ResizeObserver(() => {}); + + expect(() => { + // $FlowExpectedError[incompatible-type] + observer.observe(null); + }).toThrow( + "Failed to execute 'observe' on 'ResizeObserver': parameter 1 is null or undefined.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-type] + observer.observe(undefined); + }).toThrow( + "Failed to execute 'observe' on 'ResizeObserver': parameter 1 is null or undefined.", + ); + }); + + it('should throw if `target` is not a `ReactNativeElement`', () => { + observer = new ResizeObserver(() => {}); + expect(() => { + // $FlowExpectedError[incompatible-type] + observer.observe('something'); + }).toThrow( + "Failed to execute 'observe' on 'ResizeObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + }); + + it('should throw if `box` is not a valid enum value', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + const node = ensureReactNativeElement(nodeRef.current); + + observer = new ResizeObserver(() => {}); + expect(() => { + // $FlowExpectedError[incompatible-type] + observer.observe(node, {box: 'margin-box'}); + }).toThrow( + "Failed to execute 'observe' on 'ResizeObserver': Failed to read the 'box' property from 'ResizeObserverOptions': The provided value 'margin-box' is not a valid enum value of type ResizeObserverBoxOptions.", + ); + }); + + it('should accept valid `box` option values', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + const node = ensureReactNativeElement(nodeRef.current); + + expect(() => { + observer = new ResizeObserver(() => {}); + observer.observe(node, {box: 'content-box'}); + observer.unobserve(node); + observer.observe(node, {box: 'border-box'}); + observer.unobserve(node); + observer.observe(node, {box: 'device-pixel-content-box'}); + }).not.toThrow(); + }); + + it('should ignore calls to observe disconnected targets', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + + Fantom.runTask(() => { + root.render(<>); + }); + expect(node.isConnected).toBe(false); + + const callback = jest.fn(); + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + expect(() => { + observer.observe(node); + }).not.toThrow(); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should deliver an initial observation for a sized target', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot({devicePixelRatio: 2}); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + + expect(callback).toHaveBeenCalledTimes(1); + const [entries, reportedObserver] = callback.mock.lastCall; + expect(entries).toHaveLength(1); + expect(entries[0]).toBeInstanceOf(ResizeObserverEntry); + expect(entries[0].target).toBe(node); + expect(reportedObserver).toBe(observer); + expectEntrySizes(entries[0], { + contentWidth: 100, + contentHeight: 50, + borderWidth: 100, + borderHeight: 50, + devicePixelWidth: 200, + devicePixelHeight: 100, + }); + }); + + it('should deliver an initial observation for a zero-sized target', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + + expect(callback).toHaveBeenCalledTimes(1); + const [entries] = callback.mock.lastCall; + expectEntrySizes(entries[0], { + contentWidth: 0, + contentHeight: 0, + borderWidth: 0, + borderHeight: 0, + }); + }); + + it('should report content and border box sizes with padding and border', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + + expect(callback).toHaveBeenCalledTimes(1); + const [entries] = callback.mock.lastCall; + // border-box: 100x80 + // content insets = padding(10) + border(5) on each side → content 70x50 + // contentRect origin is padding only → (10, 10) + expectEntrySizes(entries[0], { + contentWidth: 70, + contentHeight: 50, + borderWidth: 100, + borderHeight: 80, + contentX: 10, + contentY: 10, + }); + }); + + it('should report device-pixel-content-box observations using the root pixel ratio', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot({devicePixelRatio: 3}); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node, {box: 'device-pixel-content-box'}); + }); + + expect(callback).toHaveBeenCalledTimes(1); + const [entries] = callback.mock.lastCall; + expect(entries[0].devicePixelContentBoxSize[0].inlineSize).toBe( + Math.round(10.4 * 3), + ); + expect(entries[0].devicePixelContentBoxSize[0].blockSize).toBe( + Math.round(10.6 * 3), + ); + }); + + it('should ignore subsequent observe calls for the same target and box', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + observer.observe(node); + observer.observe(node, {box: 'content-box'}); + }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should re-deliver when re-observing the same target with a different box', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node, {box: 'content-box'}); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + observer.observe(node, {box: 'border-box'}); + }); + expect(callback).toHaveBeenCalledTimes(2); + + const [entries] = callback.mock.lastCall; + expect(entries[0].borderBoxSize[0].inlineSize).toBe(100); + expect(entries[0].borderBoxSize[0].blockSize).toBe(80); + expect(entries[0].contentBoxSize[0].inlineSize).toBe(70); + expect(entries[0].contentBoxSize[0].blockSize).toBe(50); + }); + + it('should report size updates for the observed target', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + const [entries] = callback.mock.lastCall; + expectEntrySizes(entries[0], { + contentWidth: 200, + contentHeight: 75, + borderWidth: 200, + borderHeight: 75, + }); + }); + + it('should not report updates that do not change the observed box', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node, {box: 'border-box'}); + }); + expect(callback).toHaveBeenCalledTimes(1); + + // Non-layout style change should not notify. + Fantom.runTask(() => { + root.render( + , + ); + }); + expect(callback).toHaveBeenCalledTimes(1); + + // Padding changes content-box but not border-box. + Fantom.runTask(() => { + root.render( + , + ); + }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should treat the default (no `box` option) observation as content-box, not border-box', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 70, + contentHeight: 50, + borderWidth: 100, + borderHeight: 80, + contentX: 10, + contentY: 10, + }); + + // Grow the border-box while growing the border by the same amount + // (padding unchanged), so the content box stays exactly the same: + // border-box goes 100x80 -> 110x90, but content-box stays 70x50. If + // the default box were border-box, this would fire. + Fantom.runTask(() => { + root.render( + , + ); + }); + expect(callback).toHaveBeenCalledTimes(1); + + // A genuine content-box change still fires, confirming the observer + // is live and this isn't merely stuck. + Fantom.runTask(() => { + root.render( + , + ); + }); + expect(callback).toHaveBeenCalledTimes(2); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 90, + contentHeight: 50, + borderWidth: 130, + borderHeight: 90, + contentX: 10, + contentY: 10, + }); + }); + + it('should report zero sizes when the target becomes hidden', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + const [entries] = callback.mock.lastCall; + expectEntrySizes(entries[0], { + contentWidth: 0, + contentHeight: 0, + borderWidth: 0, + borderHeight: 0, + }); + }); + + it('should report a content-box change when padding is added', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node, {box: 'content-box'}); + }); + expect(callback).toHaveBeenCalledTimes(1); + + // Adding padding shrinks the content box (border box is unchanged), so a + // content-box observation must fire. This is the complement of the + // border-box case, where the same padding change does not fire. + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 80, + contentHeight: 60, + borderWidth: 100, + borderHeight: 80, + contentX: 10, + contentY: 10, + }); + }); + + it('should report border-box changes when observing the border box', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node, {box: 'border-box'}); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 130, // 140 - 2 * 5 + contentHeight: 70, // 80 - 2 * 5 + borderWidth: 140, + borderHeight: 80, + }); + }); + + it('should not deliver a callback for transforms (layout size unchanged)', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + // A CSS transform does not change the layout box, so no observation + // fires (matches the spec: observations are not triggered by transforms). + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should re-deliver the size when a hidden target is shown again', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + root.render( + , + ); + }); + expect(callback).toHaveBeenCalledTimes(2); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 0, + contentHeight: 0, + borderWidth: 0, + borderHeight: 0, + }); + + Fantom.runTask(() => { + root.render( + , + ); + }); + expect(callback).toHaveBeenCalledTimes(3); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 100, + contentHeight: 50, + borderWidth: 100, + borderHeight: 50, + }); + }); + + // Covers the hidden-ancestor path: per spec, a target under a + // display:none ancestor is "not being rendered", which reports the same + // 0x0 box as the target itself being display:none. + it('should deliver a 0x0 entry when observing a target under a display:none ancestor, then the real size when shown', () => { + const childRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const child = ensureReactNativeElement(childRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(child); + }); + + // Hidden ancestor: not being rendered — initial delivery is 0x0, same + // as the target itself being display:none. + expect(callback).toHaveBeenCalledTimes(1); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 0, + contentHeight: 0, + borderWidth: 0, + borderHeight: 0, + }); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 100, + contentHeight: 50, + borderWidth: 100, + borderHeight: 50, + }); + }); + + it('should deliver a 0x0 entry when an already-observed target is hidden via an ancestor, then the real size again once shown', () => { + const childRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const child = ensureReactNativeElement(childRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(child); + }); + + expect(callback).toHaveBeenCalledTimes(1); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 100, + contentHeight: 50, + borderWidth: 100, + borderHeight: 50, + }); + + // Hide the parent: the target is no longer being rendered, so it + // reports 0x0, same as the target itself going display:none. + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 0, + contentHeight: 0, + borderWidth: 0, + borderHeight: 0, + }); + + // Show the parent again: the box differs from the 0x0 last reported + // while hidden, so it redelivers the real size. + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(3); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 100, + contentHeight: 50, + borderWidth: 100, + borderHeight: 50, + }); + }); + + it('should deliver to multiple observers watching the same target', () => { + const nodeRef = createRef(); + let observer1: ResizeObserver; + let observer2: ResizeObserver; + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + Fantom.runTask(() => { + observer1 = new ResizeObserver(callback1); + observer2 = new ResizeObserver(callback2); + observer1.observe(node); + observer2.observe(node); + }); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback1.mock.lastCall[0][0].target).toBe(node); + expect(callback2.mock.lastCall[0][0].target).toBe(node); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledTimes(2); + + Fantom.runTask(() => { + observer1.disconnect(); + observer2.disconnect(); + }); + }); + + it('should deliver a final 0x0 observation when the target is removed from the tree', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + // Unmount the observed target *without* calling `unobserve`. + Fantom.runTask(() => { + root.render(<>); + }); + + // Matches the Web: removal from the tree fires one final notification + // with a 0x0 box. + expect(callback).toHaveBeenCalledTimes(2); + const [entries] = callback.mock.lastCall; + expect(entries).toHaveLength(1); + expect(entries[0].target).toBe(node); + expectEntrySizes(entries[0], { + contentWidth: 0, + contentHeight: 0, + borderWidth: 0, + borderHeight: 0, + }); + + // No further notifications while it stays detached. + Fantom.runTask(() => { + root.render(); + }); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should not re-deliver on the original observation when a removed target is remounted as a new host instance', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + root.render(<>); + }); + expect(callback).toHaveBeenCalledTimes(2); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 0, + contentHeight: 0, + borderWidth: 0, + borderHeight: 0, + }); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + // React remount allocates a new ShadowNodeFamily. Observations are keyed + // by family, so the original observation stays detached — unlike + // `display: 'none'`, which keeps the same family and re-delivers when + // shown again. Web-style reinsertion of the *same* Element is not + // expressible via React remount. + expect(callback).toHaveBeenCalledTimes(2); + const remountedNode = ensureReactNativeElement(nodeRef.current); + expect(remountedNode).not.toBe(node); + + Fantom.runTask(() => { + observer.observe(remountedNode); + }); + expect(callback).toHaveBeenCalledTimes(3); + const [entries] = callback.mock.lastCall; + expect(entries).toHaveLength(1); + expect(entries[0].target).toBe(remountedNode); + expectEntrySizes(entries[0], { + contentWidth: 120, + contentHeight: 60, + borderWidth: 120, + borderHeight: 60, + }); + }); + + it('should not notify when only an ancestor layout changes but the observed box is unchanged', () => { + const childRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const child = ensureReactNativeElement(childRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(child, {box: 'border-box'}); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should deliver initial observation for a target observed inside another observer callback in the same tick when it is deeper', () => { + const nodeARef = createRef(); + const nodeBRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const nodeA = ensureReactNativeElement(nodeARef.current); + const nodeB = ensureReactNativeElement(nodeBRef.current); + const callbackB = jest.fn(); + let observerB: ResizeObserver; + + const callbackA: ResizeObserverMockCallback = jest.fn(() => { + expect(callbackB).not.toHaveBeenCalled(); + observerB = new ResizeObserver(callbackB); + observerB.observe(nodeB); + expect(callbackB).not.toHaveBeenCalled(); + }); + + Fantom.runTask(() => { + observer = new ResizeObserver(callbackA); + observer.observe(nodeA); + }); + + expect(callbackA).toHaveBeenCalledTimes(1); + expect(callbackB).toHaveBeenCalledTimes(1); + expectEntrySizes(callbackB.mock.lastCall[0][0], { + contentWidth: 80, + contentHeight: 40, + borderWidth: 80, + borderHeight: 40, + }); + + Fantom.runTask(() => { + observerB.disconnect(); + }); + }); + + describe('multiple surfaces', () => { + it('should deliver resize observations independently per surface', () => { + const node1Ref = createRef(); + const node2Ref = createRef(); + const root1 = Fantom.createRoot(); + const root2 = Fantom.createRoot(); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + let observer1: ResizeObserver; + let observer2: ResizeObserver; + + Fantom.runTask(() => { + root1.render( + , + ); + root2.render( + , + ); + }); + + const node1 = ensureReactNativeElement(node1Ref.current); + const node2 = ensureReactNativeElement(node2Ref.current); + + Fantom.runTask(() => { + observer1 = new ResizeObserver(callback1); + observer2 = new ResizeObserver(callback2); + observer1.observe(node1); + observer2.observe(node2); + }); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expectEntrySizes(callback1.mock.lastCall[0][0], { + contentWidth: 100, + contentHeight: 50, + borderWidth: 100, + borderHeight: 50, + }); + expectEntrySizes(callback2.mock.lastCall[0][0], { + contentWidth: 200, + contentHeight: 80, + borderWidth: 200, + borderHeight: 80, + }); + + Fantom.runTask(() => { + root1.render( + , + ); + root2.render( + , + ); + }); + + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledTimes(2); + expect(callback1.mock.lastCall[0][0].contentRect.width).toBe(150); + expect(callback2.mock.lastCall[0][0].contentRect.height).toBe(120); + + Fantom.runTask(() => { + observer1.disconnect(); + observer2.disconnect(); + }); + }); + }); + + describe('multiple commits within a single tick', () => { + it('should coalesce multiple commits to the same target into a single callback reporting the final size', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + // Two commits land in this single tick, before the resize-observer + // step runs (which happens once per tick, not once per commit). + Fantom.runTask(() => { + root.render(); + root.render(); + }); + + expect(callback).toHaveBeenCalledTimes(2); + const [entries] = callback.mock.lastCall; + expect(entries).toHaveLength(1); + expectEntrySizes(entries[0], { + contentWidth: 200, + contentHeight: 80, + borderWidth: 200, + borderHeight: 80, + }); + }); + + it('should coalesce commits to two different observed targets into a single callback carrying both entries in observation order', () => { + const node1Ref = createRef(); + const node2Ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + const node1 = ensureReactNativeElement(node1Ref.current); + const node2 = ensureReactNativeElement(node2Ref.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node1); + observer.observe(node2); + }); + expect(callback).toHaveBeenCalledTimes(1); + + // First commit resizes node1, second (separate) commit resizes + // node2, both within the same tick. + Fantom.runTask(() => { + root.render( + <> + + + , + ); + root.render( + <> + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + const [entries] = callback.mock.lastCall; + expect(entries).toHaveLength(2); + // Observation order (node1 observed before node2), not commit order. + expect(entries[0].target).toBe(node1); + expect(entries[0].contentRect.width).toBe(60); + expect(entries[1].target).toBe(node2); + expect(entries[1].contentRect.width).toBe(100); + }); + }); + + it('should report updates to the right observers', () => { + const node1Ref = createRef(); + const node2Ref = createRef(); + let observer1: ResizeObserver; + let observer2: ResizeObserver; + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + const node1 = ensureReactNativeElement(node1Ref.current); + const node2 = ensureReactNativeElement(node2Ref.current); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + Fantom.runTask(() => { + observer1 = new ResizeObserver(callback1); + observer1.observe(node1); + observer1.observe(node2); + + observer2 = new ResizeObserver(callback2); + observer2.observe(node2); + }); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + + const [entries1, reportedObserver1] = callback1.mock.lastCall; + expect(reportedObserver1).toBe(observer1); + expect(entries1).toHaveLength(2); + expect(entries1[0].target).toBe(node1); + expect(entries1[1].target).toBe(node2); + + const [entries2, reportedObserver2] = callback2.mock.lastCall; + expect(reportedObserver2).toBe(observer2); + expect(entries2).toHaveLength(1); + expect(entries2[0].target).toBe(node2); + + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledTimes(1); + const [updateEntries] = callback1.mock.lastCall; + expect(updateEntries).toHaveLength(1); + expect(updateEntries[0].target).toBe(node1); + expect(updateEntries[0].contentRect.width).toBe(60); + + Fantom.runTask(() => { + observer1.disconnect(); + observer2.disconnect(); + }); + }); + + describe('observing multiple targets in the same observer', () => { + it('should report changes for disjoint observations in observation order', () => { + const node1Ref = createRef(); + const node2Ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + const node1 = ensureReactNativeElement(node1Ref.current); + const node2 = ensureReactNativeElement(node2Ref.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node2); + observer.observe(node1); + }); + + expect(callback).toHaveBeenCalledTimes(1); + const [entries] = callback.mock.lastCall; + expect(entries.map(entry => entry.target)).toEqual([node2, node1]); + + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + const [updateEntries] = callback.mock.lastCall; + expect(updateEntries).toHaveLength(1); + expect(updateEntries[0].target).toBe(node2); + expect(updateEntries[0].contentRect.width).toBe(90); + }); + }); + + describe('cross-observer callback ordering', () => { + it('should invoke callbacks in observe() order across different observers', () => { + const node1Ref = createRef(); + const node2Ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + const node1 = ensureReactNativeElement(node1Ref.current); + const node2 = ensureReactNativeElement(node2Ref.current); + + const callOrder: Array = []; + const callbackA: ResizeObserverMockCallback = jest.fn(() => + callOrder.push('A'), + ); + const callbackB: ResizeObserverMockCallback = jest.fn(() => + callOrder.push('B'), + ); + let observerA: ResizeObserver; + let observerB: ResizeObserver; + + Fantom.runTask(() => { + observerA = new ResizeObserver(callbackA); + observerB = new ResizeObserver(callbackB); + observerA.observe(node1); + observerB.observe(node2); + }); + + expect(callOrder).toEqual(['A', 'B']); + callOrder.length = 0; + + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + expect(callOrder).toEqual(['A', 'B']); + + Fantom.runTask(() => { + observerA.disconnect(); + observerB.disconnect(); + }); + }); + + it('should order callbacks by first-observe() order, not construction order', () => { + const node1Ref = createRef(); + const node2Ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + const node1 = ensureReactNativeElement(node1Ref.current); + const node2 = ensureReactNativeElement(node2Ref.current); + + const callOrder: Array = []; + const callbackA: ResizeObserverMockCallback = jest.fn(() => + callOrder.push('A'), + ); + const callbackB: ResizeObserverMockCallback = jest.fn(() => + callOrder.push('B'), + ); + let observerA: ResizeObserver; + let observerB: ResizeObserver; + + Fantom.runTask(() => { + // `observerA` is constructed first, but `observerB` calls + // `observe()` (and so registers with the manager) first. + // Cross-observer ordering is keyed off registration order, which + // happens lazily at the first `observe()` call, not at + // construction time — so B is expected to run before A. + observerA = new ResizeObserver(callbackA); + observerB = new ResizeObserver(callbackB); + observerB.observe(node2); + observerA.observe(node1); + }); + + expect(callOrder).toEqual(['B', 'A']); + + Fantom.runTask(() => { + observerA.disconnect(); + observerB.disconnect(); + }); + }); + }); + + describe('memory handling', () => { + it('should not retain initial children of observed targets', () => { + const root = Fantom.createRoot(); + observer = new ResizeObserver(() => {}); + + const [getReferenceCount, ref] = createShadowNodeReferenceCountingRef(); + + const observeRef: React.RefSetter< + React.ElementRef, + > = instance => { + const element = ensureReactNativeElement(instance); + observer.observe(element); + return () => { + observer.unobserve(element); + }; + }; + + function Observe({children}: Readonly<{children?: React.Node}>) { + return ( + + {children} + + ); + } + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(getReferenceCount()).toBeGreaterThan(0); + + Fantom.runTask(() => { + root.render(); + }); + + expect(getReferenceCount()).toBe(0); + }); + }); + }); + + describe('delivery timing', () => { + it('should deliver observations before a task scheduled from the same tick', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const events: Array = []; + const callback: ResizeObserverMockCallback = jest.fn(() => { + events.push('callback'); + }); + + // Observations are broadcast synchronously in the "update the rendering" + // step of the tick that observed the target, so they run before any task + // scheduled from that same tick. Asynchronous delivery inverts this. + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + Fantom.scheduleTask(() => { + events.push('task'); + }); + }); + + expect(events).toEqual(['callback', 'task']); + }); + + it('should let a callback read the committed layout it is reporting on', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const observed: Array = []; + const callback: ResizeObserverMockCallback = jest.fn(entries => { + observed.push( + `${entries[0].contentRect.width}:${node.getBoundingClientRect().width}`, + ); + }); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + // The callback now runs earlier in the tick than the mounting step, so + // its layout reads must still resolve to the commit it reports on. + expect(observed).toEqual(['100:100', '200:200']); + }); + }); + + describe('callback error handling', () => { + it('should not prevent other observers from receiving entries when a callback throws', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback1: ResizeObserverMockCallback = jest.fn(() => { + throw new Error('observer 1 failed'); + }); + const callback2 = jest.fn(); + let observer1: ResizeObserver; + let observer2: ResizeObserver; + + const originalConsoleError = console.error; + const errorSpy = jest.fn(); + // $FlowExpectedError[cannot-write] + console.error = errorSpy; + + try { + Fantom.runTask(() => { + observer1 = new ResizeObserver(callback1); + observer2 = new ResizeObserver(callback2); + observer1.observe(node); + observer2.observe(node); + }); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalled(); + } finally { + // $FlowExpectedError[cannot-write] + console.error = originalConsoleError; + Fantom.runTask(() => { + observer1.disconnect(); + observer2.disconnect(); + }); + } + }); + + it('should keep delivering later resizes to an observer whose callback always throws', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback: ResizeObserverMockCallback = jest.fn(() => { + throw new Error('observer failed'); + }); + + const originalConsoleError = console.error; + const errorSpy = jest.fn(); + // $FlowExpectedError[cannot-write] + console.error = errorSpy; + + try { + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + // The callback now runs inside the observation pass, so a throw must + // not wedge it (e.g. by leaving the re-entrancy guard set), which + // would silently stop every later delivery instead of just this one. + Fantom.runTask(() => { + root.render( + , + ); + }); + expect(callback).toHaveBeenCalledTimes(2); + + Fantom.runTask(() => { + root.render( + , + ); + }); + expect(callback).toHaveBeenCalledTimes(3); + expect(errorSpy).toHaveBeenCalledTimes(3); + } finally { + // $FlowExpectedError[cannot-write] + console.error = originalConsoleError; + } + }); + }); + + describe('callback re-entrancy', () => { + it('should allow an observer to disconnect itself from within its own callback without throwing, and not be re-notified afterwards', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + let selfObserver: ResizeObserver; + const callback: ResizeObserverMockCallback = jest.fn(() => { + selfObserver.disconnect(); + }); + + Fantom.runTask(() => { + selfObserver = new ResizeObserver(callback); + selfObserver.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + expect(() => { + Fantom.runTask(() => { + root.render(); + }); + }).not.toThrow(); + + // The observer disconnected itself during the initial delivery, so the + // subsequent resize is never reported. + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should drop a not-yet-invoked observer's entries without error when an earlier callback disconnects it in the same batch", () => { + const node1Ref = createRef(); + const node2Ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + const node1 = ensureReactNativeElement(node1Ref.current); + const node2 = ensureReactNativeElement(node2Ref.current); + + let observerA: ResizeObserver; + let observerB: ResizeObserver; + const callbackB = jest.fn(); + const callbackA: ResizeObserverMockCallback = jest.fn(() => { + // A is invoked before B (per observe() order); disconnect B before + // its own turn in this same delivery batch. + observerB.disconnect(); + }); + + expect(() => { + Fantom.runTask(() => { + observerA = new ResizeObserver(callbackA); + observerB = new ResizeObserver(callbackB); + observerA.observe(node1); + observerB.observe(node2); + }); + }).not.toThrow(); + + expect(callbackA).toHaveBeenCalledTimes(1); + // B's entries were already computed for this batch, but its + // registration was removed before its turn — doNotifyResizeObservers + // must skip it silently rather than throw or invoke a stale callback. + expect(callbackB).not.toHaveBeenCalled(); + + Fantom.runTask(() => { + observerA.disconnect(); + }); + }); + + it('should still deliver a batch to an observer whose other target an earlier callback unobserved', () => { + const node1Ref = createRef(); + const node2Ref = createRef(); + const node3Ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + <> + + + + , + ); + }); + + const node1 = ensureReactNativeElement(node1Ref.current); + const node2 = ensureReactNativeElement(node2Ref.current); + const node3 = ensureReactNativeElement(node3Ref.current); + + let observerA: ResizeObserver; + let observerB: ResizeObserver; + const callbackB = jest.fn(); + // A runs before B (observe() order), so this mutates the manager's + // observations while B's entries for this batch are still undelivered. + // Unlike `disconnect()`, B keeps its registration here. + const callbackA: ResizeObserverMockCallback = jest.fn(() => { + observerB.unobserve(node2); + }); + + expect(() => { + Fantom.runTask(() => { + observerA = new ResizeObserver(callbackA); + observerB = new ResizeObserver(callbackB); + observerA.observe(node1); + observerB.observe(node2); + observerB.observe(node3); + }); + }).not.toThrow(); + + expect(callbackA).toHaveBeenCalledTimes(1); + // The batch was snapshotted before any callback ran, so B still receives + // the entry for the target that is no longer observed. + expect(callbackB).toHaveBeenCalledTimes(1); + const [entries] = callbackB.mock.lastCall; + expect(entries.map(entry => entry.target)).toEqual([node2, node3]); + + // From the next tick on, the unobserve is in effect: only node3 reports. + Fantom.runTask(() => { + root.render( + <> + + + + , + ); + }); + + expect(callbackA).toHaveBeenCalledTimes(1); + expect(callbackB).toHaveBeenCalledTimes(2); + const [updateEntries] = callbackB.mock.lastCall; + expect(updateEntries.map(entry => entry.target)).toEqual([node3]); + + Fantom.runTask(() => { + observerA.disconnect(); + observerB.disconnect(); + }); + }); + + it('should reconnect and keep observing after a callback disconnects the last remaining observer', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + let firstObserver: ResizeObserver; + // Disconnecting the only observer tears down the whole native + // connection (commit hook, event-loop delegate, notification callback) + // while the notification invoking us is still on the stack. + const firstCallback: ResizeObserverMockCallback = jest.fn(() => { + firstObserver.disconnect(); + }); + + expect(() => { + Fantom.runTask(() => { + firstObserver = new ResizeObserver(firstCallback); + firstObserver.observe(node); + }); + }).not.toThrow(); + expect(firstCallback).toHaveBeenCalledTimes(1); + + // A later observation must reconnect: the initial delivery *and* + // subsequent resizes (which need the commit hook back) still work. + const callback = jest.fn(); + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(2); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 200, + contentHeight: 50, + borderWidth: 200, + borderHeight: 50, + }); + expect(firstCallback).toHaveBeenCalledTimes(1); + }); + + it('should deliver a deeper target observed from within the same observer callback on a follow-up loop round, without re-delivering the first target', () => { + const nodeARef = createRef(); + const nodeBRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const nodeA = ensureReactNativeElement(nodeARef.current); + const nodeB = ensureReactNativeElement(nodeBRef.current); + + // Re-enters `observe()` on the very observer being notified. Re-observing + // an already-observed target with the same box is a no-op, so this + // settles after a single follow-up loop round instead of looping. + const callback: ResizeObserverMockCallback = jest.fn(() => { + observer.observe(nodeB); + }); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(nodeA); + }); + + expect(callback).toHaveBeenCalledTimes(2); + const [firstEntries] = callback.mock.calls[0]; + expect(firstEntries.map(entry => entry.target)).toEqual([nodeA]); + // The follow-up loop round reports only the newly observed target: nodeA + // was already delivered and did not change. + const [secondEntries] = callback.mock.calls[1]; + expect(secondEntries.map(entry => entry.target)).toEqual([nodeB]); + }); + }); + + describe('resize loop error', () => { + let originalConsoleError; + let consoleErrorMock; + + // Fantom's mock functions have no `mockClear`, so "clearing" installs a + // fresh mock. + function resetConsoleErrorMock() { + consoleErrorMock = jest.fn(); + // $FlowExpectedError[cannot-write] + console.error = consoleErrorMock; + } + + beforeEach(() => { + originalConsoleError = console.error; + resetConsoleErrorMock(); + }); + + afterEach(() => { + // $FlowExpectedError[cannot-write] + console.error = originalConsoleError; + }); + + function expectNoResizeLoopError() { + expect(consoleErrorMock).not.toHaveBeenCalled(); + } + + // A React update from a callback is not applied synchronously, so this + // cascade resolves in a later tick rather than in a later loop round. The + // loop rounds only apply to work that reaches native synchronously, such as + // `observe()`. + it('should report a loop error and deliver a shallower target observed from a callback on a later pass', () => { + const nodeARef = createRef(); + const nodeBRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + const nodeA = ensureReactNativeElement(nodeARef.current); + const nodeB = ensureReactNativeElement(nodeBRef.current); + const callback: ResizeObserverMockCallback = jest.fn(() => { + observer.observe(nodeA); + }); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(nodeB); + }); + + // Only the deeper target was delivered; the shallower one was skipped. + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.mock.lastCall[0].map(entry => entry.target)).toEqual([ + nodeB, + ]); + expect(consoleErrorMock).toHaveBeenCalledTimes(1); + expect(consoleErrorMock.mock.lastCall[0]).toBe( + 'ResizeObserver loop completed with undelivered notifications.', + ); + + resetConsoleErrorMock(); + + // The skipped observation is retried on the next pass, where the depth is + // back to 0, and settles without another error. + Fantom.runTask(() => {}); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback.mock.lastCall[0].map(entry => entry.target)).toEqual([ + nodeA, + ]); + expectNoResizeLoopError(); + + Fantom.runTask(() => {}); + + expect(callback).toHaveBeenCalledTimes(2); + expectNoResizeLoopError(); + }); + }); + + describe('state updates scheduled from callbacks', () => { + it('should process a state update scheduled from a callback in a separate task, after the callback returns', () => { + const nodeRef = createRef(); + const setWidthRef: {current: ?(number) => void} = {current: null}; + const events: Array = []; + const root = Fantom.createRoot(); + + function Box() { + const [width, setWidth] = React.useState(100); + setWidthRef.current = setWidth; + events.push(`render:${width}`); + return ; + } + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + + const callback: ResizeObserverMockCallback = jest.fn(entries => { + events.push(`callback:${entries[0].contentRect.width}`); + const setWidth = setWidthRef.current; + if (setWidth != null) { + setWidth(200); + } + events.push('afterSetState'); + }); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.mock.calls[0][0][0].contentRect.width).toBe(100); + + Fantom.runTask(() => {}); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback.mock.calls[1][0][0].contentRect.width).toBe(200); + + expect(events.indexOf('callback:100')).toBeLessThan( + events.indexOf('render:200'), + ); + expect(events.indexOf('afterSetState')).toBeLessThan( + events.indexOf('render:200'), + ); + }); + + // Without `flushSync` the update is not visible to the callback that + // scheduled it: the layout it can measure is still the one it was notified + // about. This is the gap that prevents same-frame reactions. + it('should not apply a state update before the callback that scheduled it returns', () => { + const nodeRef = createRef(); + const setWidthRef: {current: ?(number) => void} = {current: null}; + const root = Fantom.createRoot(); + let widthMeasuredAfterSetState = -1; + + function Box() { + const [width, setWidth] = React.useState(100); + setWidthRef.current = setWidth; + return ; + } + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback: ResizeObserverMockCallback = jest.fn(() => { + const setWidth = setWidthRef.current; + if (setWidth != null && widthMeasuredAfterSetState === -1) { + setWidth(200); + widthMeasuredAfterSetState = node.getBoundingClientRect().width; + } + }); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + + expect(widthMeasuredAfterSetState).toBe(100); + }); + + // React batches updates scheduled from a callback, so several `setState` + // calls produce one re-render and therefore one follow-up observation. + it('should batch multiple state updates from a single callback into one re-render', () => { + const nodeRef = createRef(); + const setSizeRef: {current: ?({width: number, height: number}) => void} = + { + current: null, + }; + const root = Fantom.createRoot(); + let renderCount = 0; + + function Box() { + const [size, setSize] = React.useState({width: 100, height: 50}); + setSizeRef.current = setSize; + renderCount++; + return ; + } + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback: ResizeObserverMockCallback = jest.fn(() => { + const setSize = setSizeRef.current; + if (setSize != null && renderCount < 2) { + setSize({width: 200, height: 50}); + setSize({width: 200, height: 80}); + } + }); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + + const renderCountAfterInitialDelivery = renderCount; + + Fantom.runTask(() => {}); + + // One extra render for both updates, and one extra delivery reporting the + // final size. + expect(renderCount).toBe(renderCountAfterInitialDelivery + 1); + expect(callback).toHaveBeenCalledTimes(2); + expectEntrySizes(callback.mock.lastCall[0][0], { + contentWidth: 200, + contentHeight: 80, + borderWidth: 200, + borderHeight: 80, + }); + }); + + it('should batch state updates scheduled from different observer callbacks', () => { + const nodeARef = createRef(); + const nodeBRef = createRef(); + const setWidthARef: {current: ?(number) => void} = {current: null}; + const setWidthBRef: {current: ?(number) => void} = {current: null}; + const root = Fantom.createRoot(); + let renderCount = 0; + + function Boxes() { + const [widthA, setWidthA] = React.useState(100); + const [widthB, setWidthB] = React.useState(80); + setWidthARef.current = setWidthA; + setWidthBRef.current = setWidthB; + renderCount++; + return ( + <> + + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + const nodeA = ensureReactNativeElement(nodeARef.current); + const nodeB = ensureReactNativeElement(nodeBRef.current); + let scheduled = false; + const callbackA: ResizeObserverMockCallback = jest.fn(() => { + if (!scheduled) { + setWidthARef.current?.(150); + } + }); + const callbackB: ResizeObserverMockCallback = jest.fn(() => { + if (!scheduled) { + setWidthBRef.current?.(120); + scheduled = true; + } + }); + let observerA: ResizeObserver; + let observerB: ResizeObserver; + + Fantom.runTask(() => { + observerA = new ResizeObserver(callbackA); + observerB = new ResizeObserver(callbackB); + observerA.observe(nodeA); + observerB.observe(nodeB); + }); + + const renderCountAfterInitialDelivery = renderCount; + + Fantom.runTask(() => {}); + + // Both callbacks scheduled an update in the same tick, so React renders + // once for both. + expect(renderCount).toBe(renderCountAfterInitialDelivery + 1); + expect(callbackA).toHaveBeenCalledTimes(2); + expect(callbackB).toHaveBeenCalledTimes(2); + + Fantom.runTask(() => { + observerA.disconnect(); + observerB.disconnect(); + }); + }); + it('should deliver a deeper target resized from a callback once the update commits', () => { + const nodeARef = createRef(); + const nodeBRef = createRef(); + const sizesRef = {a: 100, b: 80}; + const root = Fantom.createRoot(); + + function Boxes() { + return ( + + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + const nodeA = ensureReactNativeElement(nodeARef.current); + const nodeB = ensureReactNativeElement(nodeBRef.current); + let resizedBInCallback = false; + const callbackA: ResizeObserverMockCallback = jest.fn(() => { + if (!resizedBInCallback) { + resizedBInCallback = true; + sizesRef.b = 120; + root.render(); + } + }); + const callbackB = jest.fn(); + let observerA: ResizeObserver; + let observerB: ResizeObserver; + + Fantom.runTask(() => { + observerA = new ResizeObserver(callbackA); + observerB = new ResizeObserver(callbackB); + observerA.observe(nodeA); + observerB.observe(nodeB); + }); + + expect(callbackA).toHaveBeenCalledTimes(1); + expect(callbackB).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + sizesRef.a = 110; + root.render(); + }); + + expect(callbackA).toHaveBeenCalledTimes(2); + expect(callbackB).toHaveBeenCalledTimes(2); + expect(callbackB.mock.lastCall[0][0].contentRect.width).toBe(120); + + Fantom.runTask(() => { + observerA.disconnect(); + observerB.disconnect(); + }); + }); + + it('should deliver the ancestor before the descendant it resizes in its callback', () => { + const nodeARef = createRef(); + const nodeBRef = createRef(); + const sizesRef = {a: 100, b: 80}; + const root = Fantom.createRoot(); + const deliveryOrder: Array = []; + + function Boxes() { + return ( + + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + const nodeA = ensureReactNativeElement(nodeARef.current); + const nodeB = ensureReactNativeElement(nodeBRef.current); + + const callbackA: ResizeObserverMockCallback = jest.fn(() => { + deliveryOrder.push('A'); + sizesRef.b = 90; + root.render(); + }); + const callbackB: ResizeObserverMockCallback = jest.fn(() => { + deliveryOrder.push('B'); + }); + + let observerA: ResizeObserver; + let observerB: ResizeObserver; + Fantom.runTask(() => { + observerA = new ResizeObserver(callbackA); + observerB = new ResizeObserver(callbackB); + observerA.observe(nodeA); + observerB.observe(nodeB); + }); + + deliveryOrder.length = 0; + + Fantom.runTask(() => { + sizesRef.a = 110; + root.render(); + }); + + expect(deliveryOrder).toEqual(['A', 'B']); + + Fantom.runTask(() => { + observerA.disconnect(); + observerB.disconnect(); + }); + }); + + it('should settle an A/B resize feedback loop instead of spinning forever', () => { + const nodeARef = createRef(); + const nodeBRef = createRef(); + const sizesRef = {a: 100, b: 80}; + const root = Fantom.createRoot(); + + function Boxes() { + return ( + + + + ); + } + + Fantom.runTask(() => { + root.render(); + }); + + const nodeA = ensureReactNativeElement(nodeARef.current); + const nodeB = ensureReactNativeElement(nodeBRef.current); + + // Each callback grows the other target every time it is notified, with no + // guard to stop the cycle. It settles only because the sizes converge on + // the shared maximum, not because the test avoids the feedback. + const callbackA: ResizeObserverMockCallback = jest.fn(() => { + const target = Math.max(sizesRef.a, sizesRef.b); + if (sizesRef.b < target) { + sizesRef.b = target; + root.render(); + } + }); + const callbackB: ResizeObserverMockCallback = jest.fn(() => { + const target = Math.max(sizesRef.a, sizesRef.b); + if (sizesRef.a < target) { + sizesRef.a = target; + root.render(); + } + }); + + let observerA: ResizeObserver; + let observerB: ResizeObserver; + Fantom.runTask(() => { + observerA = new ResizeObserver(callbackA); + observerB = new ResizeObserver(callbackB); + observerA.observe(nodeA); + observerB.observe(nodeB); + }); + + expect(callbackA).toHaveBeenCalledTimes(1); + expect(callbackB).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + sizesRef.a = 110; + root.render(); + }); + + // A is notified once more for its own resize; B twice more as it follows A + // up to 110. Then neither side has anything left to change, so the cycle + // ends with both boxes at 110 rather than growing forever. + expect(callbackA).toHaveBeenCalledTimes(2); + expect(callbackB).toHaveBeenCalledTimes(3); + expect(sizesRef.a).toBe(110); + expect(sizesRef.b).toBe(110); + expect(callbackB.mock.lastCall[0][0].contentRect.width).toBe(110); + + Fantom.runTask(() => { + observerA.disconnect(); + observerB.disconnect(); + }); + }); + }); + + describe('unobserve(target)', () => { + it('should throw if `target` is not a `ReactNativeElement`', () => { + observer = new ResizeObserver(() => {}); + expect(() => { + // $FlowExpectedError[incompatible-type] + observer.unobserve('something'); + }).toThrow( + "Failed to execute 'unobserve' on 'ResizeObserver': parameter 1 is not of type 'ReactNativeElement'.", + ); + }); + + it('should ignore the call if `target` was not observed (not fail)', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.unobserve(node); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should stop observing the target if it was observed', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + observer.unobserve(node); + }); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should stop observing the target if it was observed (detached target after observing)', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + root.render(<>); + }); + expect(node.isConnected).toBe(false); + // Removal from the tree delivers one final 0x0 observation (Web parity). + expect(callback).toHaveBeenCalledTimes(2); + + Fantom.runTask(() => { + expect(() => { + observer.unobserve(node); + }).not.toThrow(); + }); + // No further deliveries after unobserve. + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('should not report the initial state if the target is unobserved before it is delivered', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + observer.unobserve(node); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not deliver a stale entry when the target is unobserved after being dirtied but before delivery', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + expect(callback).toHaveBeenCalledTimes(1); + + // The commit inside this task dirties the target's family (recorded + // as a raw pointer in `dirtyFamiliesBySurfaceId_`), and `unobserve` + // runs before `runResizeObservations` drains that map at the end of + // the event-loop tick. This must not crash and must not deliver a + // second (stale) entry. + Fantom.runTask(() => { + root.render( + , + ); + observer.unobserve(node); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should stop only the observer that unobserved a shared target', () => { + const nodeRef = createRef(); + let observer1: ResizeObserver; + let observer2: ResizeObserver; + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + + Fantom.runTask(() => { + observer1 = new ResizeObserver(callback1); + observer2 = new ResizeObserver(callback2); + observer1.observe(node); + observer2.observe(node); + }); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + observer1.unobserve(node); + }); + + Fantom.runTask(() => { + root.render(); + }); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(2); + + // Unobserving the last observer of the target must also be safe. + Fantom.runTask(() => { + observer2.unobserve(node); + }); + + Fantom.runTask(() => { + root.render(); + }); + + expect(callback2).toHaveBeenCalledTimes(2); + }); + }); + + describe('disconnect()', () => { + it('should do nothing if no targets are observed (not fail)', () => { + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.disconnect(); + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should stop observing all observed targets', () => { + const node1Ref = createRef(); + const node2Ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + const node1 = ensureReactNativeElement(node1Ref.current); + const node2 = ensureReactNativeElement(node2Ref.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node1); + observer.observe(node2); + }); + expect(callback).toHaveBeenCalledTimes(1); + + Fantom.runTask(() => { + observer.disconnect(); + }); + + Fantom.runTask(() => { + root.render( + <> + + + , + ); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not dispatch the initial observation when disconnecting in the same task', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + const callback = jest.fn(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + observer.disconnect(); + }); + + expect(callback).not.toHaveBeenCalled(); + + Fantom.runTask(() => {}); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not dispatch further entries when disconnecting in a later task', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + + observer.observe(node); + + // Per spec, observations are broadcast synchronously in the "update the + // rendering" step, so the initial observation is already delivered by + // the time this task runs. There is no window in which a queued entry + // can be cancelled from a separate task anymore. + Fantom.scheduleTask(() => { + expect(callback).toHaveBeenCalledTimes(1); + + observer.disconnect(); + }); + }); + + expect(callback).toHaveBeenCalledTimes(1); + + // Nothing is delivered after disconnecting. + Fantom.runTask(() => { + root.render(); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not throw when disconnecting after the target was detached', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + + Fantom.runTask(() => { + observer = new ResizeObserver(() => {}); + observer.observe(node); + }); + + Fantom.runTask(() => { + root.render(<>); + }); + expect(node.isConnected).toBe(false); + + expect(() => { + observer.disconnect(); + }).not.toThrow(); + }); + }); + + describe('surface teardown', () => { + it('should stop delivering and not fail when the observed surface is stopped', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + const callback = jest.fn(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + + expect(callback).toHaveBeenCalledTimes(1); + + // Stopping the surface drops its observations in native. Nothing is + // delivered for it afterwards, and later ticks must not fail. + root.destroy(); + + Fantom.runTask(() => {}); + + expect(callback).toHaveBeenCalledTimes(1); + + // `unobserve`/`disconnect` after teardown must also be safe. + Fantom.runTask(() => { + observer.disconnect(); + }); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe('ResizeObserverEntry', () => { + it('should freeze size arrays and keep stable getter identities', () => { + const nodeRef = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const node = ensureReactNativeElement(nodeRef.current); + const callback = jest.fn(); + + Fantom.runTask(() => { + observer = new ResizeObserver(callback); + observer.observe(node); + }); + + const [entries] = callback.mock.lastCall; + const entry = entries[0]; + + expect(Object.isFrozen(entry.contentBoxSize)).toBe(true); + expect(Object.isFrozen(entry.borderBoxSize)).toBe(true); + expect(Object.isFrozen(entry.devicePixelContentBoxSize)).toBe(true); + expect(entry.contentBoxSize).toBe(entry.contentBoxSize); + expect(entry.borderBoxSize).toBe(entry.borderBoxSize); + expect(entry.devicePixelContentBoxSize).toBe( + entry.devicePixelContentBoxSize, + ); + expect(entry.contentRect).toBe(entry.contentRect); + }); + }); + + describe('ResizeObserverEntry global constructor', () => { + it('throws when called', () => { + expect( + () => + // The public stub throws regardless of arguments; the real class + // requires two so Flow needs a suppression here. + // $FlowExpectedError[incompatible-type] + new ResizeObserverEntry(), + ).toThrow( + "Failed to construct 'ResizeObserverEntry': Illegal constructor", + ); + }); + }); + + describe('ResizeObserverSize global constructor', () => { + it('throws when called', () => { + expect( + () => + // The public stub throws regardless of arguments; the real class + // requires two so Flow needs a suppression here. + // $FlowExpectedError[incompatible-type] + new ResizeObserverSize(), + ).toThrow( + "Failed to construct 'ResizeObserverSize': Illegal constructor", + ); + }); + }); +}); diff --git a/packages/react-native/src/private/webapis/resizeobserver/internals/ResizeObserverManager.js b/packages/react-native/src/private/webapis/resizeobserver/internals/ResizeObserverManager.js new file mode 100644 index 000000000000..28ede43b75c8 --- /dev/null +++ b/packages/react-native/src/private/webapis/resizeobserver/internals/ResizeObserverManager.js @@ -0,0 +1,276 @@ +/** + * 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 resize observers that are currently observing any targets. + * + * In order to reduce the communication between native and JavaScript, + * we register a single notification callback in native, and then we handle + * how to notify each entry to the right resize observer when we receive all + * the notifications together. + */ + +import type ReactNativeElement from '../../dom/nodes/ReactNativeElement'; +import type ResizeObserver, { + ResizeObserverBoxOptions, + ResizeObserverCallback, +} from '../ResizeObserver'; +import type ResizeObserverEntry from '../ResizeObserverEntry'; +import type {NativeResizeObserverToken} from '../specs/NativeResizeObserver'; + +import {trace} from '../../../../../Libraries/Performance/Systrace'; +import { + getInstanceHandle, + getNativeNodeReference, +} from '../../dom/nodes/internals/NodeInternals'; +import {createResizeObserverEntry} from '../ResizeObserverEntry'; +import NativeResizeObserver from '../specs/NativeResizeObserver'; + +export type ResizeObserverId = number; + +let nextResizeObserverId: ResizeObserverId = 1; +let isConnected: boolean = false; + +const registeredResizeObservers: Map< + ResizeObserverId, + {observer: ResizeObserver, callback: ResizeObserverCallback}, +> = new Map(); + +// Keep our own instanceHandle->target map: when a target unmounts, React +// resets its instance handle (to avoid leaks), cutting the built-in link. +const instanceHandleToTargetMap: WeakMap = + new WeakMap(); + +function getTargetFromInstanceHandle( + instanceHandle: unknown, +): ?ReactNativeElement { + // $FlowExpectedError[incompatible-type] instanceHandle is typed as mixed but we know it's an object and we need it to be to use it as a key in a WeakMap. + const key: interface {} = instanceHandle; + return instanceHandleToTargetMap.get(key); +} + +function setTargetForInstanceHandle( + instanceHandle: unknown, + target: ReactNativeElement, +): void { + // $FlowExpectedError[incompatible-type] instanceHandle is typed as mixed but we know it's an object and we need it to be to use it as a key in a WeakMap. + const key: interface {} = instanceHandle; + instanceHandleToTargetMap.set(key, target); +} + +// Keep the native token per target: on unmount a target loses its shadow node +// reference, and without the token we couldn't clean up the observation. +const targetToTokenMap: WeakMap = + new WeakMap(); + +/** + * Registers the given resize observer and returns a unique ID for it, which + * is required to start observing targets. + */ +export function registerObserver( + observer: ResizeObserver, + callback: ResizeObserverCallback, +): ResizeObserverId { + const resizeObserverId = nextResizeObserverId; + nextResizeObserverId++; + registeredResizeObservers.set(resizeObserverId, { + observer, + callback, + }); + return resizeObserverId; +} + +/** + * Unregisters the given resize observer. + * This should only be called when an observer is no longer observing any + * targets. + */ +export function unregisterObserver(resizeObserverId: ResizeObserverId): void { + const deleted = registeredResizeObservers.delete(resizeObserverId); + if (deleted && registeredResizeObservers.size === 0) { + NativeResizeObserver?.disconnect(); + isConnected = false; + } +} + +/** + * Starts observing a target on a specific resize observer. + * If this is the first target being observed, this also sets up the + * centralized notification callback in native. + * Returns `true` if the native observation was actually set up, or `false` + * if the target could not be observed (e.g. because it is disconnected). + */ +export function observe({ + resizeObserverId, + target, + box, +}: { + resizeObserverId: ResizeObserverId, + target: ReactNativeElement, + box: ResizeObserverBoxOptions, +}): boolean { + if (NativeResizeObserver == null) { + throwIfNoNativeResizeObserver(); + return false; + } + + const registeredObserver = registeredResizeObservers.get(resizeObserverId); + if (registeredObserver == null) { + console.error( + `ResizeObserverManager: could not start observing target because ResizeObserver with ID ${resizeObserverId} was not registered.`, + ); + return false; + } + + const targetNativeNodeReference = getNativeNodeReference(target); + if (targetNativeNodeReference == null) { + // The target is disconnected. We can't observe it anymore. + return false; + } + + const instanceHandle = getInstanceHandle(target); + if (instanceHandle == null) { + console.error( + 'ResizeObserverManager: could not find reference to instance handle from target', + ); + return false; + } + + // Store the mapping between the instance handle and the target so we can + // access it even after the instance handle has been unmounted. + setTargetForInstanceHandle(instanceHandle, target); + + if (!isConnected) { + NativeResizeObserver.connect(notifyResizeObservers); + isConnected = true; + } + + const token = NativeResizeObserver.observe({ + resizeObserverId, + targetShadowNode: targetNativeNodeReference, + box, + }); + targetToTokenMap.set(target, token); + + return true; +} + +/** + * Instructs the given resize observer to stop observing the specified + * target. + */ +export function unobserve( + resizeObserverId: ResizeObserverId, + target: ReactNativeElement, +): void { + if (NativeResizeObserver == null) { + throwIfNoNativeResizeObserver(); + return; + } + + const registeredObserver = registeredResizeObservers.get(resizeObserverId); + if (registeredObserver == null) { + console.error( + `ResizeObserverManager: could not stop observing target because ResizeObserver with ID ${resizeObserverId} was not registered.`, + ); + return; + } + + const targetToken = targetToTokenMap.get(target); + if (targetToken == null) { + console.error( + 'ResizeObserverManager: could not find registration data for target', + ); + return; + } + + NativeResizeObserver.unobserve(resizeObserverId, targetToken); +} + +/** + * This function is called from native when there are `ResizeObserver` + * entries to dispatch. + */ +function notifyResizeObservers(hasResizeLoopError: boolean): void { + trace('ResizeObserverManager.notifyResizeObservers', () => { + doNotifyResizeObservers(hasResizeLoopError); + }); +} + +function doNotifyResizeObservers(hasResizeLoopError: boolean): void { + if (NativeResizeObserver == null) { + throwIfNoNativeResizeObserver(); + return; + } + + const nativeEntries = NativeResizeObserver.takeRecords(); + + const entriesByObserver: Map< + ResizeObserverId, + Array, + > = new Map(); + + for (const nativeEntry of nativeEntries) { + let list = entriesByObserver.get(nativeEntry.resizeObserverId); + if (list == null) { + list = []; + entriesByObserver.set(nativeEntry.resizeObserverId, list); + } + + const target = getTargetFromInstanceHandle( + nativeEntry.targetInstanceHandle, + ); + if (target == null) { + console.warn( + 'ResizeObserverManager: could not find target to create ResizeObserverEntry', + ); + continue; + } + + list.push(createResizeObserverEntry(nativeEntry, target)); + } + + // Native delivers entries in observer registration order; preserve it (Map + // insertion order isn't enough if batches merged out of order). + const observerIds = Array.from(entriesByObserver.keys()).sort( + (a, b) => a - b, + ); + for (const resizeObserverId of observerIds) { + const entriesForObserver = entriesByObserver.get(resizeObserverId); + if (entriesForObserver == null) { + continue; + } + + const registeredObserver = registeredResizeObservers.get(resizeObserverId); + if (!registeredObserver) { + // This could happen if the observer is disconnected between commit + // and mount. In this case, we can just ignore the entries. + continue; + } + + const {observer, callback} = registeredObserver; + try { + callback.call(observer, entriesForObserver, observer); + } catch (error) { + console.error(error); + } + } + + if (hasResizeLoopError) { + console.error( + 'ResizeObserver loop completed with undelivered notifications.', + ); + } +} + +function throwIfNoNativeResizeObserver() { + throw new Error('Missing native implementation of ResizeObserver'); +} diff --git a/packages/react-native/src/private/webapis/resizeobserver/specs/NativeResizeObserver.js b/packages/react-native/src/private/webapis/resizeobserver/specs/NativeResizeObserver.js new file mode 100644 index 000000000000..e8b8aa8cd270 --- /dev/null +++ b/packages/react-native/src/private/webapis/resizeobserver/specs/NativeResizeObserver.js @@ -0,0 +1,50 @@ +/** + * 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 '../../../../../Libraries/TurboModule/RCTExport'; + +import * as TurboModuleRegistry from '../../../../../Libraries/TurboModule/TurboModuleRegistry'; + +export type NativeResizeObserverEntry = { + resizeObserverId: number, + targetInstanceHandle: unknown, + contentRect: ReadonlyArray, // It's actually a tuple with x, y, width and height + borderBoxSize: ReadonlyArray, // It's actually a tuple with inlineSize and blockSize + contentBoxSize: ReadonlyArray, // It's actually a tuple with inlineSize and blockSize + devicePixelContentBoxSize: ReadonlyArray, // It's actually a tuple with inlineSize and blockSize +}; + +export type NativeResizeObserverObserveOptions = { + resizeObserverId: number, + targetShadowNode: unknown, + // Corresponds to the `box` option of `ResizeObserver#observe`. + box?: ?string, +}; + +export opaque type NativeResizeObserverToken = unknown; + +export interface Spec extends TurboModule { + readonly observe: ( + options: NativeResizeObserverObserveOptions, + ) => NativeResizeObserverToken; + readonly unobserve: ( + resizeObserverId: number, + targetToken: NativeResizeObserverToken, + ) => void; + readonly connect: ( + notifyResizeObserversFunction: (hasResizeLoopError: boolean) => void, + ) => void; + readonly disconnect: () => void; + readonly takeRecords: () => ReadonlyArray; +} + +export default TurboModuleRegistry.get( + 'NativeResizeObserverCxx', +) as ?Spec; diff --git a/packages/rn-tester/js/examples/ResizeObserver/ResizeObserverBoxSizesExample.js b/packages/rn-tester/js/examples/ResizeObserver/ResizeObserverBoxSizesExample.js new file mode 100644 index 000000000000..43ecedae7924 --- /dev/null +++ b/packages/rn-tester/js/examples/ResizeObserver/ResizeObserverBoxSizesExample.js @@ -0,0 +1,283 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import {RNTesterThemeContext} from '../../components/RNTesterTheme'; +import * as React from 'react'; +import { + type ElementRef, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import {Animated, Button, StyleSheet, Switch, Text, View} from 'react-native'; + +export const name = 'ResizeObserverBoxSizes'; +export const title = 'Box sizes'; +export const description = + 'A single entry reports border-box, content-box and ' + + 'device-pixel-content-box. Toggle padding and border to see them diverge; ' + + 'animate width to watch sizes update on each layout.'; +export const scrollable = true; + +const MIN_WIDTH = 80; +const MAX_WIDTH = 300; +const STEP = 40; +const INITIAL_WIDTH = 160; +const ANIMATED_WIDTH = 260; +const ANIMATION_MS = 1000; +const PADDING = 20; +const BORDER_WIDTH = 8; + +type Size = {inlineSize: number, blockSize: number}; +type Boxes = { + contentRect: {x: number, y: number, width: number, height: number}, + contentBox: Size, + borderBox: Size, + devicePixelContentBox: Size, +}; + +export function render(): React.Node { + return ; +} + +function ResizeObserverBoxSizesExample(): React.Node { + const theme = useContext(RNTesterThemeContext); + const boxRef = useRef>(null); + const widthAnim = useMemo(() => new Animated.Value(INITIAL_WIDTH), []); + const [width, setWidth] = useState(INITIAL_WIDTH); + const [padded, setPadded] = useState(false); + const [bordered, setBordered] = useState(true); + const [animating, setAnimating] = useState(false); + const [boxes, setBoxes] = useState(null); + + useLayoutEffect(() => { + const box = boxRef.current; + if (box == null) { + return; + } + const observer = new ResizeObserver(entries => { + for (const entry of entries) { + const [contentBox] = entry.contentBoxSize; + const [borderBox] = entry.borderBoxSize; + const [devicePixelContentBox] = entry.devicePixelContentBoxSize; + setBoxes({ + contentRect: { + x: entry.contentRect.x, + y: entry.contentRect.y, + width: entry.contentRect.width, + height: entry.contentRect.height, + }, + contentBox: { + inlineSize: contentBox.inlineSize, + blockSize: contentBox.blockSize, + }, + borderBox: { + inlineSize: borderBox.inlineSize, + blockSize: borderBox.blockSize, + }, + devicePixelContentBox: { + inlineSize: devicePixelContentBox.inlineSize, + blockSize: devicePixelContentBox.blockSize, + }, + }); + } + }); + // $FlowFixMe[incompatible-type] + observer.observe(box); + return () => observer.disconnect(); + }, []); + + // Animate layout width (useNativeDriver: false) so ResizeObserver sees size + // changes. Transform-only animation would not notify. + useEffect(() => { + if (!animating) { + widthAnim.setValue(width); + return; + } + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(widthAnim, { + toValue: ANIMATED_WIDTH, + duration: ANIMATION_MS, + useNativeDriver: false, + }), + Animated.timing(widthAnim, { + toValue: INITIAL_WIDTH, + duration: ANIMATION_MS, + useNativeDriver: false, + }), + ]), + ); + loop.start(); + return () => { + loop.stop(); + widthAnim.setValue(width); + }; + }, [animating, width, widthAnim]); + + return ( + + + + + + + +