Skip to content

Compose stability: annotations, ImmutableList, and a check that enforces both - #35

Merged
solcott merged 14 commits into
mainfrom
feature/compose_stability
Sep 18, 2026
Merged

solcott merged 14 commits into
mainfrom
feature/compose_stability

Conversation

@solcott

@solcott solcott commented Sep 18, 2026

Copy link
Copy Markdown
Owner

What

This branch does the Compose stability work in two halves. The first twelve commits are the manual pass — @Immutable/@Stable annotations on the model and screen states, ImmutableList from each repository's public signature onward, and the rules written down in CLAUDE.md. The last commit makes the machine check all of it, because prose in a rules file is enforced by whoever remembers to read it.

The check

A new compose.stability convention plugin turns on the Compose compiler's own metrics and reports for the six modules that apply the compiler plugin (:ui, :domain, :shared, :app, :webApp, :desktopApp) and registers composeStabilityCheck. It fails on a class the compiler inferred as unstable, and on a restartable composable that is not skippable. A runtime class passes — its stability turns on a generic argument settled at run time.

Exceptions live in config/compose/unstable-classes.txt and config/compose/unskippable-composables.txt, one <module>:<fully.qualified.Name> per line, each with a comment. There is no aggregate task: Gradle's cross-project name matching already fans composeStabilityCheck out to all six.

Detekt's UnstableCollections is also on now, covering the same ground at the source level.

Two properties of the Compose plugin that shaped the wiring

Only metricsDestination is per-target. The plugin appends <target>/<compilation> to the metrics path but hands reportsDestination to every compilation verbatim, and a KMP module's targets share a Kotlin module name — ui/build/classes/kotlin/jvm/main and .../android/main both hold Recipes_ui.kotlin_module. So all six targets write the same Recipes_ui-classes.txt and the last to run wins. The check depends on exactly one compile task, which leaves a single writer. That is also why it is not wired into check, and why CI runs it as its own step before build.

reportsDestination is registered as a compiler input, not an output. A compile task whose reports had been deleted still counted as up to date and quietly wrote nothing — four of six modules failed with "no reports found" on a perfectly healthy tree. The plugin declares the directory as an output of the one designated compile task, which fixes that and additionally keeps that task the last writer after a full build.

Triage

:ui, :domain, :shared and :desktopApp came back clean — the manual pass holds up. Four classes are allowlisted, each checked individually:

  • AndroidAppGraph.Impl and WebAppGraph.Impl — nothing hands a graph to a composable on those platforms; main() reads graph.circuit and graph.subCircuit off it. DesktopAppGraph is the exception, and it is absent from the list: RecipeWindow(graph: DesktopAppGraph, ...) does take one, it carries @Stable, and :desktopApp reports nothing.
  • MainActivity and RecipeApplication — Android framework entry points, never composable parameters. Annotating either @Stable would be a lie.

One deliberate non-change

UnstableCollections flags RecipesProducer.produceByIngredients(ingredients: Set<String>). The compiler report settles it: that function carries no flags — it returns a value, so it is neither restartable nor skippable, and the parameter's stability is inert. Switching to ImmutableSet would buy nothing at run time and cost either a persistent-set copy per recomposition at the call site or a kotlinx-serialization serializer for RecipesScreen.ByIngredient, which has none for immutable collections. It is a @Suppress with that reasoning recorded instead.

Verification

ktfmtCheck checkSortDependencies, detektAll, composeStabilityCheck, all six KMP targets for :domain/:ui/:shared, plus :app:assembleDebug, :desktopApp:build, :webApp:build, and :domain:jvmTest (91 tests) — all green.

Both checks were also confirmed to fail when they should, since one that has never been seen fail is not yet known to work: removing the @Suppress produced the detekt finding, and a scratch class ScratchUnstable(var counter: Int) produced Unstable classes (1) naming it. Worth noting from that experiment — the scratch composable came out restartable skippable despite its unstable parameter, which is strong skipping working as intended and confirms the skippability half of the check only catches genuine cases.

🤖 Generated with Claude Code

solcott and others added 14 commits September 18, 2026 10:49
* **Versions**:
    * Bumped `agp` to 9.4.1.
    * Bumped `circuit` to 0.39.0.
    * Bumped `dependency-analysis` to 3.19.2.
    * Bumped `ktor` to 3.6.0.
* **Libraries**:
    * Added `compose-runtime-annotations` (`androidx.compose.runtime:runtime-annotation`) to the version catalog.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
* **Domain & Model**:
    * Added the `compose.runtime.annotations` and `kotlinx.collections.immutable` dependencies.
    * Annotated data models, `Screen`s, and `CircuitUiState`s with `@Immutable` to help the Compose compiler skip unnecessary recompositions.
* **UI**:
    * Marked `BackShortcutHost` and `BrowserNavState` as `@Stable`.
    * Converted regular `var` properties in `BackShortcutHost` and `BrowserNavState` to `MutableState` delegates (e.g. `mutableStateOf`, `mutableIntStateOf`) so Compose correctly tracks their reads and writes.
* **Desktop**:
    * Annotated `DesktopAppGraph` with `@Immutable`.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
* **Domain**:
    * Changed Presenter UI states (`AreasState`, `CategoriesState`, `HomeState`, `IngredientsState`, `RecipesState`, `SearchSuggestionStates`) to hold `ImmutableList` instead of `List`.
    * Updated Producers to return `ContentState<ImmutableList<T>>` initialized with `persistentListOf()`.
    * Updated corresponding unit test fixtures and fake repositories to match the new signatures.
* **Model**:
    * Changed `RecipeDetails` fields `tags` and `ingredients` to use `ImmutableList` and `persistentListOf()`.
* **Repository**:
    * Updated `AreaRepository`, `CategoryRepository`, `IngredientRepository`, `RecipeRepository`, and `SearchSuggestionsRepository` to expose `Flow<Outcome<ImmutableList<T>>>`.
    * Adjusted `Store`, `SourceOfTruth`, and `Converter` implementations to build and emit immutable collections using `toImmutableList()`.
* **UI**:
    * Migrated composable parameters in `AppNavigationBar`, `AppSegmentedControl`, `HomeScreen`, `RecipeTags`, `RecipeDetailsScreen`, `RecipeScaffoldScreen`, and `SearchSuggestions` to require `ImmutableList`, ensuring Compose treats the lists as stable for skipping recompositions.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
…izeClass`.

Change the `RecipeApp` composable parameter to take the full `WindowAdaptiveInfo` (defaulting to `currentWindowAdaptiveInfoV2()`) rather than extracting the size class in the default argument. `LocalWindowSizeClass` now extracts its value from this new `adaptiveInfo` parameter.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
The file contained only a package declaration and a TODO comment to move it to a different module.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
* **Build**:
    * Added a commented-out `includeBuild("../kmp-dataresult")` line to `settings.gradle.kts`.
* **Model**:
    * Formatted `IngredientSuggestion` in `SearchSuggestion` to place the `@Serializable` annotation on the same line as the class declaration, matching the style of `CategorySuggestion`.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
Creates a `.claude/launch.json` file containing a `webApp-wasm` configuration. This setup uses `./gradlew` to execute the `:webApp:wasmJsBrowserDevelopmentRun` task on port 8080, and passes `-PopenBrowser=false` to prevent it from automatically opening a new browser window.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
* **Desktop**:
    * Replaced the `@Immutable` annotation with `@Stable` on `DesktopAppGraph`.
* **Domain**:
    * Replaced the `@Immutable` annotation with `@Stable` on `HomeState`, `RecipeScaffoldState`, and `SearchState`.
    * Added explicit type arguments (`SearchSuggestion`, `Category`, `Ingredient`) to empty `persistentListOf()` calls in `SearchPresenter`.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
The `onBack` property holds a `MutableState`, which manages its own internal mutability. The property reference itself does not need to be reassigned, so it is safer and more idiomatic to declare it as a `val`.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
* **Repository**:
    * Removed `.toImmutableList()` calls in the Store converters for `AreaRepository`, `CategoryRepository`, and `IngredientRepository`. The builders expect a standard `List`, so allocating an `ImmutableList` was unnecessary.
    * Changed `fetchByIngredients` and `toIngredientEntities` to return standard `List`s instead of `ImmutableList`s, replacing `persistentListOf()` with `emptyList()`.
    * Simplified comma-separated tag parsing in `RecipeEntityWithDetailExt` to use `?: persistentListOf()` instead of `.toList().orEmpty().toImmutableList()`.
* **UI**:
    * Dropped the `remember` block wrapping `details?.tags.orEmpty().toImmutableList()` in `RecipeDetailsScreen`. The domain model already exposes tags as an `ImmutableList`, so a direct fallback to `persistentListOf()` is sufficient.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
Corrected the spacing in the `compose-runtime-annotations` dependency declaration by removing an extra space before the `module` key and adding a missing space before the closing brace.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
Document the rules for keeping Compose UI states stable and avoiding unnecessary
recompositions.

* **Collections**: Mandate `ImmutableList` for all UI-bound collections starting
  from the repository's public signature, while leaving entities and DTOs as standard
  `List`s.
* **Annotations**: Clarify the distinction between `@Immutable` (for strictly
  value-based states) and `@Stable` (for states carrying snapshot-backed observable
  holders like `TextFieldState` or `NavStack`).
* **Interfaces and Supertypes**: Note that `@Stable` does not propagate from
  supertypes, requiring explicit annotations on declared parameter types and all
  sealed `CircuitUiState` interfaces.

Signed-off-by: Scott Olcott <scottolcott@gmail.com>
The stability discipline in CLAUDE.md -- ImmutableList from the repository
signature onward, @immutable when it is true and @stable when it is not, a
marker on every sealed CircuitUiState -- was prose enforced by whoever
remembered to read it. Nothing turned on the Compose compiler's metrics, so
there was no way to check the claims against what the compiler actually
inferred.

The new `compose.stability` convention plugin sets reportsDestination and
metricsDestination for the six modules that apply the Compose compiler plugin,
and registers `composeStabilityCheck`, which fails on a class inferred
`unstable` or a `restartable` composable that is not `skippable`. A `runtime`
class passes: its stability turns on a generic argument settled at run time.
Gradle's cross-project name matching fans the task out, so there is no
aggregate task to maintain.

Two properties of the Compose plugin shape the wiring:

Only metricsDestination is per-target. reportsDestination is handed to every
compilation verbatim, and a KMP module's targets share a Kotlin module name, so
all six write the same `Recipes_ui-classes.txt` and the last one wins. The check
depends on exactly one compile task, which leaves a single writer. That is also
why it is not wired into `check` and why CI runs it before `build`.

reportsDestination is registered as a compiler *input*. A compile task whose
reports had been deleted stayed up to date and quietly wrote nothing -- four of
six modules failed with "no reports found" on a healthy tree. Declaring the
directory as an output of the designated compile task fixes that, and keeps that
task the last writer after a full build.

Triage found :ui, :domain, :shared and :desktopApp clean. The four allowlisted
classes are the Metro graph impls for :app and :webApp and the two Android entry
points, none of which is ever a composable parameter. DesktopAppGraph is the one
graph that is, it already carries @stable, and :desktopApp reports nothing.

Detekt's UnstableCollections is on. RecipesProducer.produceByIngredients is the
single @Suppress: it returns a value, so the compiler marks it neither
restartable nor skippable and the parameter's stability is inert, while an
ImmutableSet would cost a persistent-set copy per recomposition or a serializer
for RecipesScreen.ByIngredient, which has none for immutable collections.

Both checks were confirmed to fail when they should -- removing the @Suppress
produced the detekt finding, and a scratch class with a `var` produced the
unstable-class failure naming it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first run of the new workflow step failed. The Compose compiler names its
reports after the Kotlin module, which here is the Gradle project path, so they
come out as `Recipes:domain-classes.txt` -- and upload-artifact rejects a colon
in a path, because NTFS does.

Stage a renamed copy instead. It is staged immediately after the check rather
than at the end of the job so the artifact is exactly what the check read;
`build` recompiles the other targets over the same directory.

Also mark the upload `continue-on-error`. It is diagnostics, and its failure
took the whole job down and skipped `Build` -- a broken artifact upload must not
gate the build it is meant to help diagnose.

The Gradle steps themselves passed on that run, `Compose stability` included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@solcott
solcott merged commit 289ae18 into main Sep 18, 2026
1 check passed
@solcott
solcott deleted the feature/compose_stability branch September 18, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant