engsys 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/core/agents/aaron.md +152 -0
- package/core/agents/bert.md +115 -0
- package/core/agents/isabelle.md +136 -0
- package/core/agents/jody.md +150 -0
- package/core/agents/leith.md +111 -0
- package/core/agents/marcelo.md +282 -0
- package/core/agents/melvin.md +101 -0
- package/core/agents/nyx.md +152 -0
- package/core/agents/otto.md +168 -0
- package/core/agents/patricia.md +283 -0
- package/core/commands/design-audit-local.md +155 -0
- package/core/commands/design-audit.md +235 -0
- package/core/commands/design-critique.md +96 -0
- package/core/commands/file-issue.md +22 -0
- package/core/commands/generate-project.md +45 -0
- package/core/commands/implement-issue.md +37 -0
- package/core/commands/implement-project.md +40 -0
- package/core/commands/naturalize.md +61 -0
- package/core/commands/pre-push.md +29 -0
- package/core/commands/prep-review-collect.md +130 -0
- package/core/commands/prep-review-finalize.md +121 -0
- package/core/commands/prep-review-publish.md +113 -0
- package/core/commands/prep-review.md +65 -0
- package/core/commands/project-closeout.md +25 -0
- package/core/skills/agentic-eval/SKILL.md +195 -0
- package/core/skills/chrome-devtools/SKILL.md +97 -0
- package/core/skills/code-review/SKILL.md +26 -0
- package/core/skills/gh-cli/SKILL.md +2202 -0
- package/core/skills/git-commit/SKILL.md +124 -0
- package/core/skills/git-workflow-agents/SKILL.md +462 -0
- package/core/skills/git-workflow-agents/reference.md +220 -0
- package/core/skills/github-actions/SKILL.md +190 -0
- package/core/skills/github-issues/SKILL.md +154 -0
- package/core/skills/llm-structured-outputs/SKILL.md +323 -0
- package/core/skills/llm-structured-outputs/references/provider-details.md +392 -0
- package/core/skills/pre-push/SKILL.md +115 -0
- package/core/skills/refactor/SKILL.md +645 -0
- package/core/skills/web-design-reviewer/SKILL.md +371 -0
- package/core/skills/webapp-testing/SKILL.md +127 -0
- package/core/skills/webapp-testing/test-helper.js +56 -0
- package/core/templates/CLAUDE.md.tmpl +98 -0
- package/core/templates/adr-template.md +67 -0
- package/core/templates/gh-issue-templates/bug.md +39 -0
- package/core/templates/gh-issue-templates/content.md +42 -0
- package/core/templates/gh-issue-templates/enhancement.md +36 -0
- package/core/templates/gh-issue-templates/feature.md +39 -0
- package/core/templates/gh-issue-templates/infrastructure.md +41 -0
- package/core/templates/post-edit-reminders.sh.tmpl +19 -0
- package/core/templates/settings.json.tmpl +90 -0
- package/core/templates/settings.local.json.tmpl +3 -0
- package/core/workflows/agent-implementation-workflow.md +346 -0
- package/core/workflows/generate-project.md +258 -0
- package/core/workflows/implement-project-workflow.md +190 -0
- package/core/workflows/issue-tracking.md +89 -0
- package/core/workflows/project-closeout-ceremony.md +77 -0
- package/core/workflows/review-workflow.md +266 -0
- package/engsys.config.example.yaml +46 -0
- package/install +202 -0
- package/lessons-library/README.md +80 -0
- package/lessons-library/async-callbacks-verify-liveness.md +15 -0
- package/lessons-library/change-isnt-done-until-every-surface-updated.md +15 -0
- package/lessons-library/claim-then-act-for-irreversible-ops.md +16 -0
- package/lessons-library/co-commit-entangled-work.md +15 -0
- package/lessons-library/dependabot-triage-playbook.md +17 -0
- package/lessons-library/deploy-by-digest-and-verify-the-running-revision.md +15 -0
- package/lessons-library/enforce-your-guarantee-at-your-boundary.md +16 -0
- package/lessons-library/gate-changes-on-measurement-not-vibes.md +15 -0
- package/lessons-library/iac-first-no-console-changes.md +15 -0
- package/lessons-library/independent-objective-review-gate.md +15 -0
- package/lessons-library/keep-an-immutable-source-of-truth.md +15 -0
- package/lessons-library/long-agent-runs-checkpoint-not-poll.md +15 -0
- package/lessons-library/model-identity-with-stable-ids-and-provenance.md +15 -0
- package/lessons-library/operator-choices-are-first-class.md +15 -0
- package/lessons-library/prefer-tool-enforced-structured-output.md +15 -0
- package/lessons-library/prove-causation-before-acting.md +15 -0
- package/lessons-library/re-read-state-before-acting.md +14 -0
- package/lessons-library/read-layer-tolerates-unbackfilled-rows.md +15 -0
- package/lessons-library/shell-safety-pipefail-and-validate-before-teardown.md +14 -0
- package/lessons-library/shift-correctness-left-and-distrust-false-greens.md +15 -0
- package/lessons-library/stray-control-bytes-hide-changes.md +14 -0
- package/lessons-library/tests-can-assert-the-bug.md +15 -0
- package/lessons-library/verify-ground-truth-not-reports.md +15 -0
- package/lessons-library/worktrees-need-bootstrap-from-origin-main.md +15 -0
- package/lib/commands.js +356 -0
- package/lib/generate-team-avatars.mjs +251 -0
- package/lib/manifest.js +155 -0
- package/lib/render.js +135 -0
- package/lib/selftest.js +90 -0
- package/lib/util.js +89 -0
- package/lib/yaml.js +156 -0
- package/optional-agents/gary.md +86 -0
- package/optional-agents/jos.md +136 -0
- package/optional-agents/sandy.md +101 -0
- package/optional-agents/steve.md +161 -0
- package/package.json +43 -0
- package/stacks/cloud/aws/claude.fragment.md +17 -0
- package/stacks/cloud/aws/settings.fragment.json +39 -0
- package/stacks/cloud/aws/skills/aws-deployment-preflight/SKILL.md +165 -0
- package/stacks/cloud/aws/skills/cloud-architecture-aws/SKILL.md +265 -0
- package/stacks/cloud/azure/claude.fragment.md +17 -0
- package/stacks/cloud/azure/settings.fragment.json +45 -0
- package/stacks/cloud/azure/skills/azure-deployment-preflight/SKILL.md +175 -0
- package/stacks/cloud/azure/skills/cloud-architecture-azure/SKILL.md +211 -0
- package/stacks/cloud/cloudflare/claude.fragment.md +21 -0
- package/stacks/cloud/cloudflare/settings.fragment.json +31 -0
- package/stacks/cloud/cloudflare/skills/cloud-architecture-cloudflare/SKILL.md +294 -0
- package/stacks/cloud/cloudflare/skills/cloudflare-deployment-preflight/SKILL.md +175 -0
- package/stacks/cloud/gcp/claude.fragment.md +17 -0
- package/stacks/cloud/gcp/settings.fragment.json +40 -0
- package/stacks/cloud/gcp/skills/cloud-architecture-gcp/SKILL.md +208 -0
- package/stacks/cloud/gcp/skills/gcp-deployment-preflight/SKILL.md +137 -0
- package/stacks/db/mongo/skills/mongo-conventions/SKILL.md +96 -0
- package/stacks/db/prisma/claude.fragment.md +49 -0
- package/stacks/db/prisma/skills/docker-database-package-copy/SKILL.md +44 -0
- package/stacks/db/prisma/skills/prisma-conventions/SKILL.md +37 -0
- package/stacks/domain/mobile-growth/skills/apple-ads/SKILL.md +184 -0
- package/stacks/domain/mobile-growth/skills/apple-ads/references/benchmark-notes.md +47 -0
- package/stacks/domain/mobile-growth/skills/apple-ads/references/official-links.md +53 -0
- package/stacks/domain/mobile-growth/skills/google-play-growth/SKILL.md +197 -0
- package/stacks/domain/mobile-growth/skills/google-play-growth/references/benchmark-notes.md +47 -0
- package/stacks/domain/mobile-growth/skills/google-play-growth/references/official-links.md +45 -0
- package/stacks/iac/bicep/claude.fragment.md +14 -0
- package/stacks/iac/bicep/settings.fragment.json +20 -0
- package/stacks/iac/bicep/skills/iac-bicep/SKILL.md +113 -0
- package/stacks/iac/cdk/claude.fragment.md +14 -0
- package/stacks/iac/cdk/settings.fragment.json +23 -0
- package/stacks/iac/cdk/skills/iac-cdk/SKILL.md +104 -0
- package/stacks/iac/terraform/claude.fragment.md +13 -0
- package/stacks/iac/terraform/settings.fragment.json +25 -0
- package/stacks/iac/terraform/skills/iac-terraform/SKILL.md +93 -0
- package/stacks/iac/terraform/skills/terraform-conventions/SKILL.md +87 -0
- package/stacks/lang/kotlin/skills/android-testing/SKILL.md +263 -0
- package/stacks/lang/kotlin/skills/jetpack-compose/SKILL.md +264 -0
- package/stacks/lang/kotlin/skills/kotlin-coroutines/SKILL.md +329 -0
- package/stacks/lang/python/skills/python-conventions/SKILL.md +61 -0
- package/stacks/lang/shell/skills/shell-scripting/SKILL.md +110 -0
- package/stacks/lang/swift/skills/swift-concurrency/SKILL.md +423 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/approachable-concurrency.md +80 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/concurrency-patterns.md +233 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/swiftui-concurrency.md +187 -0
- package/stacks/lang/swift/skills/swift-concurrency/references/synchronization-primitives.md +341 -0
- package/stacks/lang/swift/skills/swift-testing/SKILL.md +497 -0
- package/stacks/lang/swift/skills/swift-testing/references/testing-advanced.md +106 -0
- package/stacks/lang/swift/skills/swift-testing/references/testing-patterns.md +504 -0
- package/stacks/lang/swift/skills/swiftdata/SKILL.md +334 -0
- package/stacks/lang/swift/skills/swiftdata/references/core-data-coexistence.md +504 -0
- package/stacks/lang/swift/skills/swiftdata/references/swiftdata-advanced.md +975 -0
- package/stacks/lang/swift/skills/swiftdata/references/swiftdata-queries.md +675 -0
- package/stacks/lang/swift/skills/swiftui-patterns/SKILL.md +371 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/architecture-patterns.md +486 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/deprecated-migration.md +1097 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/design-polish.md +780 -0
- package/stacks/lang/swift/skills/swiftui-patterns/references/platform-and-sharing.md +696 -0
- package/stacks/lang/typescript/skills/typescript-conventions/SKILL.md +91 -0
- package/stacks/platform/android/claude.fragment.md +40 -0
- package/stacks/platform/android/hooks/pre-push-gradle.sh +70 -0
- package/stacks/platform/android/settings.fragment.json +13 -0
- package/stacks/platform/android/skills/android-build-conventions/SKILL.md +247 -0
- package/stacks/platform/ios/claude.fragment.md +24 -0
- package/stacks/platform/ios/hooks/pre-push-xcodebuild.sh +82 -0
- package/stacks/platform/ios/settings.fragment.json +21 -0
- package/stacks/platform/ios/skills/xcodebuildmcp-simulator-logs/SKILL.md +76 -0
- package/stacks/platform/web/skills/frontend-testing/SKILL.md +246 -0
- package/stacks/platform/web/skills/react-conventions/SKILL.md +261 -0
- package/stacks/platform/web/skills/web-platform-conventions/SKILL.md +55 -0
- package/stacks/tooling/issue-tracker-github/claude.fragment.md +10 -0
- package/stacks/tooling/issue-tracker-github/settings.fragment.json +24 -0
- package/stacks/tooling/issue-tracker-github/skills/issue-tracker-github/SKILL.md +278 -0
- package/stacks/tooling/issue-tracker-linear/claude.fragment.md +17 -0
- package/stacks/tooling/issue-tracker-linear/settings.fragment.json +9 -0
- package/stacks/tooling/issue-tracker-linear/skills/issue-tracker-linear/SKILL.md +183 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: jetpack-compose
|
|
3
|
+
description: "Build Android UIs with Jetpack Compose: composition, state hoisting, remember/rememberSaveable/derivedStateOf, recomposition correctness and performance, side-effect APIs (LaunchedEffect/DisposableEffect/rememberCoroutineScope/produceState/snapshotFlow), Material 3 theming, and Compose navigation. Use when writing or reviewing @Composable functions, fixing recomposition or state bugs, hoisting state, choosing a side-effect API, applying Material 3, or improving Compose performance and stability."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Jetpack Compose
|
|
7
|
+
|
|
8
|
+
Build and review Android UIs with Jetpack Compose targeting the current Compose
|
|
9
|
+
BOM (2025.x) and Material 3. Composables are pure descriptions of UI for a given
|
|
10
|
+
state; the runtime re-invokes them (recomposes) when the state they read
|
|
11
|
+
changes. Treat composition as a function of state, keep composables side-effect
|
|
12
|
+
free except through the official effect APIs, and hoist state so UI is testable
|
|
13
|
+
and reusable. This is Android's analogue of declarative SwiftUI.
|
|
14
|
+
|
|
15
|
+
## Contents
|
|
16
|
+
|
|
17
|
+
- [Composition Model](#composition-model)
|
|
18
|
+
- [State and remember](#state-and-remember)
|
|
19
|
+
- [State Hoisting](#state-hoisting)
|
|
20
|
+
- [derivedStateOf](#derivedstateof)
|
|
21
|
+
- [Recomposition Pitfalls](#recomposition-pitfalls)
|
|
22
|
+
- [Side-Effect APIs](#side-effect-apis)
|
|
23
|
+
- [Collecting Flows](#collecting-flows)
|
|
24
|
+
- [Stability and Performance](#stability-and-performance)
|
|
25
|
+
- [Material 3](#material-3)
|
|
26
|
+
- [ViewModel Integration](#viewmodel-integration)
|
|
27
|
+
- [Common Mistakes](#common-mistakes)
|
|
28
|
+
- [Review Checklist](#review-checklist)
|
|
29
|
+
|
|
30
|
+
## Composition Model
|
|
31
|
+
|
|
32
|
+
- A `@Composable` function emits UI. It may run often, in any order, in parallel,
|
|
33
|
+
and be skipped. **It must be idempotent and free of side effects.**
|
|
34
|
+
- Never mutate external state, perform IO, or launch work directly in the body of
|
|
35
|
+
a composable. Use the effect APIs for anything that must happen *as a result*
|
|
36
|
+
of composition.
|
|
37
|
+
- Recomposition is driven by reads of *snapshot state* (`State<T>` /
|
|
38
|
+
`MutableState<T>`). Reading a state value subscribes that composition scope to
|
|
39
|
+
it; when it changes, only scopes that read it recompose.
|
|
40
|
+
|
|
41
|
+
## State and remember
|
|
42
|
+
|
|
43
|
+
| API | Use for |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `remember { }` | Keep an object across recompositions (not config changes). |
|
|
46
|
+
| `rememberSaveable { }` | Survive config changes and process death (Bundle-able). |
|
|
47
|
+
| `mutableStateOf(x)` | Observable state that triggers recomposition on change. |
|
|
48
|
+
| `derivedStateOf { }` | Computed state derived from other state (see below). |
|
|
49
|
+
| `produceState` | Convert non-Compose async source into `State`. |
|
|
50
|
+
|
|
51
|
+
```kotlin
|
|
52
|
+
@Composable
|
|
53
|
+
fun Counter() {
|
|
54
|
+
var count by rememberSaveable { mutableStateOf(0) } // survives rotation
|
|
55
|
+
Button(onClick = { count++ }) { Text("Count: $count") }
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- Use `by` delegation (`var x by remember { mutableStateOf(...) }`) for ergonomic
|
|
60
|
+
reads/writes.
|
|
61
|
+
- `remember(key1, key2) { ... }` recomputes when a key changes — use keys to
|
|
62
|
+
reset remembered state when inputs change.
|
|
63
|
+
- Prefer `rememberSaveable` for anything the user would be annoyed to lose on
|
|
64
|
+
rotation (text input, scroll selection, expanded state).
|
|
65
|
+
|
|
66
|
+
## State Hoisting
|
|
67
|
+
|
|
68
|
+
Make composables **stateless** by lifting state up to the caller. A hoisted
|
|
69
|
+
composable takes the value as a parameter and exposes changes via a callback —
|
|
70
|
+
the "state down, events up" pattern.
|
|
71
|
+
|
|
72
|
+
```kotlin
|
|
73
|
+
// Stateless, reusable, testable
|
|
74
|
+
@Composable
|
|
75
|
+
fun SearchField(query: String, onQueryChange: (String) -> Unit) {
|
|
76
|
+
TextField(value = query, onValueChange = onQueryChange)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Stateful caller owns the state
|
|
80
|
+
@Composable
|
|
81
|
+
fun SearchScreen(viewModel: SearchViewModel) {
|
|
82
|
+
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
|
83
|
+
SearchField(query = state.query, onQueryChange = viewModel::onQueryChange)
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Hoist to the lowest common ancestor that needs the state. State that belongs to
|
|
88
|
+
the screen's logic lives in the `ViewModel`; ephemeral UI state (animations,
|
|
89
|
+
focus, scroll) can stay local with `remember`.
|
|
90
|
+
|
|
91
|
+
## derivedStateOf
|
|
92
|
+
|
|
93
|
+
Use `derivedStateOf` when state should be *computed* from other state and you
|
|
94
|
+
want to recompute only when the **result** changes, not on every read of the
|
|
95
|
+
inputs. The classic case: deriving a boolean from a frequently changing value.
|
|
96
|
+
|
|
97
|
+
```kotlin
|
|
98
|
+
val listState = rememberLazyListState()
|
|
99
|
+
// Recomposes only when the boolean flips, not on every scroll pixel.
|
|
100
|
+
val showButton by remember {
|
|
101
|
+
derivedStateOf { listState.firstVisibleItemIndex > 0 }
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Do **not** use `derivedStateOf` for a plain transformation of a single state that
|
|
106
|
+
changes at the same rate as the result (e.g. `val upper = text.uppercase()`) —
|
|
107
|
+
that is just a normal calculation and the extra machinery is wasteful.
|
|
108
|
+
|
|
109
|
+
## Recomposition Pitfalls
|
|
110
|
+
|
|
111
|
+
- **Reading state too high** hurts performance; read it as deep as possible so
|
|
112
|
+
fewer composables recompose. Defer reads with lambdas (e.g. pass
|
|
113
|
+
`{ offset }` rather than `offset`) for high-frequency values like scroll/drag.
|
|
114
|
+
- **Unstable lambdas** allocated in the body cause child recomposition — prefer
|
|
115
|
+
stable method references (`viewModel::onClick`) where possible.
|
|
116
|
+
- **Backwards writes** — writing to state you already read in the same
|
|
117
|
+
composition causes an infinite recomposition loop. Never do it.
|
|
118
|
+
- **Side effects in the body** (logging, mutation, IO) run on every recomposition
|
|
119
|
+
and in undefined order. Move them into effects.
|
|
120
|
+
- **`Modifier` ordering matters** — order changes layout and draw; it is not
|
|
121
|
+
commutative.
|
|
122
|
+
- Always supply a stable `key` in `LazyColumn`/`LazyRow` `items(list, key = {...})`
|
|
123
|
+
so items keep identity across data changes (analogous to a stable
|
|
124
|
+
`Identifiable` id).
|
|
125
|
+
|
|
126
|
+
## Side-Effect APIs
|
|
127
|
+
|
|
128
|
+
Choose the narrowest effect that fits:
|
|
129
|
+
|
|
130
|
+
| API | When |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `LaunchedEffect(key)` | Run a suspend function when entering composition / when `key` changes; cancelled on leave. |
|
|
133
|
+
| `rememberCoroutineScope()` | Launch coroutines from **event callbacks** (button clicks), not composition. |
|
|
134
|
+
| `DisposableEffect(key)` | Register/unregister non-suspend resources; `onDispose { }` cleans up. |
|
|
135
|
+
| `SideEffect { }` | Publish Compose state to non-Compose code after every successful recomposition. |
|
|
136
|
+
| `produceState` | Turn a callback/Flow source into observable `State`. |
|
|
137
|
+
| `snapshotFlow { }` | Convert reads of snapshot state into a cold `Flow`. |
|
|
138
|
+
| `rememberUpdatedState` | Capture the latest value inside a long-lived effect without restarting it. |
|
|
139
|
+
|
|
140
|
+
```kotlin
|
|
141
|
+
// Run once on enter (key = Unit), re-run when userId changes:
|
|
142
|
+
LaunchedEffect(userId) {
|
|
143
|
+
viewModel.load(userId) // suspend; auto-cancelled on leave/key change
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Observe a lifecycle/listener and clean it up:
|
|
147
|
+
DisposableEffect(lifecycleOwner) {
|
|
148
|
+
val observer = LifecycleEventObserver { _, e -> handle(e) }
|
|
149
|
+
lifecycleOwner.lifecycle.addObserver(observer)
|
|
150
|
+
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// React to snapshot state as a flow:
|
|
154
|
+
LaunchedEffect(listState) {
|
|
155
|
+
snapshotFlow { listState.firstVisibleItemIndex }
|
|
156
|
+
.distinctUntilChanged()
|
|
157
|
+
.collect { analytics.logScroll(it) }
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Rules:
|
|
162
|
+
- The `key` of `LaunchedEffect`/`DisposableEffect` controls restart. Use a stable,
|
|
163
|
+
meaningful key — never `Unit` if the work depends on a changing input.
|
|
164
|
+
- Don't launch coroutines in the composable body; use `LaunchedEffect` (tied to
|
|
165
|
+
composition) or `rememberCoroutineScope` (tied to events).
|
|
166
|
+
- Use `rememberUpdatedState` to read the freshest callback inside a
|
|
167
|
+
`LaunchedEffect(Unit)` that should not restart.
|
|
168
|
+
|
|
169
|
+
## Collecting Flows
|
|
170
|
+
|
|
171
|
+
Always collect with lifecycle awareness so collection stops in the background:
|
|
172
|
+
|
|
173
|
+
```kotlin
|
|
174
|
+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`collectAsStateWithLifecycle()` (from `lifecycle-runtime-compose`) is preferred
|
|
178
|
+
over `collectAsState()` on Android — it stops collecting when the lifecycle drops
|
|
179
|
+
below `STARTED`, avoiding wasted work and leaks.
|
|
180
|
+
|
|
181
|
+
## Stability and Performance
|
|
182
|
+
|
|
183
|
+
- The compiler skips recomposition of a composable when all its parameters are
|
|
184
|
+
**stable** and unchanged. Prefer stable types: immutable `data class`es,
|
|
185
|
+
primitives, and `@Immutable`/`@Stable`-annotated types.
|
|
186
|
+
- `List<T>` is treated as unstable. Prefer `kotlinx.collections.immutable`
|
|
187
|
+
(`ImmutableList`/`PersistentList`) for list parameters, or wrap in a `@Immutable`
|
|
188
|
+
holder.
|
|
189
|
+
- The **Compose Compiler reports / strong-skipping** help find unstable params.
|
|
190
|
+
Strong skipping (default in recent compilers) skips even some unstable params by
|
|
191
|
+
comparing instances, but stable types remain the reliable path.
|
|
192
|
+
- Use `LazyColumn`/`LazyRow`/`LazyVerticalGrid` for large/scrolling collections;
|
|
193
|
+
never render large lists with a `Column` inside a `verticalScroll`.
|
|
194
|
+
- Defer high-frequency state reads (scroll offset, drag) into draw/layout lambdas
|
|
195
|
+
to avoid recomposing on every frame.
|
|
196
|
+
- Avoid `Modifier.composed { }` for new code; prefer `Modifier.Node` based
|
|
197
|
+
modifiers for custom modifiers.
|
|
198
|
+
|
|
199
|
+
## Material 3
|
|
200
|
+
|
|
201
|
+
Use `androidx.compose.material3` (Material 3 / Material You) for new UIs.
|
|
202
|
+
|
|
203
|
+
```kotlin
|
|
204
|
+
@Composable
|
|
205
|
+
fun AppTheme(content: @Composable () -> Unit) {
|
|
206
|
+
val dark = isSystemInDarkTheme()
|
|
207
|
+
val colors = if (supportsDynamicColor() && dark) {
|
|
208
|
+
dynamicDarkColorScheme(LocalContext.current) // Material You, Android 12+
|
|
209
|
+
} else if (dark) darkColorScheme() else lightColorScheme()
|
|
210
|
+
|
|
211
|
+
MaterialTheme(colorScheme = colors, typography = AppTypography, content = content)
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- Theme through `MaterialTheme` (`colorScheme`, `typography`, `shapes`); read
|
|
216
|
+
values via `MaterialTheme.colorScheme.*` rather than hardcoding colors.
|
|
217
|
+
- Support **dynamic color** on Android 12+ via `dynamicLightColorScheme` /
|
|
218
|
+
`dynamicDarkColorScheme` where the product wants Material You.
|
|
219
|
+
- Use Material 3 components (`Scaffold`, `TopAppBar`, `NavigationBar`,
|
|
220
|
+
`FilledTonalButton`, etc.) and honor `WindowInsets` / edge-to-edge.
|
|
221
|
+
- Respect the platform: this is Material, not iOS — don't port HIG idioms wholesale.
|
|
222
|
+
|
|
223
|
+
## ViewModel Integration
|
|
224
|
+
|
|
225
|
+
- Obtain the screen `ViewModel` with `viewModel()` (or Hilt's `hiltViewModel()`).
|
|
226
|
+
- Expose a single immutable UI-state object as `StateFlow`; collect with
|
|
227
|
+
`collectAsStateWithLifecycle()`.
|
|
228
|
+
- Pass state down and events up (method references to the `ViewModel`). Keep
|
|
229
|
+
composables free of business logic.
|
|
230
|
+
- Use Compose Navigation (`NavHost`/`NavController`) or Navigation 3 if the
|
|
231
|
+
project has adopted it; hoist the `NavController` to the app root.
|
|
232
|
+
|
|
233
|
+
## Common Mistakes
|
|
234
|
+
|
|
235
|
+
1. **Side effects in the composable body** — IO, mutation, logging. Use effects.
|
|
236
|
+
2. **`collectAsState()` instead of `collectAsStateWithLifecycle()`** on Android.
|
|
237
|
+
3. **No `key` in lazy list `items`** — breaks item identity and animations.
|
|
238
|
+
4. **`derivedStateOf` for a 1:1 transform** — unnecessary; just compute it.
|
|
239
|
+
5. **`remember` where `rememberSaveable` is needed** — state lost on rotation.
|
|
240
|
+
6. **Launching coroutines in the body** — use `LaunchedEffect`/event scope.
|
|
241
|
+
7. **`LaunchedEffect(Unit)` that depends on a changing value** — stale captures;
|
|
242
|
+
key it properly or use `rememberUpdatedState`.
|
|
243
|
+
8. **Unstable parameters** (`List`, lambdas allocated inline) defeating skipping.
|
|
244
|
+
9. **Reading high-frequency state too high** in the tree — recomposes too much.
|
|
245
|
+
10. **Backwards writes** — writing state already read this composition (infinite
|
|
246
|
+
loop).
|
|
247
|
+
11. **Big scrollable `Column`** instead of `LazyColumn`.
|
|
248
|
+
12. **Hoisting nothing** — monolithic stateful composables that can't be tested
|
|
249
|
+
or previewed.
|
|
250
|
+
|
|
251
|
+
## Review Checklist
|
|
252
|
+
|
|
253
|
+
- [ ] Composables are side-effect free; effects use the proper effect API
|
|
254
|
+
- [ ] State hoisted appropriately (stateless leaf composables, state in ViewModel)
|
|
255
|
+
- [ ] `rememberSaveable` used for state that must survive config changes
|
|
256
|
+
- [ ] `derivedStateOf` used only for derived state that changes less than inputs
|
|
257
|
+
- [ ] Flows collected with `collectAsStateWithLifecycle()`
|
|
258
|
+
- [ ] `LaunchedEffect`/`DisposableEffect` keyed correctly; resources disposed
|
|
259
|
+
- [ ] Coroutines launched from events via `rememberCoroutineScope`, not body
|
|
260
|
+
- [ ] Lazy lists use stable `key`s
|
|
261
|
+
- [ ] Parameters are stable (immutable data classes / ImmutableList)
|
|
262
|
+
- [ ] Material 3 theming via `MaterialTheme`; dynamic color where wanted
|
|
263
|
+
- [ ] No backwards writes; no IO/mutation in composition
|
|
264
|
+
- [ ] High-frequency reads deferred (lambdas) to limit recomposition
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kotlin-coroutines
|
|
3
|
+
description: "Write data-safe asynchronous Kotlin with coroutines, Flow, and structured concurrency on Android. Use when fixing coroutine cancellation or leak bugs, choosing dispatchers (Default/IO/Main), designing suspend functions, modeling streams with Flow/StateFlow/SharedFlow, scoping work to viewModelScope/lifecycleScope, handling exceptions with CoroutineExceptionHandler/supervisorScope, or converting callback/RxJava APIs to coroutines. Covers Kotlin 2.x, kotlinx.coroutines structured concurrency discipline, and the Android main-safety contract."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Kotlin Coroutines
|
|
7
|
+
|
|
8
|
+
Review, fix, and write concurrent Kotlin targeting Kotlin 2.1+ and
|
|
9
|
+
kotlinx.coroutines 1.9+. Apply structured concurrency, correct dispatcher
|
|
10
|
+
choice, and cooperative cancellation with minimal behavior changes. This is the
|
|
11
|
+
Kotlin equivalent of Swift's structured-concurrency discipline: scopes are the
|
|
12
|
+
unit of lifetime, cancellation is cooperative, and main-safety is non-negotiable.
|
|
13
|
+
|
|
14
|
+
## Contents
|
|
15
|
+
|
|
16
|
+
- [Triage Workflow](#triage-workflow)
|
|
17
|
+
- [Structured Concurrency](#structured-concurrency)
|
|
18
|
+
- [Coroutine Scopes](#coroutine-scopes)
|
|
19
|
+
- [Dispatchers and Main-Safety](#dispatchers-and-main-safety)
|
|
20
|
+
- [Cancellation](#cancellation)
|
|
21
|
+
- [Exception Handling](#exception-handling)
|
|
22
|
+
- [Flow](#flow)
|
|
23
|
+
- [StateFlow and SharedFlow](#stateflow-and-sharedflow)
|
|
24
|
+
- [Bridging Callback APIs](#bridging-callback-apis)
|
|
25
|
+
- [Common Mistakes](#common-mistakes)
|
|
26
|
+
- [Review Checklist](#review-checklist)
|
|
27
|
+
|
|
28
|
+
## Triage Workflow
|
|
29
|
+
|
|
30
|
+
When diagnosing a coroutine issue, follow this sequence:
|
|
31
|
+
|
|
32
|
+
### Step 1: Capture context
|
|
33
|
+
|
|
34
|
+
- Identify the scope the coroutine runs in (`viewModelScope`, `lifecycleScope`,
|
|
35
|
+
a custom `CoroutineScope`, or an unscoped `GlobalScope` — a red flag).
|
|
36
|
+
- Identify the dispatcher in effect and whether the work is CPU-bound,
|
|
37
|
+
IO-bound, or UI-bound.
|
|
38
|
+
- Determine whether the work must survive configuration changes / navigation, or
|
|
39
|
+
should be cancelled with the screen.
|
|
40
|
+
- Copy any stack trace; note whether it is a `CancellationException` (normal) or
|
|
41
|
+
a real failure.
|
|
42
|
+
|
|
43
|
+
### Step 2: Apply the smallest safe fix
|
|
44
|
+
|
|
45
|
+
| Situation | Recommended fix |
|
|
46
|
+
|---|---|
|
|
47
|
+
| Work tied to a screen | Launch in `viewModelScope`; never `GlobalScope`. |
|
|
48
|
+
| Blocking IO (disk, network, DB) | `withContext(Dispatchers.IO) { ... }`. |
|
|
49
|
+
| Heavy CPU work (parse, sort, image) | `withContext(Dispatchers.Default) { ... }`. |
|
|
50
|
+
| UI / state update | Stay on `Dispatchers.Main` (the default for `viewModelScope`). |
|
|
51
|
+
| One child failure must not kill siblings | Use `supervisorScope` / `SupervisorJob`. |
|
|
52
|
+
| Long loop never cancels | Add `ensureActive()` / `yield()` or use cancellable APIs. |
|
|
53
|
+
| Callback API | Wrap with `suspendCancellableCoroutine` or `callbackFlow`. |
|
|
54
|
+
|
|
55
|
+
### Step 3: Verify
|
|
56
|
+
|
|
57
|
+
- Confirm cancellation propagates (the work stops when the scope is cancelled).
|
|
58
|
+
- Confirm no work runs on the main thread that blocks it.
|
|
59
|
+
- Confirm exceptions surface (not silently swallowed) and `CancellationException`
|
|
60
|
+
is never caught-and-ignored.
|
|
61
|
+
|
|
62
|
+
## Structured Concurrency
|
|
63
|
+
|
|
64
|
+
Every coroutine has a parent. A parent does not complete until all children
|
|
65
|
+
complete; cancelling a parent cancels all children. This is the core safety
|
|
66
|
+
guarantee — never break out of it.
|
|
67
|
+
|
|
68
|
+
- Prefer `coroutineScope { }` to launch concurrent children that all must finish
|
|
69
|
+
before the function returns.
|
|
70
|
+
- Use `async`/`await` for concurrent work that produces values.
|
|
71
|
+
- Use `launch` for fire-and-forget work *within a scope* (not detached).
|
|
72
|
+
|
|
73
|
+
```kotlin
|
|
74
|
+
suspend fun loadDashboard(): Dashboard = coroutineScope {
|
|
75
|
+
val profile = async { repo.profile() } // runs concurrently
|
|
76
|
+
val feed = async { repo.feed() } // runs concurrently
|
|
77
|
+
Dashboard(profile.await(), feed.await()) // both joined here
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If either child throws, the scope cancels the sibling and rethrows — no leaks.
|
|
82
|
+
|
|
83
|
+
**Never** use `GlobalScope.launch`. It has no parent, no lifecycle, no
|
|
84
|
+
cancellation. It is the coroutine equivalent of a detached, unowned thread.
|
|
85
|
+
|
|
86
|
+
## Coroutine Scopes
|
|
87
|
+
|
|
88
|
+
| Scope | Lifecycle | Use for |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `viewModelScope` | Cleared when the `ViewModel` clears | All ViewModel async work |
|
|
91
|
+
| `lifecycleScope` | Tied to a `Lifecycle` (Activity/Fragment) | UI-layer work bound to a screen |
|
|
92
|
+
| `rememberCoroutineScope()` | Tied to a composable's lifetime | Launching from Compose event callbacks |
|
|
93
|
+
| custom `CoroutineScope` | You manage `cancel()` | Long-lived components (cancel explicitly) |
|
|
94
|
+
|
|
95
|
+
For a custom scope, always pair a `SupervisorJob` with a dispatcher and cancel it
|
|
96
|
+
when the owner is destroyed:
|
|
97
|
+
|
|
98
|
+
```kotlin
|
|
99
|
+
class Connection {
|
|
100
|
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
101
|
+
fun close() = scope.cancel() // cancels all children — no leaks
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
In Compose, collect with `collectAsStateWithLifecycle()` (not `collectAsState()`)
|
|
106
|
+
so collection stops when the app is in the background.
|
|
107
|
+
|
|
108
|
+
## Dispatchers and Main-Safety
|
|
109
|
+
|
|
110
|
+
A suspend function should be **main-safe**: callable from the main thread without
|
|
111
|
+
blocking it. Push the thread switch *down* into the function, not up to callers.
|
|
112
|
+
|
|
113
|
+
- `Dispatchers.Main` — UI updates. The default for `viewModelScope`.
|
|
114
|
+
- `Dispatchers.Default` — CPU-bound work (parsing, sorting, image processing).
|
|
115
|
+
Backed by a pool sized to CPU cores.
|
|
116
|
+
- `Dispatchers.IO` — blocking IO (disk, network sockets, JDBC, file). Backed by
|
|
117
|
+
a large elastic pool.
|
|
118
|
+
- `Dispatchers.Main.immediate` — runs inline if already on the main thread,
|
|
119
|
+
avoiding an unnecessary re-dispatch (use for state emission).
|
|
120
|
+
|
|
121
|
+
```kotlin
|
|
122
|
+
// Main-safe: caller can invoke from the main thread.
|
|
123
|
+
suspend fun parseLargeJson(raw: String): Model = withContext(Dispatchers.Default) {
|
|
124
|
+
json.decodeFromString(raw)
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Rules:
|
|
129
|
+
1. Never block the main thread (`Thread.sleep`, blocking network, `runBlocking`).
|
|
130
|
+
2. Use `withContext`, not `launch`, to switch dispatchers for a value-returning
|
|
131
|
+
step — `withContext` returns to the original dispatcher when done.
|
|
132
|
+
3. Don't hardcode dispatchers in the call site you want to test; inject a
|
|
133
|
+
dispatcher (or a `CoroutineDispatcher` provider) so tests can substitute
|
|
134
|
+
`StandardTestDispatcher`.
|
|
135
|
+
|
|
136
|
+
## Cancellation
|
|
137
|
+
|
|
138
|
+
Cancellation is cooperative. A coroutine only stops at a suspension point or when
|
|
139
|
+
it checks cooperatively.
|
|
140
|
+
|
|
141
|
+
```kotlin
|
|
142
|
+
suspend fun process(items: List<Item>) {
|
|
143
|
+
for (item in items) {
|
|
144
|
+
ensureActive() // throws CancellationException if cancelled
|
|
145
|
+
heavyWork(item) // CPU loops won't cancel on their own
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- Suspending functions from kotlinx.coroutines (`delay`, `withContext`, Flow
|
|
151
|
+
operators) are cancellable automatically.
|
|
152
|
+
- Tight CPU loops are **not** — insert `ensureActive()` or `yield()`.
|
|
153
|
+
- `CancellationException` is special: it signals normal cancellation. Never catch
|
|
154
|
+
it and swallow it. If you catch `Exception` broadly, rethrow it:
|
|
155
|
+
|
|
156
|
+
```kotlin
|
|
157
|
+
try {
|
|
158
|
+
risky()
|
|
159
|
+
} catch (e: CancellationException) {
|
|
160
|
+
throw e // must rethrow — never swallow
|
|
161
|
+
} catch (e: Exception) {
|
|
162
|
+
handle(e)
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- For cleanup that must run even after cancellation, use
|
|
167
|
+
`withContext(NonCancellable) { ... }` inside a `finally`.
|
|
168
|
+
|
|
169
|
+
## Exception Handling
|
|
170
|
+
|
|
171
|
+
- In `coroutineScope` / `async`, exceptions propagate to the awaiting caller —
|
|
172
|
+
handle with `try/catch` around `await()`.
|
|
173
|
+
- In `launch`, an uncaught exception propagates up and cancels the scope. Install
|
|
174
|
+
a `CoroutineExceptionHandler` on the **root** scope for last-resort handling.
|
|
175
|
+
- `supervisorScope` / `SupervisorJob` isolate child failures so one failing child
|
|
176
|
+
does not cancel its siblings:
|
|
177
|
+
|
|
178
|
+
```kotlin
|
|
179
|
+
supervisorScope {
|
|
180
|
+
launch { mightFail() } // failure here does NOT cancel the sibling
|
|
181
|
+
launch { mustSucceed() }
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- A `CoroutineExceptionHandler` only works on root `launch` coroutines, never on
|
|
186
|
+
`async` (whose exception surfaces at `await`).
|
|
187
|
+
|
|
188
|
+
## Flow
|
|
189
|
+
|
|
190
|
+
`Flow<T>` is a cold asynchronous stream — it does nothing until collected, and
|
|
191
|
+
re-runs its producer for each collector. It is the Kotlin analogue of an
|
|
192
|
+
`AsyncSequence`.
|
|
193
|
+
|
|
194
|
+
```kotlin
|
|
195
|
+
fun searchResults(query: String): Flow<List<Result>> = flow {
|
|
196
|
+
emit(emptyList()) // initial
|
|
197
|
+
emit(repo.search(query)) // suspends, emits result
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Key operators:
|
|
202
|
+
- Transform: `map`, `filter`, `transform`, `flatMapLatest` (cancel-previous —
|
|
203
|
+
ideal for search-as-you-type), `flatMapConcat`, `flatMapMerge`.
|
|
204
|
+
- Combine: `combine`, `zip`, `merge`.
|
|
205
|
+
- Lifecycle: `onStart`, `onEach`, `onCompletion`, `catch` (upstream errors only),
|
|
206
|
+
`retry`.
|
|
207
|
+
- Backpressure/threading: `flowOn(Dispatchers.IO)` changes the **upstream**
|
|
208
|
+
dispatcher; `buffer`, `conflate`, `debounce`, `sample`.
|
|
209
|
+
|
|
210
|
+
```kotlin
|
|
211
|
+
fun results(query: Flow<String>): Flow<UiState> = query
|
|
212
|
+
.debounce(300)
|
|
213
|
+
.distinctUntilChanged()
|
|
214
|
+
.flatMapLatest { q -> repo.search(q) } // cancels stale searches
|
|
215
|
+
.map(UiState::Loaded)
|
|
216
|
+
.catch { emit(UiState.Error(it)) }
|
|
217
|
+
.flowOn(Dispatchers.Default)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Rules:
|
|
221
|
+
- Use `flowOn` to set the producer dispatcher; never call `withContext` inside a
|
|
222
|
+
`flow { }` builder (it breaks context preservation — the builder enforces this).
|
|
223
|
+
- Put `catch` *downstream* of the operators whose errors you want to handle;
|
|
224
|
+
collector-side errors are not caught by `catch`.
|
|
225
|
+
|
|
226
|
+
## StateFlow and SharedFlow
|
|
227
|
+
|
|
228
|
+
These are **hot** flows for UI state and events.
|
|
229
|
+
|
|
230
|
+
- `StateFlow<T>` — holds a single current value, conflates, always has a value.
|
|
231
|
+
The standard holder for screen UI state. Emit via `MutableStateFlow`.
|
|
232
|
+
- `SharedFlow<T>` — broadcasts events to multiple collectors; configurable
|
|
233
|
+
replay. Use for one-shot events (navigation, snackbars) with `replay = 0`.
|
|
234
|
+
|
|
235
|
+
```kotlin
|
|
236
|
+
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
|
|
237
|
+
private val _state = MutableStateFlow<FeedUiState>(FeedUiState.Loading)
|
|
238
|
+
val state: StateFlow<FeedUiState> = _state.asStateFlow()
|
|
239
|
+
|
|
240
|
+
init {
|
|
241
|
+
viewModelScope.launch {
|
|
242
|
+
_state.value = try {
|
|
243
|
+
FeedUiState.Loaded(repo.feed())
|
|
244
|
+
} catch (e: CancellationException) {
|
|
245
|
+
throw e
|
|
246
|
+
} catch (e: Exception) {
|
|
247
|
+
FeedUiState.Error(e.message)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Convert a cold flow to a lifecycle-aware hot `StateFlow` with `stateIn`:
|
|
255
|
+
|
|
256
|
+
```kotlin
|
|
257
|
+
val items: StateFlow<List<Item>> = repo.observeItems()
|
|
258
|
+
.stateIn(
|
|
259
|
+
scope = viewModelScope,
|
|
260
|
+
started = SharingStarted.WhileSubscribed(5_000), // stop 5s after last collector
|
|
261
|
+
initialValue = emptyList()
|
|
262
|
+
)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`WhileSubscribed(5_000)` keeps the upstream alive briefly across configuration
|
|
266
|
+
changes while still stopping when nothing observes — the standard choice.
|
|
267
|
+
|
|
268
|
+
For one-shot events prefer a `Channel` consumed as a flow, or a `SharedFlow` with
|
|
269
|
+
`replay = 0`; do **not** model events as `StateFlow` (state is re-delivered on
|
|
270
|
+
recomposition and replays stale events).
|
|
271
|
+
|
|
272
|
+
## Bridging Callback APIs
|
|
273
|
+
|
|
274
|
+
Single value:
|
|
275
|
+
|
|
276
|
+
```kotlin
|
|
277
|
+
suspend fun awaitLocation(): Location = suspendCancellableCoroutine { cont ->
|
|
278
|
+
val cb = LocationCallback { loc -> cont.resume(loc) }
|
|
279
|
+
locationManager.request(cb)
|
|
280
|
+
cont.invokeOnCancellation { locationManager.remove(cb) } // clean up
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Stream of values:
|
|
285
|
+
|
|
286
|
+
```kotlin
|
|
287
|
+
fun locationUpdates(): Flow<Location> = callbackFlow {
|
|
288
|
+
val cb = LocationCallback { loc -> trySend(loc) }
|
|
289
|
+
locationManager.request(cb)
|
|
290
|
+
awaitClose { locationManager.remove(cb) } // required — cleans up on cancel
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Common Mistakes
|
|
295
|
+
|
|
296
|
+
1. **`GlobalScope.launch`** — no lifecycle, leaks. Use a real scope.
|
|
297
|
+
2. **`runBlocking` in production code** — blocks the thread; it is for `main()`
|
|
298
|
+
and tests only.
|
|
299
|
+
3. **Swallowing `CancellationException`** — breaks structured cancellation;
|
|
300
|
+
always rethrow it.
|
|
301
|
+
4. **Blocking the main thread** — `Thread.sleep`, blocking IO, or heavy CPU on
|
|
302
|
+
`Dispatchers.Main`.
|
|
303
|
+
5. **`withContext` inside a `flow { }` builder** — use `flowOn` instead.
|
|
304
|
+
6. **Collecting flows with `collectAsState()` in Compose** — use
|
|
305
|
+
`collectAsStateWithLifecycle()` so collection pauses in the background.
|
|
306
|
+
7. **Hardcoded dispatchers** — inject them so tests can swap in test
|
|
307
|
+
dispatchers.
|
|
308
|
+
8. **Modeling one-shot events as `StateFlow`** — they replay stale events; use a
|
|
309
|
+
`Channel`/`SharedFlow(replay = 0)`.
|
|
310
|
+
9. **Launching independent work that should be concurrent sequentially** — use
|
|
311
|
+
`async`/`await` in a `coroutineScope`.
|
|
312
|
+
10. **Catching exceptions on `async` with a `CoroutineExceptionHandler`** — it
|
|
313
|
+
does not fire for `async`; catch at `await`.
|
|
314
|
+
11. **Forgetting `awaitClose` in `callbackFlow`** — leaks the callback.
|
|
315
|
+
|
|
316
|
+
## Review Checklist
|
|
317
|
+
|
|
318
|
+
- [ ] No `GlobalScope`; work runs in `viewModelScope`/`lifecycleScope`/owned scope
|
|
319
|
+
- [ ] No `runBlocking` outside `main`/tests
|
|
320
|
+
- [ ] Suspend functions are main-safe (`withContext` pushes the switch down)
|
|
321
|
+
- [ ] CPU loops check cancellation (`ensureActive`/`yield`)
|
|
322
|
+
- [ ] `CancellationException` is rethrown, never swallowed
|
|
323
|
+
- [ ] Child-failure isolation uses `supervisorScope`/`SupervisorJob` where needed
|
|
324
|
+
- [ ] `flowOn` sets upstream dispatcher; no `withContext` inside `flow {}`
|
|
325
|
+
- [ ] UI state exposed as immutable `StateFlow` (backed by `MutableStateFlow`)
|
|
326
|
+
- [ ] `stateIn` uses `WhileSubscribed(5_000)` for screen state
|
|
327
|
+
- [ ] One-shot events are not modeled as `StateFlow`
|
|
328
|
+
- [ ] `callbackFlow`/`suspendCancellableCoroutine` clean up on cancellation
|
|
329
|
+
- [ ] Dispatchers injected for testability
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: python-conventions
|
|
3
|
+
description: Python coding conventions for this repo (Python 3.12+, type hints, PEP 257/8). Use when writing, reviewing, or editing any *.py file. Covers type hints, docstrings, formatting, exception handling, async I/O, virtualenv discipline, and test expectations.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Python Coding Conventions
|
|
7
|
+
|
|
8
|
+
Applies to: any `*.py` file in this repo. Target: Python 3.12+.
|
|
9
|
+
|
|
10
|
+
> Naturalize: confirm the venv location, package manager (uv / pip / poetry), and test runner in `CLAUDE.md`. The defaults below assume a `uv`-managed `.venv/` and `pytest`.
|
|
11
|
+
|
|
12
|
+
## Function-level expectations
|
|
13
|
+
|
|
14
|
+
- Descriptive function names; type hints on parameters and returns (use builtin generics on modern targets, e.g. `list[str]`, `dict[str, int]`; fall back to `typing.List` / `typing.Dict` only on older runtimes).
|
|
15
|
+
- PEP 257 docstrings placed immediately after `def` / `class`.
|
|
16
|
+
- Break complex functions into smaller, single-purpose helpers.
|
|
17
|
+
- Handle edge cases explicitly; raise or return meaningful errors rather than silently swallowing.
|
|
18
|
+
|
|
19
|
+
## General
|
|
20
|
+
|
|
21
|
+
- Readability and clarity over cleverness.
|
|
22
|
+
- For non-obvious algorithms, include a short comment explaining the approach.
|
|
23
|
+
- Mention third-party library usage and purpose at the import site if non-obvious.
|
|
24
|
+
- Consistent naming; follow language idioms.
|
|
25
|
+
|
|
26
|
+
## Style
|
|
27
|
+
|
|
28
|
+
- **PEP 8.** 4-space indents. Keep lines reasonable (the classic guideline is 79; many repos tolerate longer for clarity — match surrounding code and any configured formatter, e.g. `ruff`/`black`).
|
|
29
|
+
- Blank lines to separate functions, classes, and logical blocks.
|
|
30
|
+
|
|
31
|
+
## Async I/O
|
|
32
|
+
|
|
33
|
+
- For async code, use `async def` / `await` end-to-end; don't block the event loop with sync I/O.
|
|
34
|
+
- Prefer async-native drivers (e.g. an async DB/HTTP client) over wrapping sync calls in threads.
|
|
35
|
+
- Use `asyncio.Lock` / structured task management for shared mutable state; guard caches against concurrent rebuilds.
|
|
36
|
+
|
|
37
|
+
## Environment & dependencies
|
|
38
|
+
|
|
39
|
+
- Use the project venv (commonly `.venv/`, often `uv`-managed). If a stale top-level `venv/` exists, prefer the project-documented one — check `CLAUDE.md`.
|
|
40
|
+
- Load config from the environment (e.g. `python-dotenv`); quote `.env` values containing spaces so `source .env` doesn't break in zsh.
|
|
41
|
+
|
|
42
|
+
## Edge cases and testing
|
|
43
|
+
|
|
44
|
+
- Cover critical paths with tests; include empty inputs, invalid types, and large-input cases where relevant.
|
|
45
|
+
- Prefer `pytest`. Run from the project venv.
|
|
46
|
+
|
|
47
|
+
## Example
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
def calculate_area(radius: float) -> float:
|
|
51
|
+
"""Calculate the area of a circle given the radius.
|
|
52
|
+
|
|
53
|
+
Parameters:
|
|
54
|
+
radius: The radius of the circle.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The area, computed as pi * radius**2.
|
|
58
|
+
"""
|
|
59
|
+
import math
|
|
60
|
+
return math.pi * radius ** 2
|
|
61
|
+
```
|