From 155d03264a334942853fd22a70fb3689c23eae78 Mon Sep 17 00:00:00 2001 From: Bartlomiej Bloniarz Date: Fri, 5 Dec 2025 04:36:24 -0800 Subject: [PATCH 1/2] 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 77bae1426b30..428ce2c0ca0e 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', () => { @@ -83,3 +84,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 5dc4369a382a..aff0bd654d3c 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); @@ -985,15 +1003,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) { @@ -1013,16 +1063,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 { @@ -1103,7 +1175,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; From 32ed76defdad265e1be3ecdfecde67f37f0ce4d5 Mon Sep 17 00:00:00 2001 From: Bartlomiej Bloniarz Date: Fri, 5 Dec 2025 04:36:24 -0800 Subject: [PATCH 2/2] Introduce `AnimationBackendCommitHook` (#54138) Summary: This diff introduces `AnimationBackendCommitHook`. It is responsible for keeping animation updates in sync with React. When React does its updates, it doesn't look at the last mounted tree. Instead it hold its own references to ShadowNodes, and commits them whenever there is a (JS) rendering update. Any props that were commited from outside of React (e.g. by Reanimated) are lost. To work around this we have the Commit Hook. It enables us to override any rendering updates that react does, just before the ShadowTree enters the layout phase. In this implementation we utilize RNSRU to push those updates back to React (whenever there is a commit originating from React). So after a render React will now hold references to ShadowNodes with the current animation state (in props). This is a new approach, in Reanimated's current implementation this is disabled, and we reapply the changes via the Commit Hook on each React commit. Reviewed By: sammy-SC, zeyap Differential Revision: D84250600 --- .../__tests__/AnimatedBackend-itest.js | 159 +++++++++++++++++- .../AnimatedPropsRegistry.cpp | 81 +++++++++ .../animationbackend/AnimatedPropsRegistry.h | 83 +++++++++ .../animationbackend/AnimationBackend.cpp | 41 +++-- .../animationbackend/AnimationBackend.h | 9 +- .../AnimationBackendCommitHook.cpp | 69 ++++++++ .../AnimationBackendCommitHook.h | 32 ++++ .../react/renderer/uimanager/UIManager.cpp | 6 + .../uimanager/UIManagerAnimationBackend.h | 1 + 9 files changed, 462 insertions(+), 19 deletions(-) create mode 100644 packages/react-native/ReactCommon/react/renderer/animationbackend/AnimatedPropsRegistry.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/animationbackend/AnimatedPropsRegistry.h create mode 100644 packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.h diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js index 428ce2c0ca0e..0fd23272409d 100644 --- a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js @@ -15,7 +15,7 @@ import type {HostInstance} from 'react-native'; import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance'; import * as Fantom from '@react-native/fantom'; -import {createRef} from 'react'; +import {createRef, useEffect, useState} 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'; @@ -141,3 +141,160 @@ test('animate layout props', () => { , ); }); + +test('animate layout props and rerender', () => { + const viewRef = createRef(); + allowStyleProp('height'); + + let _animatedHeight; + let _heightAnimation; + let _setWidth; + + function MyApp() { + const animatedHeight = useAnimatedValue(0); + const [width, setWidth] = useState(100); + _animatedHeight = animatedHeight; + _setWidth = setWidth; + return ( + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + _heightAnimation = Animated.timing(_animatedHeight, { + toValue: 100, + duration: 1000, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(500); + expect(root.getRenderedOutput({props: ['height', 'width']}).toJSX()).toEqual( + , + ); + + Fantom.runTask(() => { + _setWidth(200); + }); + + // TODO: this shouldn't be neccessary since animation should be stopped after duration + Fantom.runTask(() => { + _heightAnimation?.stop(); + }); + + // TODO: getFabricUpdateProps is not working with the cloneMutliple method + // expect(Fantom.unstable_getFabricUpdateProps(viewElement).height).toBe(50); + expect(root.getRenderedOutput({props: ['height', 'width']}).toJSX()).toEqual( + , + ); +}); + +test('animate layout props and rerender in many components', () => { + const viewRef = createRef(); + allowStyleProp('height'); + + let _animatedHeight; + let _heightAnimation; + let _setWidth; + const N = 100; + + function AnimatedComponent() { + const animatedHeight = useAnimatedValue(0); + + useEffect(() => { + Animated.timing(animatedHeight, { + toValue: 100, + duration: 1000, + useNativeDriver: true, + }).start(); + }); + return ( + + ); + } + + function MyApp() { + const animatedHeight = useAnimatedValue(0); + const [width, setWidth] = useState(100); + _animatedHeight = animatedHeight; + _setWidth = setWidth; + return ( + + {Array.from({length: N}, (_, i) => ( + + ))} + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + _heightAnimation = Animated.timing(_animatedHeight, { + toValue: 100, + duration: 1000, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(500); + expect(root.getRenderedOutput({props: ['height', 'width']}).toJSX()).toEqual( + + {Array.from({length: N}, (_, i) => ( + + ))} + , + ); + + Fantom.runTask(() => { + _setWidth(200); + }); + + // TODO: this shouldn't be neccessary since animation should be stopped after duration + Fantom.runTask(() => { + _heightAnimation?.stop(); + }); + + // TODO: getFabricUpdateProps is not working with the cloneMutliple method + // expect(Fantom.unstable_getFabricUpdateProps(viewElement).height).toBe(50); + expect(root.getRenderedOutput({props: ['height', 'width']}).toJSX()).toEqual( + + {Array.from({length: N}, (_, i) => ( + + ))} + , + ); +}); diff --git a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimatedPropsRegistry.cpp b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimatedPropsRegistry.cpp new file mode 100644 index 000000000000..bfddaeaf427c --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimatedPropsRegistry.cpp @@ -0,0 +1,81 @@ +/* + * 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 "AnimatedPropsRegistry.h" +#include +#include "AnimatedProps.h" + +namespace facebook::react { + +void AnimatedPropsRegistry::update( + const std::unordered_map& surfaceUpdates) { + auto lock = std::lock_guard(mutex_); + for (const auto& [surfaceId, updates] : surfaceUpdates) { + auto& surfaceContext = surfaceContexts_[surfaceId]; + auto& pendingMap = surfaceContext.pendingMap; + auto& pendingFamilies = surfaceContext.pendingFamilies; + + auto& updatesMap = updates.propsMap; + auto& updatesFamilies = updates.families; + + for (auto& family : updatesFamilies) { + pendingFamilies.insert(family); + } + + for (auto& [tag, animatedProps] : updatesMap) { + auto it = pendingMap.find(tag); + if (it == pendingMap.end()) { + it = pendingMap.insert_or_assign(tag, std::make_unique()) + .first; + } + auto& snapshot = it->second; + auto& viewProps = snapshot->props; + + for (const auto& animatedProp : animatedProps.props) { + snapshot->propNames.insert(animatedProp->propName); + cloneProp(viewProps, *animatedProp); + } + } + } +} + +std::pair&, SnapshotMap&> +AnimatedPropsRegistry::getMap(SurfaceId surfaceId) { + auto lock = std::lock_guard(mutex_); + auto& [pendingMap, map, pendingFamilies, families] = + surfaceContexts_[surfaceId]; + + for (auto& family : pendingFamilies) { + families.insert(family); + } + for (auto& [tag, propsSnapshot] : pendingMap) { + auto currentIt = map.find(tag); + if (currentIt == map.end()) { + map.insert_or_assign(tag, std::move(propsSnapshot)); + } else { + auto& currentSnapshot = currentIt->second; + for (auto& propName : propsSnapshot->propNames) { + currentSnapshot->propNames.insert(propName); + updateProp(propName, currentSnapshot->props, *propsSnapshot); + } + } + } + pendingMap.clear(); + pendingFamilies.clear(); + + return {families, map}; +} + +void AnimatedPropsRegistry::clear(SurfaceId surfaceId) { + auto lock = std::lock_guard(mutex_); + + auto& surfaceContext = surfaceContexts_[surfaceId]; + surfaceContext.families.clear(); + surfaceContext.map.clear(); +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimatedPropsRegistry.h b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimatedPropsRegistry.h new file mode 100644 index 000000000000..4cccbf5be6fe --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimatedPropsRegistry.h @@ -0,0 +1,83 @@ +/* + * 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 "AnimatedProps.h" + +namespace facebook::react { + +struct PropsSnapshot { + BaseViewProps props; + std::unordered_set propNames; +}; + +struct SurfaceContext { + std::unordered_map> pendingMap, map; + std::unordered_set pendingFamilies, families; +}; + +struct SurfaceUpdates { + std::unordered_set families; + std::unordered_map propsMap; +}; + +using SnapshotMap = std::unordered_map>; + +class AnimatedPropsRegistry { + public: + void update(const std::unordered_map &surfaceUpdates); + void clear(SurfaceId surfaceId); + std::pair &, SnapshotMap &> getMap(SurfaceId surfaceId); + + private: + std::unordered_map surfaceContexts_; + std::mutex mutex_; +}; + +inline void updateProp(const PropName propName, BaseViewProps &viewProps, const PropsSnapshot &snapshot) +{ + switch (propName) { + case OPACITY: + viewProps.opacity = snapshot.props.opacity; + break; + + case WIDTH: + viewProps.yogaStyle.setDimension( + yoga::Dimension::Width, snapshot.props.yogaStyle.dimension(yoga::Dimension::Width)); + break; + + case HEIGHT: { + auto d = snapshot.props.yogaStyle.dimension(yoga::Dimension::Height); + viewProps.yogaStyle.setDimension(yoga::Dimension::Height, d); + break; + } + + case TRANSFORM: + viewProps.transform = snapshot.props.transform; + break; + + case BORDER_RADII: + viewProps.borderRadii = snapshot.props.borderRadii; + break; + + case FLEX: + viewProps.yogaStyle.setFlex(snapshot.props.yogaStyle.flex()); + break; + + case BACKGROUND_COLOR: + viewProps.backgroundColor = snapshot.props.backgroundColor; + break; + } +} + +} // 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 9ee3db07610a..6ee64331da2b 100644 --- a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.cpp +++ b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.cpp @@ -9,6 +9,7 @@ #include #include #include +#include "AnimatedPropsRegistry.h" namespace facebook::react { @@ -70,12 +71,14 @@ AnimationBackend::AnimationBackend( stopOnRenderCallback_(std::move(stopOnRenderCallback)), directManipulationCallback_(std::move(directManipulationCallback)), fabricCommitCallback_(std::move(fabricCommitCallback)), - uiManager_(uiManager) {} + animatedPropsRegistry_(std::make_shared()), + uiManager_(uiManager), + commitHook_(uiManager, animatedPropsRegistry_) {} void AnimationBackend::onAnimationFrame(double timestamp) { - std::unordered_map updates; - std::unordered_map> - surfaceToFamilies; + std::unordered_map synchronousUpdates; + std::unordered_map surfaceUpdates; + bool hasAnyLayoutUpdates = false; for (auto& callback : callbacks) { auto muatations = callback(static_cast(timestamp)); @@ -83,22 +86,28 @@ void AnimationBackend::onAnimationFrame(double timestamp) { hasAnyLayoutUpdates |= mutationHasLayoutUpdates(mutation); const auto family = mutation.family; if (family != nullptr) { - surfaceToFamilies[family->getSurfaceId()].insert(family.get()); + auto& [families, updates] = surfaceUpdates[family->getSurfaceId()]; + families.insert(family.get()); + updates[mutation.tag] = std::move(mutation.props); + } else { + synchronousUpdates[mutation.tag] = std::move(mutation.props); } - updates[mutation.tag] = std::move(mutation.props); } } + animatedPropsRegistry_->update(surfaceUpdates); + if (hasAnyLayoutUpdates) { - commitUpdates(surfaceToFamilies, updates); + commitUpdates(surfaceUpdates); } else { - synchronouslyUpdateProps(updates); + synchronouslyUpdateProps(synchronousUpdates); } } void AnimationBackend::start(const Callback& callback, bool isAsync) { callbacks.push_back(callback); - // TODO: startOnRenderCallback_ should provide the timestamp from the platform + // TODO: startOnRenderCallback_ should provide the timestamp from the + // platform if (startOnRenderCallback_) { startOnRenderCallback_( [this]() { @@ -117,13 +126,11 @@ void AnimationBackend::stop(bool isAsync) { } void AnimationBackend::commitUpdates( - const std::unordered_map< - SurfaceId, - std::unordered_set>& surfaceToFamilies, - std::unordered_map& updates) { - for (const auto& surfaceEntry : surfaceToFamilies) { + std::unordered_map& surfaceUpdates) { + for (auto& surfaceEntry : surfaceUpdates) { const auto& surfaceId = surfaceEntry.first; - const auto& surfaceFamilies = surfaceEntry.second; + const auto& surfaceFamilies = surfaceEntry.second.families; + auto& updates = surfaceEntry.second.propsMap; uiManager_->getShadowTreeRegistry().visit( surfaceId, [&surfaceFamilies, &updates](const ShadowTree& shadowTree) { shadowTree.commit( @@ -165,4 +172,8 @@ void AnimationBackend::synchronouslyUpdateProps( } } +void AnimationBackend::clearRegistry(SurfaceId surfaceId) { + animatedPropsRegistry_->clear(surfaceId); +} + } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.h b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.h index 062440f0af4a..d8f7e3a326d9 100644 --- a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.h +++ b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackend.h @@ -15,6 +15,8 @@ #include #include "AnimatedProps.h" #include "AnimatedPropsBuilder.h" +#include "AnimatedPropsRegistry.h" +#include "AnimationBackendCommitHook.h" namespace facebook::react { @@ -51,7 +53,9 @@ class AnimationBackend : public UIManagerAnimationBackend { const StopOnRenderCallback stopOnRenderCallback_; const DirectManipulationCallback directManipulationCallback_; const FabricCommitCallback fabricCommitCallback_; + std::shared_ptr animatedPropsRegistry_; UIManager *uiManager_; + AnimationBackendCommitHook commitHook_; AnimationBackend( StartOnRenderCallback &&startOnRenderCallback, @@ -59,10 +63,9 @@ class AnimationBackend : public UIManagerAnimationBackend { DirectManipulationCallback &&directManipulationCallback, FabricCommitCallback &&fabricCommitCallback, UIManager *uiManager); - void commitUpdates( - const std::unordered_map> &surfaceToFamilies, - std::unordered_map &updates); + void commitUpdates(std::unordered_map &surfaceUpdates); void synchronouslyUpdateProps(const std::unordered_map &updates); + void clearRegistry(SurfaceId surfaceId) override; void onAnimationFrame(double timestamp) override; void start(const Callback &callback, bool isAsync); diff --git a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.cpp b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.cpp new file mode 100644 index 000000000000..5cb990fb68ad --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.cpp @@ -0,0 +1,69 @@ +/* + * 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 + +namespace facebook::react { + +AnimationBackendCommitHook::AnimationBackendCommitHook( + UIManager* uiManager, + std::shared_ptr animatedPropsRegistry) + : animatedPropsRegistry_(std::move(animatedPropsRegistry)) { + uiManager->registerCommitHook(*this); +} + +RootShadowNode::Unshared AnimationBackendCommitHook::shadowTreeWillCommit( + const ShadowTree& shadowTree, + const RootShadowNode::Shared& oldRootShadowNode, + const RootShadowNode::Unshared& newRootShadowNode, + const ShadowTreeCommitOptions& commitOptions) noexcept { + if (commitOptions.source != ShadowTreeCommitSource::React) { + return newRootShadowNode; + } + + const auto& res = animatedPropsRegistry_->getMap(shadowTree.getSurfaceId()); + auto& surfaceFamilies = res.first; + auto& updates = res.second; + + if (surfaceFamilies.empty()) { + return newRootShadowNode; + } + return std::static_pointer_cast( + newRootShadowNode->cloneMultiple( + surfaceFamilies, + [&surfaceFamilies, &updates]( + const ShadowNode& shadowNode, + const ShadowNodeFragment& fragment) { + auto newProps = ShadowNodeFragment::propsPlaceholder(); + std::shared_ptr viewProps = nullptr; + if (surfaceFamilies.contains(&shadowNode.getFamily()) && + updates.contains(shadowNode.getTag())) { + auto& snapshot = updates.at(shadowNode.getTag()); + if (!snapshot->propNames.empty()) { + PropsParserContext propsParserContext{ + shadowNode.getSurfaceId(), + *shadowNode.getContextContainer()}; + + newProps = shadowNode.getComponentDescriptor().cloneProps( + propsParserContext, shadowNode.getProps(), {}); + viewProps = std::const_pointer_cast( + std::static_pointer_cast(newProps)); + } + + for (const auto& propName : snapshot->propNames) { + updateProp(propName, *viewProps, *snapshot); + } + } + return shadowNode.clone( + {.props = newProps, + .children = fragment.children, + .state = shadowNode.getState(), + .runtimeShadowNodeReference = true}); + })); +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.h b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.h new file mode 100644 index 000000000000..7d6c723d6632 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/animationbackend/AnimationBackendCommitHook.h @@ -0,0 +1,32 @@ +/* + * 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 "AnimatedPropsRegistry.h" + +namespace facebook::react { + +class AnimationBackendCommitHook : public UIManagerCommitHook { + std::shared_ptr animatedPropsRegistry_; + + public: + AnimationBackendCommitHook(UIManager *uiManager, std::shared_ptr animatedPropsRegistry); + RootShadowNode::Unshared shadowTreeWillCommit( + const ShadowTree &shadowTree, + const RootShadowNode::Shared &oldRootShadowNode, + const RootShadowNode::Unshared &newRootShadowNode, + const ShadowTreeCommitOptions &commitOptions) noexcept override; + void commitHookWasRegistered(const UIManager &uiManager) noexcept override {} + void commitHookWasUnregistered(const UIManager &uiManager) noexcept override {} +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.cpp index 5582d1312a46..931bfce1c1e1 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManager.cpp @@ -205,6 +205,12 @@ void UIManager::completeSurface( // after we commit a specific one. lazyShadowTreeRevisionConsistencyManager_->updateCurrentRevision( surfaceId, shadowTree.getCurrentRevision().rootShadowNode); + + if (ReactNativeFeatureFlags::useSharedAnimatedBackend()) { + if (auto animationBackend = animationBackend_.lock()) { + animationBackend->clearRegistry(surfaceId); + } + } } }); } diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerAnimationBackend.h b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerAnimationBackend.h index ffc49ff98519..1cbdcf93b2d6 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerAnimationBackend.h +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerAnimationBackend.h @@ -19,6 +19,7 @@ class UIManagerAnimationBackend { virtual void onAnimationFrame(double timestamp) = 0; // TODO: T240293839 Move over start() function and mutation types virtual void stop(bool isAsync) = 0; + virtual void clearRegistry(SurfaceId surfaceId) = 0; }; } // namespace facebook::react