Skip to content

Commit c68479e

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 f14207f commit c68479e

8 files changed

Lines changed: 370 additions & 21 deletions

File tree

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

Lines changed: 89 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);
@@ -81,6 +87,84 @@ test('moving box by 100 points', () => {
8187
expect(viewElement.getBoundingClientRect().x).toBe(100);
8288
});
8389

90+
test('animation does not start before the end of the current event loop tick', () => {
91+
// Validates that even if the main thread progresses after animation setup,
92+
// the animation does not begin timing until after the current event loop
93+
// tick completes and the first frame is produced.
94+
if (!ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations()) {
95+
// Without the flag, the animation anchors on the first frame time
96+
// which already gives correct behavior for same-tick setup.
97+
// The deferred start flag adds the extra guarantee that even if
98+
// additional time passes before the first composite, we don't skip.
99+
return;
100+
}
101+
102+
let _translateX;
103+
const viewRef = createRef<HostInstance>();
104+
105+
function MyApp() {
106+
const translateX = useAnimatedValue(0);
107+
_translateX = translateX;
108+
return (
109+
<Animated.View
110+
ref={viewRef}
111+
style={[
112+
{
113+
width: 100,
114+
height: 100,
115+
},
116+
{transform: [{translateX}]},
117+
]}
118+
/>
119+
);
120+
}
121+
122+
const root = Fantom.createRoot();
123+
124+
Fantom.runTask(() => {
125+
root.render(<MyApp />);
126+
});
127+
128+
const viewElement = ensureInstance(viewRef.current, ReactNativeElement);
129+
130+
// Start the animation inside a task (simulating commit phase / useLayoutEffect)
131+
Fantom.runTask(() => {
132+
Animated.timing(_translateX, {
133+
toValue: 100,
134+
duration: 1000,
135+
useNativeDriver: true,
136+
}).start();
137+
});
138+
139+
// Produce exactly one frame (~16ms). With deferred start, this first frame
140+
// outputs the initial value and re-anchors timing — the animation should
141+
// NOT have progressed yet, even though wall-clock time has advanced.
142+
Fantom.unstable_produceFramesForDuration(16);
143+
144+
const transformAfterFirstFrame =
145+
// $FlowFixMe[incompatible-use]
146+
Fantom.unstable_getDirectManipulationProps(viewElement).transform[0];
147+
148+
// The animation must still be at 0 after the first frame — it has not
149+
// started timing yet because deferred start re-anchors on this frame.
150+
expect(transformAfterFirstFrame.translateX).toBe(0);
151+
152+
// Now produce 500ms of additional frames. The animation timing starts
153+
// from the re-anchored point, so we expect ~50% progress.
154+
Fantom.unstable_produceFramesForDuration(500);
155+
156+
const transformAfterHalf =
157+
// $FlowFixMe[incompatible-use]
158+
Fantom.unstable_getDirectManipulationProps(viewElement).transform[0];
159+
160+
expect(transformAfterHalf.translateX).toBeCloseTo(50, 0);
161+
162+
// Complete the animation
163+
Fantom.unstable_produceFramesForDuration(500);
164+
Fantom.runWorkLoop();
165+
expect(viewElement.getBoundingClientRect().x).toBe(100);
166+
});
167+
84168
test('animation driven by onScroll event', () => {
85169
const scrollViewRef = createRef<HostInstance>();
86170
const viewRef = createRef<HostInstance>();
@@ -248,7 +332,7 @@ test('animated opacity', () => {
248332
}).start();
249333
});
250334

251-
Fantom.unstable_produceFramesForDuration(30);
335+
Fantom.unstable_produceFramesForDuration(30 + DEFERRED_START_MS);
252336
expect(Fantom.unstable_getDirectManipulationProps(viewElement).opacity).toBe(
253337
0,
254338
);
@@ -559,7 +643,7 @@ test('animate layout props', () => {
559643
}).start();
560644
});
561645

562-
Fantom.unstable_produceFramesForDuration(10);
646+
Fantom.unstable_produceFramesForDuration(10 + DEFERRED_START_MS);
563647

564648
// TODO: this shouldn't be necessary since animation should be stopped after duration
565649
Fantom.runTask(() => {
@@ -712,7 +796,7 @@ test('Animated.sequence', () => {
712796
});
713797
});
714798

715-
Fantom.unstable_produceFramesForDuration(500);
799+
Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);
716800

717801
expect(
718802
// $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

0 commit comments

Comments
 (0)