dejavu
Wait... didn't we just compose this?
Lock in Compose performance. Catch recomposition regressions before your users.
The Problem
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:
- Layout Inspector — manual, requires a running app, can't automate, can't run in CI
- Manual tracking code —
SideEffectcounters,LaunchedEffectlogging, wrapper composables; invasive, doesn't scale, and ships in your production code - Neither gives you a testable, automatable contract you can enforce on every PR
What Dejavu Does
Dejavu 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.
- Zero production code changes — just
Modifier.testTag() - One-line test setup —
createRecompositionTrackingRule() - Rich diagnostics — source location, recomposition timeline, parameter diffs, causality analysis
- Per-instance tracking — multiple instances of the same composable get independent counters
Quick Start
1. Add dependency
// app/build.gradle.kts
dependencies {
androidTestImplementation("me.mmckenna.dejavu:dejavu:0.1.1")
}
2. Write a test
@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.
What a Failure Looks Like
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.
Use Cases
Lock In Performance Gains
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.
Give AI Agents a Performance Signal
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.
Guardrail Against Unexpected Changes
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 performance characteristics are preserved.
See the full Use Cases guide for examples.
API Reference
Assertions
// 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()
Utilities
// Reset all counts to zero mid-test
composeTestRule.resetRecompositionCounts()
// Get the current recomposition count for a tag
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()
How It Works
Dejavu hooks into the Compose runtime's CompositionTracer API (available since compose-runtime 1.2.0):
- Intercepts trace calls —
Composer.setTracer()receives callbacks for every composable enter/exit - Maps testTag to composable — walks the
CompositionDatagroup tree to find which composable encloses eachModifier.testTag() - Counts recompositions — maintains a thread-safe counter per composable, incrementing on recomposition (not initial composition)
- Tracks causality —
Snapshot.registerApplyObserverdetects state changes; dirty bits detect parameter-driven recompositions - Reports on failure — assembles source location, timeline, tracked composables, and causality into a structured error
All tracking runs in the app process on the main thread, directly accessible to instrumented tests.
Compatibility
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.0.x | Baseline |
Known Limitations
- Off-screen lazy items —
LazyColumn/LazyRowonly 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. - Activity-owned Recomposer clock —
createAndroidComposeRuleuses the Activity's realRecomposer, not a test-controlled one. This meansmainClock.advanceTimeBy()can't drive infinite animations forward. UsecreateComposeRule(without an Activity) if you need a controllable clock. - Parameter change tracking precision — parameter diffs use
Group.parametersfrom the Compose tooling data API, which was designed for Layout Inspector rather than programmatic diffing. Parameter names may be unavailable, and values are compared viahashCode/toString, so custom types without meaningfultoStringshow opaque values.
Further Reading
- Use Cases — locking in performance, AI agent guardrails, and CI enforcement
- Examples — test patterns for common scenarios
- Error Messages Guide — how to read and act on failure output
- Causality Analysis — understanding why composables recompose
Contributing
We welcome contributions! Please see CONTRIBUTING.md for guidelines and CODE_OF_CONDUCT.md for our community standards.
License
Apache 2.0
