Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:* animatedShouldSyncValueBeforeStartCallback:*
* @fantom_flags useSharedAnimatedBackend:* animatedShouldSyncValueBeforeStartCallback:* animatedDeferStartOfTimingAnimations:*
* @flow strict-local
* @format
*/
Expand All @@ -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<HostInstance>();
Expand Down Expand Up @@ -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);
Expand All @@ -81,6 +87,85 @@ test('moving box by 100 points', () => {
expect(viewElement.getBoundingClientRect().x).toBe(100);
});

// Validate that a `useNativeDriver` timing animation does not begin progressing
// until the end of the event loop tick it was started in.
//
// Tested different behavior introduced by `animatedDeferStartOfTimingAnimations`,
// the behavioral difference is animated prop value on the first frame after the tick:
// flag ON -> deferred, not progressed yet, flag OFF -> already progressing.
function startTimingAnimationAndGetTranslateXAfterFirstFrame(): number {
let _translateX;
const viewRef = createRef<HostInstance>();

function MyApp() {
const translateX = useAnimatedValue(0);
_translateX = translateX;
return (
<Animated.View
ref={viewRef}
style={[{width: 100, height: 100}, {transform: [{translateX}]}]}
/>
);
}

const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(<MyApp />);
});

const viewElement = ensureInstance(viewRef.current, ReactNativeElement);

Fantom.runTask(() => {
Animated.timing(_translateX, {
toValue: 100,
duration: 1000,
useNativeDriver: true,
}).start();

Fantom.unstable_produceFramesForDuration(500);

// The UI thread advances while we are still inside the js tick. The animation
// must not produce any direct manipulation yet, because its mount
// operations have not been flushed. This holds regardless of the flag.
expect(() =>
Fantom.unstable_getDirectManipulationProps(viewElement),
).toThrow();
});

// Produce the first frame after the tick (~16ms rounds to frame 1).
Fantom.unstable_produceFramesForDuration(16);
const translateXAfterFirstFrame =
// $FlowFixMe[incompatible-use]
Fantom.unstable_getDirectManipulationProps(viewElement).transform[0]
.translateX;

// Drain the animation so it completes and the message queue is empty for the
// next test.
Fantom.unstable_produceFramesForDuration(1000);
Fantom.runWorkLoop();
expect(viewElement.getBoundingClientRect().x).toBe(100);

return translateXAfterFirstFrame;
}

if (ReactNativeFeatureFlags.animatedDeferStartOfTimingAnimations()) {
test('animation does not start before the end of the current event loop tick', () => {
// With deferred start, the first frame after the tick outputs the initial
// value and re-anchors timing, so the animation has not progressed yet —
// no frames were skipped despite the UI thread advancing inside the tick.
expect(startTimingAnimationAndGetTranslateXAfterFirstFrame()).toBe(0);
});
} else {
test('animation might start before the end of the current event loop tick', () => {
// Without deferred start, the animation begins progressing immediately — it
// has effectively started before the end of the tick.
expect(
startTimingAnimationAndGetTranslateXAfterFirstFrame(),
).toBeGreaterThan(0);
});
}

test('animation driven by onScroll event', () => {
const scrollViewRef = createRef<HostInstance>();
const viewRef = createRef<HostInstance>();
Expand Down Expand Up @@ -248,7 +333,7 @@ test('animated opacity', () => {
}).start();
});

Fantom.unstable_produceFramesForDuration(30);
Fantom.unstable_produceFramesForDuration(30 + DEFERRED_START_MS);
expect(Fantom.unstable_getDirectManipulationProps(viewElement).opacity).toBe(
0,
);
Expand Down Expand Up @@ -559,7 +644,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(() => {
Expand Down Expand Up @@ -712,7 +797,7 @@ test('Animated.sequence', () => {
});
});

Fantom.unstable_produceFramesForDuration(500);
Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);

expect(
// $FlowFixMe[incompatible-use]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -69,6 +70,7 @@ export default class TimingAnimation extends Animation {
_animationFrame: ?AnimationFrameID;
_timeout: ?TimeoutID;
_platformConfig: ?PlatformConfig;
_deferredStart: boolean;

constructor(config: TimingAnimationConfigSingle) {
super(config);
Expand All @@ -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<{
Expand All @@ -102,6 +105,7 @@ export default class TimingAnimation extends Animation {
iterations: this.__iterations,
platformConfig: this._platformConfig,
debugID: this.__getDebugID(),
deferredStart: this._deferredStart,
};
}

Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {AnimatedNodeConfig} from './AnimatedNode';
import type AnimatedTracking from './AnimatedTracking';

import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
import AnimatedInterpolation from './AnimatedInterpolation';
import AnimatedWithChildren from './AnimatedWithChildren';

Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,17 @@ const definitions: FeatureFlagDefinitions = {

jsOnly: {
...testDefinitions.jsOnly,
animatedDeferStartOfTimingAnimations: {
defaultValue: false,
metadata: {
dateAdded: '2026-05-26',
description:
'When enabled, the JS Animated layer defers the start of native-driven timing animations to the first rendered frame and re-anchors 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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ describe('Native Animated', () => {
frames: expect.any(Array),
toValue: expect.any(Number),
iterations: 1,
deferredStart: false,
},
expect.any(Function),
);
Expand Down Expand Up @@ -1219,6 +1220,7 @@ describe('Native Animated', () => {
frames: expect.any(Array),
toValue: expect.any(Number),
iterations: 1,
deferredStart: false,
},
expect.any(Function),
);
Expand Down Expand Up @@ -1360,6 +1362,7 @@ describe('Native Animated', () => {
frames: expect.any(Array),
toValue: expect.any(Number),
iterations: 1,
deferredStart: false,
},
expect.any(Function),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<<77b178e216aa86a309f46cbf661d9122>>
* @generated SignedSource<<4cc0d1231e555cb7f8ca416d5439c2fd>>
* @flow strict
* @noformat
*/
Expand All @@ -29,6 +29,7 @@ import {

export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{
jsOnlyTestFlag: Getter<boolean>,
animatedDeferStartOfTimingAnimations: Getter<boolean>,
animatedShouldDebounceQueueFlush: Getter<boolean>,
animatedShouldSyncValueBeforeStartCallback: Getter<boolean>,
animatedShouldUseSingleOp: Getter<boolean>,
Expand Down Expand Up @@ -142,6 +143,11 @@ export type ReactNativeFeatureFlags = $ReadOnly<{
*/
export const jsOnlyTestFlag: Getter<boolean> = createJavaScriptFlagGetter('jsOnlyTestFlag', false);

/**
* When enabled, the JS Animated layer defers the start of native-driven timing animations to the first rendered frame and re-anchors timing to prevent skipping initial frames when the UI thread is busy with layout work.
*/
export const animatedDeferStartOfTimingAnimations: Getter<boolean> = createJavaScriptFlagGetter('animatedDeferStartOfTimingAnimations', false);

/**
* Enables an experimental flush-queue debouncing in Animated.js.
*/
Expand Down
Loading