Animated: Minimize AnimatedProps Invalidation (#46386)

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}`

Changelog:
[General][Added] - Created an experimental optimization to improve memoization within `Animated` to improve product performance

Reviewed By: javache

Differential Revision: D61997128

fbshipit-source-id: 2ca3cfb1038ba9a0abd82904acbc5cb4eb41a45d
This commit is contained in:
Tim Yung
2024-09-11 15:34:22 -07:00
committed by Facebook GitHub Bot
parent 401f735e35
commit dc84d00db6
11 changed files with 982 additions and 20 deletions
@@ -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();
@@ -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} = {};
@@ -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<Object> {
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} = {};
@@ -91,6 +91,18 @@ export default class AnimatedTransform extends AnimatedWithChildren {
);
}
__getValueWithStaticTransforms(
staticTransforms: $ReadOnlyArray<Object>,
): $ReadOnlyArray<Object> {
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<Transform<any>> {
return mapTransforms(this._transforms, animatedNode =>
animatedNode.__getAnimatedValue(),
@@ -28,6 +28,7 @@ import {
useReducer,
useRef,
} from 'react';
import {useAnimatedPropsMemo} from '../../src/private/animated/useAnimatedPropsMemo';
type ReducedProps<TProps> = {
...TProps,
@@ -41,23 +42,24 @@ type AnimatedValueListeners = Array<{
listenerId: string,
}>;
const useMemoOrAnimatedPropsMemo =
ReactNativeFeatureFlags.enableAnimatedPropsMemo()
? useAnimatedPropsMemo
: useMemo;
export default function useAnimatedProps<TProps: {...}, TInstance>(
props: TProps,
allowlist?: ?AnimatedPropsAllowlist,
): [ReducedProps<TProps>, CallbackRef<TInstance | null>] {
const [, scheduleUpdate] = useReducer<number, void>(count => count + 1, 0);
const onUpdateRef = useRef<?() => void>(null);
const onUpdateRef = useRef<(() => void) | null>(null);
const timerRef = useRef<TimeoutID | null>(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,
@@ -66,6 +68,7 @@ export default function useAnimatedProps<TProps: {...}, TInstance>(
),
[allowlistIfEnabled, props],
);
const useNativePropsInFabric =
ReactNativeFeatureFlags.shouldUseSetNativePropsInFabric();
const useSetNativePropsInNativeAnimationsInFabric =
@@ -205,14 +208,19 @@ export default function useAnimatedProps<TProps: {...}, TInstance>(
);
const callbackRef = useRefEffect<TInstance>(refEffect);
return [reduceAnimatedProps<TProps>(node), callbackRef];
return [reduceAnimatedProps<TProps>(node, props), callbackRef];
}
function reduceAnimatedProps<TProps>(node: AnimatedNode): ReducedProps<TProps> {
function reduceAnimatedProps<TProps>(
node: AnimatedProps,
props: TProps,
): ReducedProps<TProps> {
// 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,
};
}
@@ -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<AnimatedNode>, 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<Object>;
__getValueWithStaticStyle(staticStyle: Object): Object | Array<Object>;
__getAnimatedValue(): Object;
__attach(): void;
__detach(): void;
@@ -1061,6 +1067,9 @@ declare export default class AnimatedTransform extends AnimatedWithChildren {
): void;
__makeNative(platformConfig: ?PlatformConfig): void;
__getValue(): $ReadOnlyArray<Transform<any>>;
__getValueWithStaticTransforms(
staticTransforms: $ReadOnlyArray<Object>
): $ReadOnlyArray<Object>;
__getAnimatedValue(): $ReadOnlyArray<Transform<any>>;
__attach(): void;
__detach(): void;
@@ -298,6 +298,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:
@@ -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(<Animated.View style={{opacity}} />);
@@ -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(<Animated.View style={{opacity}} />);
@@ -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(<Animated.View style={{borderRadius, opacity}} />);
@@ -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(<Animated.View propA={propA} propB={propB} />);
@@ -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(<Animated.View style={{opacity}} />);
@@ -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(<Animated.View style={{left}} />);
@@ -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(<Animated.View style={{opacity}} />);
expect(NativeAnimatedModule.restoreDefaultValues).not.toHaveBeenCalled();
await update(root, <Animated.View style={{opacity}} />);
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(<Animated.View style={{opacity: opacityA}} />);
expect(NativeAnimatedModule.restoreDefaultValues).not.toHaveBeenCalled();
await update(root, <Animated.View style={{opacity: opacityB}} />);
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(<Animated.View style={{opacity}} />);
@@ -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(<Animated.View ref={ref} style={{opacity}} />);
@@ -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);
});
});
@@ -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<mixed>
| $ReadOnly<{[string]: mixed}>,
};
type CompositeKeyComponent =
| AnimatedNode
| Array<CompositeKeyComponent | null>
| {[string]: CompositeKeyComponent};
type $ReadOnlyCompositeKey = $ReadOnly<{
style?: $ReadOnly<{[string]: CompositeKeyComponent}>,
[string]:
| $ReadOnlyCompositeKeyComponent
| AnimatedEvent
| $ReadOnlyArray<mixed>
| $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<mixed>,
): $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;
}
@@ -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<<20de2dddd88f49d4f3a6491ca5c2fe6f>>
* @generated SignedSource<<d6b7a8d93e08da169a3e6d60de9e3155>>
* @flow strict-local
*/
@@ -31,6 +31,7 @@ export type ReactNativeFeatureFlagsJsOnly = {
animatedShouldUseSingleOp: Getter<boolean>,
enableAccessToHostTreeInFabric: Getter<boolean>,
enableAnimatedAllowlist: Getter<boolean>,
enableAnimatedPropsMemo: Getter<boolean>,
enableOptimisedVirtualizedCells: Getter<boolean>,
isLayoutAnimationEnabled: Getter<boolean>,
shouldSkipStateUpdatesForLoopingAnimations: Getter<boolean>,
@@ -122,6 +123,11 @@ export const enableAccessToHostTreeInFabric: Getter<boolean> = createJavaScriptF
*/
export const enableAnimatedAllowlist: Getter<boolean> = createJavaScriptFlagGetter('enableAnimatedAllowlist', false);
/**
* Enables Animated to analyze props to minimize invalidating `AnimatedProps`.
*/
export const enableAnimatedPropsMemo: Getter<boolean> = createJavaScriptFlagGetter('enableAnimatedPropsMemo', false);
/**
* Removing unnecessary rerenders Virtualized cells after any rerenders of Virualized list. Works with strict=true option
*/