Skip to content

Rive composable causes ComposeNotIdleException in Espresso/Compose UI tests due to infinite withFrameNanos loop #444

@rostyslav-prokopenko-liven

Description

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

  1. Place a Rive composable on any screen.
  2. Write a Compose UI test that navigates to that screen.
  3. Call composeTestRule.onNodeWithTag("any_tag") or waitUntil { ... }.
  4. Test times out with ComposeNotIdleException.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions