Skip to content

Commit ee6958a

Browse files
fabriziocuccimeta-codesync[bot]
authored andcommitted
Sync JS-side AnimatedValue before invoking native-driver completion callback (#57021)
Summary: Pull Request resolved: #57021 `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 `<Animated.View>` 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 fbshipit-source-id: c6f27956030d2a9c4016a30e2dff8d9424ad7b33
1 parent a18f53d commit ee6958a

4 files changed

Lines changed: 103 additions & 7 deletions

File tree

packages/react-native/Libraries/Animated/__tests__/Animated-itest.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @fantom_flags useSharedAnimatedBackend:*
7+
* @fantom_flags useSharedAnimatedBackend:* animatedShouldSyncValueBeforeStartCallback:*
88
* @flow strict-local
99
* @format
1010
*/
@@ -735,3 +735,74 @@ test('Animated.sequence', () => {
735735

736736
expect(_isSequenceFinished).toBe(true);
737737
});
738+
739+
// Regression test for native-driver completion-callback ordering.
740+
//
741+
// `Animation.__startAnimationIfNative` must sync the JS-side AnimatedValue
742+
// with the post-animation value BEFORE firing the user's `start({finished})`
743+
// callback. Otherwise, code that reads the AnimatedValue from inside the
744+
// callback — or from any React re-render the callback triggers — observes the
745+
// pre-animation value and renders stale style (e.g. a `scaleX` interpolation
746+
// that resolves to the starting `outputRange[0]` instead of the final
747+
// `outputRange[1]`).
748+
//
749+
// The fix is gated behind `animatedShouldSyncValueBeforeStartCallback`. This
750+
// test runs under both flag values (via `@fantom_flags ...:*`) and asserts:
751+
// - flag OFF: bug is present (callback observes pre-animation value).
752+
// - flag ON : bug is fixed (callback observes post-animation value).
753+
test('useNativeDriver: JS-side _value is synced before the completion callback fires', () => {
754+
let _value;
755+
let _valueInCallback = null;
756+
let _interpolationInCallback = null;
757+
758+
function MyApp() {
759+
const value = useAnimatedValue(0);
760+
_value = value;
761+
const scaleX = value.interpolate({
762+
inputRange: [0, 1],
763+
outputRange: [0.5, 1],
764+
});
765+
return (
766+
<Animated.View
767+
style={[{width: 100, height: 100}, {transform: [{scaleX}]}]}
768+
/>
769+
);
770+
}
771+
772+
const root = Fantom.createRoot();
773+
774+
Fantom.runTask(() => {
775+
root.render(<MyApp />);
776+
});
777+
778+
Fantom.runTask(() => {
779+
Animated.timing(_value, {
780+
toValue: 1,
781+
duration: 100,
782+
useNativeDriver: true,
783+
}).start(({finished}) => {
784+
if (finished) {
785+
// $FlowFixMe[prop-missing] _value is internal but stable for testing.
786+
_valueInCallback = _value._value;
787+
// $FlowFixMe[prop-missing]
788+
_interpolationInCallback = _value
789+
.interpolate({inputRange: [0, 1], outputRange: [0.5, 1]})
790+
.__getValue();
791+
}
792+
});
793+
});
794+
795+
Fantom.unstable_produceFramesForDuration(150);
796+
Fantom.runWorkLoop();
797+
798+
if (ReactNativeFeatureFlags.animatedShouldSyncValueBeforeStartCallback()) {
799+
// With the fix: the callback observes the post-animation value.
800+
expect(_valueInCallback).toBe(1);
801+
expect(_interpolationInCallback).toBe(1);
802+
} else {
803+
// Without the fix: the callback observes the pre-animation value.
804+
// interp(0) = outputRange[0] = 0.5.
805+
expect(_valueInCallback).toBe(0);
806+
expect(_interpolationInCallback).toBe(0.5);
807+
}
808+
});

packages/react-native/Libraries/Animated/animations/Animation.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,22 @@ export default class Animation {
141141
animatedValue.__getNativeTag(),
142142
config,
143143
result => {
144-
this.__notifyAnimationEnd(result);
145-
146144
// When using natively driven animations, once the animation completes,
147145
// we need to ensure that the JS side nodes are synced with the updated
148146
// values.
149147
const {value, offset} = result;
150-
if (value != null) {
148+
const syncBeforeCallback =
149+
ReactNativeFeatureFlags.animatedShouldSyncValueBeforeStartCallback();
150+
if (syncBeforeCallback && value != null) {
151151
animatedValue.__onAnimatedValueUpdateReceived(value, offset);
152+
}
153+
154+
this.__notifyAnimationEnd(result);
155+
156+
if (value != null) {
157+
if (!syncBeforeCallback) {
158+
animatedValue.__onAnimatedValueUpdateReceived(value, offset);
159+
}
152160

153161
const isJsSyncRemoved =
154162
ReactNativeFeatureFlags.cxxNativeAnimatedEnabled();
@@ -158,8 +166,8 @@ export default class Animation {
158166
}
159167
}
160168

161-
// Once the JS side node is synced with the updated values, trigger an
162-
// update on the AnimatedProps nodes to call any registered callbacks.
169+
// Trigger an update on the AnimatedProps nodes to call any
170+
// registered callbacks now that the JS-side node is in sync.
163171
this.__findAnimatedPropsNodes(animatedValue).forEach(node =>
164172
node.update(),
165173
);

packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,17 @@ const definitions: FeatureFlagDefinitions = {
992992
},
993993
ossReleaseStage: 'none',
994994
},
995+
animatedShouldSyncValueBeforeStartCallback: {
996+
defaultValue: true,
997+
metadata: {
998+
dateAdded: '2026-06-01',
999+
description:
1000+
'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.',
1001+
expectedReleaseValue: true,
1002+
purpose: 'experimentation',
1003+
},
1004+
ossReleaseStage: 'none',
1005+
},
9951006
animatedShouldUseSingleOp: {
9961007
defaultValue: false,
9971008
metadata: {

packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<7f48f734cd7a098d04cb147980ef364a>>
7+
* @generated SignedSource<<1f78266600508274a623ff1032fa7124>>
88
* @flow strict
99
* @noformat
1010
*/
@@ -30,6 +30,7 @@ import {
3030
export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{
3131
jsOnlyTestFlag: Getter<boolean>,
3232
animatedShouldDebounceQueueFlush: Getter<boolean>,
33+
animatedShouldSyncValueBeforeStartCallback: Getter<boolean>,
3334
animatedShouldUseSingleOp: Getter<boolean>,
3435
deferFlatListFocusChangeRenderUpdate: Getter<boolean>,
3536
enableNativeEventTargetEventDispatching: Getter<boolean>,
@@ -145,6 +146,11 @@ export const jsOnlyTestFlag: Getter<boolean> = createJavaScriptFlagGetter('jsOnl
145146
*/
146147
export const animatedShouldDebounceQueueFlush: Getter<boolean> = createJavaScriptFlagGetter('animatedShouldDebounceQueueFlush', false);
147148

149+
/**
150+
* 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.
151+
*/
152+
export const animatedShouldSyncValueBeforeStartCallback: Getter<boolean> = createJavaScriptFlagGetter('animatedShouldSyncValueBeforeStartCallback', true);
153+
148154
/**
149155
* 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.
150156
*/

0 commit comments

Comments
 (0)