Dejavu error triage
Diagnose and fix a Dejavu test failure. Use when a Compose UI test failed with UnexpectedRecompositionsError, when CI or a local gradle/IDE test run shows a Dejavu assertion failure, when the user pastes the failure output (sections like "Possible cause", "Recomposition timeline", "All tracked composables"), or when the user asks why a Dejavu test is failing and how to fix it — without setting up an iterative optimization loop.From its SKILL.md
npx -y skills add himattm/dejavu --skill dejavu-error-triageAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
6.8 KB, as published. Nobody here has run it
Dejavu Error Triage
A Compose UI test failed with UnexpectedRecompositionsError. This skill walks
the failure output section-by-section, names the underlying recomposition
pattern, and points at the canonical fix. Apply the fix, re-run the failing
test, done — no iteration loop.
If the goal is iterative optimization (loose atMost = N baseline → tighten
to assertStable()), use the dejavu-perf-loop skill instead.
If no Dejavu test exists yet, use dejavu-test-writer first.
Reference docs (read first)
docs/error-messages.md— full anatomy of the error block (sections 1–7, lines 11–40 for the canonical example) and "Common Failure Patterns" 1–5 (lines 168–256).docs/causality-analysis.md— what each "Possible cause" line means, including same-value writes and dirty-bit signals.
Locate the failure in the test output
The error block is what the test runner (gradle, Robolectric, the IDE runner, CI logs) prints when a Dejavu assertion throws. Look for:
dejavu.UnexpectedRecompositionsError:— the exception class line.- The failing test method (e.g.
MyTest > assertionFailed FAILED). - The full multi-line block that follows, ending with the semantic tree dump.
Capture the complete block — every section is diagnostic input. Truncated output (just the exception message) hides the cause. If the user pasted only a header, ask for the full output.
Read the error in this order
docs/error-messages.md "Reading the Error Quickly" (lines 260–269)
prescribes this; follow it strictly:
- Expected vs Actual — quantify the gap (e.g. expected 0, actual 3).
- All tracked composables — find the
<-- FAILEDmarker. If the parent's count equals the failed child's count, you have a parent cascade. - Possible cause —
same-value writeis an immediate bug;Parameter/parent change detected (dirty bits set)means dirty bits fired. - Recomposition timeline —
param slots changed: [N]tells you which parameter's dirty bit fired (slot 0 = first parameter). - Composable: name (File.kt:NN) — jump to source.
Don't guess. Read all five before naming a cause.
Diagnosis → fix table
Apply the first matching row. After applying, re-run the failing test once.
| # | Signal in the error | Diagnosis | Fix |
|---|---|---|---|
| 1 | Possible cause: includes same-value write | Snapshot fired an apply notification even though the new value equaled the old one, so the consumer recomposed unnecessarily | data class for the state holder, OR mutableStateOf(value, policy = structuralEqualityPolicy()), OR guard write site with if (newValue != state.value) state.value = newValue |
| 2 | Parameter/parent change detected (dirty bits set) AND param slots changed: [N] AND the param at slot N is a non-data-class | Reference equality on an unstable type; new instance ≠ old instance even with identical fields | Convert param's class to data class, or annotate @Immutable / @Stable |
| 3 | Multiple recompositions on a single interaction; param type broader than what the function actually uses (e.g. Int only used in > 0) | Type granularity is too coarse | Narrow the parameter type (Int → Boolean, List → Boolean/size) |
| 4 | Same as #3 but the consumer reads a fine-grained value at every parent recomposition AND a derived signal flips less often | Fine-grained state should be coalesced before the boundary | val coarse by remember { derivedStateOf { fineState.someProperty } }; pass coarse |
| 5 | Parent and many siblings have the same count in All tracked composables | Child cascades whenever parent recomposes for unrelated reasons | Hoist the state read into the leaf composable that needs it; or pass State<T> and read .value in the consumer |
| 6 | Same composable repeats in the timeline once per list item, no key() block in the source | Compose can't track item identity across reorderings | items(list, key = { it.id }); or key(it.id) { … } |
| 7 | Many siblings recompose together because parent re-runs for a coarse reason | Parent state is too broad; siblings inherit the cascade | Move the state into a CompositionLocal; or split parent into a stable shell + a state-reading inner composable |
| 8 | Recomposition timeline shows #1, #2, #3 at increasing timestamps with different param slots changed slots | Multiple independent state writes, each invalidating different slots | Batch with Snapshot.withMutableSnapshot { … }; consolidate related state into one object; or use derivedStateOf to coalesce |
| ? | Pattern doesn't match | Don't guess | Re-read docs/error-messages.md "Common Failure Patterns" 1–5 (lines 168–256) and docs/causality-analysis.md summary table (lines 251–257) before suggesting a fix |
Special cases
Warning: testTag '...' could not be mapped to a composable function— the tag is on a framework-internal node. MoveModifier.testTag()to the outermost user-defined composable's modifier. (docs/error-messages.mdPattern 3.)Actual: composable was never composed or isn't being tracked— the tag doesn't exist in the composition. For lazy lists, scroll the item into view before asserting. (docs/error-messages.mdPattern 5.)IllegalArgumentExceptionfrom the assertion itself — invalid parameter combination (e.g.exactly = 1, atLeast = 1). Fix the assertion call, not the composable.
Wrap-up
Report back to the user with:
- The diagnosis — quote the specific signal that matched (e.g. "row #2:
Parameter/parent change detected+param slots changed: [0]+class CartSummaryis not adata class"). - The recommended fix — one specific, applicable change with the file and line.
- Whether to apply it now — only edit code if the user explicitly asked for a fix, not just a diagnosis. If you're unsure, ask.
- How to verify — re-run the specific failing test, not the full suite.
For gradle:
./gradlew :<module>:<task> --tests "<ClassName>.<testName>". The fix worked iff the assertion that previously threw now passes and no other asserts regressed. - Whether iteration is needed — if the count is much higher than expected
(e.g. expected
exactly = 0, actual7) and one fix likely won't get all the way to the floor, recommend invokingdejavu-perf-loopinstead of guessing the next fix.