From e3774ee63981eb33aa94e774f4b3d2d010d6b82a Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Tue, 26 May 2026 10:43:59 -0700 Subject: [PATCH] Defer animation start time in FrameAnimationDriver (#56929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56929 ## Changelog: [Internal] [Fixed] - Defer animation start time in FrameAnimationDriver **Problem**: In complex apps, if animation is started in commit phase (the case if animation starts in useLayoutEffect, or from ViewTransition event handlers), it'll skip initial frames — the user sees the animation snap to an intermediate position. This happens because `FrameAnimationDriver` anchors its start time on the first `runAnimationStep` call, but the UI thread may be busy with layout/mount work for several frames before the view actually composites. The elapsed wall-clock time advances, causing `frameIndex` to jump ahead. **Why**: `startFrameTimeMs_` is set to the Choreographer frame time on the first tick. If the UI thread is blocked processing a heavy tree (many views mounting), subsequent ticks arrive much later — `timeDeltaMs` jumps and the animation skips to a mid-point. - Every major framework solves this: Flutter uses lazy start (`_startTime ??= timeStamp` on first actual tick), Android native uses `CALLBACK_COMMIT` to adjust post-traversal, and CSS View Transitions spec defers start until post-composite. **Fix**: On the very first `update()` call, output the starting value (frame 0) and reset `startFrameTimeMs_ = -1`. This causes the base class to re-anchor on the next `runAnimationStep`, so elapsed time is measured from the first frame that has actually been rendered — not from when `startAnimatingNode` was dispatched. The flag disables itself after one use, so all subsequent frames use pure elapsed-time with no behavioral change. Differential Revision: D106007152 --- .../Animated/__tests__/Animated-itest.js | 16 ++- .../Animated/animations/TimingAnimation.js | 8 ++ .../Libraries/Animated/nodes/AnimatedValue.js | 8 ++ .../animated/drivers/FrameAnimationDriver.cpp | 21 ++- .../animated/drivers/FrameAnimationDriver.h | 1 + .../animated/tests/AnimationDriverTests.cpp | 128 ++++++++++++++++-- .../ReactNativeFeatureFlags.config.js | 11 ++ .../featureflags/ReactNativeFeatureFlags.js | 8 +- 8 files changed, 180 insertions(+), 21 deletions(-) diff --git a/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js b/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js index 995bb369bd81..ab14f823283a 100644 --- a/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/Animated-itest.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. * - * @fantom_flags useSharedAnimatedBackend:* + * @fantom_flags useSharedAnimatedBackend:* animatedDeferStartOfTimingAnimations:* * @flow strict-local * @format */ @@ -21,6 +21,12 @@ import {Animated, View, useAnimatedValue} from 'react-native'; import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; +// Deferred start outputs the initial value on the first animation frame and +// re-anchors timing on the second. This delays animation progress by one +// frame interval (~16ms at 60 fps). +const DEFERRED_START_MS = + ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations() ? 16 : 0; + test('moving box by 100 points', () => { let _translateX; const viewRef = createRef(); @@ -60,7 +66,7 @@ test('moving box by 100 points', () => { }).start(); }); - Fantom.unstable_produceFramesForDuration(500); + Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS); // shadow tree is not synchronised yet, position X is still 0. expect(viewElement.getBoundingClientRect().x).toBe(0); @@ -248,7 +254,7 @@ test('animated opacity', () => { }).start(); }); - Fantom.unstable_produceFramesForDuration(30); + Fantom.unstable_produceFramesForDuration(30 + DEFERRED_START_MS); expect(Fantom.unstable_getDirectManipulationProps(viewElement).opacity).toBe( 0, ); @@ -559,7 +565,7 @@ test('animate layout props', () => { }).start(); }); - Fantom.unstable_produceFramesForDuration(10); + Fantom.unstable_produceFramesForDuration(10 + DEFERRED_START_MS); // TODO: this shouldn't be necessary since animation should be stopped after duration Fantom.runTask(() => { @@ -712,7 +718,7 @@ test('Animated.sequence', () => { }); }); - Fantom.unstable_produceFramesForDuration(500); + Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS); expect( // $FlowFixMe[incompatible-use] diff --git a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js index f872d098bdf7..c464334cc376 100644 --- a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js @@ -15,6 +15,7 @@ import type AnimatedValue from '../nodes/AnimatedValue'; import type AnimatedValueXY from '../nodes/AnimatedValueXY'; import type {AnimationConfig, EndCallback} from './Animation'; +import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; import AnimatedColor from '../nodes/AnimatedColor'; import Animation from './Animation'; @@ -69,6 +70,7 @@ export default class TimingAnimation extends Animation { _animationFrame: ?AnimationFrameID; _timeout: ?TimeoutID; _platformConfig: ?PlatformConfig; + _deferredStart: boolean; constructor(config: TimingAnimationConfigSingle) { super(config); @@ -78,6 +80,7 @@ export default class TimingAnimation extends Animation { this._duration = config.duration ?? 500; this._delay = config.delay ?? 0; this._platformConfig = config.platformConfig; + this._deferredStart = false; } __getNativeAnimationConfig(): Readonly<{ @@ -102,6 +105,7 @@ export default class TimingAnimation extends Animation { iterations: this.__iterations, platformConfig: this._platformConfig, debugID: this.__getDebugID(), + deferredStart: this._deferredStart, }; } @@ -116,6 +120,10 @@ export default class TimingAnimation extends Animation { this._fromValue = fromValue; this._onUpdate = onUpdate; + if (ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations()) { + this._deferredStart = animatedValue.__deferAnimationStart; + animatedValue.__deferAnimationStart = false; + } const start = () => { this._startTime = Date.now(); diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js index 8650912edf6f..afe6693c6e16 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js @@ -20,6 +20,7 @@ import type AnimatedNode from './AnimatedNode'; import type {AnimatedNodeConfig} from './AnimatedNode'; import type AnimatedTracking from './AnimatedTracking'; +import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags'; import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper'; import AnimatedInterpolation from './AnimatedInterpolation'; import AnimatedWithChildren from './AnimatedWithChildren'; @@ -95,6 +96,7 @@ export default class AnimatedValue extends AnimatedWithChildren { _offset: number; _animation: ?Animation; _tracking: ?AnimatedTracking; + __deferAnimationStart: boolean; constructor(value: number, config?: ?AnimatedValueConfig) { super(config); @@ -107,6 +109,8 @@ export default class AnimatedValue extends AnimatedWithChildren { this._startingValue = this._value = value; this._offset = 0; + this.__deferAnimationStart = + ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations(); this._animation = null; if (config && config.useNativeDriver) { this.__makeNative(); @@ -327,6 +331,10 @@ export default class AnimatedValue extends AnimatedWithChildren { result => { this._animation = null; callback && callback(result); + if (this._animation == null) { + this.__deferAnimationStart = + ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations(); + } }, previousAnimation, this, diff --git a/packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.cpp b/packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.cpp index 46af79a137bb..87b896f4d37e 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.cpp +++ b/packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.cpp @@ -49,15 +49,34 @@ void FrameAnimationDriver::onConfigChanged() { frames_.push_back(frameValue); } toValue_ = config_["toValue"].asDouble(); + auto deferIt = config_.find("deferredStart"); + deferredStart_ = deferIt == config_.items().end() || deferIt->second.asBool(); } -bool FrameAnimationDriver::update(double timeDeltaMs, bool /*restarting*/) { +bool FrameAnimationDriver::update(double timeDeltaMs, bool restarting) { if (auto node = manager_->getAnimatedNode(animatedValueTag_)) { if (!startValue_) { startValue_ = node->getRawValue(); } + if (deferredStart_ && restarting) { + // On the very first update after start: output the starting value + // (frame 0) and defer the time anchor. The base class will re-anchor + // startFrameTimeMs_ on the next call, so elapsed time is measured + // from the first frame that has actually been rendered — not from + // when startAnimatingNode was dispatched. + // + // This prevents skipping initial frames when the UI thread is busy + // with layout/mount work between animation start and first composite. + node->setRawValue( + startValue_.value() + frames_[0] * (toValue_ - startValue_.value())); + markNodeUpdated(node->tag()); + startFrameTimeMs_ = -1; + deferredStart_ = false; + return false; + } + const auto startIndex = static_cast(std::round(timeDeltaMs / SingleFrameIntervalMs)); assert(startIndex >= 0); diff --git a/packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.h b/packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.h index 7bcbc4a04484..5c2933b44a2b 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.h +++ b/packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.h @@ -35,6 +35,7 @@ class FrameAnimationDriver : public AnimationDriver { std::vector frames_{}; double toValue_{0}; std::optional startValue_{}; + bool deferredStart_{true}; }; } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/animated/tests/AnimationDriverTests.cpp b/packages/react-native/ReactCommon/react/renderer/animated/tests/AnimationDriverTests.cpp index e5de4cd7198e..3fc6cf5d9bdd 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/tests/AnimationDriverTests.cpp +++ b/packages/react-native/ReactCommon/react/renderer/animated/tests/AnimationDriverTests.cpp @@ -42,19 +42,29 @@ TEST_F(AnimationDriverTests, framesAnimation) { const double startTimeInTick = 12345; + // Frame 1: deferred start outputs frame 0 and re-anchors timing runAnimationFrame(startTimeInTick); EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0); - runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 2.5); + // Frame 2: re-anchor completes, timeDelta=0 → still frame 0 + runAnimationFrame(startTimeInTick + SingleFrameIntervalMs); + EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0); + + // Subsequent frames are measured from the re-anchored start + runAnimationFrame( + startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 2.5); EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 65); - runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 3); + runAnimationFrame( + startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 3); EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 90); - runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 4); + runAnimationFrame( + startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 4); EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue); - runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 10); + runAnimationFrame( + startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 10); EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue); } @@ -84,12 +94,14 @@ TEST_F(AnimationDriverTests, framesAnimationReconfigurationClearsFrames) { const double startTimeInTick = 12345; - // Run first frame + // Deferred start frame + re-anchor frame runAnimationFrame(startTimeInTick); EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0); + runAnimationFrame(startTimeInTick + SingleFrameIntervalMs); + EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0); - // Reconfigure the same animation (same animationId) with new frames - // This triggers updateConfig on the existing FrameAnimationDriver + // Reconfigure the same animation (same animationId) with new frames. + // This triggers updateConfig → onConfigChanged → deferredStart_ = true again. const auto frames2 = folly::dynamic::array(0.0f, 0.5f, 1.0f); const auto toValue2 = 200; nodesManager_->startAnimatingNode( @@ -99,22 +111,110 @@ TEST_F(AnimationDriverTests, framesAnimationReconfigurationClearsFrames) { "toValue", toValue2), std::nullopt); - // Reset animation timing const double newStartTimeInTick = 20000; - // Run animation at halfway point (1 frame into 3-frame animation) + // Deferred start frame for reconfigured animation runAnimationFrame(newStartTimeInTick); - runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 1); + EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0); + + // Re-anchor frame + runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs); + EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0); // At frame 1 of 3 frames (50% progress), value should be approximately: // startValue (0) + 0.5 * (toValue2 - startValue) = 0 + 0.5 * 200 = 100 // If frames accumulated (5 + 3 = 8 frames), we'd be at wrong position - // Use ceil rounding so 100.00x becomes 100.01 - EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 100.01); + runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 2); + EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 100, 0.01); // Complete the animation - runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 2); - EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue2); + runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 3); + EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue2); +} + +TEST_F(AnimationDriverTests, framesAnimationDeferredStartPreventsSkipping) { + // Deferred start outputs frame 0 on the first update and re-anchors + // startFrameTimeMs_ so the second update also sees timeDelta=0. + // Without the fix the second frame would already be at value 25. + initNodesManager(); + + auto rootTag = getNextRootViewTag(); + + auto valueNodeTag = ++rootTag; + nodesManager_->createAnimatedNode( + valueNodeTag, + folly::dynamic::object("type", "value")("value", 0)("offset", 0)); + + const auto animationId = 1; + const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f); + const auto toValue = 100; + nodesManager_->startAnimatingNode( + animationId, + valueNodeTag, + folly::dynamic::object("type", "frames")("frames", frames)( + "toValue", toValue), + std::nullopt); + + const double t = 12345; + + // Frame 1: both with and without fix, timeDelta=0 → value=0 + runAnimationFrame(t); + EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0); + + // Frame 2: WITHOUT fix timeDelta=SI → value≈25. + // WITH fix the deferred start re-anchored startFrameTimeMs_, so + // timeDelta=0 → value=0. This assertion fails without the fix. + runAnimationFrame(t + SingleFrameIntervalMs); + EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0); + + // Frame 3: now timeDelta=SI from the re-anchored start + runAnimationFrame(t + SingleFrameIntervalMs * 2); + EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01); + + // Frame 4 + runAnimationFrame(t + SingleFrameIntervalMs * 3); + EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 50, 0.01); + + // Complete + runAnimationFrame(t + SingleFrameIntervalMs * 5); + EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue); +} + +TEST_F(AnimationDriverTests, framesAnimationDeferredStartOptOut) { + // When deferredStart is false, the animation starts immediately without + // the extra re-anchor frame. + initNodesManager(); + + auto rootTag = getNextRootViewTag(); + + auto valueNodeTag = ++rootTag; + nodesManager_->createAnimatedNode( + valueNodeTag, + folly::dynamic::object("type", "value")("value", 0)("offset", 0)); + + const auto animationId = 1; + const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f); + const auto toValue = 100; + nodesManager_->startAnimatingNode( + animationId, + valueNodeTag, + folly::dynamic::object("type", "frames")("frames", frames)( + "toValue", toValue)("deferredStart", false), + std::nullopt); + + const double t = 12345; + + // Frame 1: timeDelta=0, value=0 (no deferred start delay) + runAnimationFrame(t); + EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0); + + // Frame 2: timeDelta=SI, value≈25 (animation progresses immediately) + runAnimationFrame(t + SingleFrameIntervalMs); + EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01); + + // Complete + runAnimationFrame(t + SingleFrameIntervalMs * 4); + EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue); } } // namespace facebook::react diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index aff2b57424b8..b1c31ae9b214 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -981,6 +981,17 @@ const definitions: FeatureFlagDefinitions = { jsOnly: { ...testDefinitions.jsOnly, + animatedDeferStartOfTimingAnimations: { + defaultValue: false, + metadata: { + dateAdded: '2026-05-26', + description: + 'When enabled, native timing animations defer their first frame and re-anchor timing to prevent skipping initial frames when the UI thread is busy with layout work.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, animatedShouldDebounceQueueFlush: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index dcf70518a3e3..27a8ee83bff3 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<> + * @generated SignedSource<<0fa75542ae2962e624a651b64f829245>> * @flow strict * @noformat */ @@ -29,6 +29,7 @@ import { export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{ jsOnlyTestFlag: Getter, + animatedDeferStartOfTimingAnimations: Getter, animatedShouldDebounceQueueFlush: Getter, animatedShouldUseSingleOp: Getter, deferFlatListFocusChangeRenderUpdate: Getter, @@ -140,6 +141,11 @@ export type ReactNativeFeatureFlags = $ReadOnly<{ */ export const jsOnlyTestFlag: Getter = createJavaScriptFlagGetter('jsOnlyTestFlag', false); +/** + * When enabled, native timing animations defer their first frame and re-anchor timing to prevent skipping initial frames when the UI thread is busy with layout work. + */ +export const animatedDeferStartOfTimingAnimations: Getter = createJavaScriptFlagGetter('animatedDeferStartOfTimingAnimations', false); + /** * Enables an experimental flush-queue debouncing in Animated.js. */