https://github.com/himattm/dejavu
A lightweight framework for tracking and asserting against Jetpack Compose recompositions.
https://github.com/himattm/dejavu
android compose-testing jetpack-compose kotlin recomposition
Last synced: about 2 months ago
JSON representation
A lightweight framework for tracking and asserting against Jetpack Compose recompositions.
- Host: GitHub
- URL: https://github.com/himattm/dejavu
- Owner: himattm
- License: apache-2.0
- Created: 2026-03-01T17:03:14.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-03-26T13:49:54.000Z (2 months ago)
- Last Synced: 2026-03-26T19:49:16.213Z (2 months ago)
- Topics: android, compose-testing, jetpack-compose, kotlin, recomposition
- Language: Kotlin
- Homepage: https://dejavu.mmckenna.me/
- Size: 1.12 MB
- Stars: 69
- Watchers: 2
- Forks: 0
- Open Issues: 3
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
Awesome Lists containing this project
README

# Dejavu
*Wait... didn't we just compose this?*
[](https://github.com/himattm/dejavu/actions/workflows/ci.yml)
[](https://central.sonatype.com/artifact/me.mmckenna.dejavu/dejavu)
[](https://developer.android.com/develop/ui/compose)
[](https://opensource.org/licenses/Apache-2.0)
**[Full Documentation](https://dejavu.mmckenna.me)**
**Guard your Compose UI efficiency. 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** — `SideEffect` counters, `LaunchedEffect` logging, 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
```kotlin
// app/build.gradle.kts
dependencies {
androidTestImplementation("me.mmckenna.dejavu:dejavu:0.3.0")
}
```
### 2. Write a test
```kotlin
@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](https://dejavu.mmckenna.me/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](https://dejavu.mmckenna.me/error-messages/) for how to read and act on each section.
## Use Cases
### Lock In Efficiency 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 Recomposition 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 recomposition behavior is preserved.
See the full [Use Cases](https://dejavu.mmckenna.me/use-cases/) guide for examples.
## API Reference
### Assertions
```kotlin
// 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
```kotlin
// 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()
```
## How It Works
Dejavu hooks into the Compose runtime's `CompositionTracer` API (available since compose-runtime 1.2.0):
1. **Intercepts trace calls** — `Composer.setTracer()` receives callbacks for every composable enter/exit
2. **Maps testTag to composable** — walks the `CompositionData` group tree to find which composable encloses each `Modifier.testTag()`
3. **Counts recompositions** — maintains a thread-safe counter per composable, incrementing on recomposition (not initial composition)
4. **Tracks causality** — `Snapshot.registerApplyObserver` detects state changes; dirty bits detect parameter-driven recompositions
5. **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.1.x+ | Tested |
| 2026.03.01 | 1.10.x | 2.1.x+ | Baseline |
## Kotlin Multiplatform
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 |
### KMP Test Setup
For non-Android platforms, use `runRecompositionTrackingUiTest` with `setTrackedContent`:
```kotlin
@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.
### Known Gaps
- **iOS/WasmJs: `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.
- **WasmJs: Exception propagation** — The Wasm test runner intercepts exceptions at a higher level than the test code, preventing try-catch from capturing `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`.
## Known Limitations
- **Off-screen lazy items** — `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.
- **Activity-owned Recomposer clock** — `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.
- **Parameter change tracking precision** — parameter diffs use `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.
## Further Reading
- [Use Cases](https://dejavu.mmckenna.me/use-cases/) — locking in UI efficiency, AI agent guardrails, and CI enforcement
- [Examples](https://dejavu.mmckenna.me/examples/) — test patterns for common scenarios
- [Error Messages Guide](https://dejavu.mmckenna.me/error-messages/) — how to read and act on failure output
- [Causality Analysis](https://dejavu.mmckenna.me/causality-analysis/) — understanding why composables recompose
## Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for our community standards.
## License
Apache 2.0