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
<-- FAILED marker. If the parent's
count equals the failed child's count, you have a parent cascade.
- Possible cause —
same-value write is 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. Move Modifier.testTag() to the
outermost user-defined composable's modifier. (docs/error-messages.md
Pattern 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.md Pattern 5.)
IllegalArgumentException from 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 CartSummary is not a data 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, actual 7) and one fix likely won't get all
the way to the floor, recommend invoking dejavu-perf-loop instead of
guessing the next fix.
Source: himattm/dejavu — distributed by TomeVault.
1---2name: dejavu-error-triage3description: 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. Use when this capability is needed.4---56# Dejavu Error Triage78A Compose UI test failed with `UnexpectedRecompositionsError`. This skill walks9the failure output section-by-section, names the underlying recomposition10pattern, and points at the canonical fix. Apply the fix, re-run the failing11test, done — no iteration loop.1213If the goal is iterative optimization (loose `atMost = N` baseline → tighten14to `assertStable()`), use the **`dejavu-perf-loop`** skill instead.15If no Dejavu test exists yet, use **`dejavu-test-writer`** first.1617## Reference docs (read first)1819- `docs/error-messages.md` — full anatomy of the error block (sections 1–7,20 lines 11–40 for the canonical example) and "Common Failure Patterns" 1–521 (lines 168–256).22- `docs/causality-analysis.md` — what each "Possible cause" line means,23 including same-value writes and dirty-bit signals.2425## Locate the failure in the test output2627The error block is what the test runner (gradle, Robolectric, the IDE runner,28CI logs) prints when a Dejavu assertion throws. Look for:2930- `dejavu.UnexpectedRecompositionsError:` — the exception class line.31- The failing test method (e.g. `MyTest > assertionFailed FAILED`).32- The full multi-line block that follows, ending with the semantic tree dump.3334Capture the **complete** block — every section is diagnostic input. Truncated35output (just the exception message) hides the cause. If the user pasted only a36header, ask for the full output.3738## Read the error in this order3940`docs/error-messages.md` "Reading the Error Quickly" (lines 260–269)41prescribes this; follow it strictly:42431. **Expected vs Actual** — quantify the gap (e.g. expected 0, actual 3).442. **All tracked composables** — find the `<-- FAILED` marker. If the parent's45 count equals the failed child's count, you have a parent cascade.463. **Possible cause** — `same-value write` is an immediate bug;47 `Parameter/parent change detected (dirty bits set)` means dirty bits fired.484. **Recomposition timeline** — `param slots changed: [N]` tells you which49 parameter's dirty bit fired (slot 0 = first parameter).505. **Composable: name (File.kt:NN)** — jump to source.5152Don't guess. Read all five before naming a cause.5354## Diagnosis → fix table5556Apply the **first matching row**. After applying, re-run the failing test once.5758| # | Signal in the error | Diagnosis | Fix |59|---|---|---|---|60| 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` |61| 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` |62| 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`) |63| 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` |64| 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 |65| 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) { … }` |66| 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 |67| 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 |68| ? | 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 |6970## Special cases7172- **`Warning: testTag '...' could not be mapped to a composable function`** — the73 tag is on a framework-internal node. Move `Modifier.testTag()` to the74 outermost user-defined composable's modifier. (`docs/error-messages.md`75 Pattern 3.)76- **`Actual: composable was never composed or isn't being tracked`** — the tag77 doesn't exist in the composition. For lazy lists, scroll the item into view78 before asserting. (`docs/error-messages.md` Pattern 5.)79- **`IllegalArgumentException` from the assertion itself** — invalid parameter80 combination (e.g. `exactly = 1, atLeast = 1`). Fix the assertion call, not81 the composable.8283## Wrap-up8485Report back to the user with:86871. **The diagnosis** — quote the specific signal that matched (e.g. "row #2:88 `Parameter/parent change detected` + `param slots changed: [0]` + `class89 CartSummary` is not a `data class`").902. **The recommended fix** — one specific, applicable change with the file91 and line.923. **Whether to apply it now** — only edit code if the user explicitly asked93 for a fix, not just a diagnosis. If you're unsure, ask.944. **How to verify** — re-run the specific failing test, not the full suite.95 For gradle:96 `./gradlew :<module>:<task> --tests "<ClassName>.<testName>"`. The fix97 worked iff the assertion that previously threw now passes and no other98 asserts regressed.995. **Whether iteration is needed** — if the count is much higher than expected100 (e.g. expected `exactly = 0`, actual `7`) and one fix likely won't get all101 the way to the floor, recommend invoking `dejavu-perf-loop` instead of102 guessing the next fix.103104---105> Source: [himattm/dejavu](https://github.com/himattm/dejavu) — distributed by [TomeVault](https://tomevault.io).106<!-- tomevault:4.0:skill_md:2026-06-22 -->