Skip to content

Commit 9e36b92

Browse files
zeyapfacebook-github-bot
authored andcommitted
Defer timing animation start through JS Animated (facebook#57003)
Summary: Wire `animatedDeferStartOfTimingAnimations` through the JS Animated layer: `AnimatedValue` arms a one-shot `__deferAnimationStart`, and `TimingAnimation` forwards `deferredStart` in the native animation config so the native `FrameAnimationDriver` defers its start to the first rendered frame. Gated behind the runtime flag (default off). Adds Fantom integration tests asserting the behavior with the flag on and off. ## Changelog: [Internal] [Changed] - Defer timing animation start through JS Animated Differential Revision: D106825746
1 parent 4ef2971 commit 9e36b92

3 files changed

Lines changed: 106 additions & 5 deletions

File tree

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

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

90+
// Validate that a `useNativeDriver` timing animation does not begin progressing
91+
// until the end of the event loop tick it was started in.
92+
//
93+
// Tested different behavior introduced by `animatedDeferStartOfTimingAnimations`,
94+
// the behavioral difference is animated prop value on the first frame after the tick:
95+
// flag ON -> deferred, not progressed yet, flag OFF -> already progressing.
96+
function startTimingAnimationAndGetTranslateXAfterFirstFrame(): number {
97+
let _translateX;
98+
const viewRef = createRef<HostInstance>();
99+
100+
function MyApp() {
101+
const translateX = useAnimatedValue(0);
102+
_translateX = translateX;
103+
return (
104+
<Animated.View
105+
ref={viewRef}
106+
style={[{width: 100, height: 100}, {transform: [{translateX}]}]}
107+
/>
108+
);
109+
}
110+
111+
const root = Fantom.createRoot();
112+
113+
Fantom.runTask(() => {
114+
root.render(<MyApp />);
115+
});
116+
117+
const viewElement = ensureInstance(viewRef.current, ReactNativeElement);
118+
119+
Fantom.runTask(() => {
120+
Animated.timing(_translateX, {
121+
toValue: 100,
122+
duration: 1000,
123+
useNativeDriver: true,
124+
}).start();
125+
126+
Fantom.unstable_produceFramesForDuration(500);
127+
128+
// The UI thread advances while we are still inside the js tick. The animation
129+
// must not produce any direct manipulation yet, because its mount
130+
// operations have not been flushed. This holds regardless of the flag.
131+
expect(() =>
132+
Fantom.unstable_getDirectManipulationProps(viewElement),
133+
).toThrow();
134+
});
135+
136+
// Produce the first frame after the tick (~16ms rounds to frame 1).
137+
Fantom.unstable_produceFramesForDuration(16);
138+
const translateXAfterFirstFrame =
139+
// $FlowFixMe[incompatible-use]
140+
Fantom.unstable_getDirectManipulationProps(viewElement).transform[0]
141+
.translateX;
142+
143+
// Drain the animation so it completes and the message queue is empty for the
144+
// next test.
145+
Fantom.unstable_produceFramesForDuration(1000);
146+
Fantom.runWorkLoop();
147+
expect(viewElement.getBoundingClientRect().x).toBe(100);
148+
149+
return translateXAfterFirstFrame;
150+
}
151+
152+
if (ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations()) {
153+
test('animation does not start before the end of the current event loop tick', () => {
154+
// With deferred start, the first frame after the tick outputs the initial
155+
// value and re-anchors timing, so the animation has not progressed yet —
156+
// no frames were skipped despite the UI thread advancing inside the tick.
157+
expect(startTimingAnimationAndGetTranslateXAfterFirstFrame()).toBe(0);
158+
});
159+
} else {
160+
test('animation might start before the end of the current event loop tick', () => {
161+
// Without deferred start, the animation begins progressing immediately — it
162+
// has effectively started before the end of the tick.
163+
expect(
164+
startTimingAnimationAndGetTranslateXAfterFirstFrame(),
165+
).toBeGreaterThan(0);
166+
});
167+
}
168+
84169
test('animation driven by onScroll event', () => {
85170
const scrollViewRef = createRef<HostInstance>();
86171
const viewRef = createRef<HostInstance>();
@@ -248,7 +333,7 @@ test('animated opacity', () => {
248333
}).start();
249334
});
250335

251-
Fantom.unstable_produceFramesForDuration(30);
336+
Fantom.unstable_produceFramesForDuration(30 + DEFERRED_START_MS);
252337
expect(Fantom.unstable_getDirectManipulationProps(viewElement).opacity).toBe(
253338
0,
254339
);
@@ -559,7 +644,7 @@ test('animate layout props', () => {
559644
}).start();
560645
});
561646

562-
Fantom.unstable_produceFramesForDuration(10);
647+
Fantom.unstable_produceFramesForDuration(10 + DEFERRED_START_MS);
563648

564649
// TODO: this shouldn't be necessary since animation should be stopped after duration
565650
Fantom.runTask(() => {
@@ -712,7 +797,7 @@ test('Animated.sequence', () => {
712797
});
713798
});
714799

715-
Fantom.unstable_produceFramesForDuration(500);
800+
Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);
716801

717802
expect(
718803
// $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,

0 commit comments

Comments
 (0)