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.
*/