From 7a353a33b5e3fc6a3267ed6e5241ea17d2681916 Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Tue, 16 Jun 2026 07:01:29 -0700 Subject: [PATCH 1/2] Flip cxxNativeAnimatedEnabled featureflag default to true (#57204) Summary: ## Changelog: [General] [Changed] - Flip cxxNativeAnimatedEnabled featureflag default to true Reviewed By: javache Differential Revision: D108323433 --- .../ReactNativeFeatureFlagsDefaults.kt | 4 +-- .../ReactNativeFeatureFlagsDefaults.h | 4 +-- .../ReactNativeFeatureFlags.config.js | 2 +- .../private/animated/NativeAnimatedHelper.js | 25 +++++++++++++------ .../featureflags/ReactNativeFeatureFlags.js | 4 +-- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index 818eb338551d..4ac77546a85d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -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<<061d668cf04041f4d3d2f48f11dc739f>> + * @generated SignedSource<<5f6861a5aa2d6024ad8d4c236652bf64>> */ /** @@ -27,7 +27,7 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun cdpInteractionMetricsEnabled(): Boolean = false - override fun cxxNativeAnimatedEnabled(): Boolean = false + override fun cxxNativeAnimatedEnabled(): Boolean = true override fun defaultTextToOverflowHidden(): Boolean = true diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index e0bebd010bc9..9c404b1453fa 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -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<<8dfc52502bd539e5e43d547f895a6d33>> + * @generated SignedSource<> */ /** @@ -36,7 +36,7 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { } bool cxxNativeAnimatedEnabled() override { - return false; + return true; } bool defaultTextToOverflowHidden() override { diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 15976d671629..52da0a057444 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -72,7 +72,7 @@ const definitions: FeatureFlagDefinitions = { ossReleaseStage: 'none', }, cxxNativeAnimatedEnabled: { - defaultValue: false, + defaultValue: true, metadata: { dateAdded: '2025-03-14', description: diff --git a/packages/react-native/src/private/animated/NativeAnimatedHelper.js b/packages/react-native/src/private/animated/NativeAnimatedHelper.js index f27650b3d327..9afd64374e5c 100644 --- a/packages/react-native/src/private/animated/NativeAnimatedHelper.js +++ b/packages/react-native/src/private/animated/NativeAnimatedHelper.js @@ -71,6 +71,21 @@ 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. +function scheduleQueueFlush(): void { + clearImmediate(flushQueueImmediate); + if (process.env.NODE_ENV === 'test') { + // TODO: T275950736 - remove this path + API.flushQueue(); + } else { + flushQueueImmediate = setImmediate(API.flushQueue); + } +} + function createNativeOperations(): NonNullable { const methodNames = [ 'createAnimatedNode', // 1 @@ -116,8 +131,7 @@ function createNativeOperations(): NonNullable { // details, see `NativeAnimatedModule.queueAndExecuteBatchedOperations`. singleOpQueue.push(operationID, ...args); if (shouldSignalBatch) { - clearImmediate(flushQueueImmediate); - flushQueueImmediate = setImmediate(API.flushQueue); + scheduleQueueFlush(); } }; } @@ -137,8 +151,7 @@ function createNativeOperations(): NonNullable { } else if (shouldSignalBatch) { // $FlowExpectedError[incompatible-call] - Dynamism. queue.push(() => method(...args)); - clearImmediate(flushQueueImmediate); - flushQueueImmediate = setImmediate(API.flushQueue); + scheduleQueueFlush(); } else { // $FlowExpectedError[incompatible-call] - Dynamism. method(...args); @@ -190,9 +203,7 @@ const API = { invariant(NativeAnimatedModule, 'Native animated module is not available'); if (ReactNativeFeatureFlags.animatedShouldDebounceQueueFlush()) { - const prevImmediate = flushQueueImmediate; - clearImmediate(prevImmediate); - flushQueueImmediate = setImmediate(API.flushQueue); + scheduleQueueFlush(); } else { API.flushQueue(); } diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 403f339511aa..fee4c4c52e6a 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<<609451fd0a38e0f8eaf685e7cf534e27>> + * @generated SignedSource<<1de542d9c189f934f52fc258fd591b06>> * @flow strict * @noformat */ @@ -224,7 +224,7 @@ export const cdpInteractionMetricsEnabled: Getter = createNativeFlagGet /** * Use a C++ implementation of Native Animated instead of the platform implementation. */ -export const cxxNativeAnimatedEnabled: Getter = createNativeFlagGetter('cxxNativeAnimatedEnabled', false); +export const cxxNativeAnimatedEnabled: Getter = createNativeFlagGetter('cxxNativeAnimatedEnabled', true); /** * When enabled, sets the default overflow style for Text components to hidden instead of visible. */ From 01c81b5cc41011952d7535b4b5a9b60c9b91d514 Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Tue, 16 Jun 2026 07:01:29 -0700 Subject: [PATCH 2/2] Flush NativeAnimated batch via microtask instead of set/clearImmediate (#57209) Summary: We used `setImmediate` to schedule the operations in order to use microtasks queue (given the immediate shim used in RN). But jest tests treat setImmediate callback as macrotask like setTimeout (which is browser-aligned behavior). This makes it difficult to test the change introduced by featureflag `animatedShouldDebounceQueueFlush` and `cxxNativeAnimatedEnabled` Reviewed By: cipolleschi Differential Revision: D108640934 --- .../private/animated/NativeAnimatedHelper.js | 34 ++--- .../animated/__tests__/AnimatedNative-test.js | 62 +++++++- .../AnimatedNativeCxxScheduling-test.js | 139 ++++++++++++++++++ 3 files changed, 208 insertions(+), 27 deletions(-) create mode 100644 packages/react-native/src/private/animated/__tests__/AnimatedNativeCxxScheduling-test.js 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), + ); + }); +});