Animated: Enqueue Completion Callbacks in Microtask (#46714)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/46714

Sets up a feature flag to experiment with scheduling `Animation` completion callbacks in a microtask instead of executing them synchronously. (The completion callback is the callback passed into `animation.start(<callback>)`).

There are currently 4 potential scheduling behaviors of the animation completion callback:

- Synchronously when `start()` is invoked, for spring and timing animations that immediately complete (e.g. no delays and not native driver).
- Synchronously when `stop()` is invoked, if the animation has not yet completed.
- Synchronously during the layout effect phase, or commit phase (if the `useInsertionEffectsForAnimations` feature flag is enabled), if the animation has not yet completed.
- Asynchronously when the animation completes (e.g. non-zero delay or when the native driver is employed).

The wide number of variables and the timing-dependent nature of these variables means that the behavior of the completion callback is very non-deterministic.

Imperically, we have also found that most codebases using `useNativeDriver` mostly have completion callbacks executed asynchronously. However, this leads to surprising problems where the callback causes unpredictable behavior when it is called synchronously (e.g. when the animating component is unmounted *during* the animation).

Changelog:
[Internal]

Reviewed By: javache

Differential Revision: D63573322

fbshipit-source-id: 16c48be78c9ac65a64021249fad10f7265737c44
This commit is contained in:
Tim Yung
2024-10-02 02:07:00 -07:00
committed by Facebook GitHub Bot
parent 11bf3a32e1
commit de927e6138
3 changed files with 21 additions and 2 deletions
@@ -165,7 +165,11 @@ export default class Animation {
const callback = this.#onEnd;
if (callback != null) {
this.#onEnd = null;
callback(result);
if (ReactNativeFeatureFlags.scheduleAnimatedEndCallbackInMicrotask()) {
queueMicrotask(() => callback(result));
} else {
callback(result);
}
}
}
}
@@ -541,6 +541,15 @@ const definitions: FeatureFlagDefinitions = {
purpose: 'release',
},
},
scheduleAnimatedEndCallbackInMicrotask: {
defaultValue: false,
metadata: {
dateAdded: '2024-09-27',
description:
'Changes the completion callback supplied via `Animation#start` to be scheduled in a microtask instead of synchronously executed.',
purpose: 'experimentation',
},
},
shouldSkipStateUpdatesForLoopingAnimations: {
defaultValue: false,
metadata: {
@@ -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<<eae8bf8e1f529a034a65377353cda539>>
* @generated SignedSource<<36900baefd540f0fbb5920e9001b969c>>
* @flow strict
*/
@@ -36,6 +36,7 @@ export type ReactNativeFeatureFlagsJsOnly = {
enableAnimatedPropsMemo: Getter<boolean>,
enableOptimisedVirtualizedCells: Getter<boolean>,
isLayoutAnimationEnabled: Getter<boolean>,
scheduleAnimatedEndCallbackInMicrotask: Getter<boolean>,
shouldSkipStateUpdatesForLoopingAnimations: Getter<boolean>,
shouldUseAnimatedObjectForTransform: Getter<boolean>,
shouldUseRemoveClippedSubviewsAsDefaultOnIOS: Getter<boolean>,
@@ -146,6 +147,11 @@ export const enableOptimisedVirtualizedCells: Getter<boolean> = createJavaScript
*/
export const isLayoutAnimationEnabled: Getter<boolean> = createJavaScriptFlagGetter('isLayoutAnimationEnabled', true);
/**
* Changes the completion callback supplied via `Animation#start` to be scheduled in a microtask instead of synchronously executed.
*/
export const scheduleAnimatedEndCallbackInMicrotask: Getter<boolean> = createJavaScriptFlagGetter('scheduleAnimatedEndCallbackInMicrotask', false);
/**
* If the animation is within Animated.loop, we do not send state updates to React.
*/