Skip to content

Commit

Permalink
Animated: Minimize AnimatedProps Invalidation (facebook#46386)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: facebook#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
  • Loading branch information
yungsters authored and facebook-github-bot committed Sep 10, 2024
1 parent d5a72eb commit 60e8fb3
Show file tree
Hide file tree
Showing 12 changed files with 560 additions and 17 deletions.
16 changes: 13 additions & 3 deletions packages/react-native/Libraries/Animated/nodes/AnimatedObject.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down Expand Up @@ -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();
Expand Down
25 changes: 25 additions & 0 deletions packages/react-native/Libraries/Animated/nodes/AnimatedProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} = {};

Expand Down
36 changes: 36 additions & 0 deletions packages/react-native/Libraries/Animated/nodes/AnimatedStyle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object> {
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} = {};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
26 changes: 17 additions & 9 deletions packages/react-native/Libraries/Animated/useAnimatedProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
useReducer,
useRef,
} from 'react';
import {useAnimatedPropsMemo} from '../../src/private/animated/useAnimatedPropsMemo';

type ReducedProps<TProps> = {
...TProps,
Expand All @@ -40,23 +41,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,
Expand All @@ -65,6 +67,7 @@ export default function useAnimatedProps<TProps: {...}, TInstance>(
),
[allowlistIfEnabled, props],
);

const useNativePropsInFabric =
ReactNativeFeatureFlags.shouldUseSetNativePropsInFabric();
const useSetNativePropsInNativeAnimationsInFabric =
Expand Down Expand Up @@ -204,14 +207,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,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1281,16 +1281,33 @@ 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();

const root = await create(<Animated.View style={{opacity}} />);
expect(NativeAnimatedModule.restoreDefaultValues).not.toHaveBeenCalled();

await update(root, <Animated.View style={{opacity}} />);
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(<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);
Expand Down
Loading

0 comments on commit 60e8fb3

Please sign in to comment.