From 0e5667613f9d681e560413aac2a596ea0c57efad Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Fri, 5 Jun 2026 08:46:33 -0700 Subject: [PATCH 1/2] Add animatedDeferStartOfTimingAnimations feature flag (#57004) Summary: Add the `animatedDeferStartOfTimingAnimations` common (native + JS) feature flag (default off). On its own this is a no-op; it gates deferring the start of native-driven timing animations to the first rendered frame, implemented in the following diffs. The flag is defined as a `common` flag (not JS-only) so the same value can be read from both the JS Animated layer and the native C++ animation code. This diff includes all the generated accessors, regenerated via `yarn featureflags --update`: Kotlin (`ReactNativeFeatureFlags*.kt`), C++ (`ReactNativeFeatureFlags*.h`/`.cpp` and the JNI interop), the JS `ReactNativeFeatureFlags.js`, and the native module spec. Changelog: [Internal] Reviewed By: rubennorte Differential Revision: D106825629 --- .../featureflags/ReactNativeFeatureFlags.config.js | 11 +++++++++++ .../private/featureflags/ReactNativeFeatureFlags.js | 8 +++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 98dfa7b6eab..6e8f8b97bc4 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -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: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index cf32422016e..14a90c7045f 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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 */ @@ -29,6 +29,7 @@ import { export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{ jsOnlyTestFlag: Getter, + animatedDeferStartOfTimingAnimations: Getter, animatedShouldDebounceQueueFlush: Getter, animatedShouldSyncValueBeforeStartCallback: Getter, animatedShouldUseSingleOp: Getter, @@ -142,6 +143,11 @@ export type ReactNativeFeatureFlags = $ReadOnly<{ */ export const jsOnlyTestFlag: Getter = 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 = createJavaScriptFlagGetter('animatedDeferStartOfTimingAnimations', false); + /** * Enables an experimental flush-queue debouncing in Animated.js. */ From 5d481e3deebdde7ded55b60a54bf2423edf365ad Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Fri, 5 Jun 2026 08:46:33 -0700 Subject: [PATCH 2/2] Defer timing animation start through JS Animated (#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] Reviewed By: christophpurrer Differential Revision: D106825746 --- .../Animated/__tests__/Animated-itest.js | 95 ++++++++++++++++++- .../Animated/animations/TimingAnimation.js | 8 ++ .../Libraries/Animated/nodes/AnimatedValue.js | 8 ++ .../animated/__tests__/AnimatedNative-test.js | 3 + 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js b/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js index 6b85fbb6b69..ad9a1d8c4ec 100644 --- a/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js @@ -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 */ @@ -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(); @@ -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); @@ -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(); + + function MyApp() { + const translateX = useAnimatedValue(0); + _translateX = translateX; + return ( + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + 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(); const viewRef = createRef(); @@ -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, ); @@ -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(() => { @@ -712,7 +797,7 @@ test('Animated.sequence', () => { }); }); - Fantom.unstable_produceFramesForDuration(500); + Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS); expect( // $FlowFixMe[incompatible-use] diff --git a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js index f872d098bdf..c464334cc37 100644 --- a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js @@ -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'; @@ -69,6 +70,7 @@ export default class TimingAnimation extends Animation { _animationFrame: ?AnimationFrameID; _timeout: ?TimeoutID; _platformConfig: ?PlatformConfig; + _deferredStart: boolean; constructor(config: TimingAnimationConfigSingle) { super(config); @@ -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<{ @@ -102,6 +105,7 @@ export default class TimingAnimation extends Animation { iterations: this.__iterations, platformConfig: this._platformConfig, debugID: this.__getDebugID(), + deferredStart: this._deferredStart, }; } @@ -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(); diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js index 8650912edf6..76dba2196f4 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js @@ -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'; @@ -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); @@ -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(); @@ -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, diff --git a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js index 88178f6fbd8..7ba17f98b83 100644 --- a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js @@ -406,6 +406,7 @@ describe('Native Animated', () => { frames: expect.any(Array), toValue: expect.any(Number), iterations: 1, + deferredStart: false, }, expect.any(Function), ); @@ -1219,6 +1220,7 @@ describe('Native Animated', () => { frames: expect.any(Array), toValue: expect.any(Number), iterations: 1, + deferredStart: false, }, expect.any(Function), ); @@ -1360,6 +1362,7 @@ describe('Native Animated', () => { frames: expect.any(Array), toValue: expect.any(Number), iterations: 1, + deferredStart: false, }, expect.any(Function), );