From 60e8fb34e07d6bda75cdf88b22f4d519c683e1e2 Mon Sep 17 00:00:00 2001 From: Tim Yung Date: Mon, 9 Sep 2024 20:55:45 -0700 Subject: [PATCH] Animated: Minimize `AnimatedProps` Invalidation (#46386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/46386 Currently, `useAnimatedProps` attempts (and fails) to memoize the `AnimatedProps` instance by using `props` in the dependency array: https://www.internalfb.com/code/fbsource/[de6bf108ae11fad0e8516617cde6f0cf3152d129]/xplat/js/react-native-github/packages/react-native/Libraries/Animated/useAnimatedProps.js?lines=56-59 However, `props` is very easily invalidated whenever the component is updated by its parent. The only time when it will not be invalidated is if the component that directly uses this hook — `createAnimatedComponent`'s return value — updates state… which never happens. This changes `useAnimatedProps` so that we memoize `AnimatedProps` using only the nested property values that actually require a new `AnimatedProps` object to be created, which are `AnimatedNode` subclasses. A minor detail is that in order for `AnimatedProps.prototype.__getValue` to continue working, we must supply `props` when we're actualizing the `AnimatedNode` instances into real values. This is accomplished by introducing a new method to select `AnimatedNode` subclasses: `__getValueFromStatic{Props,Object,Style,Transforms}` [General][Added] - Created an experimental optimization to improve memoization within `Animated` to improve product performance Differential Revision: D61997128 --- .../Animated/nodes/AnimatedObject.js | 16 +- .../Libraries/Animated/nodes/AnimatedProps.js | 25 +++ .../Libraries/Animated/nodes/AnimatedStyle.js | 36 +++ .../Animated/nodes/AnimatedTransform.js | 12 + .../Libraries/Animated/useAnimatedProps.js | 26 ++- .../__snapshots__/public-api-test.js.snap | 11 +- .../ReactNativeFeatureFlags.config.js | 5 + .../animated/__tests__/AnimatedNative-test.js | 23 +- .../__tests__/useAnimatedPropsMemo-test.js | 210 ++++++++++++++++++ .../private/animated/useAnimatedPropsMemo.js | 164 ++++++++++++++ .../useMemoWithDynamicDependencies.js | 41 ++++ .../featureflags/ReactNativeFeatureFlags.js | 8 +- 12 files changed, 560 insertions(+), 17 deletions(-) create mode 100644 packages/react-native/src/private/animated/__tests__/useAnimatedPropsMemo-test.js create mode 100644 packages/react-native/src/private/animated/useAnimatedPropsMemo.js create mode 100644 packages/react-native/src/private/animated/useMemoWithDynamicDependencies.js diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedObject.js b/packages/react-native/Libraries/Animated/nodes/AnimatedObject.js index 2f3ff765de60ff..10988615bbb5a2 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 83709c6640afa5..46523cded66cd6 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 9086986d17a658..5da70f98fa6ced 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedStyle.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedStyle.js @@ -138,6 +138,42 @@ 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 + ? {...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 67da5be13a2d56..50f484e3b76d5b 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 28086bedfa6edf..1093e42ae618a6 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 8362293e4d142c..fd0fa65e5cdf6a 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 f55cef723f4509..69c99e11dc0a35 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 46a80a9dd5eda7..e479496a4917a8 100644 --- a/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js +++ b/packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js @@ -1281,7 +1281,7 @@ describe('Native Animated', () => { }); describe('Animated Components', () => { - it('should restore default values on prop updates only', async () => { + it('preserves current values on update and unmount', async () => { const opacity = new Animated.Value(0); opacity.__makeNative(); @@ -1289,8 +1289,25 @@ describe('Native Animated', () => { expect(NativeAnimatedModule.restoreDefaultValues).not.toHaveBeenCalled(); await update(root, ); - expect(NativeAnimatedModule.restoreDefaultValues).toHaveBeenCalledWith( - expect.any(Number), + 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 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); 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 00000000000000..84d9ccd07ec6c2 --- /dev/null +++ b/packages/react-native/src/private/animated/__tests__/useAnimatedPropsMemo-test.js @@ -0,0 +1,210 @@ +/** + * 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 { + computeDependenciesWithoutAllowlist, + computeDependenciesWithAllowlist, +} from '../useAnimatedPropsMemo'; + +describe('computeDependenciesWithoutAllowlist', () => { + it('excludes non-array and non-object props', () => { + const props = {string: 'abc', number: 123, boolean: true, function() {}}; + const dependencies = computeDependenciesWithoutAllowlist(props); + + expect(dependencies).toEqual([]); + }); + + it('includes without traversing into array props', () => { + const props = {array: [{letter: 'a'}, {letter: 'b'}, {letter: 'c'}]}; + const dependencies = computeDependenciesWithoutAllowlist(props); + + expect(dependencies).toEqual(['{"array":true}', props.array]); + expect(dependencies[1]).toBe(props.array); + }); + + it('includes without traversing into object props', () => { + const props = {object: {foo: [1], bar: [2], baz: [3]}}; + const dependencies = computeDependenciesWithoutAllowlist(props); + + expect(dependencies).toEqual(['{"object":true}', props.object]); + expect(dependencies[1]).toBe(props.object); + }); + + it('includes `AnimatedEvent` props at first depth', () => { + const props = { + foo: new AnimatedEvent([], {useNativeDriver: true}), + object: { + bar: new AnimatedEvent([], {useNativeDriver: true}), + }, + style: { + // This is invalid usage, but including for testing. + baz: new AnimatedEvent([], {useNativeDriver: true}), + }, + }; + const dependencies = computeDependenciesWithoutAllowlist(props); + + expect(dependencies).toEqual([ + '{"foo":true,"object":true,"style":{}}', + props.foo, + props.object, + ]); + expect(dependencies[1]).toBe(props.foo); + expect(dependencies[2]).toBe(props.object); + }); + + it('includes `AnimatedNode` props', () => { + const foo = new AnimatedValue(1); + const bar = new AnimatedValue(1); + const props = {foo, bar}; + const dependencies = computeDependenciesWithoutAllowlist(props); + + expect(dependencies).toEqual(['{"foo":true,"bar":true}', foo, bar]); + expect(dependencies[1]).toBe(foo); + expect(dependencies[2]).toBe(bar); + }); + + it('traverses 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 dependencies = computeDependenciesWithoutAllowlist(props); + + expect(dependencies).toEqual([ + '{"style":{"opacity":true,"transform":[{},{"rotateY":true},{}]}}', + opacity, + rotateY, + ]); + expect(dependencies[1]).toBe(opacity); + expect(dependencies[2]).toBe(rotateY); + }); + + it('flattens the `style` prop before traversing it', () => { + const opacityA = new AnimatedValue(1); + const opacityB = new AnimatedValue(1); + const props = { + style: [{opacity: opacityA}, {opacity: opacityB}], + }; + const dependencies = computeDependenciesWithoutAllowlist(props); + + expect(dependencies).toEqual(['{"style":{"opacity":true}}', opacityB]); + expect(dependencies[1]).toBe(opacityB); + }); +}); + +describe('computeDependenciesWithAllowlist', () => { + 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 dependencies = computeDependenciesWithAllowlist(props, allowlist); + + expect(dependencies).toEqual([]); + }); + + it('does not traverse into non-allowlisted props', () => { + const getter = jest.fn().mockReturnValue({}); + const props = { + object: { + // $FlowExpectedError[unsafe-getters-setters] + get property() { + return getter(); + }, + }, + }; + const allowlist = {}; + const dependencies = computeDependenciesWithAllowlist(props, allowlist); + + expect(dependencies).toEqual([]); + expect(getter).not.toHaveBeenCalled(); + }); + + it('includes allowlisted `AnimatedEvent` props at first depth', () => { + const props = { + foo: new AnimatedEvent([], {useNativeDriver: true}), + bar: new AnimatedEvent([], {useNativeDriver: true}), + style: { + // This is invalid usage, but including for testing. + baz: new AnimatedEvent([], {useNativeDriver: true}), + }, + }; + const allowlist = { + bar: true, + style: { + baz: true, + }, + }; + const dependencies = computeDependenciesWithAllowlist(props, allowlist); + + expect(dependencies).toEqual(['{"bar":true,"style":{}}', props.bar]); + expect(dependencies[1]).toBe(props.bar); + }); + + it('includes allowlisted `AnimatedNode` props', () => { + const props = { + foo: new AnimatedValue(1), + bar: new AnimatedValue(1), + }; + const allowlist = { + bar: true, + }; + const dependencies = computeDependenciesWithAllowlist(props, allowlist); + + expect(dependencies).toEqual(['{"bar":true}', props.bar]); + expect(dependencies[1]).toBe(props.bar); + }); + + it('excludes non-allowlisted `style` props', () => { + const props = { + style: {opacity: new AnimatedValue(1)}, + }; + const allowlist = {}; + const dependencies = computeDependenciesWithAllowlist(props, allowlist); + + expect(dependencies).toEqual([]); + }); + + it('traverses 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 dependencies = computeDependenciesWithAllowlist(props, allowlist); + + expect(dependencies).toEqual([ + '{"style":{"transform":[{},{"rotateY":true},{}]}}', + rotateY, + ]); + expect(dependencies[1]).toBe(rotateY); + }); + + it('flattens the `style` prop before traversing it', () => { + const opacityA = new AnimatedValue(1); + const opacityB = new AnimatedValue(1); + const props = { + style: [{opacity: opacityA}, {opacity: opacityB}], + }; + const allowlist = {style: {opacity: true}}; + const dependencies = computeDependenciesWithAllowlist(props, allowlist); + + expect(dependencies).toEqual(['{"style":{"opacity":true}}', opacityB]); + expect(dependencies[1]).toBe(opacityB); + }); +}); 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 00000000000000..7def37ee532dff --- /dev/null +++ b/packages/react-native/src/private/animated/useAnimatedPropsMemo.js @@ -0,0 +1,164 @@ +/** + * 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 {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 useMemoWithDynamicDependencies from './useMemoWithDynamicDependencies'; + +import nullthrows from 'nullthrows'; +import {useMemo} from 'react'; + +/** + * A hook that returns an `AnimatedProps` object that is memoized based on the + * subet of `props` that are enumerated by `allowlist`. If `allowlist` is null, + * this will memoize on the identity of `props` (i.e. every render). + */ +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 dependencies = useMemo( + () => + allowlist == null + ? computeDependenciesWithoutAllowlist(props) + : computeDependenciesWithAllowlist(props, allowlist), + [allowlist, props], + ); + + return useMemoWithDynamicDependencies(create, dependencies); +} + +/** + * Computes the dependencies by traversing `props` to find all `AnimatedEvent` + * instances at the first depth and any `AnimatedNode` instances in `style`. + * Other props that are objects or arrays must be identical in order to reuse + * the memoized `AnimatedProps` instance. + * + * Without an allowlist, we do not traverse objects and arrays on props because + * they may be potentially very large data structures. We safely traverse the + * `style` prop only because its complexity is bounded. + */ +export function computeDependenciesWithoutAllowlist(props: { + [string]: mixed, +}): $ReadOnlyArray { + const dependencies: Array = []; + + // If we find a `style` prop, we flatten it and store it here. + let style: ?{[string]: mixed}; + + const checksum = JSON.stringify( + props, + function (this: mixed, key: string, value: mixed): mixed { + // This is only reachable if we are traversing the `style` prop. + if (value instanceof AnimatedNode) { + dependencies.push(value); + return true; + } + if (this === props) { + if (key === 'style') { + // $FlowExpectedError[incompatible-call] - `prop.style` is valid. + // $FlowExpectedError[incompatible-type] - `style` values are mixed. + style = flattenStyle(value); + return style; + } + // `AnimatedEvent` are only allowed at the first depth. + if (value instanceof AnimatedEvent) { + dependencies.push(value); + return true; + } + // Without an allowlist, we do not traverse objects and arrays on props. + // Add them to `dependencies` to be compared for equality. + if (Array.isArray(value) || isPlainObject(value)) { + dependencies.push(value); + return true; + } + } else { + // Code path is only reachable if we are traversing the `style` prop. + if (Array.isArray(value) || isPlainObject(value)) { + return value; + } + } + return undefined; + }, + ); + if (dependencies.length > 0) { + dependencies.unshift(checksum); + } + return dependencies; +} + +/** + * Computes the dependencies by traversing `props` to find all `AnimatedEvent` + * instances at the first depth and all `AnimatedNode` instances at all depths, + * and generating a checksum that represents the path to each of these. + * + * Only properties on the allowlist are traverse, which allows us to traverse + * objects and arrays on props without potentially traversing extremely large + * data structures. + */ +export function computeDependenciesWithAllowlist( + props: {[string]: mixed}, + allowlist: AnimatedPropsAllowlist, +): $ReadOnlyArray { + const dependencies: Array = []; + + // If we find a `style` prop, we flatten it and store it here. + let style: ?{[string]: mixed}; + + const checksum = JSON.stringify( + props, + function (this: mixed, key: string, value: mixed): mixed { + if (this === props) { + if (!Object.hasOwn(allowlist, key)) { + return undefined; + } + if (key === 'style') { + // $FlowExpectedError[incompatible-call] - `prop.style` is valid. + // $FlowExpectedError[incompatible-type] - `style` values are mixed. + style = flattenStyle(value); + return style; + } + // `AnimatedEvent` are only allowed at the first depth. + if (value instanceof AnimatedEvent) { + dependencies.push(value); + return true; + } + } else if (this === style && style != null) { + if (!Object.hasOwn(nullthrows(allowlist.style), key)) { + return undefined; + } + } + if (value instanceof AnimatedNode) { + dependencies.push(value); + return true; + } + if (Array.isArray(value) || isPlainObject(value)) { + return value; + } + // Returning `undefined` in this replacer causes properties with no + // `AnimatedNode` or `AnimatedEvent` to be omitted. However, this will + // retain null values in arrays. + return undefined; + }, + ); + if (dependencies.length > 0) { + dependencies.unshift(checksum); + } + return dependencies; +} diff --git a/packages/react-native/src/private/animated/useMemoWithDynamicDependencies.js b/packages/react-native/src/private/animated/useMemoWithDynamicDependencies.js new file mode 100644 index 00000000000000..4f23136bd312d9 --- /dev/null +++ b/packages/react-native/src/private/animated/useMemoWithDynamicDependencies.js @@ -0,0 +1,41 @@ +/** + * 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 + * @format + * @oncall react_native + */ + +import {useState} from 'react'; + +/** + * A hook like `useMemo` that allows the dependencies to be a dynamic array of + * values. The dependencies are still compared shallowly and by reference, but + * the list of dependencies can change. + * + * NOTE: This uses `useState` internally and potentially requires more render + * overhead than `useMemo`. Use `useMemo` whenever possible. + */ +export default function useMemoWithDynamicDependencies( + create: () => T, + dependencies: $ReadOnlyArray, +): T { + const [state, setState] = useState(() => ({ + dependencies, + value: create(), + })); + + if ( + state.dependencies !== dependencies && + (state.dependencies.length !== dependencies.length || + state.dependencies.some( + (dependency, index) => dependency !== dependencies[index], + )) + ) { + setState({dependencies, value: create()}); + } + return state.value; +} diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index faf46976887e8c..5c3628d0b0afae 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 */