diff --git a/packages/react-native/src/private/animated/NativeAnimatedHelper.js b/packages/react-native/src/private/animated/NativeAnimatedHelper.js index 9afd64374e5c..b8e1ea877538 100644 --- a/packages/react-native/src/private/animated/NativeAnimatedHelper.js +++ b/packages/react-native/src/private/animated/NativeAnimatedHelper.js @@ -23,6 +23,7 @@ import type {EventSubscription} from '../../../Libraries/vendor/emitter/EventEmi import NativeAnimatedNonTurboModule from '../../../Libraries/Animated/NativeAnimatedModule'; import NativeAnimatedTurboModule from '../../../Libraries/Animated/NativeAnimatedTurboModule'; +import queueMicrotask from '../../../Libraries/Core/Timers/queueMicrotask'; import NativeEventEmitter from '../../../Libraries/EventEmitter/NativeEventEmitter'; import RCTDeviceEventEmitter from '../../../Libraries/EventEmitter/RCTDeviceEventEmitter'; import Platform from '../../../Libraries/Utilities/Platform'; @@ -57,7 +58,6 @@ const isSingleOpBatching = Platform.OS === 'android' && NativeAnimatedModule?.queueAndExecuteBatchedOperations != null && ReactNativeFeatureFlags.animatedShouldUseSingleOp(); -let flushQueueImmediate = null; const eventListenerGetValueCallbacks: { [number]: (value: number) => void, @@ -71,19 +71,18 @@ let globalEventEmitterAnimationFinishedListener: ?EventSubscription = null; const shouldSignalBatch: boolean = ReactNativeFeatureFlags.cxxNativeAnimatedEnabled(); -// Schedules `API.flushQueue` after the current batch, replacing any pending -// flush. On device `setImmediate` is a microtask; under jest's fake timers it's -// a fake-timer entry that only `runAllTimers` drains — not `await` or -// `advanceTimersByTime` — so the deferred flush wouldn't run before a test's -// assertions. Flush synchronously in tests instead. +let flushQueueGeneration = 1; function scheduleQueueFlush(): void { - clearImmediate(flushQueueImmediate); - if (process.env.NODE_ENV === 'test') { - // TODO: T275950736 - remove this path + const generation = ++flushQueueGeneration; + queueMicrotask(() => { + if (generation !== flushQueueGeneration) { + return; + } API.flushQueue(); - } else { - flushQueueImmediate = setImmediate(API.flushQueue); - } + }); +} +function cancelQueueFlush(): void { + flushQueueGeneration++; } function createNativeOperations(): NonNullable { @@ -229,7 +228,6 @@ const API = { NativeAnimatedModule, 'Native animated module is not available', ); - flushQueueImmediate = null; if (singleOpQueue.length === 0) { return; @@ -250,7 +248,6 @@ const API = { NativeAnimatedModule, 'Native animated module is not available', ); - flushQueueImmediate = null; if (queue.length === 0) { return; @@ -310,11 +307,10 @@ const API = { waitingForQueuedOperations.add(id); queueOperations = true; - if ( - ReactNativeFeatureFlags.animatedShouldDebounceQueueFlush() && - flushQueueImmediate - ) { - clearImmediate(flushQueueImmediate); + // Entering explicit queue mode: drop any flush already scheduled so ops + // accumulate until `disableQueue`. + if (ReactNativeFeatureFlags.animatedShouldDebounceQueueFlush()) { + cancelQueueFlush(); } }, startAnimatingNode: (isSingleOpBatching diff --git a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js index 7ba17f98b836..e5e31875445f 100644 --- a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js @@ -31,6 +31,15 @@ describe('Native Animated', () => { }; } + // Native Animated batches operations and flushes them to the native module on + // a microtask. `create`/`update`/`unmount` already await (draining + // microtasks), but after a synchronous Animated API call (`setValue`, + // `.start()`, `addListener`, …) the flush must be awaited before asserting + // that the native module received the operations. + function flushNativeOperations() { + jest.runAllTicks(); + } + beforeEach(() => { jest.resetModules(); jest.restoreAllMocks(); @@ -101,6 +110,7 @@ describe('Native Animated', () => { opacity.setValue(0.5); + await flushNativeOperations(); expect(NativeAnimatedModule.setAnimatedNodeValue).toBeCalledWith( expect.any(Number), 0.5, @@ -123,6 +133,7 @@ describe('Native Animated', () => { {type: 'value', value: 0, offset: 10}, ); opacity.setOffset(20); + await flushNativeOperations(); expect(NativeAnimatedModule.setAnimatedNodeOffset).toBeCalledWith( expect.any(Number), 20, @@ -142,6 +153,7 @@ describe('Native Animated', () => { {type: 'value', value: 0, offset: 0}, ); opacity.flattenOffset(); + await flushNativeOperations(); expect(NativeAnimatedModule.flattenAnimatedNodeOffset).toBeCalledWith( expect.any(Number), ); @@ -164,6 +176,7 @@ describe('Native Animated', () => { await unmount(root); jest.runAllTicks(); + await flushNativeOperations(); expect(NativeAnimatedModule.getValue).toBeCalledWith( tag, expect.any(Function), @@ -190,6 +203,7 @@ describe('Native Animated', () => { await unmount(root); jest.runAllTicks(); + await flushNativeOperations(); expect(NativeAnimatedModule.getValue).toBeCalledWith( tag, expect.any(Function), @@ -210,6 +224,7 @@ describe('Native Animated', () => { {type: 'value', value: 0, offset: 0}, ); opacity.extractOffset(); + await flushNativeOperations(); expect(NativeAnimatedModule.extractAnimatedNodeOffset).toBeCalledWith( expect.any(Number), ); @@ -217,13 +232,14 @@ describe('Native Animated', () => { }); describe('Animated Listeners', () => { - it('should get updates', () => { + it('should get updates', async () => { const {Animated, NativeAnimatedHelper} = importModules(); const value1 = new Animated.Value(0); value1.__makeNative(); const listener = jest.fn(); const id = value1.addListener(listener); + await flushNativeOperations(); expect( NativeAnimatedModule.startListeningToAnimatedNodeValue, ).toHaveBeenCalledWith(value1.__getNativeTag()); @@ -245,6 +261,7 @@ describe('Native Animated', () => { expect(value1.__getValue()).toBe(7); value1.removeListener(id); + await flushNativeOperations(); expect( NativeAnimatedModule.stopListeningToAnimatedNodeValue, ).toHaveBeenCalledWith(value1.__getNativeTag()); @@ -257,13 +274,14 @@ describe('Native Animated', () => { expect(value1.__getValue()).toBe(7); }); - it('should removeAll', () => { + it('should removeAll', async () => { const {Animated, NativeAnimatedHelper} = importModules(); const value1 = new Animated.Value(0); value1.__makeNative(); const listener = jest.fn(); [1, 2, 3, 4].forEach(() => value1.addListener(listener)); + await flushNativeOperations(); expect( NativeAnimatedModule.startListeningToAnimatedNodeValue, ).toHaveBeenCalledWith(value1.__getNativeTag()); @@ -276,6 +294,7 @@ describe('Native Animated', () => { expect(listener).toBeCalledWith({value: 42}); value1.removeAllListeners(); + await flushNativeOperations(); expect( NativeAnimatedModule.stopListeningToAnimatedNodeValue, ).toHaveBeenCalledWith(value1.__getNativeTag()); @@ -393,6 +412,7 @@ describe('Native Animated', () => { useNativeDriver: true, }).start(); + await flushNativeOperations(); expect(NativeAnimatedModule.createAnimatedNode).toHaveBeenCalledTimes(3); expect(NativeAnimatedModule.connectAnimatedNodes).toHaveBeenCalledTimes( 2, @@ -418,6 +438,7 @@ describe('Native Animated', () => { await unmount(root); jest.runAllTicks(); + await flushNativeOperations(); expect( NativeAnimatedModule.disconnectAnimatedNodes, ).toHaveBeenCalledTimes(2); @@ -436,6 +457,7 @@ describe('Native Animated', () => { useNativeDriver: true, }).start(); + await flushNativeOperations(); expect(NativeAnimatedModule.createAnimatedNode).toBeCalledWith( expect.any(Number), {type: 'value', value: 0, offset: 0}, @@ -771,6 +793,7 @@ describe('Native Animated', () => { useNativeDriver: true, }).start(); + await flushNativeOperations(); // $FlowFixMe[prop-missing] const createCalls = NativeAnimatedModule.createAnimatedNode.mock.calls; const createCallOrder = @@ -877,6 +900,7 @@ describe('Native Animated', () => { useNativeDriver: true, }).start(); + await flushNativeOperations(); // $FlowFixMe[prop-missing] const createCalls = NativeAnimatedModule.createAnimatedNode.mock.calls; const createCallOrder = @@ -1000,6 +1024,7 @@ describe('Native Animated', () => { useNativeDriver: true, }).start(); + await flushNativeOperations(); // $FlowFixMe[prop-missing] const createCalls = NativeAnimatedModule.createAnimatedNode.mock.calls; const createCallOrder = @@ -1202,7 +1227,7 @@ describe('Native Animated', () => { }); describe('Animations', () => { - it('sends a valid timing animation description', () => { + it('sends a valid timing animation description', async () => { const {Animated} = importModules(); const anim = new Animated.Value(0); @@ -1212,6 +1237,7 @@ describe('Native Animated', () => { useNativeDriver: true, }).start(); + await flushNativeOperations(); expect(NativeAnimatedModule.startAnimatingNode).toBeCalledWith( expect.any(Number), expect.any(Number), @@ -1226,7 +1252,7 @@ describe('Native Animated', () => { ); }); - it('sends a valid spring animation description', () => { + it('sends a valid spring animation description', async () => { const {Animated} = importModules(); const anim = new Animated.Value(0); @@ -1236,6 +1262,7 @@ describe('Native Animated', () => { tension: 164, useNativeDriver: true, }).start(); + await flushNativeOperations(); expect(NativeAnimatedModule.startAnimatingNode).toBeCalledWith( expect.any(Number), expect.any(Number), @@ -1261,6 +1288,7 @@ describe('Native Animated', () => { mass: 3, useNativeDriver: true, }).start(); + await flushNativeOperations(); expect(NativeAnimatedModule.startAnimatingNode).toBeCalledWith( expect.any(Number), expect.any(Number), @@ -1285,6 +1313,7 @@ describe('Native Animated', () => { speed: 10, useNativeDriver: true, }).start(); + await flushNativeOperations(); expect(NativeAnimatedModule.startAnimatingNode).toBeCalledWith( expect.any(Number), expect.any(Number), @@ -1304,7 +1333,7 @@ describe('Native Animated', () => { ); }); - it('sends a valid decay animation description', () => { + it('sends a valid decay animation description', async () => { const {Animated} = importModules(); const anim = new Animated.Value(0); @@ -1314,6 +1343,7 @@ describe('Native Animated', () => { useNativeDriver: true, }).start(); + await flushNativeOperations(); expect(NativeAnimatedModule.startAnimatingNode).toBeCalledWith( expect.any(Number), expect.any(Number), @@ -1322,7 +1352,7 @@ describe('Native Animated', () => { ); }); - it('works with Animated.loop', () => { + it('works with Animated.loop', async () => { const {Animated} = importModules(); const anim = new Animated.Value(0); @@ -1335,6 +1365,7 @@ describe('Native Animated', () => { {iterations: 10}, ).start(); + await flushNativeOperations(); expect(NativeAnimatedModule.startAnimatingNode).toBeCalledWith( expect.any(Number), expect.any(Number), @@ -1343,7 +1374,7 @@ describe('Native Animated', () => { ); }); - it('sends stopAnimation command to native', () => { + it('sends stopAnimation command to native', async () => { const {Animated} = importModules(); const value = new Animated.Value(0); @@ -1354,6 +1385,7 @@ describe('Native Animated', () => { }); animation.start(); + await flushNativeOperations(); expect(NativeAnimatedModule.startAnimatingNode).toBeCalledWith( expect.any(Number), expect.any(Number), @@ -1371,10 +1403,11 @@ describe('Native Animated', () => { NativeAnimatedModule.startAnimatingNode.mock.calls[0][0]; animation.stop(); + await flushNativeOperations(); expect(NativeAnimatedModule.stopAnimation).toBeCalledWith(animationId); }); - it('calls stopAnimation callback with native value', () => { + it('calls stopAnimation callback with native value', async () => { const {Animated} = importModules(); jest @@ -1397,6 +1430,7 @@ describe('Native Animated', () => { currentValue = value; }); + await flushNativeOperations(); expect(NativeAnimatedModule.getValue).toBeCalledWith( tag, expect.any(Function), @@ -1437,12 +1471,14 @@ describe('Native Animated', () => { await update(root, ); jest.runAllTicks(); + await flushNativeOperations(); expect(NativeAnimatedModule.restoreDefaultValues).toHaveBeenCalledTimes( 1, ); await unmount(root); jest.runAllTicks(); + await flushNativeOperations(); // Make sure it doesn't get called on unmount. expect(NativeAnimatedModule.restoreDefaultValues).toHaveBeenCalledTimes( 1, @@ -1461,6 +1497,7 @@ describe('Native Animated', () => { .__getChildren()[0] .__getChildren()[0] .__getNativeTag(); + await flushNativeOperations(); expect(NativeAnimatedModule.connectAnimatedNodeToView).toBeCalledWith( propsTag, 1, @@ -1468,6 +1505,7 @@ describe('Native Animated', () => { await unmount(root); jest.runAllTicks(); + await flushNativeOperations(); expect( NativeAnimatedModule.disconnectAnimatedNodeFromView, ).toBeCalledWith(propsTag, 1); @@ -1483,6 +1521,7 @@ describe('Native Animated', () => { // AnimatedProps > AnimatedStyle > opacity AnimatedValue const propsNode = opacity.__getChildren()[0].__getChildren()[0]; let propsTag = propsNode.__nativeTag; + await flushNativeOperations(); expect(NativeAnimatedModule.connectAnimatedNodeToView).nthCalledWith( 1, propsTag, @@ -1491,6 +1530,7 @@ describe('Native Animated', () => { // Simulate what happens when React.Activity unmounts and remounts propsNode.__detach(); + await flushNativeOperations(); expect( NativeAnimatedModule.disconnectAnimatedNodeFromView, ).toBeCalledWith(propsTag, 1); @@ -1500,6 +1540,7 @@ describe('Native Animated', () => { propsNode.setNativeView(ref.current); propsTag = propsNode.__nativeTag; + await flushNativeOperations(); expect(NativeAnimatedModule.connectAnimatedNodeToView).nthCalledWith( 2, propsTag, @@ -1543,6 +1584,7 @@ describe('Native Animated', () => { let createAnimatedNodeCalledTimes = 0; let dropAnimatedNodeCalledTimes = 0; + await flushNativeOperations(); expect( // $FlowFixMe[prop-missing] NativeAnimatedModule.createAnimatedNode.mock.calls.slice(0, 5), @@ -1591,6 +1633,7 @@ describe('Native Animated', () => { ); jest.runAllTicks(); + await flushNativeOperations(); expect( // $FlowFixMe[prop-missing] NativeAnimatedModule.createAnimatedNode.mock.calls.slice(5, 9), @@ -1652,6 +1695,7 @@ describe('Native Animated', () => { ); jest.runAllTicks(); + await flushNativeOperations(); expect( // $FlowFixMe[prop-missing] NativeAnimatedModule.createAnimatedNode.mock.calls.slice(9, 13), @@ -1713,6 +1757,7 @@ describe('Native Animated', () => { ); jest.runAllTicks(); + await flushNativeOperations(); { const droppedTags = [10, 11, 12, 13]; for (let i = 0; i < droppedTags.length; i++) { @@ -1745,6 +1790,7 @@ describe('Native Animated', () => { // 5. Unmount await unmount(root); jest.runAllTicks(); + await flushNativeOperations(); // No change for Animated nodes on unmount. expect(NativeAnimatedModule.createAnimatedNode).toHaveBeenCalledTimes( createAnimatedNodeCalledTimes, diff --git a/packages/react-native/src/private/animated/__tests__/AnimatedNativeCxxScheduling-test.js b/packages/react-native/src/private/animated/__tests__/AnimatedNativeCxxScheduling-test.js new file mode 100644 index 000000000000..c86a4dd3e25f --- /dev/null +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNativeCxxScheduling-test.js @@ -0,0 +1,139 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import typeof TNativeAnimatedModule from '../../specs_DEPRECATED/modules/NativeAnimatedModule'; + +import {create} from '@react-native/jest-preset/jest/renderer'; +import * as React from 'react'; + +// The C++ backend flushes batched native operations on a microtask +// (`scheduleQueueFlush` -> `queueMicrotask`), so drain it before asserting. +const flushMicrotasks = (): Promise => Promise.resolve(); + +describe('Native Animated scheduling (cxxNativeAnimatedEnabled)', () => { + let NativeAnimatedModule: Exclude; + + function importModules() { + return { + // $FlowFixMe[unsafe-getters-setters] + get Animated() { + return require('../../../../Libraries/Animated/Animated').default; + }, + // $FlowFixMe[unsafe-getters-setters] + get ReactNativeFeatureFlags() { + return require('../../featureflags/ReactNativeFeatureFlags'); + }, + }; + } + + beforeEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); + jest + .mock('../../../../Libraries/BatchedBridge/NativeModules', () => ({ + __esModule: true, + default: { + NativeAnimatedModule: {}, + PlatformConstants: { + getConstants() { + return {}; + }, + }, + }, + })) + .mock('../../specs_DEPRECATED/modules/NativeAnimatedModule') + .mock('../../../../Libraries/EventEmitter/NativeEventEmitter') + // findNodeHandle is imported from RendererProxy so mock that whole module. + .setMock('../../../../Libraries/ReactNative/RendererProxy', { + findNodeHandle: () => 1, + }); + + NativeAnimatedModule = + // $FlowFixMe[incompatible-type] + require('../../specs_DEPRECATED/modules/NativeAnimatedModule').default; + // $FlowFixMe[cannot-write] + // $FlowFixMe[incompatible-use] + // $FlowFixMe[unsafe-object-assign] + Object.assign(NativeAnimatedModule, { + getValue: jest.fn(), + addAnimatedEventToView: jest.fn(), + connectAnimatedNodes: jest.fn(), + connectAnimatedNodeToView: jest.fn(), + createAnimatedNode: jest.fn(), + disconnectAnimatedNodeFromView: jest.fn(), + disconnectAnimatedNodes: jest.fn(), + dropAnimatedNode: jest.fn(), + extractAnimatedNodeOffset: jest.fn(), + flattenAnimatedNodeOffset: jest.fn(), + removeAnimatedEventFromView: jest.fn(), + restoreDefaultValues: jest.fn(), + setAnimatedNodeOffset: jest.fn(), + setAnimatedNodeValue: jest.fn(), + startAnimatingNode: jest.fn(), + startListeningToAnimatedNodeValue: jest.fn(), + stopAnimation: jest.fn(), + stopListeningToAnimatedNodeValue: jest.fn(), + }); + }); + + it('runs with cxxNativeAnimatedEnabled forced on', () => { + const {ReactNativeFeatureFlags} = importModules(); + expect(ReactNativeFeatureFlags.cxxNativeAnimatedEnabled()).toBe(true); + }); + + it('batches a synchronous Animated operation and flushes it on a microtask', async () => { + const {Animated} = importModules(); + + const opacity = new Animated.Value(0); + opacity.__makeNative(); + await create(); + + // With the C++ backend a synchronous Animated call is batched rather than + // dispatched inline... + opacity.setValue(0.5); + expect(NativeAnimatedModule.setAnimatedNodeValue).not.toHaveBeenCalled(); + + // ...and reaches the native module once the microtask drains, with the same + // arguments the inline (platform) backend would have sent. + await flushMicrotasks(); + expect(NativeAnimatedModule.setAnimatedNodeValue).toHaveBeenCalledWith( + expect.any(Number), + 0.5, + ); + }); + + it('batches a native-driven animation start and flushes it on a microtask', async () => { + const {Animated} = importModules(); + + const opacity = new Animated.Value(0); + // Mount first so the style/props nodes are already created and flushed; this + // isolates the `startAnimatingNode` operation produced by `start()` below + // (otherwise `create()` would drain the flush before we can observe it). + await create(); + + // Starting a native-driven animation batches `startAnimatingNode` rather + // than dispatching it inline... + Animated.timing(opacity, { + toValue: 10, + duration: 1000, + useNativeDriver: true, + }).start(); + expect(NativeAnimatedModule.startAnimatingNode).not.toHaveBeenCalled(); + + // ...and it reaches the native module once the microtask drains. + await flushMicrotasks(); + expect(NativeAnimatedModule.startAnimatingNode).toHaveBeenCalledWith( + expect.any(Number), + expect.any(Number), + expect.objectContaining({type: 'frames'}), + expect.any(Function), + ); + }); +});