From d2732dc59a58458cf70a930c38d2f09c23a69d82 Mon Sep 17 00:00:00 2001 From: Bartlomiej Bloniarz Date: Tue, 2 Dec 2025 06:00:57 -0800 Subject: [PATCH] Pass families to Native Animated (#54613) Summary: This PR allows C++ Native Animated to use `ShadowNodeFamily` instances to use the `cloneMultiple` method when pushing updates through the `ShadowTree` in `AnimationBackend` # Changelog [General] [Added] - Add `connectAnimatedNodeToShadowNodeFamily` method to `NativeAnimatedModule` and `NativeAnimatedTurboModule` Reviewed By: sammy-SC, zeyap Differential Revision: D84055752 --- .../__tests__/AnimatedBackend-itest.js | 58 ++++++++++++ .../Libraries/Animated/nodes/AnimatedProps.js | 32 ++++++- .../RCTNativeAnimatedTurboModule.mm | 7 ++ .../React-RCTAnimation.podspec | 1 + .../renderer/animated/AnimatedModule.cpp | 20 ++++- .../react/renderer/animated/AnimatedModule.h | 8 ++ .../animated/NativeAnimatedNodesManager.cpp | 89 +++++++++++++++++-- .../animated/NativeAnimatedNodesManager.h | 6 ++ .../animated/nodes/PropsAnimatedNode.h | 1 + .../animationbackend/AnimationBackend.cpp | 2 +- .../animationbackend/AnimationBackend.h | 2 +- .../private/animated/NativeAnimatedHelper.js | 30 ++++++- .../modules/NativeAnimatedModule.js | 4 + 13 files changed, 245 insertions(+), 15 deletions(-) diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js index ae7e2f61ca84..cf6604aa4a7b 100644 --- a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js @@ -17,6 +17,7 @@ import ensureInstance from '../../../src/private/__tests__/utilities/ensureInsta import * as Fantom from '@react-native/fantom'; import {createRef} from 'react'; import {Animated, useAnimatedValue} from 'react-native'; +import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; test('animated opacity', () => { @@ -73,3 +74,60 @@ test('animated opacity', () => { , ); }); + +test('animate layout props', () => { + const viewRef = createRef(); + allowStyleProp('height'); + + let _animatedHeight; + let _heightAnimation; + + function MyApp() { + const animatedHeight = useAnimatedValue(0); + _animatedHeight = animatedHeight; + return ( + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + _heightAnimation = Animated.timing(_animatedHeight, { + toValue: 100, + duration: 200, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(100); + + // TODO: getFabricUpdateProps is not working with the cloneMutliple method + // expect(Fantom.unstable_getFabricUpdateProps(viewElement).height).toBe(100); + expect(root.getRenderedOutput({props: ['height']}).toJSX()).toEqual( + , + ); + + Fantom.unstable_produceFramesForDuration(100); + + // TODO: this shouldn't be neccessary since animation should be stopped after duration + Fantom.runTask(() => { + _heightAnimation?.stop(); + }); + + expect(root.getRenderedOutput({props: ['height']}).toJSX()).toEqual( + , + ); +}); diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js b/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js index 5f631431b8be..aaf06696b425 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js @@ -14,7 +14,9 @@ import type {AnimatedNodeConfig} from './AnimatedNode'; import type {AnimatedStyleAllowlist} from './AnimatedStyle'; import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper'; +import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; import {findNodeHandle} from '../../ReactNative/RendererProxy'; +import {getNodeFromPublicInstance} from '../../ReactPrivate/ReactNativePrivateInterface'; import flattenStyle from '../../StyleSheet/flattenStyle'; import {AnimatedEvent} from '../AnimatedEvent'; import AnimatedNode from './AnimatedNode'; @@ -251,7 +253,9 @@ export default class AnimatedProps extends AnimatedNode { super.__setPlatformConfig(platformConfig); if (this._target != null) { - this.#connectAnimatedView(this._target); + const target = this._target; + this.#connectAnimatedView(target); + this.#connectShadowNode(target); } } } @@ -260,9 +264,10 @@ export default class AnimatedProps extends AnimatedNode { if (this._target?.instance === instance) { return; } - this._target = {instance, connectedViewTag: null}; + const target = (this._target = {instance, connectedViewTag: null}); if (this.__isNative) { - this.#connectAnimatedView(this._target); + this.#connectAnimatedView(target); + this.#connectShadowNode(target); } } @@ -283,6 +288,27 @@ export default class AnimatedProps extends AnimatedNode { target.connectedViewTag = viewTag; } + #connectShadowNode(target: TargetView): void { + if ( + !ReactNativeFeatureFlags.cxxNativeAnimatedEnabled() || + //eslint-disable-next-line + !ReactNativeFeatureFlags.useSharedAnimatedBackend() + ) { + return; + } + + invariant(this.__isNative, 'Expected node to be marked as "native"'); + // $FlowExpectedError[incompatible-type] - target.instance may be an HTMLElement but we need ReactNativeElement for Fabric + const shadowNode = getNodeFromPublicInstance(target.instance); + if (shadowNode == null) { + return; + } + NativeAnimatedHelper.API.connectAnimatedNodeToShadowNodeFamily( + this.__getNativeTag(), + shadowNode, + ); + } + #disconnectAnimatedView(target: TargetView): void { invariant(this.__isNative, 'Expected node to be marked as "native"'); const viewTag = target.connectedViewTag; diff --git a/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedTurboModule.mm b/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedTurboModule.mm index a168bddff83a..e8245b74a44b 100644 --- a/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedTurboModule.mm +++ b/packages/react-native/Libraries/NativeAnimation/RCTNativeAnimatedTurboModule.mm @@ -10,6 +10,7 @@ #import #import #import +#import #import #import "RCTAnimationPlugins.h" @@ -165,6 +166,12 @@ - (void)setSurfacePresenter:(id)surfacePresenter }]; } +RCT_EXPORT_METHOD(connectAnimatedNodeToShadowNodeFamily : (double)nodeTag shadowNode : (NSDictionary *)shadowNode) +{ + // This method should only be called when using CxxNativeAnimated + react_native_assert(false); +} + RCT_EXPORT_METHOD(disconnectAnimatedNodeFromView : (double)nodeTag viewTag : (double)viewTag) { [self queueOperationBlock:^(RCTNativeAnimatedNodesManager *nodesManager) { diff --git a/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec b/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec index cc2f5c3d7e63..0f875f66b0cb 100644 --- a/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec +++ b/packages/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec @@ -48,6 +48,7 @@ Pod::Spec.new do |s| add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"]) add_dependency(s, "React-NativeModulesApple") add_dependency(s, "React-featureflags") + add_dependency(s, "React-debug") add_rn_third_party_dependencies(s) add_rncore_dependency(s) diff --git a/packages/react-native/ReactCommon/react/renderer/animated/AnimatedModule.cpp b/packages/react-native/ReactCommon/react/renderer/animated/AnimatedModule.cpp index 8a8b8edc79d2..0410bd6e7bc2 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/AnimatedModule.cpp +++ b/packages/react-native/ReactCommon/react/renderer/animated/AnimatedModule.cpp @@ -9,9 +9,9 @@ #include #include +#include namespace facebook::react { - AnimatedModule::AnimatedModule( std::shared_ptr jsInvoker, std::shared_ptr nodesManagerProvider) @@ -160,6 +160,19 @@ void AnimatedModule::connectAnimatedNodeToView( ConnectAnimatedNodeToViewOp{.nodeTag = nodeTag, .viewTag = viewTag}); } +void AnimatedModule::connectAnimatedNodeToShadowNodeFamily( + jsi::Runtime& rt, + Tag nodeTag, + jsi::Object shadowNodeObj) { + const auto& shadowNode = Bridging>::fromJs( + rt, jsi::Value(rt, shadowNodeObj)); + + operations_.emplace_back( + ConnectAnimatedNodeToShadowNodeFamilyOp{ + .nodeTag = nodeTag, + .shadowNodeFamily = shadowNode->getFamilyShared()}); +} + void AnimatedModule::disconnectAnimatedNodeFromView( jsi::Runtime& /*rt*/, Tag nodeTag, @@ -282,6 +295,11 @@ void AnimatedModule::executeOperation( DisconnectAnimatedNodeFromViewOp>) { nodesManager->disconnectAnimatedNodeFromView( op.nodeTag, op.viewTag); + } else if constexpr (std::is_same_v< + T, + ConnectAnimatedNodeToShadowNodeFamilyOp>) { + nodesManager->connectAnimatedNodeToShadowNodeFamily( + op.nodeTag, op.shadowNodeFamily); } else if constexpr (std::is_same_v) { nodesManager->restoreDefaultValues(op.nodeTag); } else if constexpr (std::is_same_v) { diff --git a/packages/react-native/ReactCommon/react/renderer/animated/AnimatedModule.h b/packages/react-native/ReactCommon/react/renderer/animated/AnimatedModule.h index a54254e6a6b7..858cd95e1b46 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/AnimatedModule.h +++ b/packages/react-native/ReactCommon/react/renderer/animated/AnimatedModule.h @@ -87,6 +87,11 @@ class AnimatedModule : public NativeAnimatedModuleCxxSpec, publi Tag viewTag{}; }; + struct ConnectAnimatedNodeToShadowNodeFamilyOp { + Tag nodeTag{}; + std::shared_ptr shadowNodeFamily{}; + }; + struct DisconnectAnimatedNodeFromViewOp { Tag nodeTag{}; Tag viewTag{}; @@ -124,6 +129,7 @@ class AnimatedModule : public NativeAnimatedModuleCxxSpec, publi SetAnimatedNodeOffsetOp, SetAnimatedNodeValueOp, ConnectAnimatedNodeToViewOp, + ConnectAnimatedNodeToShadowNodeFamilyOp, DisconnectAnimatedNodeFromViewOp, RestoreDefaultValuesOp, FlattenAnimatedNodeOffsetOp, @@ -176,6 +182,8 @@ class AnimatedModule : public NativeAnimatedModuleCxxSpec, publi void connectAnimatedNodeToView(jsi::Runtime &rt, Tag nodeTag, Tag viewTag); + void connectAnimatedNodeToShadowNodeFamily(jsi::Runtime &rt, Tag nodeTag, jsi::Object shadowNode); + void disconnectAnimatedNodeFromView(jsi::Runtime &rt, Tag nodeTag, Tag viewTag); void restoreDefaultValues(jsi::Runtime &rt, Tag nodeTag); diff --git a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp index 036eeaf3992f..a5084a571af0 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp +++ b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp @@ -238,6 +238,20 @@ void NativeAnimatedNodesManager::connectAnimatedNodeToView( } } +void NativeAnimatedNodesManager::connectAnimatedNodeToShadowNodeFamily( + Tag propsNodeTag, + std::shared_ptr family) noexcept { + react_native_assert(propsNodeTag); + auto node = getAnimatedNode(propsNodeTag); + if (node != nullptr && family != nullptr) { + std::lock_guard lock(tagToShadowNodeFamilyMutex_); + tagToShadowNodeFamily_[family->getTag()] = family; + } else { + LOG(WARNING) + << "Cannot ConnectAnimatedNodeToShadowNodeFamily, animated node has to be props type"; + } +} + void NativeAnimatedNodesManager::disconnectAnimatedNodeFromView( Tag propsNodeTag, Tag viewTag) noexcept { @@ -251,6 +265,10 @@ void NativeAnimatedNodesManager::disconnectAnimatedNodeFromView( std::lock_guard lock(connectedAnimatedNodesMutex_); connectedAnimatedNodes_.erase(viewTag); } + { + std::lock_guard lock(tagToShadowNodeFamilyMutex_); + tagToShadowNodeFamily_.erase(viewTag); + } updatedNodeTags_.insert(node->tag()); onManagedPropsRemoved(viewTag); @@ -989,15 +1007,47 @@ AnimationMutations NativeAnimatedNodesManager::pullAnimationMutations() { } for (auto& [tag, props] : updateViewPropsDirect_) { - // TODO: also handle layout props (updateViewProps_). It is skipped for - // now, because the backend requires shadowNodeFamilies to be able to - // commit to the ShadowTree propsBuilder.storeDynamic(props); mutations.push_back( AnimationMutation{tag, nullptr, propsBuilder.get()}); containsChange = true; } - updateViewPropsDirect_.clear(); + { + std::lock_guard lock(tagToShadowNodeFamilyMutex_); + for (auto& [tag, props] : updateViewProps_) { + auto familyIt = tagToShadowNodeFamily_.find(tag); + if (familyIt == tagToShadowNodeFamily_.end()) { + continue; + } + if (auto family = familyIt->second.lock()) { + // C++ Animated produces props in the form of a folly::dynamic, so + // it wouldn't make sense to unpack it here. However, for the + // purposes of testing, we want to be able to use the statically + // typed AnimationMutation. At a later stage we will instead just + // pass the dynamic directly to propsBuilder and the new API could + // be used by 3rd party libraries or in the fututre by Animated. + if (props.find("width") != props.items().end()) { + propsBuilder.setWidth( + yoga::Style::SizeLength::points(props["width"].asDouble())); + } + if (props.find("height") != props.items().end()) { + propsBuilder.setHeight( + yoga::Style::SizeLength::points(props["height"].asDouble())); + } + mutations.push_back( + AnimationMutation{ + .tag = tag, + .family = family, + .props = propsBuilder.get(), + }); + } + containsChange = true; + } + } + if (containsChange) { + updateViewPropsDirect_.clear(); + updateViewProps_.clear(); + } } if (!containsChange) { @@ -1017,16 +1067,38 @@ AnimationMutations NativeAnimatedNodesManager::pullAnimationMutations() { } } - // Step 2: update all nodes that are connected to the finished animations. + // Step 2: update all nodes that are connected to the finished + // animations. updateNodes(finishedAnimationValueNodes); isEventAnimationInProgress_ = false; for (auto& [tag, props] : updateViewPropsDirect_) { - // TODO: handle layout props propsBuilder.storeDynamic(props); mutations.push_back( - AnimationMutation{tag, nullptr, propsBuilder.get()}); + AnimationMutation{ + .tag = tag, + .family = nullptr, + .props = propsBuilder.get(), + }); + } + { + std::lock_guard lock(tagToShadowNodeFamilyMutex_); + for (auto& [tag, props] : updateViewProps_) { + auto familyIt = tagToShadowNodeFamily_.find(tag); + if (familyIt == tagToShadowNodeFamily_.end()) { + continue; + } + if (auto family = familyIt->second.lock()) { + propsBuilder.storeDynamic(props); + mutations.push_back( + AnimationMutation{ + .tag = tag, + .family = family, + .props = propsBuilder.get(), + }); + } + } } } } else { @@ -1107,7 +1179,8 @@ void NativeAnimatedNodesManager::onRender() { } } - // Step 2: update all nodes that are connected to the finished animations. + // Step 2: update all nodes that are connected to the finished + // animations. updateNodes(finishedAnimationValueNodes); isEventAnimationInProgress_ = false; diff --git a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h index 5e86b3bd00b8..40632cbc1dcf 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h +++ b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h @@ -21,6 +21,7 @@ #include #endif #include +#include #include #include #include @@ -101,6 +102,8 @@ class NativeAnimatedNodesManager { void connectAnimatedNodeToView(Tag propsNodeTag, Tag viewTag) noexcept; + void connectAnimatedNodeToShadowNodeFamily(Tag propsNodeTag, std::shared_ptr family) noexcept; + void disconnectAnimatedNodes(Tag parentTag, Tag childTag) noexcept; void disconnectAnimatedNodeFromView(Tag propsNodeTag, Tag viewTag) noexcept; @@ -258,6 +261,9 @@ class NativeAnimatedNodesManager { std::unordered_map updateViewProps_{}; std::unordered_map updateViewPropsDirect_{}; + mutable std::mutex tagToShadowNodeFamilyMutex_; + std::unordered_map> tagToShadowNodeFamily_{}; + /* * Sometimes a view is not longer connected to a PropsAnimatedNode, but * NativeAnimated has previously changed the view's props via direct diff --git a/packages/react-native/ReactCommon/react/renderer/animated/nodes/PropsAnimatedNode.h b/packages/react-native/ReactCommon/react/renderer/animated/nodes/PropsAnimatedNode.h index e8105de34b6f..3da4db587d46 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/nodes/PropsAnimatedNode.h +++ b/packages/react-native/ReactCommon/react/renderer/animated/nodes/PropsAnimatedNode.h @@ -14,6 +14,7 @@ #include "AnimatedNode.h" #include +#include #include namespace facebook::react { diff --git a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.cpp b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.cpp index 308f19922a08..9ee3db07610a 100644 --- a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.cpp +++ b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.cpp @@ -83,7 +83,7 @@ void AnimationBackend::onAnimationFrame(double timestamp) { hasAnyLayoutUpdates |= mutationHasLayoutUpdates(mutation); const auto family = mutation.family; if (family != nullptr) { - surfaceToFamilies[family->getSurfaceId()].insert(family); + surfaceToFamilies[family->getSurfaceId()].insert(family.get()); } updates[mutation.tag] = std::move(mutation.props); } diff --git a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.h b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.h index ed19f5a52eed..062440f0af4a 100644 --- a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.h +++ b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.h @@ -32,7 +32,7 @@ class UIManagerNativeAnimatedDelegateBackendImpl : public UIManagerNativeAnimate struct AnimationMutation { Tag tag; - const ShadowNodeFamily *family; + std::shared_ptr family; AnimatedProps props; }; diff --git a/packages/react-native/src/private/animated/NativeAnimatedHelper.js b/packages/react-native/src/private/animated/NativeAnimatedHelper.js index 5cde2ceef75d..9efb2b94b300 100644 --- a/packages/react-native/src/private/animated/NativeAnimatedHelper.js +++ b/packages/react-native/src/private/animated/NativeAnimatedHelper.js @@ -17,6 +17,8 @@ import type { AnimatedNodeConfig, EventMapping, } from '../../../Libraries/Animated/NativeAnimatedModule'; +import type {Spec as NativeAnimatedTurboModuleSpec} from '../../../Libraries/Animated/NativeAnimatedTurboModule'; +import type {Node} from '../../../Libraries/Renderer/shims/ReactNativeTypes'; import type {EventSubscription} from '../../../Libraries/vendor/emitter/EventEmitter'; import NativeAnimatedNonTurboModule from '../../../Libraries/Animated/NativeAnimatedModule'; @@ -28,8 +30,17 @@ import * as ReactNativeFeatureFlags from '../featureflags/ReactNativeFeatureFlag import invariant from 'invariant'; import nullthrows from 'nullthrows'; +interface NativeAnimatedModuleSpec extends NativeAnimatedTurboModuleSpec { + // connectAnimatedNodeToShadowNodeFamily is available only in NativeAnimatedNonTurboModule + +connectAnimatedNodeToShadowNodeFamily?: ( + nodeTag: number, + // $FlowExpectedError[unclear-type]. + shadowNode: Object, + ) => void; +} + // TODO T69437152 @petetheheat - Delete this fork when Fabric ships to 100%. -const NativeAnimatedModule: typeof NativeAnimatedTurboModule = +const NativeAnimatedModule: ?NativeAnimatedModuleSpec = NativeAnimatedNonTurboModule ?? NativeAnimatedTurboModule; let __nativeAnimatedNodeTagCount = 1; /* used for animated nodes */ @@ -84,6 +95,13 @@ function createNativeOperations(): $NonMaybeType { 'addListener', // 20 'removeListener', // 21 ]; + if ( + ReactNativeFeatureFlags.cxxNativeAnimatedEnabled() && + //eslint-disable-next-line + ReactNativeFeatureFlags.useSharedAnimatedBackend() + ) { + methodNames.push('connectAnimatedNodeToShadowNodeFamily'); + } const nativeOperations: { [$Values]: (...$ReadOnlyArray) => void, } = {}; @@ -312,6 +330,16 @@ const API = { NativeOperations.connectAnimatedNodeToView(nodeTag, viewTag); }, + connectAnimatedNodeToShadowNodeFamily( + nodeTag: number, + shadowNode: Node, + ): void { + NativeOperations.connectAnimatedNodeToShadowNodeFamily?.( + nodeTag, + shadowNode, + ); + }, + disconnectAnimatedNodeFromView(nodeTag: number, viewTag: number): void { NativeOperations.disconnectAnimatedNodeFromView(nodeTag, viewTag); }, diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAnimatedModule.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAnimatedModule.js index 91f64cdcdc39..f71e7f9dcf5c 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAnimatedModule.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeAnimatedModule.js @@ -49,6 +49,10 @@ export interface Spec extends TurboModule { +flattenAnimatedNodeOffset: (nodeTag: number) => void; +extractAnimatedNodeOffset: (nodeTag: number) => void; +connectAnimatedNodeToView: (nodeTag: number, viewTag: number) => void; + +connectAnimatedNodeToShadowNodeFamily?: ( + nodeTag: number, + shadowNode: Object, + ) => void; +disconnectAnimatedNodeFromView: (nodeTag: number, viewTag: number) => void; +restoreDefaultValues: (nodeTag: number) => void; +dropAnimatedNode: (tag: number) => void;