diff --git a/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js b/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js index 995bb369bd8..6b85fbb6b69 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:* + * @fantom_flags useSharedAnimatedBackend:* animatedShouldSyncValueBeforeStartCallback:* * @flow strict-local * @format */ @@ -735,3 +735,74 @@ test('Animated.sequence', () => { expect(_isSequenceFinished).toBe(true); }); + +// Regression test for native-driver completion-callback ordering. +// +// `Animation.__startAnimationIfNative` must sync the JS-side AnimatedValue +// with the post-animation value BEFORE firing the user's `start({finished})` +// callback. Otherwise, code that reads the AnimatedValue from inside the +// callback — or from any React re-render the callback triggers — observes the +// pre-animation value and renders stale style (e.g. a `scaleX` interpolation +// that resolves to the starting `outputRange[0]` instead of the final +// `outputRange[1]`). +// +// The fix is gated behind `animatedShouldSyncValueBeforeStartCallback`. This +// test runs under both flag values (via `@fantom_flags ...:*`) and asserts: +// - flag OFF: bug is present (callback observes pre-animation value). +// - flag ON : bug is fixed (callback observes post-animation value). +test('useNativeDriver: JS-side _value is synced before the completion callback fires', () => { + let _value; + let _valueInCallback = null; + let _interpolationInCallback = null; + + function MyApp() { + const value = useAnimatedValue(0); + _value = value; + const scaleX = value.interpolate({ + inputRange: [0, 1], + outputRange: [0.5, 1], + }); + return ( + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + Animated.timing(_value, { + toValue: 1, + duration: 100, + useNativeDriver: true, + }).start(({finished}) => { + if (finished) { + // $FlowFixMe[prop-missing] _value is internal but stable for testing. + _valueInCallback = _value._value; + // $FlowFixMe[prop-missing] + _interpolationInCallback = _value + .interpolate({inputRange: [0, 1], outputRange: [0.5, 1]}) + .__getValue(); + } + }); + }); + + Fantom.unstable_produceFramesForDuration(150); + Fantom.runWorkLoop(); + + if (ReactNativeFeatureFlags.animatedShouldSyncValueBeforeStartCallback()) { + // With the fix: the callback observes the post-animation value. + expect(_valueInCallback).toBe(1); + expect(_interpolationInCallback).toBe(1); + } else { + // Without the fix: the callback observes the pre-animation value. + // interp(0) = outputRange[0] = 0.5. + expect(_valueInCallback).toBe(0); + expect(_interpolationInCallback).toBe(0.5); + } +}); diff --git a/packages/react-native/Libraries/Animated/animations/Animation.js b/packages/react-native/Libraries/Animated/animations/Animation.js index 238958f1490..7322ec03c6b 100644 --- a/packages/react-native/Libraries/Animated/animations/Animation.js +++ b/packages/react-native/Libraries/Animated/animations/Animation.js @@ -141,14 +141,22 @@ export default class Animation { animatedValue.__getNativeTag(), config, result => { - this.__notifyAnimationEnd(result); - // When using natively driven animations, once the animation completes, // we need to ensure that the JS side nodes are synced with the updated // values. const {value, offset} = result; - if (value != null) { + const syncBeforeCallback = + ReactNativeFeatureFlags.animatedShouldSyncValueBeforeStartCallback(); + if (syncBeforeCallback && value != null) { animatedValue.__onAnimatedValueUpdateReceived(value, offset); + } + + this.__notifyAnimationEnd(result); + + if (value != null) { + if (!syncBeforeCallback) { + animatedValue.__onAnimatedValueUpdateReceived(value, offset); + } const isJsSyncRemoved = ReactNativeFeatureFlags.cxxNativeAnimatedEnabled(); @@ -158,8 +166,8 @@ export default class Animation { } } - // Once the JS side node is synced with the updated values, trigger an - // update on the AnimatedProps nodes to call any registered callbacks. + // Trigger an update on the AnimatedProps nodes to call any + // registered callbacks now that the JS-side node is in sync. this.__findAnimatedPropsNodes(animatedValue).forEach(node => node.update(), ); diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 0bf9d70039b..c976861c56b 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 = { }, ossReleaseStage: 'none', }, + animatedShouldSyncValueBeforeStartCallback: { + defaultValue: true, + metadata: { + dateAdded: '2026-06-01', + description: + 'When a useNativeDriver animation completes, syncs the JS-side AnimatedValue with the post-animation value BEFORE invoking the user-supplied start({finished}) callback. Without the flag, the callback observes the pre-animation value, which can cause downstream re-renders to read stale interpolation outputs.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, animatedShouldUseSingleOp: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 8b92e20f338..5e77c1dbd66 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<<7f48f734cd7a098d04cb147980ef364a>> + * @generated SignedSource<<1f78266600508274a623ff1032fa7124>> * @flow strict * @noformat */ @@ -30,6 +30,7 @@ import { export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{ jsOnlyTestFlag: Getter, animatedShouldDebounceQueueFlush: Getter, + animatedShouldSyncValueBeforeStartCallback: Getter, animatedShouldUseSingleOp: Getter, deferFlatListFocusChangeRenderUpdate: Getter, enableNativeEventTargetEventDispatching: Getter, @@ -145,6 +146,11 @@ export const jsOnlyTestFlag: Getter = createJavaScriptFlagGetter('jsOnl */ export const animatedShouldDebounceQueueFlush: Getter = createJavaScriptFlagGetter('animatedShouldDebounceQueueFlush', false); +/** + * When a useNativeDriver animation completes, syncs the JS-side AnimatedValue with the post-animation value BEFORE invoking the user-supplied start({finished}) callback. Without the flag, the callback observes the pre-animation value, which can cause downstream re-renders to read stale interpolation outputs. + */ +export const animatedShouldSyncValueBeforeStartCallback: Getter = createJavaScriptFlagGetter('animatedShouldSyncValueBeforeStartCallback', true); + /** * Enables an experimental mega-operation for Animated.js that replaces many calls to native with a single call into native, to reduce JSI/JNI traffic. */