Skip to content
Open
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:*
* @fantom_flags useSharedAnimatedBackend:* 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 Down Expand Up @@ -248,7 +254,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 +565,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 +718,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 @@ -8,7 +8,7 @@
* @format
*/

import type {EventSubscription} from '../../vendor/emitter/EventEmitter';

Check warning on line 11 in packages/react-native/Libraries/Animated/nodes/AnimatedValue.js

View workflow job for this annotation

GitHub Actions / test_js (22.13.0)

Requires should be sorted alphabetically, with at least one line between imports/requires and code

Check warning on line 11 in packages/react-native/Libraries/Animated/nodes/AnimatedValue.js

View workflow job for this annotation

GitHub Actions / test_js (24)

Requires should be sorted alphabetically, with at least one line between imports/requires and code
import type {PlatformConfig} from '../AnimatedPlatformConfig';
import type Animation from '../animations/Animation';
import type {EndCallback} from '../animations/Animation';
Expand All @@ -20,6 +20,7 @@
import type {AnimatedNodeConfig} from './AnimatedNode';
import type AnimatedTracking from './AnimatedTracking';

import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
import AnimatedInterpolation from './AnimatedInterpolation';
import AnimatedWithChildren from './AnimatedWithChildren';
Expand Down Expand Up @@ -95,6 +96,7 @@
_offset: number;
_animation: ?Animation;
_tracking: ?AnimatedTracking;
__deferAnimationStart: boolean;

constructor(value: number, config?: ?AnimatedValueConfig) {
super(config);
Expand All @@ -107,6 +109,8 @@

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 @@
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 @@ -49,15 +49,34 @@ void FrameAnimationDriver::onConfigChanged() {
frames_.push_back(frameValue);
}
toValue_ = config_["toValue"].asDouble();
auto deferIt = config_.find("deferredStart");
deferredStart_ = deferIt == config_.items().end() || deferIt->second.asBool();
}

bool FrameAnimationDriver::update(double timeDeltaMs, bool /*restarting*/) {
bool FrameAnimationDriver::update(double timeDeltaMs, bool restarting) {
if (auto node =
manager_->getAnimatedNode<ValueAnimatedNode>(animatedValueTag_)) {
if (!startValue_) {
startValue_ = node->getRawValue();
}

if (deferredStart_ && restarting) {
// On the very first update after start: output the starting value
// (frame 0) and defer the time anchor. The base class will re-anchor
// startFrameTimeMs_ on the next call, so elapsed time is measured
// from the first frame that has actually been rendered — not from
// when startAnimatingNode was dispatched.
//
// This prevents skipping initial frames when the UI thread is busy
// with layout/mount work between animation start and first composite.
node->setRawValue(
startValue_.value() + frames_[0] * (toValue_ - startValue_.value()));
markNodeUpdated(node->tag());
startFrameTimeMs_ = -1;
deferredStart_ = false;
return false;
}

const auto startIndex =
static_cast<size_t>(std::round(timeDeltaMs / SingleFrameIntervalMs));
assert(startIndex >= 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class FrameAnimationDriver : public AnimationDriver {
std::vector<double> frames_{};
double toValue_{0};
std::optional<double> startValue_{};
bool deferredStart_{true};
};

} // namespace facebook::react
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,29 @@ TEST_F(AnimationDriverTests, framesAnimation) {

const double startTimeInTick = 12345;

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

runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 2.5);
// Frame 2: re-anchor completes, timeDelta=0 → still frame 0
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs);
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);

// Subsequent frames are measured from the re-anchored start
runAnimationFrame(
startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 2.5);
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 65);

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

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

runAnimationFrame(startTimeInTick + SingleFrameIntervalMs * 10);
runAnimationFrame(
startTimeInTick + SingleFrameIntervalMs + SingleFrameIntervalMs * 10);
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue);
}

Expand Down Expand Up @@ -84,12 +94,14 @@ TEST_F(AnimationDriverTests, framesAnimationReconfigurationClearsFrames) {

const double startTimeInTick = 12345;

// Run first frame
// Deferred start frame + re-anchor frame
runAnimationFrame(startTimeInTick);
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);
runAnimationFrame(startTimeInTick + SingleFrameIntervalMs);
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), 0);

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

// Reset animation timing
const double newStartTimeInTick = 20000;

// Run animation at halfway point (1 frame into 3-frame animation)
// Deferred start frame for reconfigured animation
runAnimationFrame(newStartTimeInTick);
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 1);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);

// Re-anchor frame
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);

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

// Complete the animation
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 2);
EXPECT_EQ(round(nodesManager_->getValue(valueNodeTag).value()), toValue2);
runAnimationFrame(newStartTimeInTick + SingleFrameIntervalMs * 3);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue2);
}

TEST_F(AnimationDriverTests, framesAnimationDeferredStartPreventsSkipping) {
// Deferred start outputs frame 0 on the first update and re-anchors
// startFrameTimeMs_ so the second update also sees timeDelta=0.
// Without the fix the second frame would already be at value 25.
initNodesManager();

auto rootTag = getNextRootViewTag();

auto valueNodeTag = ++rootTag;
nodesManager_->createAnimatedNode(
valueNodeTag,
folly::dynamic::object("type", "value")("value", 0)("offset", 0));

const auto animationId = 1;
const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f);
const auto toValue = 100;
nodesManager_->startAnimatingNode(
animationId,
valueNodeTag,
folly::dynamic::object("type", "frames")("frames", frames)(
"toValue", toValue),
std::nullopt);

const double t = 12345;

// Frame 1: both with and without fix, timeDelta=0 → value=0
runAnimationFrame(t);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);

// Frame 2: WITHOUT fix timeDelta=SI → value≈25.
// WITH fix the deferred start re-anchored startFrameTimeMs_, so
// timeDelta=0 → value=0. This assertion fails without the fix.
runAnimationFrame(t + SingleFrameIntervalMs);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);

// Frame 3: now timeDelta=SI from the re-anchored start
runAnimationFrame(t + SingleFrameIntervalMs * 2);
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01);

// Frame 4
runAnimationFrame(t + SingleFrameIntervalMs * 3);
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 50, 0.01);

// Complete
runAnimationFrame(t + SingleFrameIntervalMs * 5);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue);
}

TEST_F(AnimationDriverTests, framesAnimationDeferredStartOptOut) {
// When deferredStart is false, the animation starts immediately without
// the extra re-anchor frame.
initNodesManager();

auto rootTag = getNextRootViewTag();

auto valueNodeTag = ++rootTag;
nodesManager_->createAnimatedNode(
valueNodeTag,
folly::dynamic::object("type", "value")("value", 0)("offset", 0));

const auto animationId = 1;
const auto frames = folly::dynamic::array(0.0f, 0.25f, 0.5f, 0.75f, 1.0f);
const auto toValue = 100;
nodesManager_->startAnimatingNode(
animationId,
valueNodeTag,
folly::dynamic::object("type", "frames")("frames", frames)(
"toValue", toValue)("deferredStart", false),
std::nullopt);

const double t = 12345;

// Frame 1: timeDelta=0, value=0 (no deferred start delay)
runAnimationFrame(t);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), 0);

// Frame 2: timeDelta=SI, value≈25 (animation progresses immediately)
runAnimationFrame(t + SingleFrameIntervalMs);
EXPECT_NEAR(nodesManager_->getValue(valueNodeTag).value(), 25, 0.01);

// Complete
runAnimationFrame(t + SingleFrameIntervalMs * 4);
EXPECT_EQ(nodesManager_->getValue(valueNodeTag).value(), toValue);
}

} // namespace facebook::react
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,17 @@ const definitions: FeatureFlagDefinitions = {

jsOnly: {
...testDefinitions.jsOnly,
animatedDeferStartOfTimingAnimations: {
defaultValue: false,
metadata: {
dateAdded: '2026-05-26',
description:
'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.',
expectedReleaseValue: true,
purpose: 'experimentation',
},
ossReleaseStage: 'none',
},
animatedShouldDebounceQueueFlush: {
defaultValue: false,
metadata: {
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<<f67507377832e158acd47c7362a7211a>>
* @generated SignedSource<<0fa75542ae2962e624a651b64f829245>>
* @flow strict
* @noformat
*/
Expand All @@ -29,6 +29,7 @@ import {

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

/**
* 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.
*/
export const animatedDeferStartOfTimingAnimations: Getter<boolean> = createJavaScriptFlagGetter('animatedDeferStartOfTimingAnimations', false);

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