From 742267765f3b7833126b3797b66542ae3aa7772f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Tue, 11 Aug 2026 16:06:17 +0200 Subject: [PATCH 01/15] Batch transition commands into one main-looper message Starting a transition posted its own message to the UI thread, so a commit touching every view on screen posted one per view. Commands are now queued and drained by a single asynchronous message per burst. The queue and its double buffer are taken from the frame pump prototype in the closed #10170, where they were introduced alongside the player change; they apply to the ObjectAnimator player unchanged. --- .../css/CSSPlatformTransitionsManager.kt | 126 +++++++++++++++--- 1 file changed, 107 insertions(+), 19 deletions(-) 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 9554e18ab705..3da534792911 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 @@ -4,13 +4,15 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.animation.TimeInterpolator +import android.os.Handler +import android.os.Looper +import android.os.Message 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 @@ -59,6 +61,12 @@ internal class CSSPlatformTransitionsManager( private var nextStartToken = 0L + private val commandLock = Any() + private var pendingCommands = ArrayList() + private var spareCommands = ArrayList() + private var flushScheduled = false + private val mainHandler = Handler(Looper.getMainLooper()) + /** * A C++ mutex serialises the callers but is not a happens-before edge for Java, so the * map supplies that ordering itself. @@ -82,6 +90,26 @@ internal class CSSPlatformTransitionsManager( fun currentValue(): Float = heldValue ?: if (animator.isRunning) animator.animatedValue as Float else startValue } + private sealed class Command { + abstract val key: Key + + class Start( + override val key: Key, + val writer: FloatProperty, + val fromValue: Double, + val toValue: Double, + val durationMs: Double, + val startTimestampMs: Double, + val interpolator: TimeInterpolator, + val scale: Float, + val persistent: Boolean, + ) : Command() + + class Remove( + override val key: Key, + ) : Command() + } + /** Holds the start value for [delayFraction], then plays [inner] over the rest. */ private class HoldThenEase( private val delayFraction: Float, @@ -114,19 +142,19 @@ internal class CSSPlatformTransitionsManager( val scale = DurationScale.effectiveScale(context) if (scale <= 0f) return false - UiThreadUtil.runOnUiThread { - if (invalidated) return@runOnUiThread - val key = Key(viewTag, propertyId) - // A fresh token invalidates any retry still queued for this key. - val token = ++nextStartToken - startTokens[key] = token - - beginWhenMounted(key, token, startTimestampMs + durationMs) { - viewForTag(viewTag)?.also { view -> - start(view, key, writer, fromValue, toValue, durationMs, startTimestampMs, interpolator, scale, persistent) - } != null - } - } + enqueue( + Command.Start( + Key(viewTag, propertyId), + writer, + fromValue, + toValue, + durationMs, + startTimestampMs, + interpolator, + scale, + persistent, + ), + ) return true } @@ -137,7 +165,7 @@ internal class CSSPlatformTransitionsManager( invalidated = true @OptIn(UnstableReactNativeAPI::class) fabricUIManager.removeUIManagerEventListener(mountListener) - UiThreadUtil.runOnUiThread { + mainHandler.post { startTokens.clear() // Snapshot first: cancel() runs onAnimationEnd, which reads the map. val running = animators.values.toList() @@ -152,10 +180,70 @@ internal class CSSPlatformTransitionsManager( viewTag: Int, propertyId: Int, ) { - UiThreadUtil.runOnUiThread { - val key = Key(viewTag, propertyId) - startTokens.remove(key) - animators.remove(key)?.animator?.cancel() + enqueue(Command.Remove(Key(viewTag, propertyId))) + } + + /** + * One main-looper message per burst rather than one per transition: a commit can start + * a transition on every view on screen, and a message each floods the looper. The + * message is asynchronous so a traversal's sync barrier cannot defer it past the frame + * whose committed value it is meant to replace. + */ + private fun enqueue(command: Command) { + val schedule: Boolean + synchronized(commandLock) { + pendingCommands.add(command) + schedule = !flushScheduled + if (schedule) flushScheduled = true + } + if (schedule) { + val message = Message.obtain(mainHandler) { flushCommands() } + message.isAsynchronous = true + mainHandler.sendMessage(message) + } + } + + private fun flushCommands() { + val batch: ArrayList + synchronized(commandLock) { + batch = pendingCommands + pendingCommands = spareCommands + flushScheduled = false + } + for (command in batch) { + when (command) { + is Command.Start -> beginStart(command) + is Command.Remove -> { + startTokens.remove(command.key) + animators.remove(command.key)?.animator?.cancel() + } + } + } + batch.clear() + spareCommands = batch + } + + private fun beginStart(command: Command.Start) { + val key = command.key + // Claiming a fresh token invalidates any retry still queued for this key, + // so a superseded request cannot start on a later frame. + val token = ++nextStartToken + startTokens[key] = token + beginWhenMounted(key, token, command.startTimestampMs + command.durationMs) { + viewForTag(key.viewTag)?.also { view -> + start( + view, + key, + command.writer, + command.fromValue, + command.toValue, + command.durationMs, + command.startTimestampMs, + command.interpolator, + command.scale, + command.persistent, + ) + } != null } } From 7f83011af22fd9d891ac7f2a8d2ece2ed660f970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 13 Aug 2026 10:56:31 +0200 Subject: [PATCH 02/15] Do not run queued commands after invalidation --- .../css/CSSPlatformTransitionsManager.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 3da534792911..257ff8872f59 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 @@ -210,12 +210,15 @@ internal class CSSPlatformTransitionsManager( pendingCommands = spareCommands flushScheduled = false } - for (command in batch) { - when (command) { - is Command.Start -> beginStart(command) - is Command.Remove -> { - startTokens.remove(command.key) - animators.remove(command.key)?.animator?.cancel() + // A start enqueued before invalidate() must not register an animator behind the cleanup. + if (!invalidated) { + for (command in batch) { + when (command) { + is Command.Start -> beginStart(command) + is Command.Remove -> { + startTokens.remove(command.key) + animators.remove(command.key)?.animator?.cancel() + } } } } From 10efa18d5187885fd90a177170166d599558a15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 13 Aug 2026 13:39:45 +0200 Subject: [PATCH 03/15] Recycle the command buffer even when a command throws --- .../css/CSSPlatformTransitionsManager.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) 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 257ff8872f59..0042e56f7880 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 @@ -210,20 +210,24 @@ internal class CSSPlatformTransitionsManager( pendingCommands = spareCommands flushScheduled = false } - // A start enqueued before invalidate() must not register an animator behind the cleanup. - if (!invalidated) { - for (command in batch) { - when (command) { - is Command.Start -> beginStart(command) - is Command.Remove -> { - startTokens.remove(command.key) - animators.remove(command.key)?.animator?.cancel() + try { + // A start enqueued before invalidate() must not register an animator behind the cleanup. + if (!invalidated) { + for (command in batch) { + when (command) { + is Command.Start -> beginStart(command) + is Command.Remove -> { + startTokens.remove(command.key) + animators.remove(command.key)?.animator?.cancel() + } } } } + } finally { + // A command that throws would otherwise leave the spare holding stale starts. + batch.clear() + spareCommands = batch } - batch.clear() - spareCommands = batch } private fun beginStart(command: Command.Start) { From 6f5d0463c1daad27a37d4c5dbc46de0271d3e2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 13 Aug 2026 16:40:23 +0200 Subject: [PATCH 04/15] Describe what the command buffer recycle actually prevents --- .../swmansion/reanimated/css/CSSPlatformTransitionsManager.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 0042e56f7880..115eab75aea3 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 @@ -224,7 +224,8 @@ internal class CSSPlatformTransitionsManager( } } } finally { - // A command that throws would otherwise leave the spare holding stale starts. + // Recycle even when a command throws: until then the spare still aliases the live + // queue, and the next flush would clear commands out from under enqueue(). batch.clear() spareCommands = batch } From b2279ba456b75aa2cb8f3f8c218422a5e2da0a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 13 Aug 2026 16:50:42 +0200 Subject: [PATCH 05/15] Pass the start command instead of splatting its fields --- .../css/CSSPlatformTransitionsManager.kt | 40 ++++++------------- 1 file changed, 12 insertions(+), 28 deletions(-) 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 115eab75aea3..e0832660d5ff 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 @@ -238,20 +238,7 @@ internal class CSSPlatformTransitionsManager( val token = ++nextStartToken startTokens[key] = token beginWhenMounted(key, token, command.startTimestampMs + command.durationMs) { - viewForTag(key.viewTag)?.also { view -> - start( - view, - key, - command.writer, - command.fromValue, - command.toValue, - command.durationMs, - command.startTimestampMs, - command.interpolator, - command.scale, - command.persistent, - ) - } != null + viewForTag(key.viewTag)?.also { view -> start(view, command) } != null } } @@ -275,26 +262,23 @@ internal class CSSPlatformTransitionsManager( private fun start( view: View, - key: Key, - writer: FloatProperty, - fromValue: Double, - toValue: Double, - durationMs: Double, - startTimestampMs: Double, - interpolator: TimeInterpolator, - scale: Float, - persistent: Boolean, + command: Command.Start, ) { + val key = command.key + val writer = command.writer + val durationMs = command.durationMs + val interpolator = command.interpolator + // Resume from what is on screen; fromValue is the committed style, which would snap back. val interrupted = animators.remove(key) - val startValue = if (interrupted != null) writer.get(view) else fromValue.toFloat() + val startValue = if (interrupted != null) writer.get(view) else command.fromValue.toFloat() interrupted?.animator?.cancel() // ObjectAnimator writes nothing until its first frame, so the view would show the // already-committed target for the whole delay. writer.setValue(view, startValue) - val animator = ObjectAnimator.ofFloat(view, writer, startValue, toValue.toFloat()) + val animator = ObjectAnimator.ofFloat(view, writer, startValue, command.toValue.toFloat()) animator.addListener( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { @@ -302,7 +286,7 @@ internal class CSSPlatformTransitionsManager( if (running.animator !== animation) return // A persistent value has no committed style behind it, so dropping the entry // would let the next commit revert the view. - if (persistent) { + if (command.persistent) { running.heldValue = animator.animatedValue as Float } else { animators.remove(key) @@ -312,11 +296,11 @@ internal class CSSPlatformTransitionsManager( ) // ObjectAnimator has no absolute start time, so resolve it after the thread hop // rather than in C++, which would shift the timeline late. - val elapsedMs = animationTimestamp().toDouble() - startTimestampMs + val elapsedMs = animationTimestamp().toDouble() - command.startTimestampMs val delayMs = if (elapsedMs < 0) -elapsedMs else 0.0 // startDelay writes nothing while it waits, so a commit landing in the delay would // stay on screen. Folding the delay into the curve rewrites the property instead. - animator.duration = ((delayMs + durationMs) / scale).toLong().coerceAtLeast(1L) + animator.duration = ((delayMs + durationMs) / command.scale).toLong().coerceAtLeast(1L) animator.interpolator = if (delayMs > 0) HoldThenEase((delayMs / (delayMs + durationMs)).toFloat(), interpolator) else interpolator if (elapsedMs > 0 && durationMs > 0) { From 8909c46223da46fc47e35048eac989c6db9be99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 14 Aug 2026 12:00:59 +0200 Subject: [PATCH 06/15] Stop a queued mount retry from starting after invalidation --- .../swmansion/reanimated/css/CSSPlatformTransitionsManager.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 e0832660d5ff..d45106aa92c3 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 @@ -252,7 +252,8 @@ internal class CSSPlatformTransitionsManager( endTimestampMs: Double, begin: () -> Boolean, ) { - if (startTokens[key] != token) return + // A queued callback can outlive invalidate(), which only posts its cleanup. + if (invalidated || startTokens[key] != token) return if (begin() || animationTimestamp() >= endTimestampMs) { startTokens.remove(key) return From 32864ea6c6022c5db1666ae89c78608739c6a55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 14 Aug 2026 12:33:41 +0200 Subject: [PATCH 07/15] Lend the spare command buffer out instead of sharing it --- .../css/CSSPlatformTransitionsManager.kt | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) 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 d45106aa92c3..ec564ad6953c 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 @@ -63,9 +63,12 @@ internal class CSSPlatformTransitionsManager( private val commandLock = Any() private var pendingCommands = ArrayList() - private var spareCommands = ArrayList() + + /** Null while the flush holds it, so the two buffers can never be the same list. */ + private var spareCommands: ArrayList? = ArrayList() private var flushScheduled = false private val mainHandler = Handler(Looper.getMainLooper()) + private val flushRunnable = Runnable { flushCommands() } /** * A C++ mutex serialises the callers but is not a happens-before edge for Java, so the @@ -190,6 +193,9 @@ internal class CSSPlatformTransitionsManager( * whose committed value it is meant to replace. */ private fun enqueue(command: Command) { + // Teardown streams a removal per routed property, and each would post its own message. + if (invalidated) return + val schedule: Boolean synchronized(commandLock) { pendingCommands.add(command) @@ -197,7 +203,7 @@ internal class CSSPlatformTransitionsManager( if (schedule) flushScheduled = true } if (schedule) { - val message = Message.obtain(mainHandler) { flushCommands() } + val message = Message.obtain(mainHandler, flushRunnable) message.isAsynchronous = true mainHandler.sendMessage(message) } @@ -207,27 +213,25 @@ internal class CSSPlatformTransitionsManager( val batch: ArrayList synchronized(commandLock) { batch = pendingCommands - pendingCommands = spareCommands + pendingCommands = spareCommands ?: ArrayList() + spareCommands = null flushScheduled = false } try { - // A start enqueued before invalidate() must not register an animator behind the cleanup. - if (!invalidated) { - for (command in batch) { - when (command) { - is Command.Start -> beginStart(command) - is Command.Remove -> { - startTokens.remove(command.key) - animators.remove(command.key)?.animator?.cancel() - } + for (command in batch) { + // A start must not register an animator behind cleanup that is already posted. + if (invalidated) break + when (command) { + is Command.Start -> beginStart(command) + is Command.Remove -> { + startTokens.remove(command.key) + animators.remove(command.key)?.animator?.cancel() } } } } finally { - // Recycle even when a command throws: until then the spare still aliases the live - // queue, and the next flush would clear commands out from under enqueue(). batch.clear() - spareCommands = batch + synchronized(commandLock) { spareCommands = batch } } } From 6a67155dc7767b31c23088bbba8c3b012056a2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 14 Aug 2026 13:10:20 +0200 Subject: [PATCH 08/15] Drain queued transition commands in the pre-draw pass --- .../css/CSSPlatformTransitionsManager.kt | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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 ec564ad6953c..3fa649850ac3 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 @@ -190,7 +190,8 @@ internal class CSSPlatformTransitionsManager( * One main-looper message per burst rather than one per transition: a commit can start * a transition on every view on screen, and a message each floods the looper. The * message is asynchronous so a traversal's sync barrier cannot defer it past the frame - * whose committed value it is meant to replace. + * whose committed value it is meant to replace. Whichever comes first, this message or + * the pre-draw pass, drains the queue; the other then finds it empty. */ private fun enqueue(command: Command) { // Teardown streams a removal per routed property, and each would post its own message. @@ -324,7 +325,12 @@ internal class CSSPlatformTransitionsManager( /** Re-asserts each animator's own value wherever a commit overwrote it. */ private fun repairClobberedValues(): Boolean { - if (!reactWroteSinceLastDraw) return animators.isNotEmpty() + // Drain here too, not only from the posted message: this runs after the commit that + // wrote the target and before the draw, so a queued start replaces it in the same + // frame instead of showing it once. Ahead of the repair, so those starts are covered. + flushCommands() + + if (!reactWroteSinceLastDraw) return isAnimating() reactWroteSinceLastDraw = false animators.values.forEach { running -> // target is held weakly, so read the View through it rather than keeping one. @@ -332,7 +338,13 @@ internal class CSSPlatformTransitionsManager( val current = running.currentValue() if (running.writer.get(view) != current) running.writer.setValue(view, current) } - return animators.isNotEmpty() + return isAnimating() + } + + /** Retiring with commands still queued would drop the drain above along with the listener. */ + private fun isAnimating(): Boolean { + if (animators.isNotEmpty()) return true + synchronized(commandLock) { return pendingCommands.isNotEmpty() } } /** PathInterpolator flattens its curve natively on construction, so build once per id. */ From 10a2d43c073770284817e73c6e467faf985ad049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 14 Aug 2026 13:29:03 +0200 Subject: [PATCH 09/15] Name the pre-draw step for what it does --- .../css/CSSPlatformTransitionsManager.kt | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) 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 3fa649850ac3..16c94ac55fb8 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 @@ -53,7 +53,7 @@ internal class CSSPlatformTransitionsManager( fabricUIManager.addUIManagerEventListener(mountListener) } - private val reconciler = CSSPlatformTransitionReconciler(::repairClobberedValues) + private val reconciler = CSSPlatformTransitionReconciler(::onPreDraw) private val startTokens = HashMap() @Volatile @@ -190,8 +190,7 @@ internal class CSSPlatformTransitionsManager( * One main-looper message per burst rather than one per transition: a commit can start * a transition on every view on screen, and a message each floods the looper. The * message is asynchronous so a traversal's sync barrier cannot defer it past the frame - * whose committed value it is meant to replace. Whichever comes first, this message or - * the pre-draw pass, drains the queue; the other then finds it empty. + * whose committed value it is meant to replace. */ private fun enqueue(command: Command) { // Teardown streams a removal per routed property, and each would post its own message. @@ -323,14 +322,20 @@ internal class CSSPlatformTransitionsManager( reactWroteSinceLastDraw = true } - /** Re-asserts each animator's own value wherever a commit overwrote it. */ - private fun repairClobberedValues(): Boolean { - // Drain here too, not only from the posted message: this runs after the commit that - // wrote the target and before the draw, so a queued start replaces it in the same - // frame instead of showing it once. Ahead of the repair, so those starts are covered. + /** + * Draining here as well as from the posted message puts a queued start after the commit + * that wrote the target and before the draw, so it replaces the target in the same frame + * rather than showing it once. Returns whether the listener is still needed. + */ + private fun onPreDraw(): Boolean { flushCommands() + repairClobberedValues() + return animators.isNotEmpty() || hasQueuedCommands() + } - if (!reactWroteSinceLastDraw) return isAnimating() + /** Re-asserts each animator's own value wherever a commit overwrote it. */ + private fun repairClobberedValues() { + if (!reactWroteSinceLastDraw) return reactWroteSinceLastDraw = false animators.values.forEach { running -> // target is held weakly, so read the View through it rather than keeping one. @@ -338,14 +343,10 @@ internal class CSSPlatformTransitionsManager( val current = running.currentValue() if (running.writer.get(view) != current) running.writer.setValue(view, current) } - return isAnimating() } - /** Retiring with commands still queued would drop the drain above along with the listener. */ - private fun isAnimating(): Boolean { - if (animators.isNotEmpty()) return true - synchronized(commandLock) { return pendingCommands.isNotEmpty() } - } + /** Retiring with commands still queued would take the pre-draw drain with the listener. */ + private fun hasQueuedCommands(): Boolean = synchronized(commandLock) { pendingCommands.isNotEmpty() } /** PathInterpolator flattens its curve natively on construction, so build once per id. */ fun defineEasing( From e491dcbc02abb241bbf4b59d0065a07566ad999b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 14 Aug 2026 13:58:42 +0200 Subject: [PATCH 10/15] Give the remove command its own executor --- .../reanimated/css/CSSPlatformTransitionsManager.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 16c94ac55fb8..39e341012e09 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 @@ -223,10 +223,7 @@ internal class CSSPlatformTransitionsManager( if (invalidated) break when (command) { is Command.Start -> beginStart(command) - is Command.Remove -> { - startTokens.remove(command.key) - animators.remove(command.key)?.animator?.cancel() - } + is Command.Remove -> removeNow(command.key) } } } finally { @@ -235,6 +232,11 @@ internal class CSSPlatformTransitionsManager( } } + private fun removeNow(key: Key) { + startTokens.remove(key) + animators.remove(key)?.animator?.cancel() + } + private fun beginStart(command: Command.Start) { val key = command.key // Claiming a fresh token invalidates any retry still queued for this key, From 7efd746036fb4dc887ce26b9fd001edd6095c2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 14 Aug 2026 14:23:10 +0200 Subject: [PATCH 11/15] Match the reconciler callback name to what it runs --- .../reanimated/css/CSSPlatformTransitionReconciler.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 4849cd598e2c..be6318fe02e0 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 @@ -5,11 +5,11 @@ import android.view.ViewTreeObserver /** * React writes the same property the animator drives, so a commit can overwrite it mid-flight. - * Re-asserting just before the draw covers every writer without knowing which one ran, since - * they all run earlier in the frame. [repair] returns whether anything is still animating. + * Running [onPreDraw] just before the draw puts it after every writer without knowing which one + * ran, since they all run earlier in the frame. Its return says whether the listener stays. */ internal class CSSPlatformTransitionReconciler( - private val repair: () -> Boolean, + private val onPreDraw: () -> Boolean, ) { /** Keyed by window: getViewTreeObserver is per window, not per view. */ private val tracked = HashMap() @@ -28,7 +28,7 @@ internal class CSSPlatformTransitionReconciler( val listener = object : ViewTreeObserver.OnPreDrawListener { override fun onPreDraw(): Boolean { - if (repair()) return true + if (this@CSSPlatformTransitionReconciler.onPreDraw()) return true // Retire here, not when the registry empties: a cancel fires its end // callback mid-replacement, when the registry is briefly empty. From 351cb3b8491d88ba938b5d9e4c7130b9c5b91d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 14 Aug 2026 14:27:27 +0200 Subject: [PATCH 12/15] Hold the command queue in its own class --- .../css/CSSPlatformTransitionsManager.kt | 66 ++++--------------- .../reanimated/css/MainThreadCommandQueue.kt | 58 ++++++++++++++++ 2 files changed, 69 insertions(+), 55 deletions(-) create mode 100644 packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/MainThreadCommandQueue.kt 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 39e341012e09..a5a77ef75cb6 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 @@ -61,14 +61,8 @@ internal class CSSPlatformTransitionsManager( private var nextStartToken = 0L - private val commandLock = Any() - private var pendingCommands = ArrayList() - - /** Null while the flush holds it, so the two buffers can never be the same list. */ - private var spareCommands: ArrayList? = ArrayList() - private var flushScheduled = false + private val commands = MainThreadCommandQueue(::executeCommand) private val mainHandler = Handler(Looper.getMainLooper()) - private val flushRunnable = Runnable { flushCommands() } /** * A C++ mutex serialises the callers but is not a happens-before edge for Java, so the @@ -145,7 +139,7 @@ internal class CSSPlatformTransitionsManager( val scale = DurationScale.effectiveScale(context) if (scale <= 0f) return false - enqueue( + commands.enqueue( Command.Start( Key(viewTag, propertyId), writer, @@ -183,52 +177,17 @@ internal class CSSPlatformTransitionsManager( viewTag: Int, propertyId: Int, ) { - enqueue(Command.Remove(Key(viewTag, propertyId))) - } - - /** - * One main-looper message per burst rather than one per transition: a commit can start - * a transition on every view on screen, and a message each floods the looper. The - * message is asynchronous so a traversal's sync barrier cannot defer it past the frame - * whose committed value it is meant to replace. - */ - private fun enqueue(command: Command) { // Teardown streams a removal per routed property, and each would post its own message. if (invalidated) return - - val schedule: Boolean - synchronized(commandLock) { - pendingCommands.add(command) - schedule = !flushScheduled - if (schedule) flushScheduled = true - } - if (schedule) { - val message = Message.obtain(mainHandler, flushRunnable) - message.isAsynchronous = true - mainHandler.sendMessage(message) - } + commands.enqueue(Command.Remove(Key(viewTag, propertyId))) } - private fun flushCommands() { - val batch: ArrayList - synchronized(commandLock) { - batch = pendingCommands - pendingCommands = spareCommands ?: ArrayList() - spareCommands = null - flushScheduled = false - } - try { - for (command in batch) { - // A start must not register an animator behind cleanup that is already posted. - if (invalidated) break - when (command) { - is Command.Start -> beginStart(command) - is Command.Remove -> removeNow(command.key) - } - } - } finally { - batch.clear() - synchronized(commandLock) { spareCommands = batch } + private fun executeCommand(command: Command) { + // A start must not register an animator behind cleanup that is already posted. + if (invalidated) return + when (command) { + is Command.Start -> beginStart(command) + is Command.Remove -> removeNow(command.key) } } @@ -330,9 +289,9 @@ internal class CSSPlatformTransitionsManager( * rather than showing it once. Returns whether the listener is still needed. */ private fun onPreDraw(): Boolean { - flushCommands() + commands.drain() repairClobberedValues() - return animators.isNotEmpty() || hasQueuedCommands() + return animators.isNotEmpty() || commands.hasPending() } /** Re-asserts each animator's own value wherever a commit overwrote it. */ @@ -347,9 +306,6 @@ internal class CSSPlatformTransitionsManager( } } - /** Retiring with commands still queued would take the pre-draw drain with the listener. */ - private fun hasQueuedCommands(): Boolean = synchronized(commandLock) { pendingCommands.isNotEmpty() } - /** PathInterpolator flattens its curve natively on construction, so build once per id. */ fun defineEasing( easingId: Int, diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/MainThreadCommandQueue.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/MainThreadCommandQueue.kt new file mode 100644 index 000000000000..ac298ecb5935 --- /dev/null +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/MainThreadCommandQueue.kt @@ -0,0 +1,58 @@ +package com.swmansion.reanimated.css + +import android.os.Handler +import android.os.Looper +import android.os.Message + +/** + * Coalesces commands from any thread into one main-looper message per burst: a commit can + * produce a command per view on screen, and a message each floods the looper. The message is + * asynchronous so a traversal's sync barrier cannot defer it past the frame whose committed + * values the commands are meant to replace. [execute] runs on the main thread, from the posted + * message or from an explicit [drain]. + */ +internal class MainThreadCommandQueue( + private val execute: (T) -> Unit, +) { + private val lock = Any() + private var pending = ArrayList() + + /** Null while a drain holds it, so the two buffers can never be the same list. */ + private var spare: ArrayList? = ArrayList() + private var scheduled = false + private val mainHandler = Handler(Looper.getMainLooper()) + private val drainRunnable = Runnable { drain() } + + fun enqueue(command: T) { + val schedule: Boolean + synchronized(lock) { + pending.add(command) + schedule = !scheduled + if (schedule) scheduled = true + } + if (schedule) { + val message = Message.obtain(mainHandler, drainRunnable) + message.isAsynchronous = true + mainHandler.sendMessage(message) + } + } + + /** Runs every queued command now; a later message for the same burst finds nothing left. */ + fun drain() { + val batch: ArrayList + synchronized(lock) { + batch = pending + pending = spare ?: ArrayList() + spare = null + scheduled = false + } + try { + for (command in batch) execute(command) + } finally { + batch.clear() + synchronized(lock) { spare = batch } + } + } + + fun hasPending(): Boolean = synchronized(lock) { pending.isNotEmpty() } +} From 6f234ae22047d4950490b2d6e58f8022674b93c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 14 Aug 2026 14:28:04 +0200 Subject: [PATCH 13/15] Drop the message import the queue took along --- .../swmansion/reanimated/css/CSSPlatformTransitionsManager.kt | 1 - 1 file changed, 1 deletion(-) 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 a5a77ef75cb6..890fa77f33c6 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 @@ -6,7 +6,6 @@ import android.animation.ObjectAnimator import android.animation.TimeInterpolator import android.os.Handler import android.os.Looper -import android.os.Message import android.util.FloatProperty import android.view.Choreographer import android.view.View From 126e09612aa2283a671f0542f7c5bbe9cc19399c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 14 Aug 2026 14:53:01 +0200 Subject: [PATCH 14/15] Post the drain message before another drain can clear the flag --- .../swmansion/reanimated/css/MainThreadCommandQueue.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/MainThreadCommandQueue.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/MainThreadCommandQueue.kt index ac298ecb5935..f0a6dba4179e 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/MainThreadCommandQueue.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/MainThreadCommandQueue.kt @@ -24,13 +24,12 @@ internal class MainThreadCommandQueue( private val drainRunnable = Runnable { drain() } fun enqueue(command: T) { - val schedule: Boolean synchronized(lock) { pending.add(command) - schedule = !scheduled - if (schedule) scheduled = true - } - if (schedule) { + if (scheduled) return + scheduled = true + // Posted under the lock: a drain between deciding and posting would reset + // `scheduled`, and the next enqueue would post a second message for this burst. val message = Message.obtain(mainHandler, drainRunnable) message.isAsynchronous = true mainHandler.sendMessage(message) From 1852b3dced925e05ad66d53ddcee739e8a56f9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 14 Aug 2026 15:20:08 +0200 Subject: [PATCH 15/15] Name the reconciler callback so it cannot shadow the listener --- .../reanimated/css/CSSPlatformTransitionReconciler.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 be6318fe02e0..79e66face03e 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 @@ -5,11 +5,11 @@ import android.view.ViewTreeObserver /** * React writes the same property the animator drives, so a commit can overwrite it mid-flight. - * Running [onPreDraw] just before the draw puts it after every writer without knowing which one - * ran, since they all run earlier in the frame. Its return says whether the listener stays. + * Running [beforeDraw] at that point puts it after every writer without knowing which one ran, + * since they all run earlier in the frame. Its return says whether the listener stays. */ internal class CSSPlatformTransitionReconciler( - private val onPreDraw: () -> Boolean, + private val beforeDraw: () -> Boolean, ) { /** Keyed by window: getViewTreeObserver is per window, not per view. */ private val tracked = HashMap() @@ -28,7 +28,7 @@ internal class CSSPlatformTransitionReconciler( val listener = object : ViewTreeObserver.OnPreDrawListener { override fun onPreDraw(): Boolean { - if (this@CSSPlatformTransitionReconciler.onPreDraw()) return true + if (beforeDraw()) return true // Retire here, not when the registry empties: a cancel fires its end // callback mid-replacement, when the registry is briefly empty.