From cf84371743af59ba05cc93fa7ea6ffd6b0b9fdff Mon Sep 17 00:00:00 2001 From: Mark Verlingieri Date: Wed, 28 Oct 2026 11:34:35 -0700 Subject: [PATCH] Step time-based animations only on frames with the shared animated backend (#59565) Summary: Fixes native-driven animations stepping backward for a frame when the shared animated backend is on and a native event update (e.g. an `Animated.event` `onScroll`) runs between frames. Without the shared backend this does not happen. With the shared backend, an event update also steps every active timing, spring and decay animation, at the time of the update. Frames step them at the platform frame time (on Android, the Choreographer vsync time), which is often earlier: 1. An event update steps an animation at time T. 2. The next frame steps it at T - 8..25 ms. 3. The animation moves backward for that frame; a timing animation started by the event overshoots past its start value. Event updates now only propagate the event values, and time-based animations step only on frames. This aligns the shared backend with the implementations it replaces: the Kotlin `NativeAnimatedNodesManager` updates nodes on events without stepping animations, and the C++ path without the shared backend steps everything on one clock. As on Kotlin, a timing animation whose `toValue` tracks an event-driven value restarts on each event and advances only on frames between events. Changelog: [General][Fixed] - Fix native-driven animations stepping backward when a native event update runs between frames with the shared animated backend Reviewed By: zeyap Differential Revision: D127496079 --- .../__tests__/AnimatedBackend-itest.js | 100 ++++++++++++++++ .../animated/NativeAnimatedNodesManager.cpp | 20 +++- .../animated/NativeAnimatedNodesManager.h | 7 +- .../tests/EventAnimationDriverTests.cpp | 110 +++++++++++++++++- .../ReactAndroidFrameworksCxx.api | 4 +- .../api-snapshots/ReactAppleFrameworksCxx.api | 4 +- .../ReactCommonFrameworksCxx.api | 4 +- 7 files changed, 235 insertions(+), 14 deletions(-) diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js index df8472d8fe90..a20d9cdb0a90 100644 --- a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js @@ -912,3 +912,103 @@ test('animate width with memo and rerender (js sync test)', () => { , ); }); + +// A native scroll event pushes its values outside the frame loop. It must not +// step running time-based animations: frames step them with the frame time, so +// stepping them at the push's own time as well would move them whenever the +// two times differ. +test('native scroll event does not step a running timing animation', () => { + const scrollViewRef = createRef(); + const viewRef = createRef(); + const scrollFollowerRef = createRef(); + let _translateX; + + component MyApp() { + const scrollY = useAnimatedValue(0); + const translateX = useAnimatedValue(0); + _translateX = translateX; + return ( + + + + + + + + ); + } + + // Stamp pushes far later than the frame clock, as on device, where a push + // between frames runs later than the next frame's vsync time. + const mockClock = Fantom.installHighResTimeStampMock(); + mockClock.setTime(1e12); + let animation; + try { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(); + }); + const scrollViewElement = nullthrows(scrollViewRef.current); + const viewElement = nullthrows(viewRef.current); + const scrollFollowerElement = nullthrows(scrollFollowerRef.current); + + const getTransformValue = (element: HostInstance, key: string): number => { + const transform = + Fantom.unstable_getDirectManipulationProps(element).transform; + const first = Array.isArray(transform) ? transform[0] : null; + if ( + first == null || + typeof first !== 'object' || + typeof first[key] !== 'number' + ) { + throw new Error(`Expected a numeric ${key} transform`); + } + return first[key]; + }; + const getTranslateX = (): number => + getTransformValue(viewElement, 'translateX'); + + Fantom.runTask(() => { + animation = Animated.timing(nullthrows(_translateX), { + toValue: 100, + duration: 1000, + easing: Easing.linear, + useNativeDriver: true, + }); + animation.start(); + }); + Fantom.unstable_produceFramesForDuration(500); + + const beforeScroll = getTranslateX(); + expect(beforeScroll).toBeGreaterThan(0); + expect(beforeScroll).toBeLessThan(100); + + Fantom.scrollTo(scrollViewElement, {x: 0, y: 10}); + // The scroll event reached the native graph... + expect(getTransformValue(scrollFollowerElement, 'translateY')).toBe(10); + // ...and left the running timing animation where the frames put it. + expect(getTranslateX()).toBe(beforeScroll); + + Fantom.unstable_produceFramesForDuration(100); + expect(getTranslateX()).toBeGreaterThan(beforeScroll); + expect(getTranslateX()).toBeLessThan(100); + } finally { + Fantom.runTask(() => { + animation?.stop(); + }); + // Flush the work queued by stopping the animation. + Fantom.runWorkLoop(); + mockClock.uninstall(); + } +}); diff --git a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp index 5c4d70b1ef5f..de87a75b0c5a 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp +++ b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.cpp @@ -555,9 +555,14 @@ void NativeAnimatedNodesManager::handleAnimatedEvent( // frames. if (useSharedAnimatedBackend_) { if (auto animationBackend = animationBackend_.lock()) { + // Pass stepAnimations false so this push only propagates event values. + // Frames step time-based drivers with the vsync time, which can be + // earlier than this push's time, so stepping them here would move them + // backward on the next frame. animationBackend->pushAnimationMutations( [this](AnimationTimestamp timestamp) -> AnimationMutations { - return pullAnimationMutations(timestamp); + return pullAnimationMutations( + timestamp, /* stepAnimations */ false); }); } } else { @@ -1038,14 +1043,17 @@ void NativeAnimatedNodesManager::insertMutations( AnimationMutations NativeAnimatedNodesManager::onAnimationFrameForBackend( AnimatedPropsBuilder& propsBuilder, - AnimationTimestamp timestamp) { + AnimationTimestamp timestamp, + bool stepAnimations) { AnimationMutations mutations{}; auto timestampMs = timestamp.count(); // Run all active animations auto hasFinishedAnimations = false; std::set finishedAnimationValueNodes; for (const auto& [_id, driver] : activeAnimations_) { - driver->runAnimationStep(timestampMs); + if (stepAnimations) { + driver->runAnimationStep(timestampMs); + } if (driver->getIsComplete()) { hasFinishedAnimations = true; @@ -1083,7 +1091,8 @@ AnimationMutations NativeAnimatedNodesManager::onAnimationFrameForBackend( } AnimationMutations NativeAnimatedNodesManager::pullAnimationMutations( - AnimationTimestamp timestamp) { + AnimationTimestamp timestamp, + bool stepAnimations) { if (!useSharedAnimatedBackend_) { return {}; } @@ -1113,7 +1122,8 @@ AnimationMutations NativeAnimatedNodesManager::pullAnimationMutations( // Step through the animation loop if (isAnimationUpdateNeeded()) { AnimatedPropsBuilder propsBuilder; - mutations = onAnimationFrameForBackend(propsBuilder, timestamp); + mutations = + onAnimationFrameForBackend(propsBuilder, timestamp, stepAnimations); if (mutations.batch.empty()) { // The last animation tick didn't result in any changes to the UI. diff --git a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h index ff630b31209f..a85cbf4e6d95 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h +++ b/packages/react-native/ReactCommon/react/renderer/animated/NativeAnimatedNodesManager.h @@ -133,8 +133,11 @@ class NativeAnimatedNodesManager : public std::enable_shared_from_this #include #include namespace facebook::react { -class EventAnimationDriverTests : public AnimationTestsBase {}; +namespace { + +// Runs event-driven updates at a timestamp the test sets, like a platform +// whose current time is later than the vsync time of the next frame. +class FakeAnimationBackend : public UIManagerAnimationBackend { + public: + AnimationTimestamp pushTimestamp{0}; + + void onAnimationFrame(AnimationTimestamp /*timestamp*/) override {} + CallbackId start(const Callback& /*callback*/) override { + return 0; + } + void stop(CallbackId /*callbackId*/) override {} + void clearRegistry(SurfaceId /*surfaceId*/) override {} + void clearRegistryOnSurfaceStop(SurfaceId /*surfaceId*/) override {} + void trigger() override {} + void pushAnimationMutations(const Callback& callback) override { + callback(pushTimestamp); + } + void registerJSInvoker(std::shared_ptr /*jsInvoker*/) override {} +}; + +} // namespace + +class EventAnimationDriverTests : public AnimationTestsBase { + protected: + static constexpr double kStartTime = 1000; + + std::shared_ptr backend_ = + std::make_shared(); + Tag tag_{getNextRootViewTag()}; + Tag viewTag_{++tag_}; + Tag scrollValueTag_{++tag_}; + Tag animatedValueTag_{++tag_}; + + // A shared-backend manager with a scroll event mapped to scrollValueTag_ and + // the given animation running on animatedValueTag_. + void startSharedBackendAnimation(const folly::dynamic& animationConfig) { + // Marks this thread as the render thread. + initNodesManager(); + nodesManager_ = std::make_shared(backend_); + + const folly::dynamic valueNodeConfig = + folly::dynamic::object("type", "value")("value", 0)("offset", 0); + nodesManager_->createAnimatedNode(scrollValueTag_, valueNodeConfig); + nodesManager_->addAnimatedEventToView( + viewTag_, + "scroll", + folly::dynamic::object("animatedValueTag", scrollValueTag_)( + "nativeEventPath", folly::dynamic::array("contentOffset", "y"))); + nodesManager_->createAnimatedNode(animatedValueTag_, valueNodeConfig); + nodesManager_->startAnimatingNode( + 1, animatedValueTag_, animationConfig, std::nullopt); + } + + void runBackendFrame(double timestamp) { + nodesManager_->pullAnimationMutations(AnimationTimestamp{timestamp}); + } + + void dispatchScrollAt(double timestamp, double offsetY) { + backend_->pushTimestamp = AnimationTimestamp{timestamp}; + auto scrollEvent = std::make_shared(); + scrollEvent->contentOffset = {.x = 0, .y = static_cast(offsetY)}; + (*nodesManager_->getEventEmitterListener())( + viewTag_, "scroll", *scrollEvent); + } + + double animatedValue() { + return nodesManager_->getValue(animatedValueTag_).value(); + } +}; TEST_F(EventAnimationDriverTests, subscribeToViewEvent) { initNodesManager(); @@ -67,4 +138,41 @@ TEST_F(EventAnimationDriverTests, subscribeToViewEvent) { EXPECT_EQ(nodesManager_->getValue(animatedValueTag2), 11); } +TEST_F( + EventAnimationDriverTests, + sharedBackendEventDoesNotStepTimingAnimationAheadOfFrames) { + startSharedBackendAnimation( + folly::dynamic::object("type", "frames")( + "frames", folly::dynamic::array(0.0, 0.25, 0.5, 0.75, 1.0))( + "toValue", 100)); + + runBackendFrame(kStartTime); + runBackendFrame(kStartTime + SingleFrameIntervalMs); + const auto valueBeforeEvent = animatedValue(); + EXPECT_GT(valueBeforeEvent, 0); + + // The event is handled later than the next frame's vsync time. + dispatchScrollAt(kStartTime + SingleFrameIntervalMs * 3, 4); + EXPECT_EQ(nodesManager_->getValue(scrollValueTag_), 4); + EXPECT_EQ(animatedValue(), valueBeforeEvent); + + runBackendFrame(kStartTime + SingleFrameIntervalMs * 2); + EXPECT_GT(animatedValue(), valueBeforeEvent); +} + +TEST_F(EventAnimationDriverTests, sharedBackendEventDoesNotEndDecayAnimation) { + startSharedBackendAnimation( + folly::dynamic::object("type", "decay")("velocity", 1.0)( + "deceleration", 0.998)("iterations", 1)); + + runBackendFrame(kStartTime); + runBackendFrame(kStartTime + SingleFrameIntervalMs); + dispatchScrollAt(kStartTime + SingleFrameIntervalMs * 2, 4); + runBackendFrame(kStartTime + SingleFrameIntervalMs * 2); + const auto valueAfterEvent = animatedValue(); + + runBackendFrame(kStartTime + SingleFrameIntervalMs * 3); + EXPECT_GT(animatedValue(), valueAfterEvent); +} + } // namespace facebook::react diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api index 2c501ad938e5..5e5dad5f5ce5 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -2839,8 +2839,8 @@ class facebook::react::NativeAnimatedNodesManager : public std::enable_shared_fr public bool hasManagedProps() const noexcept; public bool isOnRenderThread() const noexcept; public bool useSharedAnimatedBackend() const noexcept; - public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp); - public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp); + public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); + public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); public facebook::react::NativeAnimatedNodesManager& operator=(const facebook::react::NativeAnimatedNodesManager&) = delete; public facebook::react::NativeAnimatedNodesManager& operator=(facebook::react::NativeAnimatedNodesManager&&) = delete; public folly::dynamic getManagedProps(facebook::react::Tag tag) const noexcept; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index b6fcaa20bde3..cb8ca685d4f2 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -5038,8 +5038,8 @@ class facebook::react::NativeAnimatedNodesManager : public std::enable_shared_fr public bool hasManagedProps() const noexcept; public bool isOnRenderThread() const noexcept; public bool useSharedAnimatedBackend() const noexcept; - public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp); - public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp); + public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); + public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); public facebook::react::NativeAnimatedNodesManager& operator=(const facebook::react::NativeAnimatedNodesManager&) = delete; public facebook::react::NativeAnimatedNodesManager& operator=(facebook::react::NativeAnimatedNodesManager&&) = delete; public folly::dynamic getManagedProps(facebook::react::Tag tag) const noexcept; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api index e1cce2df0794..cb97eccc7f11 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api @@ -1703,8 +1703,8 @@ class facebook::react::NativeAnimatedNodesManager : public std::enable_shared_fr public bool hasManagedProps() const noexcept; public bool isOnRenderThread() const noexcept; public bool useSharedAnimatedBackend() const noexcept; - public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp); - public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp); + public facebook::react::AnimationMutations onAnimationFrameForBackend(facebook::react::AnimatedPropsBuilder& propsBuilder, facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); + public facebook::react::AnimationMutations pullAnimationMutations(facebook::react::AnimationTimestamp timestamp, bool stepAnimations = true); public facebook::react::NativeAnimatedNodesManager& operator=(const facebook::react::NativeAnimatedNodesManager&) = delete; public facebook::react::NativeAnimatedNodesManager& operator=(facebook::react::NativeAnimatedNodesManager&&) = delete; public folly::dynamic getManagedProps(facebook::react::Tag tag) const noexcept;