Bug: Rive composable keeps Compose recomposer perpetually busy
Labels: bug testing compose
Any screen containing a Rive composable (from the Compose integration in rive-android v11.3.1) prevents Espresso and Compose UI Test from ever reaching idle state. All waitForIdle(), waitUntil(), and fetchSemanticsNodes() calls time out with ComposeNotIdleException.
Root cause
There are two sources of continuous withFrameNanos usage.
Source 1 — Rive composable: draw loop never yields
In Rive.kt, the drawing loop calls withFrameNanos on every frame, even when the state machine is settled. When isSettled = true, the loop continues spinning on the frame clock without doing any work:
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
var lastFrameTime = 0.nanoseconds
while (isActive) {
val deltaTime = withFrameNanos { ... } // runs every frame
if (isSettled) {
continue // skips draw but still occupies the frame clock
}
// advance and draw
}
}
Source 2 — rememberRiveWorker: uses ComposeFrameTicker
In rememberRiveWorker.kt, the worker begins polling with ComposeFrameTicker, which also uses withFrameNanos — adding a second continuous consumer of the Compose frame clock:
worker.beginPolling(lifecycleOwner.lifecycle, ComposeFrameTicker)
Impact
ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.
- [busy] ComposeIdlingResource is busy due to pending recompositions.
- Debug: hadRecomposerChanges = true, hadSnapshotChanges = false, hadAwaitersOnMainClock = false
This affects all Rive animations — not just actively playing ones — since settled animations still run the withFrameNanos loop. Any screen containing a Rive composable makes it impossible to:
- Find nodes via
onNodeWithTag() / onNodeWithText()
- Wait for conditions via
waitUntil()
- Perform any assertion that requires idle state
Suggested fix
Option A — Break the loop when settled (minimal change)
Suspend until isSettled flips to false instead of spinning on withFrameNanos:
while (isActive) {
if (isSettled) {
// suspend until unsettled, instead of spinning on withFrameNanos
snapshotFlow { isSettled }.first { !it }
continue
}
val deltaTime = withFrameNanos { ... }
// advance and draw
}
Option B — Use ChoreographerFrameTicker for the draw loop
Replace the withFrameNanos call in the draw loop with ChoreographerFrameTicker, which already exists in the codebase. This decouples animation rendering from Compose's frame clock entirely — matching the behavior of the View-based API.
Option C — Make FrameTicker configurable
Expose a parameter on rememberRiveWorker and/or the Rive composable to choose between ComposeFrameTicker and ChoreographerFrameTicker. This lets apps opt into Choreographer-based timing for test compatibility.
Environment
|
|
rive-android |
11.3.1 |
| Compose BOM |
2024.x |
| Test framework |
compose-ui-test-junit4 + Espresso |
| Android API |
34 |
Reproduction
- Place a
Rive composable on any screen.
- Write a Compose UI test that navigates to that screen.
- Call
composeTestRule.onNodeWithTag("any_tag") or waitUntil { ... }.
- Test times out with
ComposeNotIdleException.
Bug: Rive composable keeps Compose recomposer perpetually busy
Labels:
bugtestingcomposeAny screen containing a
Rivecomposable (from the Compose integration inrive-android v11.3.1) prevents Espresso and Compose UI Test from ever reaching idle state. AllwaitForIdle(),waitUntil(), andfetchSemanticsNodes()calls time out withComposeNotIdleException.Root cause
There are two sources of continuous
withFrameNanosusage.Source 1 — Rive composable: draw loop never yields
In
Rive.kt, the drawing loop callswithFrameNanoson every frame, even when the state machine is settled. WhenisSettled = true, the loop continues spinning on the frame clock without doing any work:Source 2 —
rememberRiveWorker: usesComposeFrameTickerIn
rememberRiveWorker.kt, the worker begins polling withComposeFrameTicker, which also useswithFrameNanos— adding a second continuous consumer of the Compose frame clock:worker.beginPolling(lifecycleOwner.lifecycle, ComposeFrameTicker)Impact
This affects all Rive animations — not just actively playing ones — since settled animations still run the
withFrameNanosloop. Any screen containing aRivecomposable makes it impossible to:onNodeWithTag()/onNodeWithText()waitUntil()Suggested fix
Option A — Break the loop when settled (minimal change)
Suspend until
isSettledflips tofalseinstead of spinning onwithFrameNanos:Option B — Use
ChoreographerFrameTickerfor the draw loopReplace the
withFrameNanoscall in the draw loop withChoreographerFrameTicker, which already exists in the codebase. This decouples animation rendering from Compose's frame clock entirely — matching the behavior of the View-based API.Option C — Make
FrameTickerconfigurableExpose a parameter on
rememberRiveWorkerand/or theRivecomposable to choose betweenComposeFrameTickerandChoreographerFrameTicker. This lets apps opt into Choreographer-based timing for test compatibility.Environment
rive-androidcompose-ui-test-junit4+ EspressoReproduction
Rivecomposable on any screen.composeTestRule.onNodeWithTag("any_tag")orwaitUntil { ... }.ComposeNotIdleException.