From 2a9450d47dc03dc69f2abd5956f550c85c3950c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Wed, 12 Aug 2026 13:14:44 +0200 Subject: [PATCH] Drop the animators and their listeners when the context goes --- .../com/swmansion/reanimated/NativeProxy.kt | 1 + .../css/CSSPlatformTransitionReconciler.kt | 36 ++++++++++++++----- .../css/CSSPlatformTransitionsManager.kt | 20 +++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt index b201c03afbe1..12455777960a 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt @@ -139,6 +139,7 @@ open class NativeProxy { return } pseudoSelectorManager.invalidate() + cssPlatformTransitionsManager.invalidate() if (mHybridData.isValid) { invalidateCpp() } diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt index 0bdd4a6da847..4849cd598e2c 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt @@ -12,27 +12,47 @@ internal class CSSPlatformTransitionReconciler( private val repair: () -> Boolean, ) { /** Keyed by window: getViewTreeObserver is per window, not per view. */ - private val tracked = HashSet() + private val tracked = HashMap() fun track(view: View) { // A window torn down mid-animation never draws again, so its listener never retires. - tracked.removeAll { !it.isAlive } + tracked.keys.removeAll { !it.isAlive } + + // A detached view hands out a temporary observer that is killed once the view attaches + // and its listeners are merged into the window's, leaving nothing to remove them from. + if (!view.isAttachedToWindow) return val observer = view.viewTreeObserver - if (!observer.isAlive || !tracked.add(observer)) return + if (!observer.isAlive || tracked.containsKey(observer)) return - observer.addOnPreDrawListener( + val listener = object : ViewTreeObserver.OnPreDrawListener { override fun onPreDraw(): Boolean { if (repair()) return true // Retire here, not when the registry empties: a cancel fires its end // callback mid-replacement, when the registry is briefly empty. - if (observer.isAlive) observer.removeOnPreDrawListener(this) - tracked.remove(observer) + retire(observer, this) return true } - }, - ) + } + tracked[observer] = listener + observer.addOnPreDrawListener(listener) + } + + /** The listeners live on the window, which outlives the React context. */ + fun invalidate() { + tracked.forEach { (observer, listener) -> + if (observer.isAlive) observer.removeOnPreDrawListener(listener) + } + tracked.clear() + } + + private fun retire( + observer: ViewTreeObserver, + listener: ViewTreeObserver.OnPreDrawListener, + ) { + if (observer.isAlive) observer.removeOnPreDrawListener(listener) + tracked.remove(observer) } } diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt index 6c51c6042507..26f37cd74130 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt @@ -22,6 +22,9 @@ internal class CSSPlatformTransitionsManager( private val reconciler = CSSPlatformTransitionReconciler(::repairClobberedValues) private val startTokens = HashMap() + @Volatile + private var invalidated = false + private var nextStartToken = 0L private val interpolators = HashMap() @@ -75,12 +78,14 @@ internal class CSSPlatformTransitionsManager( easingPointsY: FloatArray, persistent: Boolean, ): Boolean { + if (invalidated) return false val writer = cssPropertyWriterFor(propertyName) ?: return false val context = reactContext.get() ?: return false val scale = DurationScale.effectiveScale(context) if (scale <= 0f) return false UiThreadUtil.runOnUiThread { + if (invalidated) return@runOnUiThread val key = Key(viewTag, propertyName) // A fresh token invalidates any retry still queued for this key. val token = ++nextStartToken @@ -96,6 +101,21 @@ internal class CSSPlatformTransitionsManager( return true } + /** Animators keep ticking on their own, so a dead context has to stop them. */ + fun invalidate() { + // Set before posting: a start already queued would otherwise register a new + // animator and listener behind the cleanup. + invalidated = true + UiThreadUtil.runOnUiThread { + startTokens.clear() + // Snapshot first: cancel() runs onAnimationEnd, which reads the map. + val running = animators.values.toList() + animators.clear() + running.forEach { it.animator.cancel() } + reconciler.invalidate() + } + } + fun removeTransition( viewTag: Int, propertyName: String,