
Test-only toolkit turning UI recomposition behavior into assertions: tag composables, assert per-instance recomposition counts, get one-line test setup plus rich diagnostics and causality analysis.
Wait... didn't we just compose this?
Guard your Compose UI efficiency. Catch recomposition regressions before your users.
Compose's recomposition behavior is an implicit contract — composables should recompose when their inputs change and stay stable otherwise. But that contract breaks silently, and today's options for catching it are limited:
SideEffect counters, LaunchedEffect logging, wrapper composables; invasive, doesn't scale, and ships in your production codeDejavu is a test-only library that turns recomposition behavior into assertions. Tag your composables with standard Modifier.testTag(), write expectations against recomposition counts, and get structured diagnostics when something changes — whether from a teammate, a library upgrade, an AI agent rewriting your UI code, or a refactor that silently destabilizes a lambda.
Modifier.testTag()
createRecompositionTrackingRule()
// app/build.gradle.kts
dependencies {
androidTestImplementation("me.mmckenna.dejavu:dejavu:0.3.1")
}@get:Rule
val composeTestRule = createRecompositionTrackingRule()
@Test
fun incrementCounter_onlyValueRecomposes() {
composeTestRule.onNodeWithTag("inc_button")
.performClick()
composeTestRule.onNodeWithTag("counter_value")
.assertRecompositions(exactly = 1)
composeTestRule.onNodeWithTag("counter_title")
.assertStable() // stable = zero recompositions
}createRecompositionTrackingRule wraps createAndroidComposeRule and resets counts before each test. For createComposeRule() or other rule types, see Examples.
dejavu.UnexpectedRecompositionsError: Recomposition assertion failed for testTag='product_header'
Composable: demo.app.ui.ProductHeader (ProductList.kt:29)
Expected: exactly 0 recomposition(s)
Actual: 1 recomposition(s)
All tracked composables:
ProductListScreen = 1
ProductHeader = 1 <-- FAILED
ProductItem = 1
Recomposition timeline:
#1 at +0ms — param slots changed: [1] | parent: ProductListScreen
Possible cause:
1 state change(s) of type Int
Parameter/parent change detected (dirty bits set)
See Error Messages Guide for how to read and act on each section.
When you optimize a composable — extracting a lambda, adding remember, switching to derivedStateOf — Dejavu lets you write a test that captures the expected recomposition count. That improvement becomes part of your test suite: refactors, dependency upgrades, and new features all have to maintain it or explicitly update the expectation.
AI coding agents can refactor composables and restructure state, but they have no way to know whether their changes made recomposition better or worse. Dejavu gives them that signal. When an agent runs your tests and a Dejavu assertion fails, the structured error message tells it exactly which composable regressed, by how much, and why — turning recomposition count into an optimization metric the agent can target directly.
When AI agents or automated tooling modify your codebase, they can introduce subtle changes to recomposition behavior without touching any visible UI. Dejavu tests act as guardrails — if an agent's changes cause a composable to recompose more than expected, the test fails before the change is merged. You get the speed of automated refactoring with the confidence that recomposition behavior is preserved.
See the full Use Cases guide for examples.
// Exact count
composeTestRule.onNodeWithTag("tag").assertRecompositions(exactly = 2)
// Bounds
composeTestRule.onNodeWithTag("tag")
.assertRecompositions(atLeast = 1)
composeTestRule.onNodeWithTag("tag")
.assertRecompositions(atMost = 3)
composeTestRule.onNodeWithTag("tag")
.assertRecompositions(atLeast = 1, atMost = 5)
// Stability (alias for exactly = 0)
composeTestRule.onNodeWithTag("tag")
.assertStable()// Reset all counts to zero mid-test (Android)
composeTestRule.resetRecompositionCounts()
// Get the current recomposition count for a tag (Android)
val count: Int = composeTestRule.getRecompositionCount("tag")
// Stream recomposition events to Logcat (filter: "Dejavu")
// Useful for AI agents or external tools monitoring UI state
Dejavu.enable(app = this, logToLogcat = true)
// Disable tracking and clear all data
Dejavu.disable()Dejavu hooks into the Compose runtime's CompositionTracer API (available since compose-runtime 1.2.0):
Composer.setTracer() receives callbacks for every composable enter/exitCompositionData group tree to find which composable encloses each Modifier.testTag()
Snapshot.registerApplyObserver detects state changes; dirty bits detect parameter-driven recompositionsAll tracking runs in the app process on the main thread, directly accessible to instrumented tests.
Minimum: compose-runtime 1.2.0 (CompositionTracer API). Requires Kotlin 2.0+ with the Compose compiler plugin.
| Compose BOM | Compose | Kotlin | Status |
|---|---|---|---|
| 2024.06.00 | 1.6.x | 2.0.x | Tested |
| 2024.09.00 | 1.7.x | 2.0.x | Tested |
| 2025.01.01 | 1.8.x | 2.0.x | Tested |
| 2026.01.01 | 1.10.x | 2.1.x+ | Tested |
| 2026.03.01 | 1.10.x | 2.1.x+ | Baseline |
Dejavu supports Kotlin Multiplatform with the following targets:
| Target | Status | Notes |
|---|---|---|
| Android | Full support | Tag mapping via ui-tooling-data Group tree |
| Desktop (JVM) | Full support | Tag mapping via CompositionGroup + sourceInfo
|
| iOS (arm64, simulatorArm64, x64) | Supported | Same as JVM; LazyVerticalGrid has upstream Compose runtime bug |
| WasmJs (browser) | Supported | Exception propagation limited in test runner |
For non-Android platforms, use runRecompositionTrackingUiTest with setTrackedContent:
@Test
fun myComposable_isStable() = runRecompositionTrackingUiTest {
setTrackedContent { MyComposable() }
waitForIdle()
onNodeWithTag("my_tag").assertStable()
}runRecompositionTrackingUiTest is the KMP equivalent of Android's createRecompositionTrackingRule().
It handles all Dejavu lifecycle management automatically -- enabling the tracer, resetting state,
and cleaning up after each test. setTrackedContent wraps setContent with the inspection tables
and sub-composition layout required for tag-to-function mapping.
LazyVerticalGrid crash — The Compose runtime's internal slot table hash implementation crashes on iOS/Native and WasmJs when LazyVerticalGrid is in the composition. This is an upstream Compose bug, not a Dejavu issue. LazyColumn, LazyRow, and all other composables work correctly when LazyVerticalGrid is not present.AssertionError messages. Assertion behavior (pass/fail) works correctly; only error message inspection is affected. Parameter validation exceptions (IllegalArgumentException) are caught correctly when structured inside runComposeUiTest.LazyColumn/LazyRow only compose items that are visible. Items that haven't been composed don't exist in the composition tree, so Dejavu has nothing to track. Scroll them into view before asserting.createAndroidComposeRule uses the Activity's real Recomposer, not a test-controlled one. This means mainClock.advanceTimeBy() can't drive infinite animations forward. Use createComposeRule (without an Activity) if you need a controllable clock.Group.parameters from the Compose tooling data API, which was designed for Layout Inspector rather than programmatic diffing. Parameter names may be unavailable, and values are compared via hashCode/toString, so custom types without meaningful toString show opaque values.We welcome contributions! Please see CONTRIBUTING.md for guidelines and CODE_OF_CONDUCT.md for our community standards.
Apache 2.0
Wait... didn't we just compose this?
Guard your Compose UI efficiency. Catch recomposition regressions before your users.
Compose's recomposition behavior is an implicit contract — composables should recompose when their inputs change and stay stable otherwise. But that contract breaks silently, and today's options for catching it are limited:
SideEffect counters, LaunchedEffect logging, wrapper composables; invasive, doesn't scale, and ships in your production codeDejavu is a test-only library that turns recomposition behavior into assertions. Tag your composables with standard Modifier.testTag(), write expectations against recomposition counts, and get structured diagnostics when something changes — whether from a teammate, a library upgrade, an AI agent rewriting your UI code, or a refactor that silently destabilizes a lambda.
Modifier.testTag()
createRecompositionTrackingRule()
// app/build.gradle.kts
dependencies {
androidTestImplementation("me.mmckenna.dejavu:dejavu:0.3.1")
}@get:Rule
val composeTestRule = createRecompositionTrackingRule()
@Test
fun incrementCounter_onlyValueRecomposes() {
composeTestRule.onNodeWithTag("inc_button")
.performClick()
composeTestRule.onNodeWithTag("counter_value")
.assertRecompositions(exactly = 1)
composeTestRule.onNodeWithTag("counter_title")
.assertStable() // stable = zero recompositions
}createRecompositionTrackingRule wraps createAndroidComposeRule and resets counts before each test. For createComposeRule() or other rule types, see Examples.
dejavu.UnexpectedRecompositionsError: Recomposition assertion failed for testTag='product_header'
Composable: demo.app.ui.ProductHeader (ProductList.kt:29)
Expected: exactly 0 recomposition(s)
Actual: 1 recomposition(s)
All tracked composables:
ProductListScreen = 1
ProductHeader = 1 <-- FAILED
ProductItem = 1
Recomposition timeline:
#1 at +0ms — param slots changed: [1] | parent: ProductListScreen
Possible cause:
1 state change(s) of type Int
Parameter/parent change detected (dirty bits set)
See Error Messages Guide for how to read and act on each section.
When you optimize a composable — extracting a lambda, adding remember, switching to derivedStateOf — Dejavu lets you write a test that captures the expected recomposition count. That improvement becomes part of your test suite: refactors, dependency upgrades, and new features all have to maintain it or explicitly update the expectation.
AI coding agents can refactor composables and restructure state, but they have no way to know whether their changes made recomposition better or worse. Dejavu gives them that signal. When an agent runs your tests and a Dejavu assertion fails, the structured error message tells it exactly which composable regressed, by how much, and why — turning recomposition count into an optimization metric the agent can target directly.
When AI agents or automated tooling modify your codebase, they can introduce subtle changes to recomposition behavior without touching any visible UI. Dejavu tests act as guardrails — if an agent's changes cause a composable to recompose more than expected, the test fails before the change is merged. You get the speed of automated refactoring with the confidence that recomposition behavior is preserved.
See the full Use Cases guide for examples.
// Exact count
composeTestRule.onNodeWithTag("tag").assertRecompositions(exactly = 2)
// Bounds
composeTestRule.onNodeWithTag("tag")
.assertRecompositions(atLeast = 1)
composeTestRule.onNodeWithTag("tag")
.assertRecompositions(atMost = 3)
composeTestRule.onNodeWithTag("tag")
.assertRecompositions(atLeast = 1, atMost = 5)
// Stability (alias for exactly = 0)
composeTestRule.onNodeWithTag("tag")
.assertStable()// Reset all counts to zero mid-test (Android)
composeTestRule.resetRecompositionCounts()
// Get the current recomposition count for a tag (Android)
val count: Int = composeTestRule.getRecompositionCount("tag")
// Stream recomposition events to Logcat (filter: "Dejavu")
// Useful for AI agents or external tools monitoring UI state
Dejavu.enable(app = this, logToLogcat = true)
// Disable tracking and clear all data
Dejavu.disable()Dejavu hooks into the Compose runtime's CompositionTracer API (available since compose-runtime 1.2.0):
Composer.setTracer() receives callbacks for every composable enter/exitCompositionData group tree to find which composable encloses each Modifier.testTag()
Snapshot.registerApplyObserver detects state changes; dirty bits detect parameter-driven recompositionsAll tracking runs in the app process on the main thread, directly accessible to instrumented tests.
Minimum: compose-runtime 1.2.0 (CompositionTracer API). Requires Kotlin 2.0+ with the Compose compiler plugin.
| Compose BOM | Compose | Kotlin | Status |
|---|---|---|---|
| 2024.06.00 | 1.6.x | 2.0.x | Tested |
| 2024.09.00 | 1.7.x | 2.0.x | Tested |
| 2025.01.01 | 1.8.x | 2.0.x | Tested |
| 2026.01.01 | 1.10.x | 2.1.x+ | Tested |
| 2026.03.01 | 1.10.x | 2.1.x+ | Baseline |
Dejavu supports Kotlin Multiplatform with the following targets:
| Target | Status | Notes |
|---|---|---|
| Android | Full support | Tag mapping via ui-tooling-data Group tree |
| Desktop (JVM) | Full support | Tag mapping via CompositionGroup + sourceInfo
|
| iOS (arm64, simulatorArm64, x64) | Supported | Same as JVM; LazyVerticalGrid has upstream Compose runtime bug |
| WasmJs (browser) | Supported | Exception propagation limited in test runner |
For non-Android platforms, use runRecompositionTrackingUiTest with setTrackedContent:
@Test
fun myComposable_isStable() = runRecompositionTrackingUiTest {
setTrackedContent { MyComposable() }
waitForIdle()
onNodeWithTag("my_tag").assertStable()
}runRecompositionTrackingUiTest is the KMP equivalent of Android's createRecompositionTrackingRule().
It handles all Dejavu lifecycle management automatically -- enabling the tracer, resetting state,
and cleaning up after each test. setTrackedContent wraps setContent with the inspection tables
and sub-composition layout required for tag-to-function mapping.
LazyVerticalGrid crash — The Compose runtime's internal slot table hash implementation crashes on iOS/Native and WasmJs when LazyVerticalGrid is in the composition. This is an upstream Compose bug, not a Dejavu issue. LazyColumn, LazyRow, and all other composables work correctly when LazyVerticalGrid is not present.AssertionError messages. Assertion behavior (pass/fail) works correctly; only error message inspection is affected. Parameter validation exceptions (IllegalArgumentException) are caught correctly when structured inside runComposeUiTest.LazyColumn/LazyRow only compose items that are visible. Items that haven't been composed don't exist in the composition tree, so Dejavu has nothing to track. Scroll them into view before asserting.createAndroidComposeRule uses the Activity's real Recomposer, not a test-controlled one. This means mainClock.advanceTimeBy() can't drive infinite animations forward. Use createComposeRule (without an Activity) if you need a controllable clock.Group.parameters from the Compose tooling data API, which was designed for Layout Inspector rather than programmatic diffing. Parameter names may be unavailable, and values are compared via hashCode/toString, so custom types without meaningful toString show opaque values.We welcome contributions! Please see CONTRIBUTING.md for guidelines and CODE_OF_CONDUCT.md for our community standards.
Apache 2.0