Skip to content

fix(animated): keep user-registered listeners when an Animated node detaches - #57941

Open
giaBaoJS wants to merge 1 commit into
react:mainfrom
giaBaoJS:fix/animated-value-listeners-survive-detach
Open

fix(animated): keep user-registered listeners when an Animated node detaches#57941
giaBaoJS wants to merge 1 commit into
react:mainfrom
giaBaoJS:fix/animated-value-listeners-survive-detach

Conversation

@giaBaoJS

Copy link
Copy Markdown

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() loops node.__removeChild(this) on unmount.
  2. AnimatedWithChildren.__removeChild() does if (this._children.length === 0) { this.__detach(); } — the value detaches itself.
  3. AnimatedNode.__detach() 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:

// 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. 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() 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 and createAnimatedPropsHook, 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.

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

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.

AnimatedNode.__detach() called removeAllListeners(), which discards
callbacks registered by the caller via addListener(). Because
AnimatedWithChildren.__removeChild() detaches a node once its last child
is removed, unmounting a component silently unregistered every listener
on the Animated.Value it was bound to, even though the value itself is
still alive and animating.

Detaching now only tears down the listening state the node owns:
AnimatedValue removes its native value-update subscription before the
native node is dropped, which is what the original change (D40381895)
needed. Callbacks registered by the caller are left alone, and are
removed with removeListener()/removeAllListeners() as documented.
@meta-cla meta-cla Bot 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 Aug 13, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Aug 13, 2026
@zeyap
zeyap self-requested a review August 13, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied 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.

Animated Value listeners removed after bound elements unmounted

1 participant