Diagnosing Compose Stability — Read the Compiler Reports First
Compose skips recomposition by comparing parameters. When a parameter is unstable, skipping is disabled — this skill tells Claude how to find out which parameters are unstable and why. The output is a prioritized list of unstable types and non-skippable composables; the fix lives in ../stabilizing-compose-types/SKILL.md.
When to use this skill
- The developer asks "why does this recompose?", reports jank, dropped frames, or scroll stutter.
- A
@TraceRecompositionlog shows recomposition counts that exceed the number of meaningful state changes. - The developer mentions Compose Compiler Reports,
classes.txt,composables.txt,composables.csv,module.json, or "non-skippable". - The developer asks how to find unstable parameters, or whether
List<Foo>,LocalDateTime, or a domain type is stable. - A reviewer asks for evidence that a perf-sensitive composable is skippable.
When NOT to use this skill
- The unstable types are already known — jump straight to
../stabilizing-compose-types/SKILL.md. - The symptom is a wrong-phase state read (
Modifier.alpha(state.value)); use../../recomposition/deferring-state-reads/SKILL.md. - The symptom is a
derivedStateOfmisuse; use../../recomposition/choosing-derivedstateof/SKILL.md. - The developer wants CI gating instead of one-shot diagnosis; use
../enforcing-stability-in-ci/SKILL.md.
Prerequisites
- Kotlin 2.0.0+ with the Compose Compiler Gradle plugin applied:
id("org.jetbrains.kotlin.plugin.compose"). The pre-2.0kotlinCompilerExtensionVersionflow is obsolete. - A buildable release variant of the target module. Reports MUST be produced in release; debug adds Live Literals which makes constants look dynamic and skews every report.
- Apply the
org.jetbrains.kotlin.plugin.composeGradle plugin (Kotlin 2.0+). ThecomposeCompiler { … }extension is owned by that plugin; AGP version is incidental. - Optional but PREFERRED: the developer has a stability baseline goal, not a 100-percent-skippable goal (skydoves hot take #1 — skippability is a diagnostic, not a KPI).
Workflow
- 1. Enable the reports in the module's
build.gradle.kts. Scope to release only — debug builds emit misleading data.
// app/build.gradle.kts (or any compose module)
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
composeCompiler {
// Only emit reports for release to avoid Live Literals noise.
val isReleaseBuild = providers.gradleProperty("composeCompilerReports").orNull == "true"
if (isReleaseBuild) {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
}
Or the always-on form:
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
// Optional — opt mutable third-party types into stability:
stabilityConfigurationFiles.add(
rootProject.layout.projectDirectory.file("stability_config.conf")
)
}
- 2. Build the release variant so Live Literals and interpreted-mode noise are absent:
./gradlew :app:assembleRelease -PcomposeCompilerReports=true
For a library module: ./gradlew :feature-feed:assembleRelease. The release flavor is required — see RIGHT/WRONG below.
- 3. Locate the four output files. They are written to
<module>/build/compose_compiler/:
app/build/compose_compiler/
├── app_release-classes.txt # per-class stability
├── app_release-composables.txt # per-composable signatures
├── app_release-composables.csv # CSV mirror of the above (CI-friendly)
└── app_release-module.json # aggregate counts
If any file is missing, the plugin did not run for that variant — re-check that composeCompiler { reportsDestination = ... } is on the right module and that the build was release.
4. Open
composables.txtfirst. This is the highest-signal file. Search forrestartablelines that are not followed byskippable. Each one is a recomposition entry point that cannot be skipped. Inside each block, read the per-parameter prefix:stable,unstable,@static,@dynamic. Anyunstableparameter blocks skipping. Seereferences/reading-composables-txt.mdfor the full grammar.5. Open
classes.txtto learn why a class is unstable. For every type flaggedunstablein composables.txt, find its declaration in classes.txt. The line tells you whether avarfield, a generic parameter, or an unstable nested type is the cause. Theruntime stable class Box { stable val value: T }shape means "this class is stable iff the runtime$stable: Intfield of the substituted T says so" — seereferences/reading-classes-txt.md.6. Open
module.jsonfor triage numbers. Counts of skippable composables, restartable composables, stable classes, etc. Use this to compare before/after a fix or to decide which module to attack first. DO NOT treat these counts as a target — they exist to spot regressions, not to chase 100 percent.7. Prioritize by hot path. A non-skippable composable that runs once at startup is irrelevant; one inside a
LazyColumnitem is critical. Cross-reference with measurement (@TraceRecompositionfrom skydoves/compose-stability-analyzer, or MacrobenchmarkFrameTimingMetric) before fixing — see../../measurement/tracing-recompositions-at-runtime/SKILL.md.8. Hand off to the fix skill. Produce a list of unstable types + offending composables and apply
../stabilizing-compose-types/SKILL.md.
Patterns
Pattern: HighlightedSnacks — read a non-skippable composable
The compiler reports surface the cause directly inside the function signature. Walk the developer through this real shape.
restartable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
stable index: Int,
unstable snacks: List<Snack>, // <-- blocks skipping
stable onSnackClick: Function1<Long, Unit>,
)
Diagnosis script:
- The function is
restartablebut NOT prefixed withskippable. Therefore it always recomposes when its parent does. - The cause is the
unstable snacks: List<Snack>parameter.kotlin.collections.Listis an interface; the compiler cannot prove its implementations are immutable. - Open
classes.txtand findSnack. IfSnackitself isunstable, fix the data class first; if it isstablethen only theListwrapper is the problem. - Hand off to the fix skill: replace
List<Snack>withkotlinx.collections.immutable.ImmutableList<Snack>, or addkotlin.collections.*tostability_config.confif the developer is comfortable with that contract.
Pattern: "stable but runtime" — what runtime means
runtime stable class Box {
stable val value: T
}
This is not unstable. The compiler emits a synthetic $stable: Int field at runtime and queries it during composition. The class is stable iff the substituted T reports stable. DO NOT annotate runtime classes with @Stable to "promote" them — the runtime check is the correct mechanism.
Pattern: WRONG vs RIGHT — running the build
# WRONG
./gradlew :app:assembleDebug
# WRONG because: debug enables Live Literals; constant 0 dp becomes a getter, every literal looks dynamic, and counts in module.json drift versus what ships to users.
# RIGHT
./gradlew :app:assembleRelease -PcomposeCompilerReports=true
Pattern: WRONG vs RIGHT — reading the report file
// WRONG — reading composables.txt without checking the leading flags
fun MyScreen(...)
// WRONG because: skipping the `restartable`/`skippable` prefix discards the only data point that determines whether unstable params actually cost anything.
// RIGHT — every diagnosis quotes the full prefix and per-param annotations
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MyScreen(
stable user: User,
stable onClick: Function0<Unit>,
)
Pattern: empty output directory
If build/compose_compiler/ is missing or empty after a release build:
- Confirm the
org.jetbrains.kotlin.plugin.composeplugin is applied to this module — the extension is per-module. - Confirm the build actually compiled Kotlin sources (not an up-to-date no-op). Touch a file or run
./gradlew :app:clean :app:assembleRelease. - Confirm
reportsDestinationis set inside acomposeCompiler { }block, not the legacykotlinOptionsfreeCompilerArgs flow.
Mandatory rules
- MUST build the release variant. Debug builds emit Live Literals which makes constants look dynamic and inflates the report's "unstable" surface area.
- MUST read
composables.txtper-parameter annotations (stable/unstable/@static/@dynamic), not just the function name. The flag prefix is the diagnosis. - MUST cross-reference unstable params back to
classes.txtto identify the root cause (avar, a generic, an unstable field type, or an interface). - MUST NOT chase 100 percent skippability. Skydoves hot take #1: skippability is a diagnostic, not a KPI. A composable that runs once at startup does not need to be skippable.
- MUST NOT annotate a class as
@Stablebased on a report alone — that decision belongs to the fix skill, which evaluates the contract. - MUST NOT read
composables.csvline counts and call it done. The counts are a regression sentinel; the per-line annotations are the actual diagnosis. - PREFERRED: wire the same flags into CI via
../enforcing-stability-in-ci/SKILL.md(skydovescompose-stability-analyzerplugin or the communityComposeGuardplugin) so regressions surface on PR review. - PREFERRED: record a baseline
module.jsonper release so future regressions are visible by diff.
Verification
-
./gradlew :app:assembleRelease(or module-specific) completes successfully with theorg.jetbrains.kotlin.plugin.composeplugin applied. - All four files exist:
<module>_release-classes.txt,<module>_release-composables.txt,<module>_release-composables.csv,<module>_release-module.json. - At least one of: a list of unstable parameter sites copied from
composables.txt, OR a confirmed-zero count frommodule.jsonjustifying that no fix is needed. - Each unstable parameter has been traced back to a class in
classes.txtso the fix skill receives a concrete root cause (avar, aList, aLocalDateTime, etc.). - The developer understands that
runtime stable class …is not a problem — it is the compiler's correct lazy-stability emission.
References
- Android Developers — Diagnose stability: https://developer.android.com/develop/ui/compose/performance/stability/diagnose
- Android Developers — Stability overview: https://developer.android.com/develop/ui/compose/performance/stability
- Compose Compiler release notes: https://developer.android.com/jetpack/androidx/releases/compose-compiler
- Ben Trengrove — Jetpack Compose Stability Explained: https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8
- Chris Banes — Composable Metrics: https://chrisbanes.me/posts/composable-metrics/
- Why test perf in release: https://medium.com/androiddevelopers/why-should-you-always-test-compose-performance-in-release-4168dd0f2c71
- skydoves — Optimize App Performance by Mastering Stability: https://medium.com/proandroiddev/optimize-app-performance-by-mastering-stability-in-jetpack-compose-69f40a8c785d
- skydoves — compose-stability-analyzer: https://github.com/skydoves/compose-stability-analyzer
references/reading-classes-txt.md— full grammar for classes.txt with worked examples (stable / unstable / runtime / generic).references/reading-composables-txt.md— full grammar for composables.txt including restartable, skippable, readonly, scheme, and per-parameterstable/unstable/@static/@dynamic.