Skip to content

Animated: Setup scheduleAnimatedCleanupInMicrotask Feature Flag #48878

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading