From 60682d77b4590057a828718c40710785976bbb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 7 Aug 2026 12:02:56 +0200 Subject: [PATCH 1/2] Skip the pre-draw repair on frames without React mounts React can overwrite an animated value only on frames where a mount ran, so a UIManagerListener records mounts and the repair returns immediately on every other frame instead of walking all running transitions. --- .../css/CSSPlatformTransitionsManager.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 26f37cd74130..3b8dc9af4ff3 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 @@ -8,7 +8,10 @@ import android.util.FloatProperty import android.view.Choreographer import android.view.View import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.UIManager +import com.facebook.react.bridge.UIManagerListener import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.fabric.FabricUIManager import com.facebook.react.uimanager.IllegalViewOperationException import java.lang.ref.WeakReference @@ -19,6 +22,34 @@ internal class CSSPlatformTransitionsManager( private val animationTimestamp: () -> Long, ) { private val animators = HashMap() + + /** + * React can overwrite an animated value only on frames where a mount ran, so the + * pre-draw repair is skipped on all others. Everything here runs on the UI thread. + */ + private var mountedSinceLastDraw = true + + @OptIn(UnstableReactNativeAPI::class) + private val mountListener = + object : UIManagerListener { + override fun willDispatchViewUpdates(uiManager: UIManager) = Unit + + override fun willMountItems(uiManager: UIManager) = Unit + + override fun didMountItems(uiManager: UIManager) { + mountedSinceLastDraw = true + } + + override fun didDispatchMountItems(uiManager: UIManager) = Unit + + override fun didScheduleMountItems(uiManager: UIManager) = Unit + } + + init { + @OptIn(UnstableReactNativeAPI::class) + fabricUIManager.addUIManagerEventListener(mountListener) + } + private val reconciler = CSSPlatformTransitionReconciler(::repairClobberedValues) private val startTokens = HashMap() @@ -106,6 +137,8 @@ internal class CSSPlatformTransitionsManager( // Set before posting: a start already queued would otherwise register a new // animator and listener behind the cleanup. invalidated = true + @OptIn(UnstableReactNativeAPI::class) + fabricUIManager.removeUIManagerEventListener(mountListener) UiThreadUtil.runOnUiThread { startTokens.clear() // Snapshot first: cancel() runs onAnimationEnd, which reads the map. @@ -202,6 +235,8 @@ internal class CSSPlatformTransitionsManager( /** Re-asserts each animator's own value wherever a commit overwrote it. */ private fun repairClobberedValues(): Boolean { + if (!mountedSinceLastDraw) return animators.isNotEmpty() + mountedSinceLastDraw = false animators.values.forEach { running -> // target is held weakly, so read the View through it rather than keeping one. val view = running.animator.target as? View ?: return@forEach From dd32131c850ba793be6d0c2b53e352070743e3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 13 Aug 2026 09:22:59 +0200 Subject: [PATCH 2/2] Open the repair gate on synchronous prop writes too --- .../java/com/swmansion/reanimated/NativeProxy.kt | 1 + .../css/CSSPlatformTransitionsManager.kt | 15 ++++++++++----- 2 files changed, 11 insertions(+), 5 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 12455777960a..e690dbd37eb5 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 @@ -256,6 +256,7 @@ open class NativeProxy { intBuffer: IntArray, doubleBuffer: DoubleArray, ) { + cssPlatformTransitionsManager.onPropsWrittenSynchronously() SynchronousPropsBufferParser.parse(intBuffer, doubleBuffer) { viewTag, props -> if (BuildConfig.IS_REACT_NATIVE_86_OR_NEWER) { try { 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 3b8dc9af4ff3..2f92044076ed 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 @@ -24,10 +24,10 @@ internal class CSSPlatformTransitionsManager( private val animators = HashMap() /** - * React can overwrite an animated value only on frames where a mount ran, so the + * React can overwrite an animated value only on frames where it wrote props, so the * pre-draw repair is skipped on all others. Everything here runs on the UI thread. */ - private var mountedSinceLastDraw = true + private var reactWroteSinceLastDraw = true @OptIn(UnstableReactNativeAPI::class) private val mountListener = @@ -37,7 +37,7 @@ internal class CSSPlatformTransitionsManager( override fun willMountItems(uiManager: UIManager) = Unit override fun didMountItems(uiManager: UIManager) { - mountedSinceLastDraw = true + reactWroteSinceLastDraw = true } override fun didDispatchMountItems(uiManager: UIManager) = Unit @@ -233,10 +233,15 @@ internal class CSSPlatformTransitionsManager( reconciler.track(view) } + /** Synchronous prop writes run their mount item inline, so no listener reports them. */ + fun onPropsWrittenSynchronously() { + reactWroteSinceLastDraw = true + } + /** Re-asserts each animator's own value wherever a commit overwrote it. */ private fun repairClobberedValues(): Boolean { - if (!mountedSinceLastDraw) return animators.isNotEmpty() - mountedSinceLastDraw = false + if (!reactWroteSinceLastDraw) return animators.isNotEmpty() + reactWroteSinceLastDraw = false animators.values.forEach { running -> // target is held weakly, so read the View through it rather than keeping one. val view = running.animator.target as? View ?: return@forEach