diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedObject.js b/packages/react-native/Libraries/Animated/nodes/AnimatedObject.js index 2f3ff765de60..10988615bbb5 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedObject.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedObject.js @@ -19,9 +19,11 @@ import * as React from 'react'; const MAX_DEPTH = 5; -/* $FlowIssue[incompatible-type-guard] - Flow does not know that the prototype - and ReactElement checks preserve the type refinement of `value`. */ -function isPlainObject(value: mixed): value is $ReadOnly<{[string]: mixed}> { +export function isPlainObject( + value: mixed, + /* $FlowIssue[incompatible-type-guard] - Flow does not know that the prototype + and ReactElement checks preserve the type refinement of `value`. */ +): value is $ReadOnly<{[string]: mixed}> { return ( value !== null && typeof value === 'object' && @@ -109,6 +111,14 @@ export default class AnimatedObject extends AnimatedWithChildren { }); } + __getValueWithStaticObject(staticObject: mixed): any { + const nodes = this.#nodes; + let index = 0; + // NOTE: We can depend on `this._value` and `staticObject` sharing a + // structure because of `useAnimatedPropsMemo`. + return mapAnimatedNodes(staticObject, () => nodes[index++].__getValue()); + } + __getAnimatedValue(): any { return mapAnimatedNodes(this._value, node => { return node.__getAnimatedValue(); diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js b/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js index 83709c6640af..46523cded66c 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js @@ -113,6 +113,31 @@ export default class AnimatedProps extends AnimatedNode { return props; } + /** + * Creates a new `props` object that contains the same props as the supplied + * `staticProps` object, except with animated nodes for any props that were + * created by this `AnimatedProps` instance. + */ + __getValueWithStaticProps(staticProps: Object): Object { + const props: {[string]: mixed} = {...staticProps}; + + const keys = Object.keys(staticProps); + for (let ii = 0, length = keys.length; ii < length; ii++) { + const key = keys[ii]; + const maybeNode = this.#props[key]; + + if (key === 'style' && maybeNode instanceof AnimatedStyle) { + props[key] = maybeNode.__getValueWithStaticStyle(staticProps.style); + } else if (maybeNode instanceof AnimatedNode) { + props[key] = maybeNode.__getValue(); + } else if (maybeNode instanceof AnimatedEvent) { + props[key] = maybeNode.__getHandler(); + } + } + + return props; + } + __getAnimatedValue(): Object { const props: {[string]: mixed} = {}; diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedStyle.js b/packages/react-native/Libraries/Animated/nodes/AnimatedStyle.js index 9086986d17a6..09f439187187 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedStyle.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedStyle.js @@ -138,6 +138,43 @@ export default class AnimatedStyle extends AnimatedWithChildren { return Platform.OS === 'web' ? [this.#inputStyle, style] : style; } + /** + * Creates a new `style` object that contains the same style properties as + * the supplied `staticStyle` object, except with animated nodes for any + * style properties that were created by this `AnimatedStyle` instance. + */ + __getValueWithStaticStyle(staticStyle: Object): Object | Array { + const flatStaticStyle = flattenStyle(staticStyle); + const style: {[string]: mixed} = + flatStaticStyle == null + ? {} + : flatStaticStyle === staticStyle + ? // Copy the input style, since we'll mutate it below. + {...flatStaticStyle} + : // Reuse `flatStaticStyle` if it is a newly created object. + flatStaticStyle; + + const keys = Object.keys(style); + for (let ii = 0, length = keys.length; ii < length; ii++) { + const key = keys[ii]; + const maybeNode = this.#style[key]; + + if (key === 'transform' && maybeNode instanceof AnimatedTransform) { + style[key] = maybeNode.__getValueWithStaticTransforms( + // NOTE: This check should not be necessary, but the types are not + // enforced as of this writing. + Array.isArray(style[key]) ? style[key] : [], + ); + } else if (maybeNode instanceof AnimatedObject) { + style[key] = maybeNode.__getValueWithStaticObject(style[key]); + } else if (maybeNode instanceof AnimatedNode) { + style[key] = maybeNode.__getValue(); + } + } + + return Platform.OS === 'web' ? [this.#inputStyle, style] : style; + } + __getAnimatedValue(): Object { const style: {[string]: mixed} = {}; diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedTransform.js b/packages/react-native/Libraries/Animated/nodes/AnimatedTransform.js index 67da5be13a2d..50f484e3b76d 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedTransform.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedTransform.js @@ -91,6 +91,18 @@ export default class AnimatedTransform extends AnimatedWithChildren { ); } + __getValueWithStaticTransforms( + staticTransforms: $ReadOnlyArray, + ): $ReadOnlyArray { + const values = []; + mapTransforms(this._transforms, node => { + values.push(node.__getValue()); + }); + // NOTE: We can depend on `this._transforms` and `staticTransforms` sharing + // a structure because of `useAnimatedPropsMemo`. + return mapTransforms(staticTransforms, () => values.shift()); + } + __getAnimatedValue(): $ReadOnlyArray> { return mapTransforms(this._transforms, animatedNode => animatedNode.__getAnimatedValue(), diff --git a/packages/react-native/Libraries/Animated/useAnimatedProps.js b/packages/react-native/Libraries/Animated/useAnimatedProps.js index 28086bedfa6e..1093e42ae618 100644 --- a/packages/react-native/Libraries/Animated/useAnimatedProps.js +++ b/packages/react-native/Libraries/Animated/useAnimatedProps.js @@ -27,6 +27,7 @@ import { useReducer, useRef, } from 'react'; +import {useAnimatedPropsMemo} from '../../src/private/animated/useAnimatedPropsMemo'; type ReducedProps = { ...TProps, @@ -40,23 +41,24 @@ type AnimatedValueListeners = Array<{ listenerId: string, }>; +const useMemoOrAnimatedPropsMemo = + ReactNativeFeatureFlags.enableAnimatedPropsMemo() + ? useAnimatedPropsMemo + : useMemo; + export default function useAnimatedProps( props: TProps, allowlist?: ?AnimatedPropsAllowlist, ): [ReducedProps, CallbackRef] { const [, scheduleUpdate] = useReducer(count => count + 1, 0); - const onUpdateRef = useRef void>(null); + const onUpdateRef = useRef<(() => void) | null>(null); const timerRef = useRef(null); const allowlistIfEnabled = ReactNativeFeatureFlags.enableAnimatedAllowlist() ? allowlist : null; - // TODO: Only invalidate `node` if animated props or `style` change. In the - // previous implementation, we permitted `style` to override props with the - // same name property name as styles, so we can probably continue doing that. - // The ordering of other props *should* not matter. - const node = useMemo( + const node = useMemoOrAnimatedPropsMemo( () => new AnimatedProps( props, @@ -65,6 +67,7 @@ export default function useAnimatedProps( ), [allowlistIfEnabled, props], ); + const useNativePropsInFabric = ReactNativeFeatureFlags.shouldUseSetNativePropsInFabric(); const useSetNativePropsInNativeAnimationsInFabric = @@ -204,14 +207,19 @@ export default function useAnimatedProps( ); const callbackRef = useRefEffect(refEffect); - return [reduceAnimatedProps(node), callbackRef]; + return [reduceAnimatedProps(node, props), callbackRef]; } -function reduceAnimatedProps(node: AnimatedNode): ReducedProps { +function reduceAnimatedProps( + node: AnimatedProps, + props: TProps, +): ReducedProps { // Force `collapsable` to be false so that the native view is not flattened. // Flattened views cannot be accurately referenced by the native driver. return { - ...node.__getValue(), + ...(ReactNativeFeatureFlags.enableAnimatedPropsMemo() + ? node.__getValueWithStaticProps(props) + : node.__getValue()), collapsable: false, }; } diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index a6116afa8c78..1c2e4d0c9b64 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -938,11 +938,15 @@ exports[`public API should not change unintentionally Libraries/Animated/nodes/A `; exports[`public API should not change unintentionally Libraries/Animated/nodes/AnimatedObject.js 1`] = ` -"declare export default class AnimatedObject extends AnimatedWithChildren { +"declare export function isPlainObject( + value: mixed +): value is $ReadOnly<{ [string]: mixed }>; +declare export default class AnimatedObject extends AnimatedWithChildren { _value: mixed; static from(value: mixed): ?AnimatedObject; constructor(nodes: $ReadOnlyArray, value: mixed): void; __getValue(): any; + __getValueWithStaticObject(staticObject: mixed): any; __getAnimatedValue(): any; __attach(): void; __detach(): void; @@ -964,6 +968,7 @@ declare export default class AnimatedProps extends AnimatedNode { allowlist?: ?AnimatedPropsAllowlist ): void; __getValue(): Object; + __getValueWithStaticProps(staticProps: Object): Object; __getAnimatedValue(): Object; __attach(): void; __detach(): void; @@ -992,6 +997,7 @@ declare export default class AnimatedStyle extends AnimatedWithChildren { inputStyle: any ): void; __getValue(): Object | Array; + __getValueWithStaticStyle(staticStyle: Object): Object | Array; __getAnimatedValue(): Object; __attach(): void; __detach(): void; @@ -1061,6 +1067,9 @@ declare export default class AnimatedTransform extends AnimatedWithChildren { ): void; __makeNative(platformConfig: ?PlatformConfig): void; __getValue(): $ReadOnlyArray>; + __getValueWithStaticTransforms( + staticTransforms: $ReadOnlyArray + ): $ReadOnlyArray; __getAnimatedValue(): $ReadOnlyArray>; __attach(): void; __detach(): void; diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index f55cef723f45..69c99e11dc0a 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -293,6 +293,11 @@ const definitions: FeatureFlagDefinitions = { defaultValue: false, description: 'Enables Animated to skip non-allowlisted props and styles.', }, + enableAnimatedPropsMemo: { + defaultValue: false, + description: + 'Enables Animated to analyze props to minimize invalidating `AnimatedProps`.', + }, enableOptimisedVirtualizedCells: { defaultValue: false, description: 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 46a80a9dd5ed..3d9f4ac22089 100644 --- a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js @@ -15,9 +15,19 @@ import {createRef} from 'react'; const {create, unmount, update} = require('../../../../jest/renderer'); describe('Native Animated', () => { - let Animated; - let NativeAnimatedHelper; - let NativeAnimatedModule; + function importModules() { + return { + get Animated() { + return require('../../../../Libraries/Animated/Animated').default; + }, + get NativeAnimatedHelper() { + return require('../NativeAnimatedHelper').default; + }, + get ReactNativeFeatureFlags() { + return require('../../featureflags/ReactNativeFeatureFlags'); + }, + }; + } beforeEach(() => { jest.resetModules(); @@ -60,13 +70,12 @@ describe('Native Animated', () => { stopAnimation: jest.fn(), stopListeningToAnimatedNodeValue: jest.fn(), }); - - Animated = require('../../../../Libraries/Animated/Animated').default; - NativeAnimatedHelper = require('../NativeAnimatedHelper').default; }); describe('Animated Value', () => { it('proxies `setValue` correctly', async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0); const ref = React.createRef(null); @@ -91,6 +100,8 @@ describe('Native Animated', () => { }); it('should set offset', async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0); opacity.setOffset(10); opacity.__makeNative(); @@ -109,6 +120,8 @@ describe('Native Animated', () => { }); it('should flatten offset', async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0); opacity.__makeNative(); @@ -125,6 +138,8 @@ describe('Native Animated', () => { }); it('should save value on unmount', async () => { + const {Animated} = importModules(); + NativeAnimatedModule.getValue = jest.fn((tag, saveCallback) => { saveCallback(1); }); @@ -145,6 +160,8 @@ describe('Native Animated', () => { }); it('should deduct offset when saving value on unmount', async () => { + const {Animated} = importModules(); + NativeAnimatedModule.getValue = jest.fn((tag, saveCallback) => { // Assume current raw value of value node is 0.5, the NativeAnimated // getValue API returns the sum of raw value and offset, so return 1. @@ -167,6 +184,8 @@ describe('Native Animated', () => { }); it('should extract offset', async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0); opacity.__makeNative(); @@ -185,6 +204,8 @@ describe('Native Animated', () => { describe('Animated Listeners', () => { it('should get updates', () => { + const {Animated, NativeAnimatedHelper} = importModules(); + const value1 = new Animated.Value(0); value1.__makeNative(); const listener = jest.fn(); @@ -223,6 +244,8 @@ describe('Native Animated', () => { }); it('should removeAll', () => { + const {Animated, NativeAnimatedHelper} = importModules(); + const value1 = new Animated.Value(0); value1.__makeNative(); const listener = jest.fn(); @@ -253,6 +276,8 @@ describe('Native Animated', () => { describe('Animated Events', () => { it('should map events', async () => { + const {Animated} = importModules(); + const value = new Animated.Value(0); value.__makeNative(); const event = Animated.event([{nativeEvent: {state: {foo: value}}}], { @@ -281,6 +306,8 @@ describe('Native Animated', () => { }); it('should map AnimatedValueXY', async () => { + const {Animated} = importModules(); + const value = new Animated.ValueXY({x: 0, y: 0}); value.__makeNative(); const event = Animated.event([{nativeEvent: {state: value}}], { @@ -299,6 +326,8 @@ describe('Native Animated', () => { }); it('should throw on invalid event path', async () => { + const {Animated} = importModules(); + const value = new Animated.Value(0); value.__makeNative(); const event = Animated.event([{notNativeEvent: {foo: value}}], { @@ -323,6 +352,8 @@ describe('Native Animated', () => { }); it('should call listeners', () => { + const {Animated} = importModules(); + const value = new Animated.Value(0); value.__makeNative(); const listener = jest.fn(); @@ -339,6 +370,8 @@ describe('Native Animated', () => { describe('Animated Graph', () => { it('creates and detaches nodes', async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0); const root = await create(); @@ -379,6 +412,8 @@ describe('Native Animated', () => { }); it('sends a valid description for value, style and props nodes', async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0); await create(); @@ -403,6 +438,8 @@ describe('Native Animated', () => { }); it('sends a valid graph description for Animated.add nodes', async () => { + const {Animated} = importModules(); + const first = new Animated.Value(1); const second = new Animated.Value(2); first.__makeNative(); @@ -447,6 +484,8 @@ describe('Native Animated', () => { }); it('sends a valid graph description for Animated.subtract nodes', async () => { + const {Animated} = importModules(); + const first = new Animated.Value(2); const second = new Animated.Value(1); first.__makeNative(); @@ -491,6 +530,8 @@ describe('Native Animated', () => { }); it('sends a valid graph description for Animated.multiply nodes', async () => { + const {Animated} = importModules(); + const first = new Animated.Value(2); const second = new Animated.Value(1); first.__makeNative(); @@ -535,6 +576,8 @@ describe('Native Animated', () => { }); it('sends a valid graph description for Animated.divide nodes', async () => { + const {Animated} = importModules(); + const first = new Animated.Value(4); const second = new Animated.Value(2); first.__makeNative(); @@ -579,6 +622,8 @@ describe('Native Animated', () => { }); it('sends a valid graph description for Animated.modulo nodes', async () => { + const {Animated} = importModules(); + const value = new Animated.Value(4); value.__makeNative(); @@ -613,6 +658,8 @@ describe('Native Animated', () => { }); it('sends a valid graph description for interpolate() nodes', async () => { + const {Animated} = importModules(); + const value = new Animated.Value(10); value.__makeNative(); @@ -657,6 +704,8 @@ describe('Native Animated', () => { }); it('sends a valid graph description for transform nodes', async () => { + const {Animated} = importModules(); + const translateX = new Animated.Value(0); translateX.__makeNative(); @@ -685,6 +734,8 @@ describe('Native Animated', () => { }); it('sends create operations before connect operations for multiple animated style props', async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0); const borderRadius = new Animated.Value(0); await create(); @@ -779,6 +830,8 @@ describe('Native Animated', () => { }); it('sends create operations before connect operations for multiple animated transform props', async () => { + const {Animated} = importModules(); + const translateX = new Animated.Value(0); const translateY = new Animated.Value(0); await create( @@ -901,6 +954,8 @@ describe('Native Animated', () => { }); it('sends create operations before connect operations for multiple animated props', async () => { + const {Animated} = importModules(); + const propA = new Animated.Value(0); const propB = new Animated.Value(0); await create(); @@ -973,6 +1028,8 @@ describe('Native Animated', () => { }); it('sends a valid graph description for Animated.diffClamp nodes', async () => { + const {Animated} = importModules(); + const value = new Animated.Value(2); value.__makeNative(); @@ -1007,6 +1064,8 @@ describe('Native Animated', () => { }); it("doesn't call into native API if useNativeDriver is set to false", async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0); const root = await create(); @@ -1023,6 +1082,8 @@ describe('Native Animated', () => { }); it('fails when trying to run non-native animation on native node', async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0); const ref = React.createRef(null); @@ -1053,6 +1114,8 @@ describe('Native Animated', () => { }); it('fails for unsupported styles', async () => { + const {Animated} = importModules(); + const left = new Animated.Value(0); await create(); @@ -1073,6 +1136,8 @@ describe('Native Animated', () => { }); it('works for any `static` props and styles', async () => { + const {Animated} = importModules(); + // Passing "unsupported" props should work just fine as long as they are not animated const opacity = new Animated.Value(0); opacity.__makeNative(); @@ -1097,6 +1162,8 @@ describe('Native Animated', () => { describe('Animations', () => { it('sends a valid timing animation description', () => { + const {Animated} = importModules(); + const anim = new Animated.Value(0); Animated.timing(anim, { toValue: 10, @@ -1118,6 +1185,8 @@ describe('Native Animated', () => { }); it('sends a valid spring animation description', () => { + const {Animated} = importModules(); + const anim = new Animated.Value(0); Animated.spring(anim, { toValue: 10, @@ -1194,6 +1263,8 @@ describe('Native Animated', () => { }); it('sends a valid decay animation description', () => { + const {Animated} = importModules(); + const anim = new Animated.Value(0); Animated.decay(anim, { velocity: 10, @@ -1210,6 +1281,8 @@ describe('Native Animated', () => { }); it('works with Animated.loop', () => { + const {Animated} = importModules(); + const anim = new Animated.Value(0); Animated.loop( Animated.decay(anim, { @@ -1229,6 +1302,8 @@ describe('Native Animated', () => { }); it('sends stopAnimation command to native', () => { + const {Animated} = importModules(); + const value = new Animated.Value(0); const animation = Animated.timing(value, { toValue: 10, @@ -1256,6 +1331,8 @@ describe('Native Animated', () => { }); it('calls stopAnimation callback with native value', () => { + const {Animated} = importModules(); + NativeAnimatedModule.getValue = jest.fn((tag, saveCallback) => { saveCallback(1); }); @@ -1281,7 +1358,58 @@ describe('Native Animated', () => { }); describe('Animated Components', () => { + it('preserves current values on update and unmount', async () => { + const {ReactNativeFeatureFlags} = importModules(); + ReactNativeFeatureFlags.override({enableAnimatedPropsMemo: () => true}); + + const {Animated} = importModules(); + + const opacity = new Animated.Value(0); + opacity.__makeNative(); + + const root = await create(); + expect(NativeAnimatedModule.restoreDefaultValues).not.toHaveBeenCalled(); + + await update(root, ); + expect(NativeAnimatedModule.restoreDefaultValues).not.toHaveBeenCalled(); + + await unmount(root); + // Make sure it doesn't get called on unmount. + expect(NativeAnimatedModule.restoreDefaultValues).not.toHaveBeenCalled(); + }); + + it('restores defaults when receiving new animated values', async () => { + const {ReactNativeFeatureFlags} = importModules(); + ReactNativeFeatureFlags.override({enableAnimatedPropsMemo: () => true}); + + const {Animated} = importModules(); + + const opacityA = new Animated.Value(0); + const opacityB = new Animated.Value(0); + opacityA.__makeNative(); + opacityB.__makeNative(); + + const root = await create(); + expect(NativeAnimatedModule.restoreDefaultValues).not.toHaveBeenCalled(); + + await update(root, ); + expect(NativeAnimatedModule.restoreDefaultValues).toHaveBeenCalledTimes( + 1, + ); + + await unmount(root); + // Make sure it doesn't get called on unmount. + expect(NativeAnimatedModule.restoreDefaultValues).toHaveBeenCalledTimes( + 1, + ); + }); + it('should restore default values on prop updates only', async () => { + const {ReactNativeFeatureFlags} = importModules(); + ReactNativeFeatureFlags.override({enableAnimatedPropsMemo: () => false}); + + const {Animated} = importModules(); + const opacity = new Animated.Value(0); opacity.__makeNative(); @@ -1301,6 +1429,8 @@ describe('Native Animated', () => { }); it('connects the native view on mount and disconnects on unmount', async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0, {useNativeDriver: true}); const root = await create(); @@ -1322,6 +1452,8 @@ describe('Native Animated', () => { }); it('reconnects the native view when the component is remounted', async () => { + const {Animated} = importModules(); + const opacity = new Animated.Value(0, {useNativeDriver: true}); const ref = createRef(); await create(); diff --git a/packages/react-native/src/private/animated/__tests__/useAnimatedPropsMemo-test.js b/packages/react-native/src/private/animated/__tests__/useAnimatedPropsMemo-test.js new file mode 100644 index 000000000000..dbf696efa6d5 --- /dev/null +++ b/packages/react-native/src/private/animated/__tests__/useAnimatedPropsMemo-test.js @@ -0,0 +1,369 @@ +/** + * 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 + * @oncall react_native + */ + +import {AnimatedEvent} from '../../../../Libraries/Animated/AnimatedEvent'; +import AnimatedValue from '../../../../Libraries/Animated/nodes/AnimatedValue'; +import { + createCompositeKeyForProps, + areCompositeKeysEqual, +} from '../useAnimatedPropsMemo'; + +describe('createCompositeKeyForProps', () => { + describe('with allowlist', () => { + it('excludes non-array and non-object allowlisted props', () => { + const props = {string: 'abc', number: 123, boolean: true, function() {}}; + const allowlist = { + string: true, + number: true, + boolean: true, + function: true, + }; + const compositeKey = createCompositeKeyForProps(props, allowlist); + + expect(compositeKey).toEqual(null); + }); + + it('does not search non-allowlisted props', () => { + const getter = jest.fn().mockReturnValue({}); + const props = { + object: { + // $FlowExpectedError[unsafe-getters-setters] + get property() { + return getter(); + }, + }, + }; + const allowlist = {}; + const compositeKey = createCompositeKeyForProps(props, allowlist); + + expect(compositeKey).toEqual(null); + expect(getter).not.toHaveBeenCalled(); + }); + + it('includes allowlisted `AnimatedEvent` props at first depth', () => { + const props = { + foo: new AnimatedEvent([], {useNativeDriver: true}), + bar: new AnimatedEvent([], {useNativeDriver: true}), + }; + const allowlist = { + bar: true, + }; + const compositeKey = createCompositeKeyForProps(props, allowlist); + + expect(compositeKey).toEqual({bar: props.bar}); + expect(compositeKey?.bar).toBe(props.bar); + }); + + it('excludes allowlisted `AnimatedEvent` props in the `style` prop', () => { + const props = { + style: { + // This is invalid usage, but including for testing. + baz: new AnimatedEvent([], {useNativeDriver: true}), + }, + }; + const allowlist = { + style: { + baz: true, + }, + }; + const compositeKey = createCompositeKeyForProps(props, allowlist); + + expect(compositeKey).toEqual(null); + }); + + it('includes allowlisted `AnimatedNode` props', () => { + const props = { + foo: new AnimatedValue(1), + bar: new AnimatedValue(1), + }; + const allowlist = { + bar: true, + }; + const compositeKey = createCompositeKeyForProps(props, allowlist); + + expect(compositeKey).toEqual({bar: props.bar}); + expect(compositeKey?.bar).toBe(props.bar); + }); + + it('excludes non-allowlisted `style` props', () => { + const props = { + style: {opacity: new AnimatedValue(1)}, + }; + const allowlist = {}; + const compositeKey = createCompositeKeyForProps(props, allowlist); + + expect(compositeKey).toEqual(null); + }); + + it('searches the `style` prop for allowlisted `AnimatedNode` instances', () => { + const opacity = new AnimatedValue(1); + const rotateY = new AnimatedValue(1); + const props = { + style: {opacity, transform: [{rotateX: 1}, {rotateY}, {rotateZ: 1}]}, + }; + const allowlist = {style: {transform: true}}; + const compositeKey = createCompositeKeyForProps(props, allowlist); + + expect(compositeKey).toEqual({ + style: { + transform: [null, {rotateY}, null], + }, + }); + // $FlowIgnore[prop-missing] + expect(compositeKey?.style?.transform?.[1]?.rotateY).toBe(rotateY); + }); + + it('flattens the `style` prop before searching it', () => { + const opacityA = new AnimatedValue(1); + const opacityB = new AnimatedValue(1); + const props = { + style: [{opacity: opacityA}, {opacity: opacityB}], + }; + const allowlist = {style: {opacity: true}}; + const compositeKey = createCompositeKeyForProps(props, allowlist); + + expect(compositeKey).toEqual({style: {opacity: opacityB}}); + expect(compositeKey?.style?.opacity).toBe(opacityB); + }); + }); + + describe('without allowlist', () => { + it('excludes non-array and non-object props', () => { + const props = {string: 'abc', number: 123, boolean: true, function() {}}; + const compositeKey = createCompositeKeyForProps(props); + + expect(compositeKey).toEqual(null); + }); + + it('includes array props without searching them', () => { + const props = {array: [{letter: 'a'}, {letter: 'b'}, {letter: 'c'}]}; + const compositeKey = createCompositeKeyForProps(props); + + expect(compositeKey).toEqual({array: props.array}); + expect(compositeKey?.array).toBe(props.array); + }); + + it('includes object props without searching them', () => { + const props = {object: {foo: [1], bar: [2], baz: [3]}}; + const compositeKey = createCompositeKeyForProps(props); + + expect(compositeKey).toEqual({object: props.object}); + expect(compositeKey?.object).toBe(props.object); + }); + + it('includes `AnimatedEvent` props at first depth', () => { + const props = { + foo: new AnimatedEvent([], {useNativeDriver: true}), + object: { + bar: new AnimatedEvent([], {useNativeDriver: true}), + }, + }; + const compositeKey = createCompositeKeyForProps(props); + + expect(compositeKey).toEqual({ + foo: props.foo, + object: props.object, + }); + expect(compositeKey?.foo).toBe(props.foo); + expect(compositeKey?.object).toBe(props.object); + }); + + it('excludes `AnimatedEvent` props in the `style` prop', () => { + const props = { + style: { + // This is invalid usage, but including for testing. + baz: new AnimatedEvent([], {useNativeDriver: true}), + }, + }; + const compositeKey = createCompositeKeyForProps(props); + + expect(compositeKey).toEqual(null); + }); + + it('includes `AnimatedNode` props', () => { + const foo = new AnimatedValue(1); + const bar = new AnimatedValue(1); + const props = {foo, bar}; + const compositeKey = createCompositeKeyForProps(props); + + expect(compositeKey).toEqual({foo, bar}); + expect(compositeKey?.foo).toBe(foo); + expect(compositeKey?.bar).toBe(bar); + }); + + it('searches the `style` prop for `AnimatedNode` instances', () => { + const opacity = new AnimatedValue(1); + const rotateY = new AnimatedValue(1); + const props = { + style: {opacity, transform: [{rotateX: 1}, {rotateY}, {rotateZ: 1}]}, + }; + const compositeKey = createCompositeKeyForProps(props); + + expect(compositeKey).toEqual({ + style: { + opacity, + transform: [null, {rotateY}, null], + }, + }); + expect(compositeKey?.style?.opacity).toBe(opacity); + // $FlowIgnore[prop-missing] + expect(compositeKey?.style?.transform?.[1]?.rotateY).toBe(rotateY); + }); + + it('flattens the `style` prop before searching it', () => { + const opacityA = new AnimatedValue(1); + const opacityB = new AnimatedValue(1); + const props = { + style: [{opacity: opacityA}, {opacity: opacityB}], + }; + const compositeKey = createCompositeKeyForProps(props); + + expect(compositeKey).toEqual({ + style: {opacity: opacityB}, + }); + expect(compositeKey?.style?.opacity).toBe(opacityB); + }); + }); +}); + +describe('areCompositeKeysEqual', () => { + it('compares identical keys without traversal', () => { + const getter = jest.fn().mockReturnValue({}); + const compositeKey = { + object: { + // $FlowExpectedError[unsafe-getters-setters] + get property() { + return getter(); + }, + }, + }; + + expect(areCompositeKeysEqual(compositeKey, compositeKey, null)).toBe(true); + expect(getter).not.toHaveBeenCalled(); + }); + + it('compares null keys', () => { + const compositeKey = {foo: new AnimatedValue(1)}; + + expect(areCompositeKeysEqual(null, null, null)).toBe(true); + expect(areCompositeKeysEqual(null, compositeKey, null)).toBe(false); + expect(areCompositeKeysEqual(compositeKey, null, null)).toBe(false); + }); + + it('compares keys with different lengths', () => { + const compositeKeyA = {foo: new AnimatedValue(1)}; + const compositeKeyB = { + foo: new AnimatedValue(1), + bar: new AnimatedValue(1), + }; + + expect(areCompositeKeysEqual(compositeKeyA, compositeKeyB, null)).toBe( + false, + ); + expect(areCompositeKeysEqual(compositeKeyB, compositeKeyA, null)).toBe( + false, + ); + }); + + it('compares keys with `AnimatedNode` instances', () => { + const foo = new AnimatedValue(1); + const bar = new AnimatedValue(1); + + expect(areCompositeKeysEqual({foo, bar}, {foo, bar}, null)).toBe(true); + expect(areCompositeKeysEqual({foo}, {foo: bar}, null)).toBe(false); + }); + + it('compares keys with `AnimatedEvent` instances', () => { + const foo = new AnimatedEvent([], {useNativeDriver: true}); + const bar = new AnimatedEvent([], {useNativeDriver: true}); + + expect(areCompositeKeysEqual({foo, bar}, {foo, bar}, null)).toBe(true); + expect(areCompositeKeysEqual({foo}, {foo: bar}, null)).toBe(false); + }); + + it('compares keys with `style` props and identical `AnimatedNode`', () => { + const opacity = new AnimatedValue(1); + const rotateY = new AnimatedValue(1); + const compositeKeyA = { + style: { + opacity, + transform: [null, {rotateY}, null], + }, + }; + const compositeKeyB = { + style: { + opacity, + transform: [null, {rotateY}, null], + }, + }; + + expect(areCompositeKeysEqual(compositeKeyA, compositeKeyB, null)).toBe( + true, + ); + expect(areCompositeKeysEqual(compositeKeyB, compositeKeyA, null)).toBe( + true, + ); + }); + + it('compares keys with `style` props and different `AnimatedNode`', () => { + const opacity = new AnimatedValue(1); + const compositeKeyA = { + style: { + opacity, + transform: [null, {rotateY: new AnimatedValue(1)}, null], + }, + }; + const compositeKeyB = { + style: { + opacity, + transform: [null, {rotateY: new AnimatedValue(1)}, null], + }, + }; + + expect(areCompositeKeysEqual(compositeKeyA, compositeKeyB, null)).toBe( + false, + ); + expect(areCompositeKeysEqual(compositeKeyB, compositeKeyA, null)).toBe( + false, + ); + }); + + it('compares keys with arrays and objects', () => { + const bar = new AnimatedValue(1); + + // If not in the allowlist, must be `===`. + expect(areCompositeKeysEqual({foo: [bar]}, {foo: [bar]}, null)).toBe(false); + expect(areCompositeKeysEqual({foo: {bar}}, {foo: {bar}}, null)).toBe(false); + + // If in the allowlist, arrays and objects are traversed. + expect(areCompositeKeysEqual({foo: [bar]}, {foo: [bar]}, {foo: true})).toBe( + true, + ); + expect(areCompositeKeysEqual({foo: {bar}}, {foo: {bar}}, {foo: true})).toBe( + true, + ); + }); + + it('compares arrays with identical `AnimatedNode` at different indices', () => { + const bar = new AnimatedValue(1); + + expect( + areCompositeKeysEqual({foo: [bar, null]}, {foo: [null, bar]}, null), + ).toBe(false); + expect( + areCompositeKeysEqual( + {foo: [bar, null]}, + {foo: [null, bar]}, + {foo: true}, + ), + ).toBe(false); + }); +}); diff --git a/packages/react-native/src/private/animated/useAnimatedPropsMemo.js b/packages/react-native/src/private/animated/useAnimatedPropsMemo.js new file mode 100644 index 000000000000..d3806ec44273 --- /dev/null +++ b/packages/react-native/src/private/animated/useAnimatedPropsMemo.js @@ -0,0 +1,349 @@ +/** + * 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 + * @oncall react_native + */ + +import type AnimatedProps from '../../../Libraries/Animated/nodes/AnimatedProps'; +import type {AnimatedPropsAllowlist} from '../../../Libraries/Animated/nodes/AnimatedProps'; +import type {AnimatedStyleAllowlist} from '../../../Libraries/Animated/nodes/AnimatedStyle'; + +import {AnimatedEvent} from '../../../Libraries/Animated/AnimatedEvent'; +import AnimatedNode from '../../../Libraries/Animated/nodes/AnimatedNode'; +import {isPlainObject} from '../../../Libraries/Animated/nodes/AnimatedObject'; +import flattenStyle from '../../../Libraries/StyleSheet/flattenStyle'; + +import nullthrows from 'nullthrows'; +import {useMemo, useState} from 'react'; + +type CompositeKey = { + style?: {[string]: CompositeKeyComponent}, + [string]: + | CompositeKeyComponent + | AnimatedEvent + | $ReadOnlyArray + | $ReadOnly<{[string]: mixed}>, +}; + +type CompositeKeyComponent = + | AnimatedNode + | Array + | {[string]: CompositeKeyComponent}; + +type $ReadOnlyCompositeKey = $ReadOnly<{ + style?: $ReadOnly<{[string]: CompositeKeyComponent}>, + [string]: + | $ReadOnlyCompositeKeyComponent + | AnimatedEvent + | $ReadOnlyArray + | $ReadOnly<{[string]: mixed}>, +}>; + +type $ReadOnlyCompositeKeyComponent = + | AnimatedNode + | $ReadOnlyArray<$ReadOnlyCompositeKeyComponent | null> + | $ReadOnly<{[string]: $ReadOnlyCompositeKeyComponent}>; + +/** + * A hook that returns an `AnimatedProps` object that is memoized based on the + * subset of `props` that are instances of `AnimatedNode` or `AnimatedEvent`. + */ +export function useAnimatedPropsMemo( + create: () => AnimatedProps, + // TODO: Make this two separate arguments after the experiment is over. This + // is only an array-like structure to make it easier to experiment with this + // and `useMemo`. + [allowlist, props]: [?AnimatedPropsAllowlist, {[string]: mixed}], +): AnimatedProps { + const compositeKey = useMemo( + () => createCompositeKeyForProps(props, allowlist), + [allowlist, props], + ); + + const [state, setState] = useState<{ + allowlist: ?AnimatedPropsAllowlist, + compositeKey: $ReadOnlyCompositeKey | null, + value: AnimatedProps, + }>(() => ({ + allowlist, + compositeKey, + value: create(), + })); + + if ( + state.allowlist !== allowlist || + !areCompositeKeysEqual(state.compositeKey, compositeKey) + ) { + setState({ + allowlist, + compositeKey, + value: create(), + }); + } + return state.value; +} + +/** + * Creates a new composite key for a `props` object that can be used to detect + * whether a new `AnimatedProps` instance must be created. + * + * - With an allowlist, those props are searched for `AnimatedNode` instances. + * - Without an allowlist, `style` is searched for `AnimatedNode` instances, + * but all other objects and arrays are included (not searched). We do not + * search objects and arrays without an allowlist in case they are very large + * data structures. We safely traverse `style` becuase it is bounded. + * + * Any `AnimatedEvent` instances at the first depth are always included. + * + * If `props` contains no `AnimatedNode` or `AnimatedEvent` instances, this + * returns null. + */ +export function createCompositeKeyForProps( + props: $ReadOnly<{[string]: mixed}>, + allowlist: ?AnimatedPropsAllowlist, +): $ReadOnlyCompositeKey | null { + let compositeKey: CompositeKey | null = null; + + const keys = Object.keys(props); + for (let ii = 0, length = keys.length; ii < length; ii++) { + const key = keys[ii]; + const value = props[key]; + + if (allowlist == null || Object.hasOwn(allowlist, key)) { + let compositeKeyComponent; + if (key === 'style') { + // $FlowFixMe[incompatible-call] - `style` is a valid argument. + // $FlowFixMe[incompatible-type] - `flattenStyle` returns an object. + const flatStyle: ?{[string]: mixed} = flattenStyle(value); + if (flatStyle != null) { + compositeKeyComponent = createCompositeKeyForObject( + flatStyle, + allowlist?.style, + ); + } + } else if ( + value instanceof AnimatedNode || + value instanceof AnimatedEvent + ) { + compositeKeyComponent = value; + } else if (Array.isArray(value)) { + compositeKeyComponent = + allowlist == null ? value : createCompositeKeyForArray(value); + } else if (isPlainObject(value)) { + compositeKeyComponent = + allowlist == null ? value : createCompositeKeyForObject(value); + } + if (compositeKeyComponent != null) { + if (compositeKey == null) { + compositeKey = {} as CompositeKey; + } + compositeKey[key] = compositeKeyComponent; + } + } + } + + return compositeKey; +} + +/** + * Creates a new composite key for an array that retains all values that are or + * contain `AnimatedNode` instances, and `null` for the rest. + * + * If `array` contains no `AnimatedNode` instances, this returns null. + */ +function createCompositeKeyForArray( + array: $ReadOnlyArray, +): $ReadOnlyArray<$ReadOnlyCompositeKeyComponent | null> | null { + let compositeKey: Array<$ReadOnlyCompositeKeyComponent | null> | null = null; + + for (let ii = 0, length = array.length; ii < length; ii++) { + const value = array[ii]; + + let compositeKeyComponent; + if (value instanceof AnimatedNode) { + compositeKeyComponent = value; + } else if (Array.isArray(value)) { + compositeKeyComponent = createCompositeKeyForArray(value); + } else if (isPlainObject(value)) { + compositeKeyComponent = createCompositeKeyForObject(value); + } + if (compositeKeyComponent != null) { + if (compositeKey == null) { + compositeKey = new Array<$ReadOnlyCompositeKeyComponent | null>( + array.length, + ).fill(null); + } + compositeKey[ii] = compositeKeyComponent; + } + } + + return compositeKey; +} + +/** + * Creates a new composite key for an object that retains only properties that + * are or contain `AnimatedNode` instances. + * + * When used to create composite keys for `style` props: + * + * - With an allowlist, those properties are searched. + * - Without an allowlist, every property is searched. + * + * If `object` contains no `AnimatedNode` instances, this returns null. + */ +function createCompositeKeyForObject( + object: $ReadOnly<{[string]: mixed}>, + allowlist?: ?AnimatedStyleAllowlist, +): $ReadOnly<{[string]: $ReadOnlyCompositeKeyComponent}> | null { + let compositeKey: {[string]: $ReadOnlyCompositeKeyComponent} | null = null; + + const keys = Object.keys(object); + for (let ii = 0, length = keys.length; ii < length; ii++) { + const key = keys[ii]; + + if (allowlist == null || Object.hasOwn(allowlist, key)) { + const value = object[key]; + + let compositeKeyComponent; + if (value instanceof AnimatedNode) { + compositeKeyComponent = value; + } else if (Array.isArray(value)) { + compositeKeyComponent = createCompositeKeyForArray(value); + } else if (isPlainObject(value)) { + compositeKeyComponent = createCompositeKeyForObject(value); + } + if (compositeKeyComponent != null) { + if (compositeKey == null) { + compositeKey = {} as {[string]: $ReadOnlyCompositeKeyComponent}; + } + compositeKey[key] = compositeKeyComponent; + } + } + } + + return compositeKey; +} + +export function areCompositeKeysEqual( + maybePrev: $ReadOnlyCompositeKey | null, + maybeNext: $ReadOnlyCompositeKey | null, + allowlist: ?AnimatedPropsAllowlist, +): boolean { + if (maybePrev === maybeNext) { + return true; + } + if (maybePrev === null || maybeNext === null) { + return false; + } + // Help Flow retain the type refinements of these. + const prev = maybePrev; + const next = maybeNext; + + const keys = Object.keys(prev); + const length = keys.length; + if (length !== Object.keys(next).length) { + return false; + } + for (let ii = 0; ii < length; ii++) { + const key = keys[ii]; + if (!Object.hasOwn(next, key)) { + return false; + } + const prevComponent = prev[key]; + const nextComponent = next[key]; + + if (key === 'style') { + // We know style components are objects with non-mixed values. + if ( + !areCompositeKeyComponentsEqual( + // $FlowIgnore[incompatible-cast] + prevComponent as $ReadOnlyCompositeKeyComponent, + // $FlowIgnore[incompatible-cast] + nextComponent as $ReadOnlyCompositeKeyComponent, + ) + ) { + return false; + } + } else if ( + prevComponent instanceof AnimatedNode || + prevComponent instanceof AnimatedEvent + ) { + if (prevComponent !== nextComponent) { + return false; + } + } else { + // When `allowlist` is null, the components must be the same. Otherwise, + // we created the components using deep traversal, so deep compare them. + if (allowlist == null) { + if (prevComponent !== nextComponent) { + return false; + } + } else { + if ( + !areCompositeKeyComponentsEqual( + // $FlowIgnore[incompatible-cast] + prevComponent as $ReadOnlyCompositeKeyComponent, + // $FlowIgnore[incompatible-cast] + nextComponent as $ReadOnlyCompositeKeyComponent, + ) + ) { + return false; + } + } + } + } + return true; +} + +function areCompositeKeyComponentsEqual( + prev: $ReadOnlyCompositeKeyComponent | null, + next: $ReadOnlyCompositeKeyComponent | null, +): boolean { + if (prev === next) { + return true; + } + if (prev instanceof AnimatedNode) { + return prev === next; + } + if (Array.isArray(prev)) { + if (!Array.isArray(next)) { + return false; + } + const length = prev.length; + if (length !== next.length) { + return false; + } + for (let ii = 0; ii < length; ii++) { + if (!areCompositeKeyComponentsEqual(prev[ii], next[ii])) { + return false; + } + } + return true; + } + if (isPlainObject(prev)) { + if (!isPlainObject(next)) { + return false; + } + const keys = Object.keys(prev); + const length = keys.length; + if (length !== Object.keys(next).length) { + return false; + } + for (let ii = 0; ii < length; ii++) { + const key = keys[ii]; + if ( + !Object.hasOwn(nullthrows(next), key) || + !areCompositeKeyComponentsEqual(prev[key], next[key]) + ) { + return false; + } + } + return true; + } + return false; +} diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index faf46976887e..5c3628d0b0af 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<> + * @generated SignedSource<> * @flow strict-local */ @@ -31,6 +31,7 @@ export type ReactNativeFeatureFlagsJsOnly = { animatedShouldUseSingleOp: Getter, enableAccessToHostTreeInFabric: Getter, enableAnimatedAllowlist: Getter, + enableAnimatedPropsMemo: Getter, enableOptimisedVirtualizedCells: Getter, isLayoutAnimationEnabled: Getter, shouldSkipStateUpdatesForLoopingAnimations: Getter, @@ -121,6 +122,11 @@ export const enableAccessToHostTreeInFabric: Getter = createJavaScriptF */ export const enableAnimatedAllowlist: Getter = createJavaScriptFlagGetter('enableAnimatedAllowlist', false); +/** + * Enables Animated to analyze props to minimize invalidating `AnimatedProps`. + */ +export const enableAnimatedPropsMemo: Getter = createJavaScriptFlagGetter('enableAnimatedPropsMemo', false); + /** * Removing unnecessary rerenders Virtualized cells after any rerenders of Virualized list. Works with strict=true option */