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';
2121import { allowStyleProp } from 'react-native/Libraries/Animated/NativeAnimatedAllowlist' ;
2222import 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+
2430test ( '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+
84169test ( '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]
0 commit comments