Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,115 @@ describe('composition nodes: native driver, interpolation and detach', () => {
});
}
});

// Regression test for https://github.com/facebook/react-native/issues/49719.
// Listeners attached to a node *derived* from an `Animated.Value` (an operator
// or an interpolation) must keep firing, on both drivers. On the native driver
// the value is computed natively, so the derived node has to subscribe to
// native updates for its own tag rather than relying on the JS graph.
describe('addListener on derived value nodes', () => {
const derivedNodes = [
{
name: 'Animated.add',
make: (base: Animated.Value) => Animated.add(base, 10),
expected: 60,
},
{
name: 'Animated.subtract',
make: (base: Animated.Value) => Animated.subtract(base, 10),
expected: 40,
},
{
name: 'Animated.multiply',
make: (base: Animated.Value) => Animated.multiply(base, 2),
expected: 100,
},
{
name: 'Animated.divide',
make: (base: Animated.Value) => Animated.divide(base, 2),
expected: 25,
},
{
name: 'Animated.modulo',
make: (base: Animated.Value) => Animated.modulo(base, 7),
expected: 50 % 7,
},
{
name: 'interpolate',
make: (base: Animated.Value) =>
base.interpolate({inputRange: [0, 50], outputRange: [0, 500]}),
expected: 500,
},
];

for (const useNativeDriver of [false, true]) {
const driverName = useNativeDriver ? 'native driver' : 'JS driver';

for (const {name, make, expected} of derivedNodes) {
it(`${name} notifies its listeners on the ${driverName}`, () => {
let base: ?Animated.Value;
let node: ?Animated.Node;

function MyApp() {
const value = useAnimatedValue(0);
base = value;
node = make(value);
return (
<Animated.View
style={[
{width: 100, height: 100},
{transform: [{translateX: node}]},
]}
/>
);
}

const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(<MyApp />);
});

// The listener is attached before the node is made native, so this also
// covers the subscription being created from `__makeNative`.
const values: Array<number> = [];
const listenerId = nullthrows(node).addListener(state => {
values.push(state.value);
});

let animation: ?Animated.CompositeAnimation;
Fantom.runTask(() => {
animation = Animated.timing(nullthrows(base), {
toValue: 50,
duration: 100,
useNativeDriver,
});
animation.start();
});
Fantom.unstable_produceFramesForDuration(200);
Fantom.runWorkLoop();

expect(values.length).toBeGreaterThan(0);
expect(values[values.length - 1]).toBeCloseTo(expected, 0);

// Removing the listener stops the updates.
nullthrows(node).removeListener(listenerId);
const countAfterRemoval = values.length;
Fantom.runTask(() => {
nullthrows(base).setValue(0);
});
Fantom.unstable_produceFramesForDuration(32);
Fantom.runWorkLoop();
expect(values.length).toBe(countAfterRemoval);

Fantom.runTask(() => {
nullthrows(animation).stop();
});
Fantom.runTask(() => {
root.render(<Animated.View style={{width: 1, height: 1}} />);
});
Fantom.unstable_produceFramesForDuration(16);
Fantom.runWorkLoop();
});
}
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import AnimatedValue from './AnimatedValue';
import AnimatedWithChildren from './AnimatedWithChildren';

export default class AnimatedAddition extends AnimatedWithChildren {
__isNativeValueNode: boolean = true;

_a: AnimatedNode;
_b: AnimatedNode;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import AnimatedInterpolation from './AnimatedInterpolation';
import AnimatedWithChildren from './AnimatedWithChildren';

export default class AnimatedDiffClamp extends AnimatedWithChildren {
__isNativeValueNode: boolean = true;

_a: AnimatedNode;
_min: number;
_max: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import AnimatedValue from './AnimatedValue';
import AnimatedWithChildren from './AnimatedWithChildren';

export default class AnimatedDivision extends AnimatedWithChildren {
__isNativeValueNode: boolean = true;

_a: AnimatedNode;
_b: AnimatedNode;
_warnedAboutDivideByZero: boolean = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ function sampleEasingStops(
export default class AnimatedInterpolation<
OutputT extends InterpolationConfigSupportedOutputType,
> extends AnimatedWithChildren {
__isNativeValueNode: boolean = true;

_parent: AnimatedNode;
_config: InterpolationConfigType<OutputT>;
_interpolation: ?(input: number) => OutputT;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import AnimatedInterpolation from './AnimatedInterpolation';
import AnimatedWithChildren from './AnimatedWithChildren';

export default class AnimatedModulo extends AnimatedWithChildren {
__isNativeValueNode: boolean = true;

_a: AnimatedNode;
_modulus: number;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import AnimatedValue from './AnimatedValue';
import AnimatedWithChildren from './AnimatedWithChildren';

export default class AnimatedMultiplication extends AnimatedWithChildren {
__isNativeValueNode: boolean = true;

_a: AnimatedNode;
_b: AnimatedNode;

Expand Down
73 changes: 70 additions & 3 deletions packages/react-native/Libraries/Animated/nodes/AnimatedNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* @format
*/

import type {EventSubscription} from '../../vendor/emitter/EventEmitter';
import type {PlatformConfig} from '../AnimatedPlatformConfig';

import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
Expand Down Expand Up @@ -54,7 +55,12 @@ export default class AnimatedNode {
this.removeAllListeners();
}
if (this.__isNative && this.__nativeTag != null) {
NativeAnimatedHelper.API.dropAnimatedNode(this.__nativeTag);
const nativeTag = this.__nativeTag;
// The subscription must not outlive the native tag it observes. Any
// listeners kept around are re-subscribed by `__makeNative` if this node
// is attached again.
this.__updateSubscription?.remove();
NativeAnimatedHelper.API.dropAnimatedNode(nativeTag);
this.__nativeTag = undefined;
}
}
Expand All @@ -73,6 +79,19 @@ export default class AnimatedNode {
__nativeTag: ?number = undefined;
__disableBatchingForNativeCreate: ?boolean = undefined;

/**
* Whether the native node backing this one holds a number, and therefore
* supports `startListeningToAnimatedNodeValue`. That native module method
* only accepts tags of "value" nodes (`ValueAnimatedNode` on Android and in
* C++, `RCTValueAnimatedNode` on iOS); passing any other tag throws on
* Android and is a no-op elsewhere.
*
* Subclasses backed by a non-value native node — props, style, transform,
* object, tracking and color — must leave this `false`.
*/
__isNativeValueNode: boolean = false;
__updateSubscription: ?EventSubscription = null;

__makeNative(platformConfig: ?PlatformConfig): void {
// Subclasses are expected to set `__isNative` to true before this.
invariant(
Expand All @@ -81,6 +100,9 @@ export default class AnimatedNode {
);

this._platformConfig = platformConfig;
if (this._listeners.size > 0) {
this.__ensureUpdateSubscriptionExists();
}
}

/**
Expand All @@ -93,6 +115,9 @@ export default class AnimatedNode {
addListener(callback: (value: any) => unknown): string {
const id = String(_uniqueId++);
this._listeners.set(id, callback);
if (this.__isNative) {
this.__ensureUpdateSubscriptionExists();
}
return id;
}

Expand All @@ -104,6 +129,9 @@ export default class AnimatedNode {
*/
removeListener(id: string): void {
this._listeners.delete(id);
if (this.__isNative && this._listeners.size === 0) {
this.__updateSubscription?.remove();
}
}

/**
Expand All @@ -113,14 +141,53 @@ export default class AnimatedNode {
*/
removeAllListeners(): void {
this._listeners.clear();
if (this.__isNative) {
this.__updateSubscription?.remove();
}
}

hasListeners(): boolean {
return this._listeners.size > 0;
}

__onAnimatedValueUpdateReceived(value: number, offset: number): void {
this.__callListeners(value + offset);
/**
* Subscribes to native updates of this node's value, so that listeners keep
* firing for natively driven animations. No-op for nodes that are not backed
* by a native "value" node.
*/
__ensureUpdateSubscriptionExists(): void {
if (!this.__isNativeValueNode || this.__updateSubscription != null) {
return;
}
const nativeTag = this.__getNativeTag();
NativeAnimatedHelper.API.startListeningToAnimatedNodeValue(nativeTag);
const subscription: EventSubscription =
NativeAnimatedHelper.nativeEventEmitter.addListener(
'onAnimatedValueUpdate',
data => {
if (data.tag === nativeTag) {
this.__onAnimatedValueUpdateReceived(data.value, data.offset);
}
},
);

this.__updateSubscription = {
remove: () => {
// Only this function assigns to `this.__updateSubscription`.
if (this.__updateSubscription == null) {
return;
}
this.__updateSubscription = null;
subscription.remove();
NativeAnimatedHelper.API.stopListeningToAnimatedNodeValue(nativeTag);
},
};
}

// NOTE: `offset` is omitted by backends that do not track one separately
// (e.g. the C++ backend), in which case it is already folded into `value`.
__onAnimatedValueUpdateReceived(value: number, offset?: ?number): void {
this.__callListeners(value + (offset ?? 0));
}

__callListeners(value: number): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import AnimatedValue from './AnimatedValue';
import AnimatedWithChildren from './AnimatedWithChildren';

export default class AnimatedSubtraction extends AnimatedWithChildren {
__isNativeValueNode: boolean = true;

_a: AnimatedNode;
_b: AnimatedNode;

Expand Down
Loading