From d4da5e02df81f149acc50ea09436cce11967f548 Mon Sep 17 00:00:00 2001 From: Fabrizio Cucci Date: Mon, 1 Jun 2026 06:07:45 -0700 Subject: [PATCH] Sync JS-side AnimatedValue before invoking native-driver completion callback (#57021) Summary: `Animation.__startAnimationIfNative` invoked the user's `start({finished})` callback BEFORE syncing the JS-side `AnimatedValue` with the post-animation value reported by the native side. Any caller that read the `AnimatedValue` from inside the callback, or from a React re-render that the callback triggered, observed the **pre-animation** value, producing visual jumps. Concrete impact: an `` whose `transform: [{scaleX}]` is driven by `value.interpolate({inputRange: [0, 1], outputRange: [0.5, 1]})` would render at `scaleX = interp(0) = 0.5` after the animation finished, instead of at `scaleX = interp(1) = 1`. The same pattern affects opacity, color and any other style derived from an animated value read during a re-render scheduled by the completion callback. This change reorders the native completion callback so `animatedValue.__onAnimatedValueUpdateReceived(value, offset)` runs BEFORE `this.__notifyAnimationEnd(result)`. The user's callback (and any re-render it schedules) now observes the post-animation JS value. The reorder is gated behind a new JS-only feature flag, `animatedShouldSyncValueBeforeStartCallback`, which defaults to `true` (the fix is on by default). Set the flag to `false` to opt out and restore the pre-fix ordering as a kill-switch. A Fantom integration test in `Animated-itest.js` exercises the exact scenario: starts a `useNativeDriver: true` `Animated.timing(0 -> 1)`, captures both `_value._value` and `value.interpolate(...).__getValue()` inside the `start({finished})` callback and asserts the value matches the flag state (pre-animation when off, post-animation when on). ## Behavior change to consider The reorder also changes the order in which JS-side observers of the `AnimatedValue` are notified relative to the `start({finished})` callback. This was confirmed empirically against the current and the proposed ordering. Before (flag off): 1. `start({finished})` callback fires 2. `AnimatedValue.addListener(...)` subscribers receive the post-animation value After (flag on): 1. `AnimatedValue.addListener(...)` subscribers receive the post-animation value 2. `start({finished})` callback fires For the vast majority of callers this is irrelevant or strictly better (observers and callback now agree on the same value). The flag defaults to `true` so the fix ships immediately; the flag itself stays as a kill-switch in case real-world callers turn out to depend on the previous ordering. Once adoption has been verified the flag can be removed entirely. Changelog: [General][Fixed] - Sync JS-side `Animated.Value` with the post-animation value before invoking `Animated.timing(...).start({finished})` callbacks so reads from inside the callback (or from React re-renders it triggers) observe the post-animation value rather than the pre-animation value. Gated behind a new JS-only feature flag, `animatedShouldSyncValueBeforeStartCallback`, defaulting to `true` (set to `false` to opt out). Reviewed By: javache, zeyap Differential Revision: D106940382 --- .../Animated/__tests__/Animated-itest.js | 73 ++++++++++++++++++- .../Animated/animations/Animation.js | 18 +++-- .../ReactNativeFeatureFlags.config.js | 11 +++ .../featureflags/ReactNativeFeatureFlags.js | 8 +- 4 files changed, 103 insertions(+), 7 deletions(-) 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. */