Compose stability: annotations, ImmutableList, and a check that enforces both - #35
Merged
Merged
Conversation
* **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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
This branch does the Compose stability work in two halves. The first twelve commits are the manual pass —
@Immutable/@Stableannotations on the model and screen states,ImmutableListfrom each repository's public signature onward, and the rules written down inCLAUDE.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.stabilityconvention 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 registerscomposeStabilityCheck. It fails on a class the compiler inferred asunstable, and on arestartablecomposable that is notskippable. Aruntimeclass passes — its stability turns on a generic argument settled at run time.Exceptions live in
config/compose/unstable-classes.txtandconfig/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 fanscomposeStabilityCheckout to all six.Detekt's
UnstableCollectionsis also on now, covering the same ground at the source level.Two properties of the Compose plugin that shaped the wiring
Only
metricsDestinationis per-target. The plugin appends<target>/<compilation>to the metrics path but handsreportsDestinationto every compilation verbatim, and a KMP module's targets share a Kotlin module name —ui/build/classes/kotlin/jvm/mainand.../android/mainboth holdRecipes_ui.kotlin_module. So all six targets write the sameRecipes_ui-classes.txtand 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 intocheck, and why CI runs it as its own step beforebuild.reportsDestinationis 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 fullbuild.Triage
:ui,:domain,:sharedand:desktopAppcame back clean — the manual pass holds up. Four classes are allowlisted, each checked individually:AndroidAppGraph.ImplandWebAppGraph.Impl— nothing hands a graph to a composable on those platforms;main()readsgraph.circuitandgraph.subCircuitoff it.DesktopAppGraphis the exception, and it is absent from the list:RecipeWindow(graph: DesktopAppGraph, ...)does take one, it carries@Stable, and:desktopAppreports nothing.MainActivityandRecipeApplication— Android framework entry points, never composable parameters. Annotating either@Stablewould be a lie.One deliberate non-change
UnstableCollectionsflagsRecipesProducer.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 toImmutableSetwould buy nothing at run time and cost either a persistent-set copy per recomposition at the call site or a kotlinx-serialization serializer forRecipesScreen.ByIngredient, which has none for immutable collections. It is a@Suppresswith 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
@Suppressproduced the detekt finding, and a scratchclass ScratchUnstable(var counter: Int)producedUnstable classes (1)naming it. Worth noting from that experiment — the scratch composable came outrestartable skippabledespite 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