From 80c0292374ce95f316555bce76119669a60ebdcd Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Tue, 16 Jun 2026 08:55:36 -0700 Subject: [PATCH] remove `useNativeDriver` under featureflag animatedForceNativeDriver (#57211) Summary: Pull Request resolved: https://github.com/react/react-native/pull/57211 ## Changelog: [General] [Added] - remove `useNativeDriver` under featureflag animatedForceNativeDriver When `animatedForceNativeDriver` is enabled, it forces `useNativeDriver` to `true` for all Animated animations and events, overriding the config (explicit `false` set by user will be no-op). Has no effect unless the shared animated backend is enabled, which is required to support native driver for all props. Also using this flag to gate the js animation logic that could be cleaned up when this path is fully working. Reviewed By: javache Differential Revision: D108193641 --- .../Animated/AnimatedImplementation.js | 19 +++++- .../Animated/NativeAnimatedAllowlist.js | 36 +++++++++++ .../__tests__/AnimatedBackend-itest.js | 59 +++++++++++++++++++ .../Animated/animations/Animation.js | 1 + .../Animated/animations/DecayAnimation.js | 1 + .../Animated/animations/SpringAnimation.js | 1 + .../Animated/animations/TimingAnimation.js | 1 + .../ReactNativeFeatureFlags.config.js | 11 ++++ .../private/animated/NativeAnimatedHelper.js | 25 +++++++- .../featureflags/ReactNativeFeatureFlags.js | 8 ++- 10 files changed, 155 insertions(+), 7 deletions(-) diff --git a/packages/react-native/Libraries/Animated/AnimatedImplementation.js b/packages/react-native/Libraries/Animated/AnimatedImplementation.js index 46a08d2e8954..d14425499cab 100644 --- a/packages/react-native/Libraries/Animated/AnimatedImplementation.js +++ b/packages/react-native/Libraries/Animated/AnimatedImplementation.js @@ -20,6 +20,7 @@ import type {DecayAnimationConfig} from './animations/DecayAnimation'; import type {SpringAnimationConfig} from './animations/SpringAnimation'; import type {TimingAnimationConfig} from './animations/TimingAnimation'; +import NativeAnimatedHelper from '../../src/private/animated/NativeAnimatedHelper'; import {AnimatedEvent, attachNativeEventImpl} from './AnimatedEvent'; import DecayAnimation from './animations/DecayAnimation'; import SpringAnimation from './animations/SpringAnimation'; @@ -200,7 +201,11 @@ const springImpl = function ( }, _isUsingNativeDriver: function (): boolean { - return config.useNativeDriver || false; + return ( + NativeAnimatedHelper.isNativeDriverForced() || + config.useNativeDriver || + false + ); }, } ); @@ -254,7 +259,11 @@ const timingImpl = function ( }, _isUsingNativeDriver: function (): boolean { - return config.useNativeDriver || false; + return ( + NativeAnimatedHelper.isNativeDriverForced() || + config.useNativeDriver || + false + ); }, } ); @@ -296,7 +305,11 @@ const decayImpl = function ( }, _isUsingNativeDriver: function (): boolean { - return config.useNativeDriver || false; + return ( + NativeAnimatedHelper.isNativeDriverForced() || + config.useNativeDriver || + false + ); }, } ); diff --git a/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js b/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js index c5cecfc828c6..c91017f23fc0 100644 --- a/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js +++ b/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js @@ -78,6 +78,42 @@ const SUPPORTED_STYLES: {[string]: true} = { top: true, /* flex */ flex: true, + flexGrow: true, + flexShrink: true, + flexBasis: true, + aspectRatio: true, + /* margin */ + margin: true, + marginLeft: true, + marginRight: true, + marginTop: true, + marginBottom: true, + marginStart: true, + marginEnd: true, + marginHorizontal: true, + marginVertical: true, + /* padding */ + padding: true, + paddingLeft: true, + paddingRight: true, + paddingTop: true, + paddingBottom: true, + paddingStart: true, + paddingEnd: true, + paddingHorizontal: true, + paddingVertical: true, + /* border width */ + borderWidth: true, + borderLeftWidth: true, + borderRightWidth: true, + borderTopWidth: true, + borderBottomWidth: true, + borderStartWidth: true, + borderEndWidth: true, + /* gap */ + gap: true, + rowGap: true, + columnGap: true, } : {}), }; diff --git a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js index adeaf7e485cf..57b72ea67715 100644 --- a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js @@ -21,6 +21,65 @@ 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'; +// marginLeft (and the other margin props) are only on the native animated +// allowlist when the shared backend is enabled. This test deliberately does NOT +// call allowStyleProp('marginLeft') — it verifies the prop is supported natively +// out of the box under useSharedAnimatedBackend. +test('animate marginLeft layout prop', () => { + const viewRef = createRef(); + + let _animatedMarginLeft; + let _marginLeftAnimation; + + function MyApp() { + const animatedMarginLeft = useAnimatedValue(0); + _animatedMarginLeft = animatedMarginLeft; + return ( + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + _marginLeftAnimation = Animated.timing(_animatedMarginLeft, { + toValue: 100, + duration: 200, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(100); + + expect(root.getRenderedOutput({props: ['marginLeft']}).toJSX()).toEqual( + , + ); + + Fantom.unstable_produceFramesForDuration(100); + + // TODO: this shouldn't be necessary since animation should be stopped after duration + Fantom.runTask(() => { + _marginLeftAnimation?.stop(); + }); + + expect(root.getRenderedOutput({props: ['marginLeft']}).toJSX()).toEqual( + , + ); +}); + test('animated opacity', () => { let _opacity; let _opacityAnimation; diff --git a/packages/react-native/Libraries/Animated/animations/Animation.js b/packages/react-native/Libraries/Animated/animations/Animation.js index 7322ec03c6b3..83e1a715379a 100644 --- a/packages/react-native/Libraries/Animated/animations/Animation.js +++ b/packages/react-native/Libraries/Animated/animations/Animation.js @@ -70,6 +70,7 @@ export default class Animation { previousAnimation: ?Animation, animatedValue: AnimatedValue, ): void { + // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out if (!this._useNativeDriver && animatedValue.__isNative === true) { throw new Error( 'Attempting to run JS driven animation on animated node ' + diff --git a/packages/react-native/Libraries/Animated/animations/DecayAnimation.js b/packages/react-native/Libraries/Animated/animations/DecayAnimation.js index 35eb106f5a2b..d6b834b032ee 100644 --- a/packages/react-native/Libraries/Animated/animations/DecayAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/DecayAnimation.js @@ -85,6 +85,7 @@ export default class DecayAnimation extends Animation { this._startTime = Date.now(); const useNativeDriver = this.__startAnimationIfNative(animatedValue); + // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out if (!useNativeDriver) { this._animationFrame = requestAnimationFrame(() => this.onUpdate()); } diff --git a/packages/react-native/Libraries/Animated/animations/SpringAnimation.js b/packages/react-native/Libraries/Animated/animations/SpringAnimation.js index cb70e4454117..f04a527469b3 100644 --- a/packages/react-native/Libraries/Animated/animations/SpringAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/SpringAnimation.js @@ -225,6 +225,7 @@ export default class SpringAnimation extends Animation { const start = () => { const useNativeDriver = this.__startAnimationIfNative(animatedValue); + // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out if (!useNativeDriver) { this.onUpdate(); } diff --git a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js index c464334cc376..dffb737a9882 100644 --- a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js @@ -129,6 +129,7 @@ export default class TimingAnimation extends Animation { this._startTime = Date.now(); const useNativeDriver = this.__startAnimationIfNative(animatedValue); + // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out if (!useNativeDriver) { // Animations that sometimes have 0 duration and sometimes do not // still need to use the native driver when duration is 0 so as to diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index be1206256523..d6ceaed2129e 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -981,6 +981,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + animatedForceNativeDriver: { + defaultValue: false, + metadata: { + dateAdded: '2026-06-10', + description: + 'When enabled, forces `useNativeDriver` to `true` for all Animated animations and events, overriding the config (including an explicit `false`). Has no effect unless the shared animated backend is enabled, which is required to support native driver for all props.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, animatedShouldDebounceQueueFlush: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/animated/NativeAnimatedHelper.js b/packages/react-native/src/private/animated/NativeAnimatedHelper.js index f27650b3d327..8035f6cde926 100644 --- a/packages/react-native/src/private/animated/NativeAnimatedHelper.js +++ b/packages/react-native/src/private/animated/NativeAnimatedHelper.js @@ -406,17 +406,35 @@ function assertNativeAnimatedModule(): void { let _warnedMissingNativeAnimated = false; +// Whether the native driver should be forced on for every animation, overriding +// the config (including an explicit `useNativeDriver: false`). This is only safe +// when the shared animated backend is enabled — that backend is what makes every +// prop drivable natively. Forcing native without it would break animations of +// props the legacy native driver doesn't support. +function isNativeDriverForced(): boolean { + return ( + ReactNativeFeatureFlags.animatedForceNativeDriver() && + ReactNativeFeatureFlags.cxxNativeAnimatedEnabled() && + // eslint-disable-next-line + ReactNativeFeatureFlags.useSharedAnimatedBackend() + ); +} + function shouldUseNativeDriver( config: Readonly<{...AnimationConfig, ...}> | EventConfig, ): boolean { - if (config.useNativeDriver == null) { + const forceNativeDriver = isNativeDriverForced(); + + if (config.useNativeDriver == null && !forceNativeDriver) { console.warn( 'Animated: `useNativeDriver` was not specified. This is a required ' + 'option and must be explicitly set to `true` or `false`', ); } - if (config.useNativeDriver === true && !NativeAnimatedModule) { + const useNativeDriver = forceNativeDriver || config.useNativeDriver === true; + + if (useNativeDriver === true && !NativeAnimatedModule) { if (process.env.NODE_ENV !== 'test') { if (!_warnedMissingNativeAnimated) { console.warn( @@ -432,7 +450,7 @@ function shouldUseNativeDriver( return false; } - return config.useNativeDriver || false; + return useNativeDriver; } function transformDataType(value: number | string): number | string { @@ -458,6 +476,7 @@ export default { assertNativeAnimatedModule, generateNewAnimationId, generateNewNodeTag, + isNativeDriverForced, // $FlowExpectedError[unsafe-getters-setters] - unsafe getter lint suppression // $FlowExpectedError[missing-type-arg] - unsafe getter lint suppression get nativeEventEmitter(): NativeEventEmitter { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index b3b3f6a2eca2..e031eb36061f 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<<785d84f617e6b1870c3ff1eeed9f1c66>> + * @generated SignedSource<<2aaa9f49f9d072aca935862bf5da1630>> * @flow strict * @noformat */ @@ -30,6 +30,7 @@ import { export type ReactNativeFeatureFlagsJsOnly = Readonly<{ jsOnlyTestFlag: Getter, animatedDeferStartOfTimingAnimations: Getter, + animatedForceNativeDriver: Getter, animatedShouldDebounceQueueFlush: Getter, animatedShouldSyncValueBeforeStartCallback: Getter, animatedShouldUseSingleOp: Getter, @@ -147,6 +148,11 @@ export const jsOnlyTestFlag: Getter = createJavaScriptFlagGetter('jsOnl */ export const animatedDeferStartOfTimingAnimations: Getter = createJavaScriptFlagGetter('animatedDeferStartOfTimingAnimations', false); +/** + * When enabled, forces `useNativeDriver` to `true` for all Animated animations and events, overriding the config (including an explicit `false`). Has no effect unless the shared animated backend is enabled, which is required to support native driver for all props. + */ +export const animatedForceNativeDriver: Getter = createJavaScriptFlagGetter('animatedForceNativeDriver', false); + /** * Enables an experimental flush-queue debouncing in Animated.js. */