Skip to content

Commit

Permalink
Animated: Setup scheduleAnimatedCleanupInMicrotask Feature Flag (#4…
Browse files Browse the repository at this point in the history
…8878)

Summary:
Pull Request resolved: #48878

Creates a new `scheduleAnimatedCleanupInMicrotask` feature flag to experiment with deferring the `AnimatedProps` cleanup using the microtask queue.

This is different from the previous approach of deferring invocation of the completion callback, which impacted the timing of composite animations such as `Animated.parallel` and `Animated.sequence`, because we are deferring detaching the `AnimatedNode` graph instead. This will only impact the timing of completion callbacks as a result of invalidating `AnimatedProps` (either by passing in new `AnimatedValue` instances or unmounting the component).

This should minimally impact scheduling and have lower risk of user-visible behavior change because React already provides minimal guarantees around when updates are committed (and effects attached/detached).

This also enables us to significantly simplify the current convoluted dance we do to optimized around reference counting in the AnimatedNode graph.

Changelog:
[General][Changed] - When an Animated component is updated or unmounted, `AnimatedNode` instances will now detach in a microtask instead of synchronously in the commit phase of React. This will cause the completion callback of finished animations to execute after the commit phase instead of during it.

Reviewed By: rickhanlonii

Differential Revision: D68527096

fbshipit-source-id: 99346b8ddbf6a01725376c692b0351be679b9e89
  • Loading branch information
yungsters authored and facebook-github-bot committed Jan 27, 2025
1 parent e77fe5c commit 50b75a7
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 2 deletions.
111 changes: 111 additions & 0 deletions packages/react-native/Libraries/Animated/__tests__/Animated-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,31 @@ let Animated = require('../Animated').default;
const AnimatedProps = require('../nodes/AnimatedProps').default;
const TestRenderer = require('react-test-renderer');

// WORKAROUND: `jest.runAllTicks` skips tasks scheduled w/ `queueMicrotask`.
function mockQueueMicrotask() {
let queueMicrotask;
beforeEach(() => {
queueMicrotask = global.queueMicrotask;
// $FlowIgnore[cannot-write]
global.queueMicrotask = process.nextTick;
});
afterEach(() => {
// $FlowIgnore[cannot-write]
global.queueMicrotask = queueMicrotask;
});
}

describe('Animated', () => {
let ReactNativeFeatureFlags;

beforeEach(() => {
jest.resetModules();

ReactNativeFeatureFlags = require('../../../src/private/featureflags/ReactNativeFeatureFlags');
});

mockQueueMicrotask();

describe('Animated', () => {
it('works end to end', () => {
const anim = new Animated.Value(0);
Expand Down Expand Up @@ -94,6 +114,10 @@ describe('Animated', () => {
});

it('does not detach on updates', async () => {
ReactNativeFeatureFlags.override({
scheduleAnimatedCleanupInMicrotask: () => false,
});

const opacity = new Animated.Value(0);
opacity.__detach = jest.fn();

Expand All @@ -108,6 +132,91 @@ describe('Animated', () => {
});

it('stops animation when detached', async () => {
ReactNativeFeatureFlags.override({
scheduleAnimatedCleanupInMicrotask: () => false,
});

const opacity = new Animated.Value(0);
const callback = jest.fn();

const root = await create(<Animated.View style={{opacity}} />);

Animated.timing(opacity, {
toValue: 10,
duration: 1000,
useNativeDriver: false,
}).start(callback);

await unmount(root);

expect(callback).toBeCalledWith({finished: false});
});

it('detaches only on unmount (in a microtask)', async () => {
ReactNativeFeatureFlags.override({
scheduleAnimatedCleanupInMicrotask: () => true,
});

const opacity = new Animated.Value(0);
opacity.__detach = jest.fn();

const root = await create(<Animated.View style={{opacity}} />);
expect(opacity.__detach).not.toBeCalled();

await update(root, <Animated.View style={{opacity}} />);
expect(opacity.__detach).not.toBeCalled();
jest.runAllTicks();
expect(opacity.__detach).not.toBeCalled();

await unmount(root);
expect(opacity.__detach).not.toBeCalled();
jest.runAllTicks();
expect(opacity.__detach).toBeCalled();
});

it('restores default values only on update (in a microtask)', async () => {
ReactNativeFeatureFlags.override({
scheduleAnimatedCleanupInMicrotask: () => true,
});

const __restoreDefaultValues = jest.spyOn(
AnimatedProps.prototype,
'__restoreDefaultValues',
);

try {
const opacityA = new Animated.Value(0);
const root = await create(
<Animated.View style={{opacity: opacityA}} />,
);
expect(__restoreDefaultValues).not.toBeCalled();

const opacityB = new Animated.Value(0);
await update(root, <Animated.View style={{opacity: opacityB}} />);
expect(__restoreDefaultValues).not.toBeCalled();
jest.runAllTicks();
expect(__restoreDefaultValues).toBeCalledTimes(1);

const opacityC = new Animated.Value(0);
await update(root, <Animated.View style={{opacity: opacityC}} />);
expect(__restoreDefaultValues).toBeCalledTimes(1);
jest.runAllTicks();
expect(__restoreDefaultValues).toBeCalledTimes(2);

await unmount(root);
expect(__restoreDefaultValues).toBeCalledTimes(2);
jest.runAllTicks();
expect(__restoreDefaultValues).toBeCalledTimes(2);
} finally {
__restoreDefaultValues.mockRestore();
}
});

it('stops animation when detached (in a microtask)', async () => {
ReactNativeFeatureFlags.override({
scheduleAnimatedCleanupInMicrotask: () => true,
});

const opacity = new Animated.Value(0);
const callback = jest.fn();

Expand All @@ -121,6 +230,8 @@ describe('Animated', () => {

await unmount(root);

expect(callback).not.toBeCalled();
jest.runAllTicks();
expect(callback).toBeCalledWith({finished: false});
});

Expand Down
44 changes: 43 additions & 1 deletion packages/react-native/Libraries/Animated/useAnimatedProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ type AnimatedValueListeners = Array<{
listenerId: string,
}>;

const useAnimatedPropsLifecycle =
ReactNativeFeatureFlags.scheduleAnimatedCleanupInMicrotask()
? useAnimatedPropsLifecycleWithCleanupInMicrotask
: useAnimatedPropsLifecycleWithPrevNodeRef;

export default function useAnimatedProps<TProps: {...}, TInstance>(
props: TProps,
allowlist?: ?AnimatedPropsAllowlist,
Expand Down Expand Up @@ -248,7 +253,7 @@ function addAnimatedValuesListenersToProps(
* nodes. So in order to optimize this, we avoid detaching until the next attach
* unless we are unmounting.
*/
function useAnimatedPropsLifecycle(node: AnimatedProps): void {
function useAnimatedPropsLifecycleWithPrevNodeRef(node: AnimatedProps): void {
const prevNodeRef = useRef<?AnimatedProps>(null);
const isUnmountingRef = useRef<boolean>(false);

Expand Down Expand Up @@ -279,6 +284,43 @@ function useAnimatedPropsLifecycle(node: AnimatedProps): void {
}, [node]);
}

/**
* Manages the lifecycle of the supplied `AnimatedProps` by invoking `__attach`
* and `__detach`. However, `__detach` occurs in a microtask for these reasons:
*
* 1. Optimizes detaching and attaching `AnimatedNode` instances that rely on
* reference counting to cleanup state, by causing detach to be scheduled
* after any subsequent attach.
* 2. Avoids calling `detach` during the insertion effect phase (which
* occurs during the commit phase), which may invoke completion callbacks.
*
* We should avoid invoking completion callbacks during the commit phase because
* callbacks may update state, which is unsupported and will force synchronous
* updates.
*/
function useAnimatedPropsLifecycleWithCleanupInMicrotask(
node: AnimatedProps,
): void {
const isMounted = useRef<boolean>(false);

useInsertionEffect(() => {
isMounted.current = true;
node.__attach();

return () => {
isMounted.current = false;
queueMicrotask(() => {
// NOTE: Do not restore default values on unmount, see D18197735.
if (isMounted.current) {
// TODO: Stop restoring default values (unless `reset` is called).
node.__restoreDefaultValues();
}
node.__detach();
});
};
}, [node]);
}

function getEventTarget<TInstance>(instance: TInstance): TInstance {
return typeof instance === 'object' &&
typeof instance?.getScrollableNode === 'function'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,16 @@ const definitions: FeatureFlagDefinitions = {
purpose: 'release',
},
},
scheduleAnimatedCleanupInMicrotask: {
defaultValue: false,
metadata: {
dateAdded: '2025-01-22',
description:
'Changes the cleanup of`AnimatedProps` to occur in a microtask instead of synchronously during effect cleanup (for unmount) or subsequent mounts (for updates).',
expectedReleaseValue: true,
purpose: 'experimentation',
},
},
shouldSkipStateUpdatesForLoopingAnimations: {
defaultValue: true,
metadata: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<<b8f67626bff7429da51601f0c9eefd49>>
* @generated SignedSource<<1292b8fff14cbf6ea3bffea28d0428bc>>
* @flow strict
*/

Expand Down Expand Up @@ -35,6 +35,7 @@ export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{
enableAnimatedClearImmediateFix: Getter<boolean>,
fixVirtualizeListCollapseWindowSize: Getter<boolean>,
isLayoutAnimationEnabled: Getter<boolean>,
scheduleAnimatedCleanupInMicrotask: Getter<boolean>,
shouldSkipStateUpdatesForLoopingAnimations: Getter<boolean>,
shouldUseAnimatedObjectForTransform: Getter<boolean>,
shouldUseRemoveClippedSubviewsAsDefaultOnIOS: Getter<boolean>,
Expand Down Expand Up @@ -131,6 +132,11 @@ export const fixVirtualizeListCollapseWindowSize: Getter<boolean> = createJavaSc
*/
export const isLayoutAnimationEnabled: Getter<boolean> = createJavaScriptFlagGetter('isLayoutAnimationEnabled', true);

/**
* Changes the cleanup of`AnimatedProps` to occur in a microtask instead of synchronously during effect cleanup (for unmount) or subsequent mounts (for updates).
*/
export const scheduleAnimatedCleanupInMicrotask: Getter<boolean> = createJavaScriptFlagGetter('scheduleAnimatedCleanupInMicrotask', false);

/**
* If the animation is within Animated.loop, we do not send state updates to React.
*/
Expand Down

0 comments on commit 50b75a7

Please sign in to comment.