Skip to content

Commit e3774ee

Browse files
zeyapmeta-codesync[bot]
authored andcommitted
Defer animation start time in FrameAnimationDriver (#56929)
Summary: Pull Request resolved: #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
1 parent 083fd99 commit e3774ee

8 files changed

Lines changed: 180 additions & 21 deletions

File tree

packages/react-native/Libraries/Animated/__tests__/Animated-itest.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @fantom_flags useSharedAnimatedBackend:*
7+
* @fantom_flags useSharedAnimatedBackend:* animatedDeferStartOfTimingAnimations:*
88
* @flow strict-local
99
* @format
1010
*/
@@ -21,6 +21,12 @@ import {Animated, View, useAnimatedValue} from 'react-native';
2121
import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist';
2222
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
2323

24+
// Deferred start outputs the initial value on the first animation frame and
25+
// re-anchors timing on the second. This delays animation progress by one
26+
// frame interval (~16ms at 60 fps).
27+
const DEFERRED_START_MS =
28+
ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations() ? 16 : 0;
29+
2430
test('moving box by 100 points', () => {
2531
let _translateX;
2632
const viewRef = createRef<HostInstance>();
@@ -60,7 +66,7 @@ test('moving box by 100 points', () => {
6066
}).start();
6167
});
6268

63-
Fantom.unstable_produceFramesForDuration(500);
69+
Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);
6470

6571
// shadow tree is not synchronised yet, position X is still 0.
6672
expect(viewElement.getBoundingClientRect().x).toBe(0);
@@ -248,7 +254,7 @@ test('animated opacity', () => {
248254
}).start();
249255
});
250256

251-
Fantom.unstable_produceFramesForDuration(30);
257+
Fantom.unstable_produceFramesForDuration(30 + DEFERRED_START_MS);
252258
expect(Fantom.unstable_getDirectManipulationProps(viewElement).opacity).toBe(
253259
0,
254260
);
@@ -559,7 +565,7 @@ test('animate layout props', () => {
559565
}).start();
560566
});
561567

562-
Fantom.unstable_produceFramesForDuration(10);
568+
Fantom.unstable_produceFramesForDuration(10 + DEFERRED_START_MS);
563569

564570
// TODO: this shouldn't be necessary since animation should be stopped after duration
565571
Fantom.runTask(() => {
@@ -712,7 +718,7 @@ test('Animated.sequence', () => {
712718
});
713719
});
714720

715-
Fantom.unstable_produceFramesForDuration(500);
721+
Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);
716722

717723
expect(
718724
// $FlowFixMe[incompatible-use]

packages/react-native/Libraries/Animated/animations/TimingAnimation.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type AnimatedValue from '../nodes/AnimatedValue';
1515
import type AnimatedValueXY from '../nodes/AnimatedValueXY';
1616
import type {AnimationConfig, EndCallback} from './Animation';
1717

18+
import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
1819
import AnimatedColor from '../nodes/AnimatedColor';
1920
import Animation from './Animation';
2021

@@ -69,6 +70,7 @@ export default class TimingAnimation extends Animation {
6970
_animationFrame: ?AnimationFrameID;
7071
_timeout: ?TimeoutID;
7172
_platformConfig: ?PlatformConfig;
73+
_deferredStart: boolean;
7274

7375
constructor(config: TimingAnimationConfigSingle) {
7476
super(config);
@@ -78,6 +80,7 @@ export default class TimingAnimation extends Animation {
7880
this._duration = config.duration ?? 500;
7981
this._delay = config.delay ?? 0;
8082
this._platformConfig = config.platformConfig;
83+
this._deferredStart = false;
8184
}
8285

8386
__getNativeAnimationConfig(): Readonly<{
@@ -102,6 +105,7 @@ export default class TimingAnimation extends Animation {
102105
iterations: this.__iterations,
103106
platformConfig: this._platformConfig,
104107
debugID: this.__getDebugID(),
108+
deferredStart: this._deferredStart,
105109
};
106110
}
107111

@@ -116,6 +120,10 @@ export default class TimingAnimation extends Animation {
116120

117121
this._fromValue = fromValue;
118122
this._onUpdate = onUpdate;
123+
if (ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations()) {
124+
this._deferredStart = animatedValue.__deferAnimationStart;
125+
animatedValue.__deferAnimationStart = false;
126+
}
119127

120128
const start = () => {
121129
this._startTime = Date.now();

packages/react-native/Libraries/Animated/nodes/AnimatedValue.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type AnimatedNode from './AnimatedNode';
2020
import type {AnimatedNodeConfig} from './AnimatedNode';
2121
import type AnimatedTracking from './AnimatedTracking';
2222

23+
import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
2324
import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
2425
import AnimatedInterpolation from './AnimatedInterpolation';
2526
import AnimatedWithChildren from './AnimatedWithChildren';
@@ -95,6 +96,7 @@ export default class AnimatedValue extends AnimatedWithChildren {
9596
_offset: number;
9697
_animation: ?Animation;
9798
_tracking: ?AnimatedTracking;
99+
__deferAnimationStart: boolean;
98100

99101
constructor(value: number, config?: ?AnimatedValueConfig) {
100102
super(config);
@@ -107,6 +109,8 @@ export default class AnimatedValue extends AnimatedWithChildren {
107109

108110
this._startingValue = this._value = value;
109111
this._offset = 0;
112+
this.__deferAnimationStart =
113+
ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations();
110114
this._animation = null;
111115
if (config && config.useNativeDriver) {
112116
this.__makeNative();
@@ -327,6 +331,10 @@ export default class AnimatedValue extends AnimatedWithChildren {
327331
result => {
328332
this._animation = null;
329333
callback && callback(result);
334+
if (this._animation == null) {
335+
this.__deferAnimationStart =
336+
ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations();
337+
}
330338
},
331339
previousAnimation,
332340
this,

packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.cpp

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,34 @@ void FrameAnimationDriver::onConfigChanged() {
4949
frames_.push_back(frameValue);
5050
}
5151
toValue_ = config_["toValue"].asDouble();
52+
auto deferIt = config_.find("deferredStart");
53+
deferredStart_ = deferIt == config_.items().end() || deferIt->second.asBool();
5254
}
5355

54-
bool FrameAnimationDriver::update(double timeDeltaMs, bool /*restarting*/) {
56+
bool FrameAnimationDriver::update(double timeDeltaMs, bool restarting) {
5557
if (auto node =
5658
manager_->getAnimatedNode<ValueAnimatedNode>(animatedValueTag_)) {
5759
if (!startValue_) {
5860
startValue_ = node->getRawValue();
5961
}
6062

63+
if (deferredStart_ && restarting) {
64+
// On the very first update after start: output the starting value
65+
// (frame 0) and defer the time anchor. The base class will re-anchor
66+
// startFrameTimeMs_ on the next call, so elapsed time is measured
67+
// from the first frame that has actually been rendered — not from
68+
// when startAnimatingNode was dispatched.
69+
//
70+
// This prevents skipping initial frames when the UI thread is busy
71+
// with layout/mount work between animation start and first composite.
72+
node->setRawValue(
73+
startValue_.value() + frames_[0] * (toValue_ - startValue_.value()));
74+
markNodeUpdated(node->tag());
75+
startFrameTimeMs_ = -1;
76+
deferredStart_ = false;
77+
return false;
78+
}
79+
6180
const auto startIndex =
6281
static_cast<size_t>(std::round(timeDeltaMs / SingleFrameIntervalMs));
6382
assert(startIndex >= 0);

packages/react-native/ReactCommon/react/renderer/animated/drivers/FrameAnimationDriver.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class FrameAnimationDriver : public AnimationDriver {
3535
std::vector<double> frames_{};
3636
double toValue_{0};
3737
std::optional<double> startValue_{};
38+
bool deferredStart_{true};
3839
};
3940

4041
} // namespace facebook::react

packages/react-native/ReactCommon/react/renderer/animated/tests/AnimationDriverTests.cpp

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,29 @@ TEST_F(AnimationDriverTests, framesAnimation) {
4242

4343
const double startTimeInTick = 12345;
4444

45+
// Frame 1: deferred start outputs frame 0 and re-anchors timing
4546
runAnimationFrame(startTimeInTick);
4647
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);
4748

48-
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 2.5);
49+
// Frame 2: re-anchor completes, timeDelta=0 → still frame 0
50+
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs);
51+
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);
52+
53+
// Subsequent frames are measured from the re-anchored start
54+
runAnimationFrame(
55+
startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 2.5);
4956
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 65);
5057

51-
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 3);
58+
runAnimationFrame(
59+
startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 3);
5260
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 90);
5361

54-
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 4);
62+
runAnimationFrame(
63+
startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 4);
5564
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue);
5665

57-
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 10);
66+
runAnimationFrame(
67+
startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 10);
5868
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue);
5969
}
6070

@@ -84,12 +94,14 @@ TEST_F(AnimationDriverTests, framesAnimationReconfigurationClearsFrames) {
8494

8595
const double startTimeInTick = 12345;
8696

87-
// Run first frame
97+
// Deferred start frame + re-anchor frame
8898
runAnimationFrame(startTimeInTick);
8999
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);
100+
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs);
101+
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);
90102

91-
// Reconfigure the same animation (same animationId) with new frames
92-
// This triggers updateConfig on the existing FrameAnimationDriver
103+
// Reconfigure the same animation (same animationId) with new frames.
104+
// This triggers updateConfig → onConfigChanged → deferredStart_ = true again.
93105
const auto frames2 = folly::dynamic::array(0.0f, 0.5f, 1.0f);
94106
const auto toValue2 = 200;
95107
nodesManager_->startAnimatingNode(
@@ -99,22 +111,110 @@ TEST_F(AnimationDriverTests, framesAnimationReconfigurationClearsFrames) {
99111
"toValue", toValue2),
100112
std::nullopt);
101113

102-
// Reset animation timing
103114
const double newStartTimeInTick = 20000;
104115

105-
// Run animation at halfway point (1 frame into 3-frame animation)
116+
// Deferred start frame for reconfigured animation
106117
runAnimationFrame(newStartTimeInTick);
107-
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 1);
118+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
119+
120+
// Re-anchor frame
121+
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs);
122+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
108123

109124
// At frame 1 of 3 frames (50% progress), value should be approximately:
110125
// startValue (0) + 0.5 * (toValue2 - startValue) = 0 + 0.5 * 200 = 100
111126
// If frames accumulated (5 + 3 = 8 frames), we'd be at wrong position
112-
// Use ceil rounding so 100.00x becomes 100.01
113-
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 100.01);
127+
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 2);
128+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 100, 0.01);
114129

115130
// Complete the animation
116-
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 2);
117-
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue2);
131+
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 3);
132+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue2);
133+
}
134+
135+
TEST_F(AnimationDriverTests, framesAnimationDeferredStartPreventsSkipping) {
136+
// Deferred start outputs frame 0 on the first update and re-anchors
137+
// startFrameTimeMs_ so the second update also sees timeDelta=0.
138+
// Without the fix the second frame would already be at value 25.
139+
initNodesManager();
140+
141+
auto rootTag = getNextRootViewTag();
142+
143+
auto valueNodeTag = ++rootTag;
144+
nodesManager_->createAnimatedNode(
145+
valueNodeTag,
146+
folly::dynamic::object("type", "value")("value", 0)("offset", 0));
147+
148+
const auto animationId = 1;
149+
const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f);
150+
const auto toValue = 100;
151+
nodesManager_->startAnimatingNode(
152+
animationId,
153+
valueNodeTag,
154+
folly::dynamic::object("type", "frames")("frames", frames)(
155+
"toValue", toValue),
156+
std::nullopt);
157+
158+
const double t = 12345;
159+
160+
// Frame 1: both with and without fix, timeDelta=0 → value=0
161+
runAnimationFrame(t);
162+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
163+
164+
// Frame 2: WITHOUT fix timeDelta=SI → value≈25.
165+
// WITH fix the deferred start re-anchored startFrameTimeMs_, so
166+
// timeDelta=0 → value=0. This assertion fails without the fix.
167+
runAnimationFrame(t + SingleFrameIntervalMs);
168+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
169+
170+
// Frame 3: now timeDelta=SI from the re-anchored start
171+
runAnimationFrame(t + SingleFrameIntervalMs * 2);
172+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01);
173+
174+
// Frame 4
175+
runAnimationFrame(t + SingleFrameIntervalMs * 3);
176+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 50, 0.01);
177+
178+
// Complete
179+
runAnimationFrame(t + SingleFrameIntervalMs * 5);
180+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue);
181+
}
182+
183+
TEST_F(AnimationDriverTests, framesAnimationDeferredStartOptOut) {
184+
// When deferredStart is false, the animation starts immediately without
185+
// the extra re-anchor frame.
186+
initNodesManager();
187+
188+
auto rootTag = getNextRootViewTag();
189+
190+
auto valueNodeTag = ++rootTag;
191+
nodesManager_->createAnimatedNode(
192+
valueNodeTag,
193+
folly::dynamic::object("type", "value")("value", 0)("offset", 0));
194+
195+
const auto animationId = 1;
196+
const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f);
197+
const auto toValue = 100;
198+
nodesManager_->startAnimatingNode(
199+
animationId,
200+
valueNodeTag,
201+
folly::dynamic::object("type", "frames")("frames", frames)(
202+
"toValue", toValue)("deferredStart", false),
203+
std::nullopt);
204+
205+
const double t = 12345;
206+
207+
// Frame 1: timeDelta=0, value=0 (no deferred start delay)
208+
runAnimationFrame(t);
209+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);
210+
211+
// Frame 2: timeDelta=SI, value≈25 (animation progresses immediately)
212+
runAnimationFrame(t + SingleFrameIntervalMs);
213+
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01);
214+
215+
// Complete
216+
runAnimationFrame(t + SingleFrameIntervalMs * 4);
217+
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue);
118218
}
119219

120220
} // namespace facebook::react

packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,17 @@ const definitions: FeatureFlagDefinitions = {
981981

982982
jsOnly: {
983983
...testDefinitions.jsOnly,
984+
animatedDeferStartOfTimingAnimations: {
985+
defaultValue: false,
986+
metadata: {
987+
dateAdded: '2026-05-26',
988+
description:
989+
'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.',
990+
expectedReleaseValue: true,
991+
purpose: 'experimentation',
992+
},
993+
ossReleaseStage: 'none',
994+
},
984995
animatedShouldDebounceQueueFlush: {
985996
defaultValue: false,
986997
metadata: {

packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<f67507377832e158acd47c7362a7211a>>
7+
* @generated SignedSource<<0fa75542ae2962e624a651b64f829245>>
88
* @flow strict
99
* @noformat
1010
*/
@@ -29,6 +29,7 @@ import {
2929

3030
export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{
3131
jsOnlyTestFlag: Getter<boolean>,
32+
animatedDeferStartOfTimingAnimations: Getter<boolean>,
3233
animatedShouldDebounceQueueFlush: Getter<boolean>,
3334
animatedShouldUseSingleOp: Getter<boolean>,
3435
deferFlatListFocusChangeRenderUpdate: Getter<boolean>,
@@ -140,6 +141,11 @@ export type ReactNativeFeatureFlags = $ReadOnly<{
140141
*/
141142
export const jsOnlyTestFlag: Getter<boolean> = createJavaScriptFlagGetter('jsOnlyTestFlag', false);
142143

144+
/**
145+
* 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.
146+
*/
147+
export const animatedDeferStartOfTimingAnimations: Getter<boolean> = createJavaScriptFlagGetter('animatedDeferStartOfTimingAnimations', false);
148+
143149
/**
144150
* Enables an experimental flush-queue debouncing in Animated.js.
145151
*/

0 commit comments

Comments
 (0)