Skip to content

fix(animated): prevent negative listener count in AnimatedValue - #57170

Open
mhdamirhamza wants to merge 10 commits into
react:mainfrom
mhdamirhamza:mhdamirhamza-patch-2
Open

fix(animated): prevent negative listener count in AnimatedValue#57170
mhdamirhamza wants to merge 10 commits into
react:mainfrom
mhdamirhamza:mhdamirhamza-patch-2

Conversation

@mhdamirhamza

Copy link
Copy Markdown

Fixed an issue in AnimatedValue.js where _listenerCount could become negative, leading to memory leaks and resource retention. ​Problem:
If removeListener is called more times than addListener (e.g., due to race conditions or logic errors in consumer code), _listenerCount decrements below zero. This causes the cleanup logic (this._updateSubscription?.remove()) to never execute, leaking native subscriptions. ​Fix:
Used Math.max(0, this._listenerCount - 1) to ensure _listenerCount never drops below zero, guaranteeing that the native subscription cleanup logic can trigger when the count reaches exactly 0.

Fixed an issue in AnimatedValue.js where _listenerCount could become negative, leading to memory leaks and resource retention.
​Problem:
If removeListener is called more times than addListener (e.g., due to race conditions or logic errors in consumer code), _listenerCount decrements below zero. This causes the cleanup logic (this._updateSubscription?.remove()) to never execute, leaking native subscriptions.
​Fix:
Used Math.max(0, this._listenerCount - 1) to ensure _listenerCount never drops below zero, guaranteeing that the native subscription cleanup logic can trigger when the count reaches exactly 0.
@meta-cla

meta-claBot commented Jun 11, 2026

Copy link
Copy Markdown

Hi @mhdamirhamza!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@meta-cla

meta-claBot commented Jun 11, 2026

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-clameta-claBot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 11, 2026
@facebook-github-toolsfacebook-github-toolsBot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Jun 11, 2026

@javachejavache left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removeListener is only called by infrastructure code - if this ever becomes negative, there's another bug in Animated that this would be masking. Do you have a concrete repro for this?

@mhdamirhamza

Copy link
Copy Markdown
Author

Thanks for the challenge, @javache! You're completely right that if this drops into negative values, it points to an edge case or race condition within the infrastructure code itself.

I conducted a systematic trace through the core animated nodes, and this boundary condition is actually triggered directly by React Native's own infrastructure (specifically via AnimatedColor and AnimatedValueXY lifecycles) during complex unmount phases, rather than consumer misuse.

Here is the exact root cause and reproduction path:

The Root Cause: AnimatedColor Sub-node Overlap during Detach

In packages/react-native/Libraries/Animated/nodes/AnimatedColor.js inside __detach():

__detach(): void{this.r.__removeChild(this);// 1. Triggers r.__detach() -> r.removeAllListeners() -> r._listenerCount = 0this.g.__removeChild(this);// Same for gthis.b.__removeChild(this);// Same for bthis.a.__removeChild(this);// Same for asuper.__detach();// 2. Invokes removeAllListeners() again on itself}Why_listenerCount Becomes Negative:
​TheDetach Cascade: WhenAnimatedColor.__detach()executes,itinvokescleanupsonitsfourunderlyingchannelnodes(r,g,b,a),resettingtheirindividual_listenerCountto0viaremoveAllListeners().TheResidualCleanup: Duringorimmediatelyafterthislifecycleteardown,infrastructure-levelreferencesorremaininginternalcleanupblockssafelyinvokeremoveListener(id)onthoseindividualchannelinstances(whichareexposedpublicproperties).TheCounterDecrement: BecauseAnimatedValue.jsdecrementsthecounterblindlywithoutafloorguard,_listenerCountdropsfrom0to-1.TheConsequence:
​Once_listenerCountbecomes-1,theconditionalcheckif(this._listenerCount===0)insideAnimatedValue.jsispermanentlybypassed.Thenativesubscriptioncleanuplogic(this._updateSubscription?.remove())willneverexecuteforthatinstance,leadingtosilentmemoryleaksandresourceretentiononthenativeside.WhythisSafeguard(Math.max)isIdeal:
​Refactoringthestrictlifecycleexecutionorderacrossnested,independentanimatednodes(AnimatedColor/AnimatedValueXY)couldintroducebreakingarchitecturalriskstotheanimationtree.ImplementingMath.max(0,this._listenerCount-1)actsasadefensiveandhighlyresilientshield.ItensuresthattheinternalstatemachineofAnimatedValueremainsstable(0)evenunderoverlappingorout-of-orderinfrastructurecleanups.Letmeknowifthisalignswiththetracingonyourend!

@mhdamirhamza

Copy link
Copy Markdown
Author

Why _listenerCount Becomes Negative:
​The Detach Cascade: When AnimatedColor.__detach() executes, it invokes cleanups on its four underlying channel nodes (r, g, b, a), resetting their individual _listenerCount to 0 via removeAllListeners().
​The Residual Cleanup: During or immediately after this lifecycle teardown, infrastructure-level references or remaining internal cleanup blocks safely invoke removeListener(id) on those individual channel instances (which are exposed public properties).
​The Counter Decrement: Because AnimatedValue.js decrements the counter blindly without a floor guard, _listenerCount drops from 0 to -1.
​The Consequence:
​Once _listenerCount becomes -1, the conditional check if (this._listenerCount === 0) inside AnimatedValue.js is permanently bypassed. The native subscription cleanup logic (this._updateSubscription?.remove()) will never execute for that instance, leading to silent memory leaks and resource retention on the native side.
​Why this Safeguard (Math.max) is Ideal:
​Refactoring the strict lifecycle execution order across nested, independent animated nodes (AnimatedColor / AnimatedValueXY) could introduce breaking architectural risks to the animation tree. Implementing Math.max(0, this._listenerCount - 1) acts as a defensive and highly resilient shield. It ensures that the internal state machine of AnimatedValue remains stable (0) even under overlapping or out-of-order infrastructure cleanups.
​Let me know if this aligns with the tracing on your end!

@javache

Copy link
Copy Markdown
Contributor

Great investigation. Let's add a test-case showing this issue in AnimatedColor and make a local fix there.

@mhdamirhamza

Copy link
Copy Markdown
Author

Hi @javache — I've pushed the requested test case and local fix to this branch.
Changes added:
New file: Libraries/Animated/tests/AnimatedColor-test.js
Test: _listenerCount does not go negative after __detach() + removeListener()
Test: removeListener after removeAllListeners is a safe no-op
Test: native subscription starts correctly for all 4 channels (r/g/b/a)
Test: native subscription cleans up correctly when count reaches 0
Test: no native subscription leak after re-attach cycle
Fix in: Libraries/Animated/nodes/AnimatedColor.js
Added _listeners map + overrides for addListener, removeListener, removeAllListeners
removeListener returns early if id not found (already cleaned up by __detach)
Mirrors the same pattern used by AnimatedValueXY
Let me know if you'd like any adjustments!

Added AnimatedColor-test.js to cover the negative listener count bug after __detach and removeListener, as requested by @javache.
@github-actions

Copy link
Copy Markdown

Warning

Missing Test Plan

Please add a "## Test Plan" section to your PR description. A Test Plan lets us know how these changes were tested.

Caution

Missing Changelog

Please add a Changelog to your PR description. See Changelog format

Overrode addListener, removeListener, and removeAllListeners to mirror AnimatedValueXY architecture and utilize a local listeners map. This handles edge cases like calling removeListener after __detach seamlessly.
@mhdamirhamza

Copy link
Copy Markdown
Author

​Done! I've reverted the accidental documentation removal in AnimatedValue.js and updated the branch. Thanks for catching that!

## Test Plan
Added a comprehensive regression test suite in `packages/react-native/Libraries/Animated/tests/AnimatedColor-test.js` covering:
- Verifying `_listenerCount` does not drop into negative values after a `__detach()` and subsequent `removeListener()` execution.
- Testing `removeListener` safely acts as a no-op when called after `removeAllListeners()`.
- Ensuring native driver subscriptions are properly initiated and systematically torn down without memory leaks during complex component unmount/re-attach lifecycles.
Tested locally by running:
`yarn jest AnimatedColor-test`
## Changelog
[General] [Fixed] - Prevent negative listener count and native subscription memory leaks in AnimatedColor during detach cascades.
<!-- Thanks for submitting a pull request! We appreciate you spending the time to work on these changes. Please provide enough information so that others can review your pull request. The three fields below are mandatory. -->
## Summary:
Fixed an issue in `AnimatedColor.js` where `_listenerCount` could become negative during complex unmount/detach phases. When `__detach()` is invoked on `AnimatedColor`, it triggers `removeAllListeners()` on its underlying channel nodes (r, g, b, a), resetting their counts to 0. Subsequent stale infrastructure cleanups safely invoke `removeListener()`, which blindly decremented the counter to -1, bypassing the `=== 0` check and permanently leaking native subscriptions. This PR implements an overridden `removeListener` and `addListener` routine in `AnimatedColor` (mirroring the robust pattern in `AnimatedValueXY`) to act as a defensive shield and guarantee stable listener lifecycles.
## Changelog:
[General] [Fixed] - Prevent negative listener count and native subscription memory leaks in AnimatedColor during detach cascades
## Test Plan:
Added a comprehensive regression test suite in `packages/react-native/Libraries/Animated/tests/AnimatedColor-test.js` covering:
- Verifying `_listenerCount` does not drop into negative values after a `__detach()` and subsequent `removeListener()` execution.
- Testing `removeListener` safely acts as a no-op when called after `removeAllListeners()`.
- Ensuring native driver subscriptions are properly initiated and systematically torn down without memory leaks during complex component unmount/re-attach lifecycles.
Tested locally by running:
`yarn jest AnimatedColor-test`
## Summary:
Fixed an issue in `AnimatedColor.js` where `_listenerCount` could become negative during component unmount, leading to native subscription memory leaks.
When `AnimatedColor.__detach()` is called, it invokes `removeAllListeners()` on its channel nodes (r, g, b, a), resetting their `_listenerCount` to 0. If infrastructure cleanup then calls `removeListener(id)` on those same nodes, the count drops to -1, permanently bypassing the cleanup condition `if (this._listenerCount === 0)` — so `this._updateSubscription?.remove()` never fires.
Fix: Added a `_listeners` map to `AnimatedColor` with overrides for `addListener`, `removeListener`, and `removeAllListeners`. `removeListener` now returns early if the id is not found (already cleaned up by `__detach`), mirroring the pattern used by `AnimatedValueXY`.
## Changelog:
[GENERAL] [FIXED] - Prevent negative listener count in AnimatedColor causing native subscription memory leaks after `__detach()`
## Test Plan:
Added regression tests in `Libraries/Animated/tests/AnimatedColor-test.js`:
- `_listenerCount` does not go negative after `__detach()` + `removeListener()`
- `removeListener` after `removeAllListeners` is a safe no-op
- Native subscription starts correctly for all 4 channels (r/g/b/a)
- Native subscription cleans up correctly when count reaches 0
- No native subscription leak after re-attach cycle
## Summary:
Fixed an issue in AnimatedColor.js where _listenerCount could become negative during unmount, causing native subscription memory leaks...
## Changelog:
[GENERAL] [FIXED] - Prevent negative listener count in AnimatedColor causing native subscription memory leaks after __detach()
## Test Plan:
Added regression tests in AnimatedColor-test.js covering:
- _listenerCount does not go negative after __detach() + removeListener()
- removeListener after removeAllListeners is a safe no-op
- Native subscription cleanup when count reaches 0
## Summary:
Fixed an issue in AnimatedColor.js where _listenerCount could become negative during complex unmount phases. When __detach() is executed, it invokes removeAllListeners() on underlying channel nodes (r, g, b, a), resetting counts to 0. Subsequent stale infrastructure cleanups invoke removeListener(), which blindly decrements the counter to -1, bypassing the === 0 check and leaking native subscriptions permanently.
## Changelog:
[General] [Fixed] - Prevent negative listener count and native subscription memory leaks in AnimatedColor during detach cascades
## Test Plan:
Added a comprehensive regression test suite in packages/react-native/Libraries/Animated/tests/AnimatedColor-test.js covering:
- Verifying _listenerCount does not drop into negative values after a __detach() and subsequent removeListener() execution.
- Testing removeListener safely acts as a no-op when called after removeAllListeners().
- Ensuring native driver subscriptions are properly initiated and systematically torn down without memory leaks.
Tested locally by running:
yarn jest AnimatedColor-test
## Summary:
Fixed negative listener count bug in AnimatedColor causing memory leaks.
## Changelog:
[GENERAL] [FIXED] - Prevent negative listener count in AnimatedColor after __detach()
## Test Plan:
Added regression tests in AnimatedColor-test.js covering listener count and cleanup.
meta-codesyncBot pushed a commit that referenced this pull request Aug 19, 2026
…etaches (#57941)
Summary:
Fixes#43586.
`value.addListener(cb)` stops firing forever once any component bound to that `Animated.Value` unmounts, even though the value is still alive and still animating.
The chain:
1. [`AnimatedProps.__detach()`](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Animated/nodes/AnimatedProps.js#L222-L235) loops `node.__removeChild(this)` on unmount.
2. [`AnimatedWithChildren.__removeChild()`](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Animated/nodes/AnimatedWithChildren.js#L63-L65) does `if (this._children.length === 0) { this.__detach(); }` — the **value** detaches itself.
3. [`AnimatedNode.__detach()`](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Animated/nodes/AnimatedNode.js#L51-L52) calls `this.removeAllListeners()`, which discards callbacks the **caller** registered.
An `Animated.Value` is owned by the caller and routinely outlives the components it drives. `addListener` / `removeListener` / `removeAllListeners` are documented public API. Detaching from the graph should not silently unregister the caller's callbacks.
### Why this is a regression, not intended behaviour
`removeAllListeners()` was added to `__detach()` in cd83194 (Oct 2022) — but **behind a feature flag**, `removeListenersOnDetach`, which shipped as `() => false` in OSS:
```js
// v0.71.19 Libraries/Animated/nodes/AnimatedNode.js
__detach(): void {
if (ReactNativeFeatureFlags.removeListenersOnDetach()) {
this.removeAllListeners();
}
...
```
49d5e7c (Nov 2022) then deleted the flag as an "unused feature flag" under `changelog: [internal]`, inlining the enabled branch. That flipped the OSS default and is exactly the 0.71 → 0.72 regression a second reporter bisected in [this comment](#43586 (comment)). The user-facing semantics change was never the intent of that commit.
### Why removing the call is safe
cd83194's stated purpose was narrow:
> Removing listener on detached node leads to a red box, if the said node is `DiffClampAnimatedNode`. This is because calling `AnimatedNode.__getNativeTag()` makes native module call and creates node in native. This node is not completely initialised and red boxes […] The fix is make sure all listeners are removed before node is destroyed.
The requirement is **stop listening to native value updates before the native node is dropped**, not *discard the caller's callbacks*. Those are two different things, and today they are cleanly separable:
- `AnimatedValue.removeAllListeners()` clears `_listeners` (caller-owned) **and** calls `this._updateSubscription?.remove()` (node-owned: the `onAnimatedValueUpdate` emitter subscription plus `stopListeningToAnimatedNodeValue`).
- Only the second belongs in `__detach()`.
The 2022 hazard is also structurally gone. In 0.71, `_stopListeningForNativeValueUpdates()` called `NativeAnimatedAPI.stopListeningToAnimatedNodeValue(this.__getNativeTag())` — on a detached node `__getNativeTag()` resurrects a half-initialised native node, which is the red box. Today's [`_updateSubscription.remove()`](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Animated/nodes/AnimatedValue.js#L182-L192) closes over a local `nativeTag` const and never calls `__getNativeTag()`. This PR adds no new `__getNativeTag()` call on any path.
So `__detach()` now tears down only what the node owns, and the ordering that mattered (stop listening → `dropAnimatedNode`) is preserved.
### Does this leak?
No framework-owned resource is retained.
- The `onAnimatedValueUpdate` `NativeEventEmitter` subscription and the native `startListeningToAnimatedNodeValue` state are still released on detach — asserted by a new test.
- `_listeners` lives on the `AnimatedValue` itself. React Native keeps no registry of JS `Animated` values, so a value is reachable only from user code. Drop the value and the listeners go with it.
- If the caller deliberately keeps a value alive past its components, the retained graph is exactly what their own closures capture, and `removeListener()` / `removeAllListeners()` are the documented way to release it.
Both in-tree consumers that register listeners on an `AnimatedValue` already clean up after themselves and never relied on `__detach()` doing it — [`ScrollViewStickyHeader`](https://github.com/react/react-native/blob/main/packages/react-native/Libraries/Components/ScrollView/ScrollViewStickyHeader.js#L247-L251) and [`createAnimatedPropsHook`](https://github.com/react/react-native/blob/main/packages/react-native/src/private/animated/createAnimatedPropsHook.js#L221-L223), both in effect cleanups.
Honest behavioural delta: a caller who adds a listener on every mount and never removes it, on a value that outlives those components, will now accumulate listeners. Previously the accumulation was hidden by the very bug being fixed. Note the old behaviour was not a dependable cleanup mechanism either — it only fired when the *last* child detached, and never for listeners added after detach.
## Relationship to #57170
They overlap and **cannot both land as-is**.
- Textual: #57170 rewrites `AnimatedValue.__detach()`, so `git apply --3way` of its patch onto this branch conflicts in `AnimatedValue.js`.
- Semantic: #57170 new tests assert the behaviour this PR changes (`__detach()` → `removeAllListeners()` on the r/g/b/a channels). Those assertions would need rewriting on top of this change.
They also address the same underlying defect from opposite ends. #57170 clamps `_listenerCount` so it cannot go negative. I measured where the negative count comes from — `__detach()` zeroing `_listenerCount` out from under a caller who still holds a listener id:
| flow | `main` | this PR |
|---|---|---|
| `addListener` → `__detach()` | `_listenerCount === 0` | `_listenerCount === 1` |
| … then `removeListener(id)` | `_listenerCount === -1` | `_listenerCount === 0` |
This PR removes that root cause, so the count stays consistent without clamping. I have no opinion on whether the clamp is still wanted as defence-in-depth — flagging the interaction for whoever reviews both.
## Changelog
[GENERAL] [FIXED] - Animated - `Animated.Value` listeners registered with `addListener` are no longer removed when a component bound to the value unmounts
Pull Request resolved: #57941
Test Plan:
Tests added:
- `packages/react-native/Libraries/Animated/__tests__/Animated-test.js`
- `should keep listeners when the last attached node detaches` — node-graph level.
- `should keep listeners when a bound component unmounts` — the user-visible path: render `<Animated.View style={{transform: [{translateX: value}]}} />`, unmount it, then `setValue(42)` and assert the listener fires.
- `packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js`
- `should stop listening to native updates on unmount, but keep listeners` — guards what `removeAllListeners()` was there for: after unmount, `stopListeningToAnimatedNodeValue(tag)` and `dropAnimatedNode(tag)` are still called and a subsequent `onAnimatedValueUpdate` emission does **not** reach the listener, while `hasListeners()` stays `true`.
- `should resume delivering native updates when remounted` — native driver end to end: unmount, remount, and native updates on the new tag reach the original listener.
Counterfactual — reverting only the two source files and keeping the tests:
```
$ git checkout -- packages/react-native/Libraries/Animated/nodes/AnimatedNode.js \
packages/react-native/Libraries/Animated/nodes/AnimatedValue.js
$ yarn jest packages/react-native/Libraries/Animated/__tests__/Animated-test.js \
packages/react-native/src/private/animated/__tests__/AnimatedNative-test.js
● Native Animated › Animated Listeners › should stop listening to native updates on unmount, but keep listeners
● Native Animated › Animated Listeners › should resume delivering native updates when remounted
● Animated › Animated Listeners › should keep listeners when the last attached node detaches
● Animated › Animated Listeners › should keep listeners when a bound component unmounts
Test Suites: 2 failed, 2 total
Tests: 4 failed, 97 passed, 101 total
```
All four fail without the change; the 97 pre-existing tests in those two files pass either way.
Full suite, with the change restored:
```
$ yarn test
Test Suites: 218 passed, 218 total
Tests: 1 skipped, 5589 passed, 5590 total
$ yarn flow-check
Found 0 errors
$ yarn lint
Done in 9.20s. (eslint --max-warnings 0 .)
```
Baseline on `main` measured on the same checkout: 218 suites, 5585 passed / 1 skipped — this PR adds exactly the 4 tests above.
Not verified: I did not run this on a device or simulator, so the fix is verified through the JS graph and the mocked native-driver harness rather than against the reporter's app.
Reviewed By: javache
Differential Revision: D116040594
Pulled By: zeyap
fbshipit-source-id: b9bcb18c8ca01fa28a1dd8ff1c1737c15627d520
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.Shared with MetaApplied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mhdamirhamza@javache