Skip to content

Commit ce621bb

Browse files
dennytospmeta-codesync[bot]
authored andcommitted
Fix Animated listeners on nodes derived from Animated.Value (#58037)
Summary: Fixes #49719. `addListener` stopped firing on any `AnimatedNode` *derived* from an `Animated.Value` — `Animated.add`/`subtract`/`multiply`/`divide`/`modulo`/`diffClamp` and `.interpolate()` — for natively driven animations. It worked in 0.77 and regressed in 0.78. The issue has since been reported for both `Animated.add` and `.interpolate()`, on both architectures, and the only workaround is to listen to the source value and recompute the derived value in JS — which observes intermediate values that never reach the screen. There are two independent causes. ### 1. JS: derived nodes never subscribe to native updates Before 38c46fe ("Animated: Lower `onAnimatedValueUpdate` to `AnimatedValue`", #48514), `AnimatedNode.addListener` called `startListeningToAnimatedNodeValue` for any node that had been made native. That commit moved the logic down into `AnimatedValue`, on the premise that: > the `startListeningToAnimatedNodeValue` native module method only supports native tags for instances of `AnimatedValue` […] On Android, `startListeningToAnimatedNodeValue` throws if the node is not an instance of `ValueAnimatedNode`. On iOS, it does nothing if node is not an instance of `RCTValueAnimatedNode`. That holds for props/style/transform/object/tracking/color nodes, but not for the operator and interpolation nodes, which *are* value nodes natively: - **Android** — `AdditionAnimatedNode`, `SubtractionAnimatedNode`, `MultiplicationAnimatedNode`, `DivisionAnimatedNode`, `ModulusAnimatedNode`, `DiffClampAnimatedNode` and `InterpolationAnimatedNode` all extend `ValueAnimatedNode`, and `NativeAnimatedNodesManager` checks `node !is ValueAnimatedNode`. - **iOS** — the corresponding `RCT*AnimatedNode` classes all inherit `RCTValueAnimatedNode`, and the manager checks `isKindOfClass:[RCTValueAnimatedNode class]`. So the subscription was dropped for a set of nodes that natively support it. This restores it on `AnimatedNode`, gated on a new `__isNativeValueNode` flag that is `true` only for nodes backed by a native value node. That keeps the guarantee #48514 was after — never call `startListeningToAnimatedNodeValue` with a non-value tag — but states it explicitly instead of leaving it implicit in the class hierarchy. `AnimatedValue` now inherits that machinery rather than duplicating it. `AnimatedNode.__detach` also drops the subscription before `dropAnimatedNode`, so it can never outlive the tag it observes. ### 2. C++: `startListeningToAnimatedNodeValue` rejects derived value nodes Unlike Android and iOS, the C++ backend used by the New Architecture compares the node's *exact* type tag instead of testing for a `ValueAnimatedNode` subclass: ```c++ iter->second->type() == AnimatedNodeType::Value ``` so an addition or interpolation node was rejected with `"does not exist, or is not a 'value' node"`, even though `static_cast<ValueAnimatedNode*>` would have been valid. Replaced with an exhaustive `isValueNodeType` predicate, so every node type whose C++ class derives from `ValueAnimatedNode` (including `Round`) is accepted and the non-value ones are still rejected. `AnimatedNode.__onAnimatedValueUpdateReceived` had to accept a missing `offset` as well: the C++ backend emits `onAnimatedValueUpdate` without one, which would otherwise produce `NaN` (`value + undefined`). ## Changelog: [GENERAL] [FIXED] - `addListener` fires again for natively driven `Animated` values derived with `add`/`subtract`/`multiply`/`divide`/`modulo`/`diffClamp`/`interpolate` Pull Request resolved: #58037 Test Plan: ### New tests **`Libraries/Animated/__tests__/AnimatedComposition-itest.js`** — renders a derived node bound to `translateX`, attaches a listener to the *derived* node before it is made native, runs a `timing` animation on the source value, and asserts the listener observed the derived value; then asserts `removeListener` stops the updates. Parameterised over the five operators plus `interpolate`, and over both drivers. **`ReactCommon/react/renderer/animated/tests/AnimatedNodeTests.cpp`** — `StartListeningToDerivedValueNode` asserts the C++ backend delivers updates for an `addition` node and stops on `stopListeningToAnimatedNodeValue`. `StartListeningToNonValueNodeIsIgnored` asserts registering on a `transform` node is still a no-op. ### Results ``` $ yarn fantom packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js addListener on derived value nodes ✓ Animated.add notifies its listeners on the JS driver ✓ Animated.subtract notifies its listeners on the JS driver ✓ Animated.multiply notifies its listeners on the JS driver ✓ Animated.divide notifies its listeners on the JS driver ✓ Animated.modulo notifies its listeners on the JS driver ✓ interpolate notifies its listeners on the JS driver ✓ Animated.add notifies its listeners on the native driver ✓ Animated.subtract notifies its listeners on the native driver ✓ Animated.multiply notifies its listeners on the native driver ✓ Animated.divide notifies its listeners on the native driver ✓ Animated.modulo notifies its listeners on the native driver ✓ interpolate notifies its listeners on the native driver Tests: 26 passed, 26 total ``` Reverting only the fix (keeping the new test) fails exactly the six `native driver` cases, which is the reported bug; the `JS driver` cases were never broken: ``` ✕ Animated.add notifies its listeners on the native driver Expected 0 to be greater than or equal to 0 > 469 | expect(values.length).toBeGreaterThan(0); … Tests: 6 failed, 20 passed, 26 total ``` No regressions in the rest of the Animated suite: ``` $ yarn fantom packages/react-native/Libraries/Animated/__tests__/ Test Suites: 2 skipped, 16 passed, 16 of 18 total Tests: 3 skipped, 297 passed, 300 total $ yarn jest packages/react-native/Libraries/Animated Test Suites: 3 passed, 3 total Tests: 66 passed, 66 total $ yarn flow-check Found 0 errors $ yarn lint # changed files (no output) ``` `yarn build-types` regenerated `ReactNativeApi.d.ts`; the only substantive change is that `AnimatedValue`'s `removeListener`/`removeAllListeners` are now inherited from `AnimatedNode` rather than redeclared. `addListener` keeps its narrower `ValueListenerCallback` signature. Correction to an earlier revision of this description: the C++ tests were not run locally, and they are not covered by the public CI either. `react/renderer/animated/tests` is excluded from the iOS build (`React-Fabric.podspec`: `ss.exclude_files = "react/renderer/animated/tests"`) and is not in the Android CMake glob (`react/renderer/animated/CMakeLists.txt` globs `*.cpp drivers/*.cpp event_drivers/*.cpp internal/*.cpp nodes/*.cpp`), so they build only in the internal build reached at import time. What is verified here: `NativeAnimatedNodesManager.cpp` compiles clean under `-Wall -Werror -Wpedantic` as part of the Fantom tester build, and the JS-side changes are covered by the Fantom and Jest runs above. ### Notes For a colour `interpolate()` on the native driver the listener receives the interpolated colour as a number rather than a string, because the value is computed natively. That matches pre-0.78 behaviour and is out of scope here. Reviewed By: christophpurrer, cipolleschi Differential Revision: D117209945 Pulled By: zeyap fbshipit-source-id: b50657a7b2d25e10e980b43cb1e9e1f1db5f3906
1 parent 8e1559a commit ce621bb

14 files changed

Lines changed: 392 additions & 98 deletions

File tree

packages/react-native/Libraries/Animated/__tests__/AnimatedComposition-itest.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,124 @@ describe('composition nodes: native driver, interpolation and detach', () => {
379379
});
380380
}
381381
});
382+
383+
// Regression test for https://github.com/facebook/react-native/issues/49719.
384+
// Listeners attached to a node *derived* from an `Animated.Value` (an operator
385+
// or an interpolation) must keep firing, on both drivers. On the native driver
386+
// the value is computed natively, so the derived node has to subscribe to
387+
// native updates for its own tag rather than relying on the JS graph.
388+
describe('addListener on derived value nodes', () => {
389+
const derivedNodes = [
390+
{
391+
name: 'Animated.add',
392+
make: (base: Animated.Value) => Animated.add(base, 10),
393+
expected: 60,
394+
},
395+
{
396+
name: 'Animated.subtract',
397+
make: (base: Animated.Value) => Animated.subtract(base, 10),
398+
expected: 40,
399+
},
400+
{
401+
name: 'Animated.multiply',
402+
make: (base: Animated.Value) => Animated.multiply(base, 2),
403+
expected: 100,
404+
},
405+
{
406+
name: 'Animated.divide',
407+
make: (base: Animated.Value) => Animated.divide(base, 2),
408+
expected: 25,
409+
},
410+
{
411+
name: 'Animated.modulo',
412+
make: (base: Animated.Value) => Animated.modulo(base, 7),
413+
expected: 50 % 7,
414+
},
415+
{
416+
// Accumulates the delta of its input and clamps it: as `base` climbs
417+
// from 0 to 50 the accumulated delta saturates at `max`.
418+
name: 'Animated.diffClamp',
419+
make: (base: Animated.Value) => Animated.diffClamp(base, 0, 20),
420+
expected: 20,
421+
},
422+
{
423+
name: 'interpolate',
424+
make: (base: Animated.Value) =>
425+
base.interpolate({inputRange: [0, 50], outputRange: [0, 500]}),
426+
expected: 500,
427+
},
428+
];
429+
430+
for (const useNativeDriver of [false, true]) {
431+
const driverName = useNativeDriver ? 'native driver' : 'JS driver';
432+
433+
for (const {name, make, expected} of derivedNodes) {
434+
it(`${name} notifies its listeners on the ${driverName}`, () => {
435+
let base: ?Animated.Value;
436+
let node: ?Animated.Node;
437+
438+
function MyApp() {
439+
const value = useAnimatedValue(0);
440+
base = value;
441+
node = make(value);
442+
return (
443+
<Animated.View
444+
style={[
445+
{width: 100, height: 100},
446+
{transform: [{translateX: node}]},
447+
]}
448+
/>
449+
);
450+
}
451+
452+
const root = Fantom.createRoot();
453+
Fantom.runTask(() => {
454+
root.render(<MyApp />);
455+
});
456+
457+
// The listener is attached before the node is made native, so this
458+
// also covers the subscription being created from `__makeNative`. The
459+
// opposite order — attaching once the node is already native — is
460+
// covered per node type by AnimatedNative-test.
461+
const values: Array<number> = [];
462+
const listenerId = nullthrows(node).addListener(state => {
463+
values.push(state.value);
464+
});
465+
466+
let animation: ?Animated.CompositeAnimation;
467+
Fantom.runTask(() => {
468+
animation = Animated.timing(nullthrows(base), {
469+
toValue: 50,
470+
duration: 100,
471+
useNativeDriver,
472+
});
473+
animation.start();
474+
});
475+
Fantom.unstable_produceFramesForDuration(200);
476+
Fantom.runWorkLoop();
477+
478+
expect(values.length).toBeGreaterThan(0);
479+
expect(values[values.length - 1]).toBeCloseTo(expected, 0);
480+
481+
// Removing the listener stops the updates.
482+
nullthrows(node).removeListener(listenerId);
483+
const countAfterRemoval = values.length;
484+
Fantom.runTask(() => {
485+
nullthrows(base).setValue(0);
486+
});
487+
Fantom.unstable_produceFramesForDuration(32);
488+
Fantom.runWorkLoop();
489+
expect(values.length).toBe(countAfterRemoval);
490+
491+
Fantom.runTask(() => {
492+
nullthrows(animation).stop();
493+
});
494+
Fantom.runTask(() => {
495+
root.render(<Animated.View style={{width: 1, height: 1}} />);
496+
});
497+
Fantom.unstable_produceFramesForDuration(16);
498+
Fantom.runWorkLoop();
499+
});
500+
}
501+
}
502+
});

packages/react-native/Libraries/Animated/nodes/AnimatedAddition.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import AnimatedValue from './AnimatedValue';
2020
import AnimatedWithChildren from './AnimatedWithChildren';
2121

2222
export default class AnimatedAddition extends AnimatedWithChildren {
23+
__isNativeValueNode: boolean = true;
24+
2325
_a: AnimatedNode;
2426
_b: AnimatedNode;
2527

packages/react-native/Libraries/Animated/nodes/AnimatedDiffClamp.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import AnimatedInterpolation from './AnimatedInterpolation';
1919
import AnimatedWithChildren from './AnimatedWithChildren';
2020

2121
export default class AnimatedDiffClamp extends AnimatedWithChildren {
22+
__isNativeValueNode: boolean = true;
23+
2224
_a: AnimatedNode;
2325
_min: number;
2426
_max: number;

packages/react-native/Libraries/Animated/nodes/AnimatedDivision.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import AnimatedValue from './AnimatedValue';
2020
import AnimatedWithChildren from './AnimatedWithChildren';
2121

2222
export default class AnimatedDivision extends AnimatedWithChildren {
23+
__isNativeValueNode: boolean = true;
24+
2325
_a: AnimatedNode;
2426
_b: AnimatedNode;
2527
_warnedAboutDivideByZero: boolean = false;

packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,8 @@ function sampleEasingStops(
444444
export default class AnimatedInterpolation<
445445
OutputT extends InterpolationConfigSupportedOutputType,
446446
> extends AnimatedWithChildren {
447+
__isNativeValueNode: boolean = true;
448+
447449
_parent: AnimatedNode;
448450
_config: InterpolationConfigType<OutputT>;
449451
_interpolation: ?(input: number) => OutputT;

packages/react-native/Libraries/Animated/nodes/AnimatedModulo.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import AnimatedInterpolation from './AnimatedInterpolation';
1919
import AnimatedWithChildren from './AnimatedWithChildren';
2020

2121
export default class AnimatedModulo extends AnimatedWithChildren {
22+
__isNativeValueNode: boolean = true;
23+
2224
_a: AnimatedNode;
2325
_modulus: number;
2426

packages/react-native/Libraries/Animated/nodes/AnimatedMultiplication.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import AnimatedValue from './AnimatedValue';
2020
import AnimatedWithChildren from './AnimatedWithChildren';
2121

2222
export default class AnimatedMultiplication extends AnimatedWithChildren {
23+
__isNativeValueNode: boolean = true;
24+
2325
_a: AnimatedNode;
2426
_b: AnimatedNode;
2527

packages/react-native/Libraries/Animated/nodes/AnimatedNode.js

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* @format
99
*/
1010

11+
import type {EventSubscription} from '../../vendor/emitter/EventEmitter';
1112
import type {PlatformConfig} from '../AnimatedPlatformConfig';
1213

1314
import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
@@ -54,7 +55,12 @@ export default class AnimatedNode {
5455
this.removeAllListeners();
5556
}
5657
if (this.__isNative && this.__nativeTag != null) {
57-
NativeAnimatedHelper.API.dropAnimatedNode(this.__nativeTag);
58+
const nativeTag = this.__nativeTag;
59+
// The subscription must not outlive the native tag it observes. Any
60+
// listeners kept around are re-subscribed by `__makeNative` if this node
61+
// is attached again.
62+
this.__updateSubscription?.remove();
63+
NativeAnimatedHelper.API.dropAnimatedNode(nativeTag);
5864
this.__nativeTag = undefined;
5965
}
6066
}
@@ -73,6 +79,19 @@ export default class AnimatedNode {
7379
__nativeTag: ?number = undefined;
7480
__disableBatchingForNativeCreate: ?boolean = undefined;
7581

82+
/**
83+
* Whether the native node backing this one holds a number, and therefore
84+
* supports `startListeningToAnimatedNodeValue`. That native module method
85+
* only accepts tags of "value" nodes (`ValueAnimatedNode` on Android and in
86+
* C++, `RCTValueAnimatedNode` on iOS); passing any other tag throws on
87+
* Android and is a no-op elsewhere.
88+
*
89+
* Subclasses backed by a non-value native node — props, style, transform,
90+
* object, tracking and color — must leave this `false`.
91+
*/
92+
__isNativeValueNode: boolean = false;
93+
__updateSubscription: ?EventSubscription = null;
94+
7695
__makeNative(platformConfig: ?PlatformConfig): void {
7796
// Subclasses are expected to set `__isNative` to true before this.
7897
invariant(
@@ -81,6 +100,9 @@ export default class AnimatedNode {
81100
);
82101

83102
this._platformConfig = platformConfig;
103+
if (this._listeners.size > 0) {
104+
this.__ensureUpdateSubscriptionExists();
105+
}
84106
}
85107

86108
/**
@@ -93,6 +115,9 @@ export default class AnimatedNode {
93115
addListener(callback: (value: any) => unknown): string {
94116
const id = String(_uniqueId++);
95117
this._listeners.set(id, callback);
118+
if (this.__isNative) {
119+
this.__ensureUpdateSubscriptionExists();
120+
}
96121
return id;
97122
}
98123

@@ -104,6 +129,9 @@ export default class AnimatedNode {
104129
*/
105130
removeListener(id: string): void {
106131
this._listeners.delete(id);
132+
if (this.__isNative && this._listeners.size === 0) {
133+
this.__updateSubscription?.remove();
134+
}
107135
}
108136

109137
/**
@@ -113,14 +141,53 @@ export default class AnimatedNode {
113141
*/
114142
removeAllListeners(): void {
115143
this._listeners.clear();
144+
if (this.__isNative) {
145+
this.__updateSubscription?.remove();
146+
}
116147
}
117148

118149
hasListeners(): boolean {
119150
return this._listeners.size > 0;
120151
}
121152

122-
__onAnimatedValueUpdateReceived(value: number, offset: number): void {
123-
this.__callListeners(value + offset);
153+
/**
154+
* Subscribes to native updates of this node's value, so that listeners keep
155+
* firing for natively driven animations. No-op for nodes that are not backed
156+
* by a native "value" node.
157+
*/
158+
__ensureUpdateSubscriptionExists(): void {
159+
if (!this.__isNativeValueNode || this.__updateSubscription != null) {
160+
return;
161+
}
162+
const nativeTag = this.__getNativeTag();
163+
NativeAnimatedHelper.API.startListeningToAnimatedNodeValue(nativeTag);
164+
const subscription: EventSubscription =
165+
NativeAnimatedHelper.nativeEventEmitter.addListener(
166+
'onAnimatedValueUpdate',
167+
data => {
168+
if (data.tag === nativeTag) {
169+
this.__onAnimatedValueUpdateReceived(data.value, data.offset);
170+
}
171+
},
172+
);
173+
174+
this.__updateSubscription = {
175+
remove: () => {
176+
// Only this function assigns to `this.__updateSubscription`.
177+
if (this.__updateSubscription == null) {
178+
return;
179+
}
180+
this.__updateSubscription = null;
181+
subscription.remove();
182+
NativeAnimatedHelper.API.stopListeningToAnimatedNodeValue(nativeTag);
183+
},
184+
};
185+
}
186+
187+
// NOTE: only Android sends an `offset`; iOS and the C++ backend omit it and
188+
// report a value that already accounts for one.
189+
__onAnimatedValueUpdateReceived(value: number, offset?: ?number): void {
190+
this.__callListeners(value + (offset ?? 0));
124191
}
125192

126193
__callListeners(value: number): void {

packages/react-native/Libraries/Animated/nodes/AnimatedSubtraction.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import AnimatedValue from './AnimatedValue';
2020
import AnimatedWithChildren from './AnimatedWithChildren';
2121

2222
export default class AnimatedSubtraction extends AnimatedWithChildren {
23+
__isNativeValueNode: boolean = true;
24+
2325
_a: AnimatedNode;
2426
_b: AnimatedNode;
2527

0 commit comments

Comments
 (0)