From d01bb1632317388a3fff2f339e3c573e7a646ba9 Mon Sep 17 00:00:00 2001
From: Ivan Morgillo
Date: Mon, 6 Jul 2026 18:28:06 +0200
Subject: [PATCH 1/5] feat(compose-agent): cross-phase back-write + false-lead
authoring guidance (4.3.1)
Follow-up to 4.3.0. The audit catches cross-phase back-writes and no-op
"recomposition fixes" after the fact; compose-agent now warns while the code is
being written, closing the drift the 4.3.0 cross-review flagged.
- performance.md: "Never Back-Write Across Phases" (layout->composition writes;
snapshot-collection mutation in a @Composable body, good/bad Kotlin) and
"Optimizations That Do Nothing" (remember(index) on pure fns, remember-ing an
auto-memoized callback under SSM, identity caches, hoisting without stabilizing
captures). Grep triggers extended.
- SKILL.md: Core Instruction, review-checklist line, Review Process step 4.
- Versions: compose-agent -> 4.3.1; jetpack-compose-audit unchanged at 4.3.0.
- evals description updated: compose-agent now covers the topic.
Continues the chrisbanes/skills (Apache-2.0) adaptation, cited against
developer.android.com.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_016ysskXsKpwEbs1n9CX1ScY
---
CHANGELOG.md | 10 +++++
README.md | 13 ++++++
docs/release-notes-4.3.1.md | 27 +++++++++++
.../compose-agent/.claude-plugin/plugin.json | 2 +-
.../compose-agent/.cursor-plugin/plugin.json | 2 +-
skills/compose-agent/SKILL.md | 6 ++-
.../compose-agent/references/performance.md | 45 +++++++++++++++++++
skills/jetpack-compose-audit/evals/evals.json | 2 +-
8 files changed, 102 insertions(+), 5 deletions(-)
create mode 100644 docs/release-notes-4.3.1.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87f8e2a..910d5f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
Full release history for the Compose Skill Suite. The newest release is summarised under **What's new** in the [README](./README.md).
+### 4.3.1 — 2026-07-06
+
+**`compose-agent` only — authoring guidance for cross-phase back-writes + false leads.**
+
+Follow-up to 4.3.0: the audit could catch these after the fact, but the authoring skill stayed silent while writing. Now `compose-agent` warns up front, closing the drift the 4.3.0 cross-review flagged.
+
+- **`performance.md`.** New **Never Back-Write Across Phases** section (layout callbacks writing composition-read state; snapshot-collection mutation in a `@Composable` body — with good/bad Kotlin) and an **Optimizations That Do Nothing** section (the false leads: `remember(index)` on pure fns, `remember`-ing an auto-memoized callback under Strong Skipping, identity caches for derived maps, hoisting without stabilizing captures). Grep triggers gain `onSizeChanged` / `onGloballyPositioned` / `onPlaced` and snapshot-collection mutation.
+- **`SKILL.md`.** New Core Instruction (no cross-phase back-writes, no no-op optimizations), review-checklist line, and Review Process step 4 now names cross-phase back-writes.
+- **Versions.** `compose-agent` → `4.3.1`. `jetpack-compose-audit` unchanged at `4.3.0`.
+
### 4.3.0 — 2026-07-06
**`jetpack-compose-audit` only — cross-phase back-write detection + false-lead guard.**
diff --git a/README.md b/README.md
index 85f65fc..d951871 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,8 @@
+**`compose-agent` 4.3.1 · 2026-07-06** — Authoring guidance so the coding-agent skill *warns while you write*: **Never Back-Write Across Phases** (layout callbacks / snapshot-collection mutation feeding composition) and **Optimizations That Do Nothing** (the false leads). The 4.3.0 audit catches these after the fact; 4.3.1 stops you writing them.
+
**`jetpack-compose-audit` 4.3.0 · 2026-07-06** — Cross-phase back-write detection (axis 3: layout callbacks writing state read in composition), a related composition-phase self-invalidation check (snapshot collections mutated in a composable body), and a **False Leads** scoring guard so the auditor stops crediting no-op "recomposition fixes." Adapted from [`chrisbanes/skills`](https://github.com/chrisbanes/skills) (Apache-2.0). `compose-agent` stays at `4.2.1`.
**Version 4.2.0 · 2026-06-17** — Paging 3 in Compose: new `paging.md` reference (LLM guardrails, not API tour), audit hooks under existing Performance/State categories, planning doc at [`docs/paging-skill-plan.md`](./docs/paging-skill-plan.md). Validated through multi-agent cross-review. Both skills ship as `4.2.0`.
@@ -29,6 +31,17 @@ Authored and cross-reviewed with every frontier model — Claude Opus 4.8, GPT-5
## What's new
+### 4.3.1 — 2026-07-06
+
+**`compose-agent` — authoring guidance for cross-phase back-writes + false leads.**
+
+- **`performance.md`.** New **Never Back-Write Across Phases** section (layout callbacks writing composition-read state; snapshot-collection mutation in a `@Composable` body, with good/bad Kotlin) and **Optimizations That Do Nothing** (the false leads). Grep triggers extended for `onSizeChanged` / `onGloballyPositioned` / `onPlaced` and snapshot-collection mutation.
+- **`SKILL.md`.** New Core Instruction, review-checklist line, and Review Process step 4 names cross-phase back-writes.
+- **Why.** Closes the drift the 4.3.0 cross-review flagged: the audit caught these after the fact, but the authoring skill stayed silent while writing the code. Now it warns up front.
+- **Versions.** `compose-agent` → `4.3.1`. `jetpack-compose-audit` unchanged at `4.3.0`.
+
+For release detail, see [`docs/release-notes-4.3.1.md`](./docs/release-notes-4.3.1.md).
+
### 4.3.0 — 2026-07-06
**`jetpack-compose-audit` — cross-phase back-writes + false-lead guard.**
diff --git a/docs/release-notes-4.3.1.md b/docs/release-notes-4.3.1.md
new file mode 100644
index 0000000..da76415
--- /dev/null
+++ b/docs/release-notes-4.3.1.md
@@ -0,0 +1,27 @@
+# Release notes — 4.3.1 (2026-07-06)
+
+**`compose-agent` only.** `jetpack-compose-audit` unchanged at `4.3.0`.
+
+Follow-up to 4.3.0. That release taught the **audit** to catch cross-phase back-writes and no-op "recomposition fixes"; this release teaches the **authoring** skill to avoid writing them in the first place. It closes the drift the 4.3.0 cross-review flagged: a `compose-agent` review could still recommend a pattern the audit rejects.
+
+## `performance.md`
+
+- **Never Back-Write Across Phases** — the reverse of "defer reads". Two shapes, each with good/bad Kotlin:
+ - Layout → composition: `onSizeChanged` / `onGloballyPositioned` / `onPlaced` writing state a sibling reads in composition. Fix: consume the measured value in layout/draw (`Layout` / `Modifier.layout { }`), or use `BoxWithConstraints` / `SubcomposeLayout` for genuine *parent* constraints — never round-trip a sibling's measured size through composition-read state.
+ - Snapshot-collection mutation in a `@Composable` body: `mutableStateListOf` / `mutableStateMapOf` (or `toMutableState*`) mutated and read in the same composition. Fix: derive from inputs with `remember(keys)`, or mutate from an event / `LaunchedEffect` / state holder.
+- **Optimizations That Do Nothing** — the false leads, so the agent stops shipping them: `remember(index)` on a pure function, `remember`-ing an already auto-memoized callback under Strong Skipping, identity caches for read-only derived maps, hoisting without stabilizing captures. Prove a win with recomposition counts / compiler reports, not the presence of a pattern.
+- **Grep triggers** extended: `onSizeChanged` / `onGloballyPositioned` / `onPlaced`, and `mutableStateListOf` / `mutableStateMapOf` / `toMutableStateList` / `toMutableStateMap`.
+
+## `SKILL.md`
+
+- New Core Instruction: no cross-phase back-writes, no no-op optimizations.
+- Review Process step 4 now names cross-phase back-writes.
+- New review-checklist line: does any layout callback or snapshot-collection mutation write state read back in composition?
+
+## Attribution
+
+Continues the 4.3.0 adaptation from [`chrisbanes/skills`](https://github.com/chrisbanes/skills) `compose-recomposition-performance` (Apache-2.0), cited against `developer.android.com`.
+
+## Versions
+
+`compose-agent` → **4.3.1** (SKILL + both `plugin.json`). `jetpack-compose-audit` stays at `4.3.0`.
diff --git a/skills/compose-agent/.claude-plugin/plugin.json b/skills/compose-agent/.claude-plugin/plugin.json
index aa50fa5..64c967d 100644
--- a/skills/compose-agent/.claude-plugin/plugin.json
+++ b/skills/compose-agent/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "compose-agent",
- "version": "4.2.1",
+ "version": "4.3.1",
"description": "Agent skill that helps AI coding assistants write modern Jetpack Compose: correct state, effects, performance-aware modifiers, Navigation 3, Paging 3 in Compose, coroutines on lifecycle, animations, UI tests, focus/keyboard navigation, Compose Multiplatform boundaries, Android launch resources, and idiomatic Kotlin. Targets the mistakes LLMs actually make in Compose code.",
"author": {
"name": "Ivan Morgillo"
diff --git a/skills/compose-agent/.cursor-plugin/plugin.json b/skills/compose-agent/.cursor-plugin/plugin.json
index aa50fa5..64c967d 100644
--- a/skills/compose-agent/.cursor-plugin/plugin.json
+++ b/skills/compose-agent/.cursor-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "compose-agent",
- "version": "4.2.1",
+ "version": "4.3.1",
"description": "Agent skill that helps AI coding assistants write modern Jetpack Compose: correct state, effects, performance-aware modifiers, Navigation 3, Paging 3 in Compose, coroutines on lifecycle, animations, UI tests, focus/keyboard navigation, Compose Multiplatform boundaries, Android launch resources, and idiomatic Kotlin. Targets the mistakes LLMs actually make in Compose code.",
"author": {
"name": "Ivan Morgillo"
diff --git a/skills/compose-agent/SKILL.md b/skills/compose-agent/SKILL.md
index 70b221d..b564339 100644
--- a/skills/compose-agent/SKILL.md
+++ b/skills/compose-agent/SKILL.md
@@ -6,7 +6,7 @@ allowed-tools: Read, Glob, Grep, Edit, Write, Bash
argument-hint: "[focus area, e.g. 'state', 'effects', 'navigation', 'paging', 'lifecycle', 'animation', 'testing', 'focus', 'kmp']"
metadata:
author: Ivan Morgillo
- version: "4.2.1"
+ version: "4.3.1"
---
# Compose Agent
@@ -27,7 +27,7 @@ If the repo pins older versions, match the repo — but call out what the modern
1. Check for **deprecated or soft-deprecated API** using `references/api.md`.
2. Validate **state and data flow** using `references/state.md`.
3. Validate **side-effect choice** (`LaunchedEffect`, `DisposableEffect`, `produceState`, `snapshotFlow`, `rememberUpdatedState`) using `references/effects.md`.
-4. Review **composable performance** — stability, lambda modifiers, lazy list keys, deferred reads — using `references/performance.md`.
+4. Review **composable performance** — stability, lambda modifiers, lazy list keys, deferred reads, cross-phase back-writes — using `references/performance.md`.
5. Review **modifier usage** — ordering, lambda-form, `Modifier.Node` over `composed { }` — using `references/modifiers.md`.
6. Review **navigation** using `references/navigation.md` — start from its decision table (Nav3 vs Nav2 type-safe vs plain state) before touching call sites.
7. Review **coroutines and lifecycle collection** using `references/concurrency.md`.
@@ -51,6 +51,7 @@ If doing a partial review, load only the relevant reference files — each refer
- Collect Flows with `collectAsStateWithLifecycle()` in UI code. Plain `collectAsState()` keeps collecting when the screen is not visible and burns battery and bandwidth. **Exception:** `Flow>` is collected with `collectAsLazyPagingItems()`, never `collectAsStateWithLifecycle()` — see `references/paging.md`.
- Prefer `rememberSaveable` over `remember` for UI state that should survive configuration change or process death, unless the value is unserializable or derivable.
- Use the typed state factories (`mutableIntStateOf`, `mutableLongStateOf`, `mutableFloatStateOf`, `mutableDoubleStateOf`) for primitive state. Raw `mutableStateOf(...)` boxes.
+- Never write state back across phases. A layout callback (`onSizeChanged` / `onGloballyPositioned` / `onPlaced`) must not write state that a sibling reads in composition, and never mutate a `mutableStateListOf` / `mutableStateMapOf` inside a composable body that also reads it — both create measure/mutate → recompose loops. Consume measured values in layout/draw, or mutate snapshot state from an event / `LaunchedEffect` / state holder. And don't ship no-op "optimizations" (`remember(index)` on a pure function, `remember`-ing an already-auto-memoized callback under Strong Skipping) — prove a win with recomposition counts, not the pattern. See `references/performance.md`.
- Never put a lambda in a `CompositionLocal`. Use explicit parameters.
- Do not introduce third-party libraries without asking first. The Accompanist libraries covering pager, swipe-refresh, flow layout, and system UI controller are **deprecated** — the functionality is in AndroidX now (`HorizontalPager`, `PullToRefreshBox`, `FlowRow`/`FlowColumn`, `enableEdgeToEdge()`).
- When an element's background reads from `MaterialTheme.colorScheme.*` (directly or via a blend), its text and icon colors must read from the same theming source. Hard-coded `Color.Black` / `Color.White` / raw ARGB literals over theme-driven backgrounds are dark-mode regressions. See `references/component-api.md`.
@@ -141,6 +142,7 @@ When the agent is **writing new code** rather than reviewing, the same rules app
- If it collects a Flow, is it `collectAsStateWithLifecycle()`? (A `Flow>` is the exception — it uses `collectAsLazyPagingItems()`.)
- Is the parameter order: data → modifier → other → content slot last?
- If it animates, is the API declarative first, remembered where needed, lifecycle-aware, and phase-correct?
+- Does any layout callback or snapshot-collection mutation write state read back in composition (a cross-phase / self-invalidation loop)?
- If it pages, are keys stable, `LoadState` handled, and refresh/retry user-driven?
- If it needs focus, testing, or platform-specific behavior, did you load the focused reference before judging?
diff --git a/skills/compose-agent/references/performance.md b/skills/compose-agent/references/performance.md
index 3702574..a5bcfd1 100644
--- a/skills/compose-agent/references/performance.md
+++ b/skills/compose-agent/references/performance.md
@@ -68,6 +68,49 @@ Modifier.graphicsLayer { this.alpha = alpha } // draw-phase read
Same idea for `rotate` and `scale` via `graphicsLayer`. `padding` does **not** have a lambda-form overload in Compose today, so animated padding still reads in composition and remeasures layout. If the effect is really positional motion, prefer `offset` / `graphicsLayer` when that is visually equivalent.
+## Never Back-Write Across Phases
+
+The reverse of deferring reads. Layout runs after composition and draw runs after layout, so if a **later phase writes state an earlier phase read**, the earlier phase re-runs — a feedback loop, often once per frame. Two shapes to avoid:
+
+**Layout → composition.** A layout callback (`onSizeChanged`, `onGloballyPositioned`, `onPlaced`) that writes state a sibling reads in composition:
+
+```kotlin
+// Bad — measure → write → recompose loop
+var labelWidth by remember { mutableIntStateOf(0) }
+Text(label, Modifier.onSizeChanged { labelWidth = it.width }) // layout writes
+Spacer(Modifier.width(with(LocalDensity.current) { labelWidth.toDp() })) // composition reads → recomposes
+
+// Good — keep the measured value in the layout phase
+Layout(content = { Text(label); Text(value) }) { measurables, constraints -> /* place value using label's measured width */ }
+```
+
+If a child genuinely needs the parent's constraints, `BoxWithConstraints` / `SubcomposeLayout` is fine — that exposes *parent constraints*, not a sibling's measured size. Do not round-trip a measured size through composition-read state.
+
+**Mutating a snapshot collection in the composition body.** A `mutableStateListOf` / `mutableStateMapOf` (or `toMutableStateList()` / `toMutableStateMap()`) mutated (`add`, `put`, `putAll`, `clear`, `[k] =`, `+=`) inside a `@Composable` body that also reads it invalidates the composition that produced it:
+
+```kotlin
+// Bad — mutate-and-read in the same composition
+val heights = remember { mutableStateMapOf() }
+rows.forEach { heights[it.id] = it.baseHeight } // write in composition
+Column { rows.forEach { Box(Modifier.height((heights[it.id] ?: 0).dp)) } } // read → self-invalidates
+
+// Good — derive from inputs, no snapshot write in composition
+val heights = remember(rows) { rows.associate { it.id to it.baseHeight } }
+```
+
+Mutate snapshot state from an event handler, `LaunchedEffect`, or a state holder — never during composition.
+
+## Optimizations That Do Nothing
+
+These *look* like recomposition fixes but change nothing. Don't write them, and don't leave them behind as "optimized":
+
+- `remember(index) { isFirstRow(index) }` — a pure, cheap function of its own key. Same inputs, no skipping benefit; inline it. Only `remember` genuinely expensive work keyed on real inputs.
+- Wrapping a callback in `remember` to "stabilize" it **under Strong Skipping** — the compiler already auto-memoizes lambdas passed to composables. That lever only matters SSM-off, on `@NonSkippableComposable` / `@DontMemoize` paths, or when the lambda captures an unstable value.
+- Identity-caching a read-only derived map to preserve reference equality — `remember(keys)` on the inputs is enough and won't serve stale data.
+- Hoisting state up without stabilizing the values passed back down — a fresh unstable instance each recomposition still defeats skipping on the child.
+
+Prove a real win with recomposition counts (Layout Inspector) or compiler reports, not the presence of a pattern.
+
## Lazy Lists Need Keys
```kotlin
@@ -167,6 +210,8 @@ Shipping a baseline profile is still one of the biggest end-user performance win
- `Modifier\.offset\(` / `Modifier\.alpha\(` / `Modifier\.scale\(` / `Modifier\.rotate\(` / `Modifier\.padding\(` — look at the argument; if it reads an animated state, recommend lambda-form
- `items\(\s*\w+\s*\)\s*\{` in a `Lazy*` without `key =` — probably missing keys
- `animateItemPlacement\(` — migrate to `animateItem()`
+- `onSizeChanged|onGloballyPositioned|onPlaced` — check the lambda writes state read in composition (layout → composition back-write)
+- `mutableStateListOf|mutableStateMapOf|toMutableStateList|toMutableStateMap` — check for `add`/`put`/`clear`/`[k] =`/`+=` mutation inside a `@Composable` body that also reads it
- `@NonSkippableComposable` / `@DontMemoize` — demand justification
## Primary Sources
diff --git a/skills/jetpack-compose-audit/evals/evals.json b/skills/jetpack-compose-audit/evals/evals.json
index 2286ad9..d8e7d9b 100644
--- a/skills/jetpack-compose-audit/evals/evals.json
+++ b/skills/jetpack-compose-audit/evals/evals.json
@@ -1,6 +1,6 @@
{
"skill_name": "jetpack-compose-audit",
- "description": "Acceptance evals for the API-hygiene, effect-correctness, cancellation, and state-boundary behavior introduced in 4.1.x, plus Paging 3 in Compose list/load-state behavior added in 4.2.0 (cases 10-11), plus cross-phase back-write detection and the false-lead scoring guard added in 4.3.0 (cases 12-13, audit-only — compose-agent has no cross-phase / false-lead guidance yet). These are the audit/review acceptance criteria the skill must preserve; the earlier cases also back compose-agent review behavior. Run a model against each prompt and check it satisfies every expectation.",
+ "description": "Acceptance evals for the API-hygiene, effect-correctness, cancellation, and state-boundary behavior introduced in 4.1.x, plus Paging 3 in Compose list/load-state behavior added in 4.2.0 (cases 10-11), plus cross-phase back-write detection and the false-lead scoring guard added in 4.3.0 (cases 12-14; compose-agent gained matching authoring guidance in 4.3.1). These are the audit/review acceptance criteria the skill must preserve; the earlier cases also back compose-agent review behavior. Run a model against each prompt and check it satisfies every expectation.",
"evals": [
{
"id": 0,
From b6cf227f2366da35a9cab48d4f5ad278fec9e47c Mon Sep 17 00:00:00 2001
From: Ivan Morgillo
Date: Mon, 6 Jul 2026 18:39:59 +0200
Subject: [PATCH 2/5] review r1: fix SSM unstable-capture carve-out suite-wide,
terminology, example
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Round-1 cross-review (Codex, Cursor; Antigravity LGTM) — no blockers:
- SSM correctness (Codex + Cursor, the big one): removed the "...unless the
lambda captures an unstable value" carve-out from BOTH skills. Per the official
Strong Skipping docs, SSM memoizes lambdas even with unstable captures, so that
exception could still bless the no-op the guard forbids. Fixed compose-agent
performance.md + all four spots in jetpack-compose-audit scoring.md. Only
remaining exception: SSM-off or @DontMemoize / @NonSkippableComposable.
-> jetpack-compose-audit bumped to 4.3.1 (correctness patch).
- Terminology (Cursor): compose-agent now labels snapshot-collection mutation as
composition-phase self-invalidation, not cross-phase, in the section, the
Core Instruction, and the example header.
- Example fix (Cursor): the layout good/bad example now uses label AND value on
both sides (value was orphaned before).
- Grep-trigger parity: added addAll/remove/-= and SnapshotStateList/Map.
- Stale note in release-notes-4.3.0.md updated (addressed in 4.3.1).
- README top-summary no longer reads as a version conflict.
Versions: compose-agent -> 4.3.1, jetpack-compose-audit -> 4.3.1.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_016ysskXsKpwEbs1n9CX1ScY
---
CHANGELOG.md | 9 ++++---
README.md | 5 ++--
docs/release-notes-4.3.0.md | 2 +-
docs/release-notes-4.3.1.md | 8 ++++--
skills/compose-agent/SKILL.md | 2 +-
.../compose-agent/references/performance.md | 27 ++++++++++++-------
.../.claude-plugin/plugin.json | 2 +-
.../.cursor-plugin/plugin.json | 2 +-
skills/jetpack-compose-audit/SKILL.md | 2 +-
.../references/scoring.md | 8 +++---
10 files changed, 40 insertions(+), 27 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 910d5f0..407a38f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,13 +4,14 @@ Full release history for the Compose Skill Suite. The newest release is summaris
### 4.3.1 — 2026-07-06
-**`compose-agent` only — authoring guidance for cross-phase back-writes + false leads.**
+**`compose-agent` authoring guidance for cross-phase back-writes + false leads, plus a suite-wide Strong Skipping correctness fix.**
Follow-up to 4.3.0: the audit could catch these after the fact, but the authoring skill stayed silent while writing. Now `compose-agent` warns up front, closing the drift the 4.3.0 cross-review flagged.
-- **`performance.md`.** New **Never Back-Write Across Phases** section (layout callbacks writing composition-read state; snapshot-collection mutation in a `@Composable` body — with good/bad Kotlin) and an **Optimizations That Do Nothing** section (the false leads: `remember(index)` on pure fns, `remember`-ing an auto-memoized callback under Strong Skipping, identity caches for derived maps, hoisting without stabilizing captures). Grep triggers gain `onSizeChanged` / `onGloballyPositioned` / `onPlaced` and snapshot-collection mutation.
-- **`SKILL.md`.** New Core Instruction (no cross-phase back-writes, no no-op optimizations), review-checklist line, and Review Process step 4 now names cross-phase back-writes.
-- **Versions.** `compose-agent` → `4.3.1`. `jetpack-compose-audit` unchanged at `4.3.0`.
+- **`compose-agent/performance.md`.** New **Never Back-Write Across Phases** section (layout→composition writes, cross-phase; snapshot-collection mutation in a `@Composable` body, composition-phase self-invalidation — with good/bad Kotlin) and an **Optimizations That Do Nothing** section (the false leads: `remember(index)` on pure fns, `remember`-ing an auto-memoized callback under Strong Skipping, identity caches for derived maps, hoisting without stabilizing captures). Grep triggers gain `onSizeChanged` / `onGloballyPositioned` / `onPlaced` and snapshot-collection mutation.
+- **`compose-agent/SKILL.md`.** New Core Instruction, review-checklist line, and Review Process step 4 now names cross-phase back-writes.
+- **`jetpack-compose-audit` Strong Skipping fix (correctness).** The 4.3.0 false-lead wording said manual callback `remember` could still matter "when the lambda captures an unstable value." Per the official [Strong Skipping docs](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping), SSM memoizes lambdas **even with unstable captures**, so that carve-out could still bless the exact no-op the guard forbids. Removed suite-wide; the only remaining exception is SSM-off or a `@DontMemoize` / `@NonSkippableComposable` path.
+- **Versions.** `compose-agent` → `4.3.1`, `jetpack-compose-audit` → `4.3.1`.
### 4.3.0 — 2026-07-06
diff --git a/README.md b/README.md
index d951871..fe49429 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
**`compose-agent` 4.3.1 · 2026-07-06** — Authoring guidance so the coding-agent skill *warns while you write*: **Never Back-Write Across Phases** (layout callbacks / snapshot-collection mutation feeding composition) and **Optimizations That Do Nothing** (the false leads). The 4.3.0 audit catches these after the fact; 4.3.1 stops you writing them.
-**`jetpack-compose-audit` 4.3.0 · 2026-07-06** — Cross-phase back-write detection (axis 3: layout callbacks writing state read in composition), a related composition-phase self-invalidation check (snapshot collections mutated in a composable body), and a **False Leads** scoring guard so the auditor stops crediting no-op "recomposition fixes." Adapted from [`chrisbanes/skills`](https://github.com/chrisbanes/skills) (Apache-2.0). `compose-agent` stays at `4.2.1`.
+**`jetpack-compose-audit` 4.3.0 · 2026-07-06** — Cross-phase back-write detection (axis 3: layout callbacks writing state read in composition), a related composition-phase self-invalidation check (snapshot collections mutated in a composable body), and a **False Leads** scoring guard so the auditor stops crediting no-op "recomposition fixes." Adapted from [`chrisbanes/skills`](https://github.com/chrisbanes/skills) (Apache-2.0). (Authoring-side guidance in `compose-agent` followed in 4.3.1.)
**Version 4.2.0 · 2026-06-17** — Paging 3 in Compose: new `paging.md` reference (LLM guardrails, not API tour), audit hooks under existing Performance/State categories, planning doc at [`docs/paging-skill-plan.md`](./docs/paging-skill-plan.md). Validated through multi-agent cross-review. Both skills ship as `4.2.0`.
@@ -37,8 +37,9 @@ Authored and cross-reviewed with every frontier model — Claude Opus 4.8, GPT-5
- **`performance.md`.** New **Never Back-Write Across Phases** section (layout callbacks writing composition-read state; snapshot-collection mutation in a `@Composable` body, with good/bad Kotlin) and **Optimizations That Do Nothing** (the false leads). Grep triggers extended for `onSizeChanged` / `onGloballyPositioned` / `onPlaced` and snapshot-collection mutation.
- **`SKILL.md`.** New Core Instruction, review-checklist line, and Review Process step 4 names cross-phase back-writes.
+- **Suite-wide Strong Skipping fix.** Removed a false-lead carve-out ("...unless the lambda captures an unstable value") that contradicted the [official Strong Skipping docs](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping) — SSM memoizes lambdas even with unstable captures, so the only real exception is SSM-off / `@DontMemoize`.
- **Why.** Closes the drift the 4.3.0 cross-review flagged: the audit caught these after the fact, but the authoring skill stayed silent while writing the code. Now it warns up front.
-- **Versions.** `compose-agent` → `4.3.1`. `jetpack-compose-audit` unchanged at `4.3.0`.
+- **Versions.** `compose-agent` → `4.3.1`, `jetpack-compose-audit` → `4.3.1`.
For release detail, see [`docs/release-notes-4.3.1.md`](./docs/release-notes-4.3.1.md).
diff --git a/docs/release-notes-4.3.0.md b/docs/release-notes-4.3.0.md
index bcba529..ae2d4a4 100644
--- a/docs/release-notes-4.3.0.md
+++ b/docs/release-notes-4.3.0.md
@@ -30,7 +30,7 @@ The guard verifies against runtime recomposition counts / compiler reports rathe
## Evals
-`evals/evals.json` cases 12–14 lock in the new behavior: cross-phase layout→composition back-write (12), false leads that must not be credited or suggested under SSM (13), and composition-phase snapshot self-invalidation (14). Audit-only — `compose-agent` has no cross-phase / false-lead guidance yet (tracked as follow-up).
+`evals/evals.json` cases 12–14 lock in the new behavior: cross-phase layout→composition back-write (12), false leads that must not be credited or suggested under SSM (13), and composition-phase snapshot self-invalidation (14). Audit-only at 4.3.0; `compose-agent` gained matching authoring guidance in 4.3.1.
## Attribution
diff --git a/docs/release-notes-4.3.1.md b/docs/release-notes-4.3.1.md
index da76415..3a43401 100644
--- a/docs/release-notes-4.3.1.md
+++ b/docs/release-notes-4.3.1.md
@@ -1,6 +1,6 @@
# Release notes — 4.3.1 (2026-07-06)
-**`compose-agent` only.** `jetpack-compose-audit` unchanged at `4.3.0`.
+**`compose-agent` authoring guidance + a suite-wide Strong Skipping correctness fix** (`jetpack-compose-audit` patched too).
Follow-up to 4.3.0. That release taught the **audit** to catch cross-phase back-writes and no-op "recomposition fixes"; this release teaches the **authoring** skill to avoid writing them in the first place. It closes the drift the 4.3.0 cross-review flagged: a `compose-agent` review could still recommend a pattern the audit rejects.
@@ -18,10 +18,14 @@ Follow-up to 4.3.0. That release taught the **audit** to catch cross-phase back-
- Review Process step 4 now names cross-phase back-writes.
- New review-checklist line: does any layout callback or snapshot-collection mutation write state read back in composition?
+## `jetpack-compose-audit` — Strong Skipping correctness fix
+
+The 4.3.0 false-lead guard said manual callback `remember` could still matter "when the lambda captures an unstable value." That contradicts the official [Strong Skipping docs](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping): SSM memoizes lambdas **even with unstable captures**, so the carve-out could still bless the exact no-op the guard forbids. Removed from `scoring.md` in all four places; the only remaining exception is SSM-off or a `@DontMemoize` / `@NonSkippableComposable` path.
+
## Attribution
Continues the 4.3.0 adaptation from [`chrisbanes/skills`](https://github.com/chrisbanes/skills) `compose-recomposition-performance` (Apache-2.0), cited against `developer.android.com`.
## Versions
-`compose-agent` → **4.3.1** (SKILL + both `plugin.json`). `jetpack-compose-audit` stays at `4.3.0`.
+`compose-agent` → **4.3.1** and `jetpack-compose-audit` → **4.3.1** (SKILL + both `plugin.json` each).
diff --git a/skills/compose-agent/SKILL.md b/skills/compose-agent/SKILL.md
index b564339..fa5c3b9 100644
--- a/skills/compose-agent/SKILL.md
+++ b/skills/compose-agent/SKILL.md
@@ -51,7 +51,7 @@ If doing a partial review, load only the relevant reference files — each refer
- Collect Flows with `collectAsStateWithLifecycle()` in UI code. Plain `collectAsState()` keeps collecting when the screen is not visible and burns battery and bandwidth. **Exception:** `Flow>` is collected with `collectAsLazyPagingItems()`, never `collectAsStateWithLifecycle()` — see `references/paging.md`.
- Prefer `rememberSaveable` over `remember` for UI state that should survive configuration change or process death, unless the value is unserializable or derivable.
- Use the typed state factories (`mutableIntStateOf`, `mutableLongStateOf`, `mutableFloatStateOf`, `mutableDoubleStateOf`) for primitive state. Raw `mutableStateOf(...)` boxes.
-- Never write state back across phases. A layout callback (`onSizeChanged` / `onGloballyPositioned` / `onPlaced`) must not write state that a sibling reads in composition, and never mutate a `mutableStateListOf` / `mutableStateMapOf` inside a composable body that also reads it — both create measure/mutate → recompose loops. Consume measured values in layout/draw, or mutate snapshot state from an event / `LaunchedEffect` / state holder. And don't ship no-op "optimizations" (`remember(index)` on a pure function, `remember`-ing an already-auto-memoized callback under Strong Skipping) — prove a win with recomposition counts, not the pattern. See `references/performance.md`.
+- Never write state a phase has already read. A layout callback (`onSizeChanged` / `onGloballyPositioned` / `onPlaced`) must not write state a sibling reads in composition (a cross-phase back-write), and never mutate a `mutableStateListOf` / `mutableStateMapOf` inside a composable body that also reads it (composition-phase self-invalidation) — both create measure/mutate → recompose loops. Consume measured values in layout/draw, or mutate snapshot state from an event / `LaunchedEffect` / state holder. And don't ship no-op "optimizations" (`remember(index)` on a pure function, `remember`-ing an already-auto-memoized callback under Strong Skipping) — prove a win with recomposition counts, not the pattern. See `references/performance.md`.
- Never put a lambda in a `CompositionLocal`. Use explicit parameters.
- Do not introduce third-party libraries without asking first. The Accompanist libraries covering pager, swipe-refresh, flow layout, and system UI controller are **deprecated** — the functionality is in AndroidX now (`HorizontalPager`, `PullToRefreshBox`, `FlowRow`/`FlowColumn`, `enableEdgeToEdge()`).
- When an element's background reads from `MaterialTheme.colorScheme.*` (directly or via a blend), its text and icon colors must read from the same theming source. Hard-coded `Color.Black` / `Color.White` / raw ARGB literals over theme-driven backgrounds are dark-mode regressions. See `references/component-api.md`.
diff --git a/skills/compose-agent/references/performance.md b/skills/compose-agent/references/performance.md
index a5bcfd1..e6b71b4 100644
--- a/skills/compose-agent/references/performance.md
+++ b/skills/compose-agent/references/performance.md
@@ -70,23 +70,30 @@ Same idea for `rotate` and `scale` via `graphicsLayer`. `padding` does **not** h
## Never Back-Write Across Phases
-The reverse of deferring reads. Layout runs after composition and draw runs after layout, so if a **later phase writes state an earlier phase read**, the earlier phase re-runs — a feedback loop, often once per frame. Two shapes to avoid:
+The reverse of deferring reads: never write state you have already read in the same pass, and never let a *later* phase write state an *earlier* phase read. Two shapes to avoid — the first is truly cross-phase, the second is composition-phase self-invalidation; the fix discipline is the same.
-**Layout → composition.** A layout callback (`onSizeChanged`, `onGloballyPositioned`, `onPlaced`) that writes state a sibling reads in composition:
+**Layout → composition (cross-phase).** A layout callback (`onSizeChanged`, `onGloballyPositioned`, `onPlaced`) that writes state a sibling reads in composition — measure runs after composition, so the write re-runs composition, often once per frame:
```kotlin
-// Bad — measure → write → recompose loop
+// Bad — measure → write → recompose loop; value's offset is read in composition
var labelWidth by remember { mutableIntStateOf(0) }
-Text(label, Modifier.onSizeChanged { labelWidth = it.width }) // layout writes
-Spacer(Modifier.width(with(LocalDensity.current) { labelWidth.toDp() })) // composition reads → recomposes
+Box {
+ Text(label, Modifier.onSizeChanged { labelWidth = it.width }) // layout writes
+ Text(value, Modifier.offset { IntOffset(labelWidth, 0) }.padding(start = 8.dp)) // was: composition read
+}
-// Good — keep the measured value in the layout phase
-Layout(content = { Text(label); Text(value) }) { measurables, constraints -> /* place value using label's measured width */ }
+// Good — one Layout measures label and places value after it; no composition-read hop
+Layout(content = { Text(label); Text(value) }) { (labelM, valueM), constraints ->
+ val l = labelM.measure(constraints); val v = valueM.measure(constraints)
+ layout(constraints.maxWidth, maxOf(l.height, v.height)) {
+ l.place(0, 0); v.place(l.width + 8.dp.roundToPx(), 0) // value placed using label's measured width, all in layout
+ }
+}
```
If a child genuinely needs the parent's constraints, `BoxWithConstraints` / `SubcomposeLayout` is fine — that exposes *parent constraints*, not a sibling's measured size. Do not round-trip a measured size through composition-read state.
-**Mutating a snapshot collection in the composition body.** A `mutableStateListOf` / `mutableStateMapOf` (or `toMutableStateList()` / `toMutableStateMap()`) mutated (`add`, `put`, `putAll`, `clear`, `[k] =`, `+=`) inside a `@Composable` body that also reads it invalidates the composition that produced it:
+**Mutating a snapshot collection in the composition body (composition-phase self-invalidation — not cross-phase).** A `mutableStateListOf` / `mutableStateMapOf` (or `toMutableStateList()` / `toMutableStateMap()`) mutated (`add`, `put`, `putAll`, `clear`, `[k] =`, `+=`) inside a `@Composable` body that also reads it invalidates the composition that produced it:
```kotlin
// Bad — mutate-and-read in the same composition
@@ -105,7 +112,7 @@ Mutate snapshot state from an event handler, `LaunchedEffect`, or a state holder
These *look* like recomposition fixes but change nothing. Don't write them, and don't leave them behind as "optimized":
- `remember(index) { isFirstRow(index) }` — a pure, cheap function of its own key. Same inputs, no skipping benefit; inline it. Only `remember` genuinely expensive work keyed on real inputs.
-- Wrapping a callback in `remember` to "stabilize" it **under Strong Skipping** — the compiler already auto-memoizes lambdas passed to composables. That lever only matters SSM-off, on `@NonSkippableComposable` / `@DontMemoize` paths, or when the lambda captures an unstable value.
+- Wrapping a callback in `remember` to "stabilize" it **under Strong Skipping** — the compiler already auto-memoizes lambdas passed to composables, *including those with unstable captures*. That lever only matters SSM-off or on a `@DontMemoize` / `@NonSkippableComposable` path.
- Identity-caching a read-only derived map to preserve reference equality — `remember(keys)` on the inputs is enough and won't serve stale data.
- Hoisting state up without stabilizing the values passed back down — a fresh unstable instance each recomposition still defeats skipping on the child.
@@ -211,7 +218,7 @@ Shipping a baseline profile is still one of the biggest end-user performance win
- `items\(\s*\w+\s*\)\s*\{` in a `Lazy*` without `key =` — probably missing keys
- `animateItemPlacement\(` — migrate to `animateItem()`
- `onSizeChanged|onGloballyPositioned|onPlaced` — check the lambda writes state read in composition (layout → composition back-write)
-- `mutableStateListOf|mutableStateMapOf|toMutableStateList|toMutableStateMap` — check for `add`/`put`/`clear`/`[k] =`/`+=` mutation inside a `@Composable` body that also reads it
+- `mutableStateListOf|mutableStateMapOf|toMutableStateList|toMutableStateMap|SnapshotStateList|SnapshotStateMap` — check for `add`/`addAll`/`put`/`putAll`/`remove`/`clear`/`[k] =`/`+=`/`-=` mutation inside a `@Composable` body that also reads it
- `@NonSkippableComposable` / `@DontMemoize` — demand justification
## Primary Sources
diff --git a/skills/jetpack-compose-audit/.claude-plugin/plugin.json b/skills/jetpack-compose-audit/.claude-plugin/plugin.json
index 55ee7af..a2f4c4b 100644
--- a/skills/jetpack-compose-audit/.claude-plugin/plugin.json
+++ b/skills/jetpack-compose-audit/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "jetpack-compose-audit",
- "version": "4.3.0",
+ "version": "4.3.1",
"description": "Strict, evidence-based audit for Android Jetpack Compose repositories. Produces a 0-100 score, per-category 0-10 scores, a COMPOSE-AUDIT-REPORT.md with every deduction cited against developer.android.com, and coverage notes for animation performance, paging list correctness, UI tests, focus/keyboard, Compose Multiplatform, and Android launch UX surfaces.",
"author": {
"name": "Ivan Morgillo"
diff --git a/skills/jetpack-compose-audit/.cursor-plugin/plugin.json b/skills/jetpack-compose-audit/.cursor-plugin/plugin.json
index 55ee7af..a2f4c4b 100644
--- a/skills/jetpack-compose-audit/.cursor-plugin/plugin.json
+++ b/skills/jetpack-compose-audit/.cursor-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "jetpack-compose-audit",
- "version": "4.3.0",
+ "version": "4.3.1",
"description": "Strict, evidence-based audit for Android Jetpack Compose repositories. Produces a 0-100 score, per-category 0-10 scores, a COMPOSE-AUDIT-REPORT.md with every deduction cited against developer.android.com, and coverage notes for animation performance, paging list correctness, UI tests, focus/keyboard, Compose Multiplatform, and Android launch UX surfaces.",
"author": {
"name": "Ivan Morgillo"
diff --git a/skills/jetpack-compose-audit/SKILL.md b/skills/jetpack-compose-audit/SKILL.md
index 2180a95..22f2425 100644
--- a/skills/jetpack-compose-audit/SKILL.md
+++ b/skills/jetpack-compose-audit/SKILL.md
@@ -9,7 +9,7 @@ argument-hint: "[repo path or module path]"
This skill audits Android Jetpack Compose repositories with a strict, evidence-based report.
-**Skill version:** 4.3.0 — released 2026-07-06. **Compose track:** Kotlin 2.0.20+ / Compose Compiler 1.5.4+ (Strong Skipping Mode default). See the README changelog for what changed.
+**Skill version:** 4.3.1 — released 2026-07-06. **Compose track:** Kotlin 2.0.20+ / Compose Compiler 1.5.4+ (Strong Skipping Mode default). See the README changelog for what changed.
It is intentionally focused on four categories:
diff --git a/skills/jetpack-compose-audit/references/scoring.md b/skills/jetpack-compose-audit/references/scoring.md
index d5362be..3be4f75 100644
--- a/skills/jetpack-compose-audit/references/scoring.md
+++ b/skills/jetpack-compose-audit/references/scoring.md
@@ -106,7 +106,7 @@ Deduct for:
- repeated broad recomposition smells across screens/components → [stability](https://developer.android.com/develop/ui/compose/performance/stability)
- raw `List`/`Map`/`Set` parameters on widely reused composables when the rest of the codebase has the immutable-collections dependency available — deduct when Strong Skipping is OFF (unstable params block skipping outright), or when Strong Skipping is ON but the collection is rebuilt per recomposition in source (e.g. `listOf(a, b)` / `mapOf(...)` in a composable body, a getter that allocates, or `.toList()` / `.filter { }` on every call). Under Strong Skipping without observable churn, do not deduct → [stability](https://developer.android.com/develop/ui/compose/performance/stability)
- unstable composable params whose `equals()` is expensive, allocating, or semantically broken — for example, a plain `class Foo(...)` with identity equality passed to a reusable composable, a `data class` wrapping a large collection (deep `equals` on every recomposition), or a `data class` with mutable fields (stale skip results). Under Strong Skipping, every recomposition runs `equals()` on each unstable param to decide whether to skip; expensive equality can make "skipping" as costly as recomposing, and broken equality makes skipping silently wrong → [strong skipping](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping), [stability](https://developer.android.com/develop/ui/compose/performance/stability)
-- instance-recreation churn in source: `listOf(...)` / `mapOf(...)` / `setOf(...)` / anonymous `object` literals / `MyParams(...)` allocated inside a hot composable body and passed as a param. The value is `!=` to the prior recomposition's value by default, so both vanilla skipping and Strong Skipping's `==` gate fail, forcing the callee to re-run body-and-children. Hoist into `remember(...)`, a state holder, or a `@Composable` caller further up. **Bare lambda literals are exempt under Strong Skipping** — the compiler auto-memoizes lambdas passed to composables, so do not treat a plain `onClick = { ... }` as churn or recommend wrapping it in `remember` (that is a false lead — see the guard below); a lambda churns only SSM-**off**, on a `@NonSkippableComposable` / `@DontMemoize` path, or when it captures an unstable value → [stability](https://developer.android.com/develop/ui/compose/performance/stability), [strong skipping](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping)
+- instance-recreation churn in source: `listOf(...)` / `mapOf(...)` / `setOf(...)` / anonymous `object` literals / `MyParams(...)` allocated inside a hot composable body and passed as a param. The value is `!=` to the prior recomposition's value by default, so both vanilla skipping and Strong Skipping's `==` gate fail, forcing the callee to re-run body-and-children. Hoist into `remember(...)`, a state holder, or a `@Composable` caller further up. **Bare lambda literals are exempt under Strong Skipping** — the compiler auto-memoizes lambdas passed to composables, so do not treat a plain `onClick = { ... }` as churn or recommend wrapping it in `remember` (that is a false lead — see the guard below); SSM memoizes lambdas *even with unstable captures*, so a lambda churns only SSM-**off** or on a `@DontMemoize` / `@NonSkippableComposable` path → [stability](https://developer.android.com/develop/ui/compose/performance/stability), [strong skipping](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping)
- `mutableStateOf` where the typed factory exists (autoboxing) → [state](https://developer.android.com/develop/ui/compose/state)
- `derivedStateOf { ... }` whose block does not actually read any `State` object (meaning it will never invalidate, and the overhead of `derivedStateOf` is wasted) → [side-effects](https://developer.android.com/develop/ui/compose/side-effects)
- `@NonSkippableComposable` / `@DontMemoize` opt-outs without a justifying comment → [strong skipping](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping)
@@ -173,7 +173,7 @@ Under Strong Skipping, all restartable composables become skippable and lambdas
Under Strong Skipping the more informative evidence is no longer "how many unstable classes are there" but:
-- **Instance-recreation churn in source** — `listOf(a, b)`, `mapOf(...)`, anonymous `object` literals, or `MyParams(a, b)` allocated inside a composable body and passed as a param. The SSM `==` check fails on every recomposition and the callee re-runs regardless of skippability. (Plain **lambda** literals are auto-memoized by SSM — exclude them here; a lambda only churns SSM-off, on an opt-out path, or when it captures an unstable value.)
+- **Instance-recreation churn in source** — `listOf(a, b)`, `mapOf(...)`, anonymous `object` literals, or `MyParams(a, b)` allocated inside a composable body and passed as a param. The SSM `==` check fails on every recomposition and the callee re-runs regardless of skippability. (Plain **lambda** literals are auto-memoized by SSM *even with unstable captures* — exclude them here; a lambda only churns SSM-off or on a `@DontMemoize` / `@NonSkippableComposable` path.)
- **`equals()` cost and correctness on unstable params** — SSM calls `equals()` on each unstable param every recomposition. Expensive equality (`data class` wrapping a large collection, custom `equals` that walks a graph) can make skipping as costly as recomposing; broken equality (identity equality on a plain `class`, mutable fields inside a `data class`) makes skipping silently wrong.
- **Runtime recomposition counts** (Layout Inspector, `Recomposer` debug output, Compose UI tests with `Modifier.testTag` + recomposition counters). When available these beat `skippable%` as the primary signal under SSM.
- **`@NonSkippableComposable` / `@DontMemoize` opt-outs** — effectively the only way to see a "not skippable" named composable under SSM; each instance should carry a justifying comment and appear on hot paths only with cause.
@@ -192,9 +192,9 @@ Changes that *look* like recomposition fixes but do not reduce recomposition cou
| Identity/instance cache for a read-only derived map (`remember { }` returning a cached map to preserve reference equality) | Can serve stale overlays; `remember(keys)` on the actual inputs is enough and correct | `remember(keys) { derive() }` — key on the inputs, not on identity |
| `mutableIntStateOf` + a layout modifier applied to **both** the measured row and its sibling | The sibling still reads the size **in composition** unless it is measure-only; boxing was never the problem | Make the sibling read measured size in layout/draw (`Modifier.layout { }`), or hoist the shared value out of the round-trip |
| Forcing `assertRecompositionCount(Exactly(1))` on **both** rows in a focus-move / selection test | One row often *correctly* recomposes 0 times; the assertion encodes a wrong expectation, not a fix | Assert the count each row should actually have; investigate only rows that exceed it |
-| Manually `remember`-ing callbacks **under Strong Skipping** (Kotlin 2.0.20+ default) to "stabilize" them | SSM auto-memoizes lambdas already; wrapping them in `remember` adds bookkeeping for no skipping benefit | Nothing — SSM has it covered. Only matters SSM-**off** (or when the lambda is on a `@NonSkippableComposable` / `@DontMemoize` path, or captures an unstable value): then hoist **and** stabilize, or pass a method reference |
+| Manually `remember`-ing callbacks **under Strong Skipping** (Kotlin 2.0.20+ default) to "stabilize" them | SSM auto-memoizes lambdas already — *including those with unstable captures*; wrapping them in `remember` adds bookkeeping for no skipping benefit | Nothing — SSM has it covered. Only matters SSM-**off** or on a `@DontMemoize` / `@NonSkippableComposable` path: then hoist **and** stabilize, or pass a method reference |
-**Strong Skipping caveat for the last row:** the "fresh lambda defeats skipping" reasoning only holds when SSM is **off**, or the lambda sits on an opt-out path (`@NonSkippableComposable` / `@DontMemoize`), or it captures an unstable value. On the default SSM track with stable captures, manual `remember` on the callback is itself the no-op — do not recommend it, and do not deduct for its absence. Decide which compiler track applies (see the Strong Skipping Mode Check) before scoring this one.
+**Strong Skipping caveat for the last row:** the "fresh lambda defeats skipping" reasoning only holds when SSM is **off**, or the lambda sits on a `@DontMemoize` / `@NonSkippableComposable` opt-out path. On the default SSM track, manual `remember` on the callback is itself the no-op — SSM memoizes lambdas *even with unstable captures*, so do not recommend it, and do not deduct for its absence. Decide which compiler track applies (see the Strong Skipping Mode Check) before scoring this one.
Route: the cross-phase and stability *detectors* live in the search playbook; this false-lead guard lives here in scoring and gates what those detectors are allowed to recommend. Before crediting any "we optimized recomposition here" claim, confirm it is not one of the above — prefer runtime recomposition counts or compiler reports as proof over the presence of the pattern.
From a2da48a715e678662c81351ec8f591974fa12e68 Mon Sep 17 00:00:00 2001
From: Ivan Morgillo
Date: Mon, 6 Jul 2026 18:51:06 +0200
Subject: [PATCH 3/5] review r2: fix cross-phase example (real composition
read) + align Lambdas section
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Round-2 cross-review (Codex, Cursor) — addresses both blockers:
- BLOCKER: the "Bad" cross-phase example used Modifier.offset { } — a layout-phase
(deferred) read, i.e. the safe pattern, not a composition read. Switched to the
non-lambda Modifier.offset(x = labelWidth.toDp()) so it actually reads in
composition and demonstrates the layout->composition loop (matches eval 12).
- BLOCKER: the pre-existing "Lambdas In Composables" section still claimed manual
remember matters for cross-module/generic composables under SSM. Aligned to the
official docs + the false-lead guard: SSM wraps all lambdas (even unstable
captures); only SSM-off / @DontMemoize / @NonSkippableComposable remain.
- README hero line bumped to jetpack-compose-audit 4.3.1 (was stale at 4.3.0).
- Core Instruction: self-invalidation is mutate->recompose, not measure-driven.
- PR description updated to reflect the audit 4.3.1 bump.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_016ysskXsKpwEbs1n9CX1ScY
---
README.md | 2 +-
skills/compose-agent/SKILL.md | 2 +-
skills/compose-agent/references/performance.md | 9 +++++----
3 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index fe49429..d391f59 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
**`compose-agent` 4.3.1 · 2026-07-06** — Authoring guidance so the coding-agent skill *warns while you write*: **Never Back-Write Across Phases** (layout callbacks / snapshot-collection mutation feeding composition) and **Optimizations That Do Nothing** (the false leads). The 4.3.0 audit catches these after the fact; 4.3.1 stops you writing them.
-**`jetpack-compose-audit` 4.3.0 · 2026-07-06** — Cross-phase back-write detection (axis 3: layout callbacks writing state read in composition), a related composition-phase self-invalidation check (snapshot collections mutated in a composable body), and a **False Leads** scoring guard so the auditor stops crediting no-op "recomposition fixes." Adapted from [`chrisbanes/skills`](https://github.com/chrisbanes/skills) (Apache-2.0). (Authoring-side guidance in `compose-agent` followed in 4.3.1.)
+**`jetpack-compose-audit` 4.3.1 · 2026-07-06** — Cross-phase back-write detection (axis 3: layout callbacks writing state read in composition), a related composition-phase self-invalidation check (snapshot collections mutated in a composable body), and a **False Leads** scoring guard so the auditor stops crediting no-op "recomposition fixes." Adapted from [`chrisbanes/skills`](https://github.com/chrisbanes/skills) (Apache-2.0).
**Version 4.2.0 · 2026-06-17** — Paging 3 in Compose: new `paging.md` reference (LLM guardrails, not API tour), audit hooks under existing Performance/State categories, planning doc at [`docs/paging-skill-plan.md`](./docs/paging-skill-plan.md). Validated through multi-agent cross-review. Both skills ship as `4.2.0`.
diff --git a/skills/compose-agent/SKILL.md b/skills/compose-agent/SKILL.md
index fa5c3b9..bf6b2df 100644
--- a/skills/compose-agent/SKILL.md
+++ b/skills/compose-agent/SKILL.md
@@ -51,7 +51,7 @@ If doing a partial review, load only the relevant reference files — each refer
- Collect Flows with `collectAsStateWithLifecycle()` in UI code. Plain `collectAsState()` keeps collecting when the screen is not visible and burns battery and bandwidth. **Exception:** `Flow>` is collected with `collectAsLazyPagingItems()`, never `collectAsStateWithLifecycle()` — see `references/paging.md`.
- Prefer `rememberSaveable` over `remember` for UI state that should survive configuration change or process death, unless the value is unserializable or derivable.
- Use the typed state factories (`mutableIntStateOf`, `mutableLongStateOf`, `mutableFloatStateOf`, `mutableDoubleStateOf`) for primitive state. Raw `mutableStateOf(...)` boxes.
-- Never write state a phase has already read. A layout callback (`onSizeChanged` / `onGloballyPositioned` / `onPlaced`) must not write state a sibling reads in composition (a cross-phase back-write), and never mutate a `mutableStateListOf` / `mutableStateMapOf` inside a composable body that also reads it (composition-phase self-invalidation) — both create measure/mutate → recompose loops. Consume measured values in layout/draw, or mutate snapshot state from an event / `LaunchedEffect` / state holder. And don't ship no-op "optimizations" (`remember(index)` on a pure function, `remember`-ing an already-auto-memoized callback under Strong Skipping) — prove a win with recomposition counts, not the pattern. See `references/performance.md`.
+- Never write state a phase has already read. A layout callback (`onSizeChanged` / `onGloballyPositioned` / `onPlaced`) must not write state a sibling reads in composition (a cross-phase back-write), and never mutate a `mutableStateListOf` / `mutableStateMapOf` inside a composable body that also reads it (composition-phase self-invalidation) — the first is a measure → recompose loop, the second a mutate → recompose loop. Consume measured values in layout/draw, or mutate snapshot state from an event / `LaunchedEffect` / state holder. And don't ship no-op "optimizations" (`remember(index)` on a pure function, `remember`-ing an already-auto-memoized callback under Strong Skipping) — prove a win with recomposition counts, not the pattern. See `references/performance.md`.
- Never put a lambda in a `CompositionLocal`. Use explicit parameters.
- Do not introduce third-party libraries without asking first. The Accompanist libraries covering pager, swipe-refresh, flow layout, and system UI controller are **deprecated** — the functionality is in AndroidX now (`HorizontalPager`, `PullToRefreshBox`, `FlowRow`/`FlowColumn`, `enableEdgeToEdge()`).
- When an element's background reads from `MaterialTheme.colorScheme.*` (directly or via a blend), its text and icon colors must read from the same theming source. Hard-coded `Color.Black` / `Color.White` / raw ARGB literals over theme-driven backgrounds are dark-mode regressions. See `references/component-api.md`.
diff --git a/skills/compose-agent/references/performance.md b/skills/compose-agent/references/performance.md
index e6b71b4..25a0f14 100644
--- a/skills/compose-agent/references/performance.md
+++ b/skills/compose-agent/references/performance.md
@@ -75,11 +75,12 @@ The reverse of deferring reads: never write state you have already read in the s
**Layout → composition (cross-phase).** A layout callback (`onSizeChanged`, `onGloballyPositioned`, `onPlaced`) that writes state a sibling reads in composition — measure runs after composition, so the write re-runs composition, often once per frame:
```kotlin
-// Bad — measure → write → recompose loop; value's offset is read in composition
+// Bad — measure → write → recompose loop; labelWidth is read in composition
var labelWidth by remember { mutableIntStateOf(0) }
Box {
Text(label, Modifier.onSizeChanged { labelWidth = it.width }) // layout writes
- Text(value, Modifier.offset { IntOffset(labelWidth, 0) }.padding(start = 8.dp)) // was: composition read
+ // non-lambda offset(x = …) reads labelWidth in COMPOSITION → recomposes on every measure
+ Text(value, Modifier.offset(x = with(LocalDensity.current) { labelWidth.toDp() }))
}
// Good — one Layout measures label and places value after it; no composition-read hop
@@ -157,11 +158,11 @@ Same for `mutableLongStateOf`, `mutableFloatStateOf`, `mutableDoubleStateOf`. Th
## Lambdas In Composables
-With SSM on, most lambdas are compiler-memoized. You do **not** need to manually wrap every callback in `remember { { ... } }`. That pattern is legacy and adds noise.
+With SSM on, Compose wraps lambdas in `remember` for you — **all** of them, including those with unstable captures. You do **not** need to manually wrap callbacks in `remember { { ... } }`. That pattern is legacy and adds noise (see *Optimizations That Do Nothing*).
Two cases where manual remembering still matters:
-1. **Lambdas passed across module boundaries to generic composables whose generics hide the type.** The compiler can miss memoization. If a profiler shows identity-based churn, `remember`.
+1. **Strong Skipping is off, or the lambda is on a `@DontMemoize` / `@NonSkippableComposable` path.** There the compiler does not wrap the lambda — `remember` it (and stabilize its captures) if a profiler shows identity-based churn.
2. **Expensive derivations inside lambdas.** If the lambda itself is cheap but allocates a large structure, that allocation happens on every call. Move the allocation outside.
## Expensive Work In Composition
From 2e6f4b6d5ad930ef04f7b913334154056c572d94 Mon Sep 17 00:00:00 2001
From: Ivan Morgillo
Date: Mon, 6 Jul 2026 19:03:03 +0200
Subject: [PATCH 4/5] review r3: fix third SSM lambda spot (baseline), example
compile, README/notes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Round-3 cross-review (Codex blocker + Cursor) — final pass:
- BLOCKER: the Strong Skipping *baseline* list (top of performance.md) still said
captured lambdas can churn via "opaque call sites / generics". Rewrote item 2:
SSM memoizes every lambda passed to a composable (even unstable captures), so
lambdas are not a skipping defeater; manual remember only matters SSM-off /
@DontMemoize. Removed the obsolete cross-module bullet (memoization is at the
caller's compilation regardless of callee module/generics) and documented why.
- Example now compiles: gap computed in MeasureScope (a Density), not in the
PlacementScope where dp.roundToPx() has no receiver.
- "all lambdas" tightened to "every lambda passed to a composable".
- README authoring-mode checklist gains step 8 (cross-phase / self-invalidation).
- Core Instruction split into two bullets (cross-phase vs no-op optimizations).
- release-notes-4.3.0 false-lead bullet marked corrected-in-4.3.1.
- release-notes-4.3.1: documented the cross-module removal + eval coverage note.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_016ysskXsKpwEbs1n9CX1ScY
---
README.md | 1 +
docs/release-notes-4.3.0.md | 2 +-
docs/release-notes-4.3.1.md | 6 ++++++
skills/compose-agent/SKILL.md | 3 ++-
skills/compose-agent/references/performance.md | 7 ++++---
5 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index d391f59..4146362 100644
--- a/README.md
+++ b/README.md
@@ -379,6 +379,7 @@ compose-agent: find deprecated API in this module.
5. If it collects a `Flow`, is it `collectAsStateWithLifecycle()`?
6. Is the parameter order data → `modifier` → other → content slot last?
7. If it animates, is the API declarative first, remembered where needed, lifecycle-aware, and phase-correct?
+8. Does any layout callback (`onSizeChanged` / `onGloballyPositioned` / `onPlaced`) or snapshot-collection mutation write state that is read back in composition? (cross-phase back-write / self-invalidation)
Any "no" without a reason → fixed before the code comes back. No extra prompt needed — loading the skill is enough.
diff --git a/docs/release-notes-4.3.0.md b/docs/release-notes-4.3.0.md
index ae2d4a4..2e63547 100644
--- a/docs/release-notes-4.3.0.md
+++ b/docs/release-notes-4.3.0.md
@@ -24,7 +24,7 @@ A guard table in `scoring.md` Performance: plausible "recomposition fixes" that
- Identity/instance caches for read-only derived maps (use `remember(keys)`).
- Layout modifiers on both the measured and the sibling row (the sibling still reads size in composition).
- `assertRecompositionCount(Exactly(1))` forced on both rows when one correctly recomposes 0 times.
-- Under **Strong Skipping**, manually `remember`-ing a callback the compiler already auto-memoizes — that lever only applies SSM-off, on `@NonSkippableComposable` / `@DontMemoize` paths, or when the lambda captures an unstable value.
+- Under **Strong Skipping**, manually `remember`-ing a callback the compiler already auto-memoizes — that lever only applies SSM-off or on `@NonSkippableComposable` / `@DontMemoize` paths. (Historical note: 4.3.0 also listed "or when the lambda captures an unstable value" — **corrected in 4.3.1**; SSM memoizes lambdas even with unstable captures.)
The guard verifies against runtime recomposition counts / compiler reports rather than the presence of a pattern.
diff --git a/docs/release-notes-4.3.1.md b/docs/release-notes-4.3.1.md
index 3a43401..dc1beb8 100644
--- a/docs/release-notes-4.3.1.md
+++ b/docs/release-notes-4.3.1.md
@@ -22,6 +22,12 @@ Follow-up to 4.3.0. That release taught the **audit** to catch cross-phase back-
The 4.3.0 false-lead guard said manual callback `remember` could still matter "when the lambda captures an unstable value." That contradicts the official [Strong Skipping docs](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping): SSM memoizes lambdas **even with unstable captures**, so the carve-out could still bless the exact no-op the guard forbids. Removed from `scoring.md` in all four places; the only remaining exception is SSM-off or a `@DontMemoize` / `@NonSkippableComposable` path.
+The same correction lands in `compose-agent/performance.md`: the *Strong Skipping baseline*, *Lambdas In Composables*, and *Optimizations That Do Nothing* sections now agree. The old "lambdas passed across module boundaries to generic composables can miss memoization" bullet was dropped — under SSM, lambda memoization happens at the caller's compilation regardless of the callee's module/generics, so that case is obsolete; the real exceptions are SSM-off / `@DontMemoize` / `@NonSkippableComposable`.
+
+## Eval coverage
+
+The authoring rules share the audit acceptance evals (`jetpack-compose-audit/evals/evals.json` cases 12–14) — `compose-agent` mirrors the same rubric. A dedicated compose-agent *write-mode* eval harness is a tracked follow-up.
+
## Attribution
Continues the 4.3.0 adaptation from [`chrisbanes/skills`](https://github.com/chrisbanes/skills) `compose-recomposition-performance` (Apache-2.0), cited against `developer.android.com`.
diff --git a/skills/compose-agent/SKILL.md b/skills/compose-agent/SKILL.md
index bf6b2df..1465538 100644
--- a/skills/compose-agent/SKILL.md
+++ b/skills/compose-agent/SKILL.md
@@ -51,7 +51,8 @@ If doing a partial review, load only the relevant reference files — each refer
- Collect Flows with `collectAsStateWithLifecycle()` in UI code. Plain `collectAsState()` keeps collecting when the screen is not visible and burns battery and bandwidth. **Exception:** `Flow>` is collected with `collectAsLazyPagingItems()`, never `collectAsStateWithLifecycle()` — see `references/paging.md`.
- Prefer `rememberSaveable` over `remember` for UI state that should survive configuration change or process death, unless the value is unserializable or derivable.
- Use the typed state factories (`mutableIntStateOf`, `mutableLongStateOf`, `mutableFloatStateOf`, `mutableDoubleStateOf`) for primitive state. Raw `mutableStateOf(...)` boxes.
-- Never write state a phase has already read. A layout callback (`onSizeChanged` / `onGloballyPositioned` / `onPlaced`) must not write state a sibling reads in composition (a cross-phase back-write), and never mutate a `mutableStateListOf` / `mutableStateMapOf` inside a composable body that also reads it (composition-phase self-invalidation) — the first is a measure → recompose loop, the second a mutate → recompose loop. Consume measured values in layout/draw, or mutate snapshot state from an event / `LaunchedEffect` / state holder. And don't ship no-op "optimizations" (`remember(index)` on a pure function, `remember`-ing an already-auto-memoized callback under Strong Skipping) — prove a win with recomposition counts, not the pattern. See `references/performance.md`.
+- Never write state a phase has already read. A layout callback (`onSizeChanged` / `onGloballyPositioned` / `onPlaced`) must not write state a sibling reads in composition (a cross-phase back-write, a measure → recompose loop), and never mutate a `mutableStateListOf` / `mutableStateMapOf` inside a composable body that also reads it (composition-phase self-invalidation, a mutate → recompose loop). Consume measured values in layout/draw, or mutate snapshot state from an event / `LaunchedEffect` / state holder. See `references/performance.md`.
+- Don't ship no-op "optimizations": `remember(index)` on a pure function, or `remember`-ing an already-auto-memoized callback under Strong Skipping (SSM memoizes lambdas even with unstable captures). Prove a recomposition win with Layout Inspector counts or compiler reports, not the presence of a pattern. See `references/performance.md`.
- Never put a lambda in a `CompositionLocal`. Use explicit parameters.
- Do not introduce third-party libraries without asking first. The Accompanist libraries covering pager, swipe-refresh, flow layout, and system UI controller are **deprecated** — the functionality is in AndroidX now (`HorizontalPager`, `PullToRefreshBox`, `FlowRow`/`FlowColumn`, `enableEdgeToEdge()`).
- When an element's background reads from `MaterialTheme.colorScheme.*` (directly or via a blend), its text and icon colors must read from the same theming source. Hard-coded `Color.Black` / `Color.White` / raw ARGB literals over theme-driven backgrounds are dark-mode regressions. See `references/component-api.md`.
diff --git a/skills/compose-agent/references/performance.md b/skills/compose-agent/references/performance.md
index 25a0f14..79627be 100644
--- a/skills/compose-agent/references/performance.md
+++ b/skills/compose-agent/references/performance.md
@@ -15,7 +15,7 @@ On Kotlin `2.0.20+` with Compose Compiler `1.5.4+`, **Strong Skipping Mode is on
What still defeats skipping under SSM:
1. **Fresh collection / fresh object literals in the call site.** `listOf(...)`, `mapOf(...)`, `MyUiModel(...)` built at the call site recompute a new identity on every recomposition. Structural equality may save you, but construction churn still costs.
-2. **Object literals and lambda literals inside composable bodies.** Lambdas that capture state read inside the composable are automatically remembered by the compiler. Lambdas that capture nothing are singletons. Lambdas in between — capturing a value — are worth double-checking; the compiler memoizes most of them, but opaque call sites (e.g. generics) can still churn.
+2. **Fresh non-lambda objects built in the composable body** — a `MyUiModel(...)` or wrapper allocated inline and passed down (an extension of 1). **Not lambdas:** SSM wraps every lambda passed to a composable in `remember`, *including ones with unstable captures*, so a plain callback does not defeat skipping — don't manually `remember` callbacks to "fix" recomposition (see *Lambdas In Composables* and *Optimizations That Do Nothing*). Manual remembering only matters SSM-off or on a `@DontMemoize` / `@NonSkippableComposable` path.
3. **Broken `equals()` on parameters.** If a data class overrides `equals()` incorrectly or is a plain class without `equals`, skipping fails for the wrong reason.
4. **Explicit `@NonSkippableComposable` / `@DontMemoize`** on hot paths.
@@ -86,8 +86,9 @@ Box {
// Good — one Layout measures label and places value after it; no composition-read hop
Layout(content = { Text(label); Text(value) }) { (labelM, valueM), constraints ->
val l = labelM.measure(constraints); val v = valueM.measure(constraints)
+ val gap = 8.dp.roundToPx() // MeasureScope is a Density
layout(constraints.maxWidth, maxOf(l.height, v.height)) {
- l.place(0, 0); v.place(l.width + 8.dp.roundToPx(), 0) // value placed using label's measured width, all in layout
+ l.place(0, 0); v.place(l.width + gap, 0) // value placed using label's measured width, all in layout
}
}
```
@@ -158,7 +159,7 @@ Same for `mutableLongStateOf`, `mutableFloatStateOf`, `mutableDoubleStateOf`. Th
## Lambdas In Composables
-With SSM on, Compose wraps lambdas in `remember` for you — **all** of them, including those with unstable captures. You do **not** need to manually wrap callbacks in `remember { { ... } }`. That pattern is legacy and adds noise (see *Optimizations That Do Nothing*).
+With SSM on, Compose wraps **every lambda passed to a composable** in `remember` for you — including those with unstable captures. You do **not** need to manually wrap callbacks in `remember { { ... } }`. That pattern is legacy and adds noise (see *Optimizations That Do Nothing*).
Two cases where manual remembering still matters:
From 385bcf5819fc53d1d9958bbe7c9411896bf902ed Mon Sep 17 00:00:00 2001
From: Ivan Morgillo
Date: Tue, 7 Jul 2026 08:42:10 +0200
Subject: [PATCH 5/5] review r3-confirm: @NonSkippableComposable is not a
lambda-memoization exception
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Confirmation round (Codex blocker + Cursor #1, doc-verified): @NonSkippableComposable
opts a COMPOSABLE out of skipping; SSM still auto-memoizes lambdas inside it.
@DontMemoize is the lambda-level opt-out. Removed @NonSkippableComposable from every
lambda-memoization exception (compose-agent performance.md x3, audit scoring.md x3,
eval 13) — kept it only in skippability / opt-out-justification rules. Only real
exception for manual callback remember: SSM-off or @DontMemoize.
Also (Cursor #2): compose-agent now states the layout-only-read exception — writing a
measured size from a layout callback is fine if consumed exclusively in layout/draw;
only a composition read creates the loop. Mirrors the audit search-playbook heuristic.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_016ysskXsKpwEbs1n9CX1ScY
---
skills/compose-agent/references/performance.md | 8 ++++----
skills/jetpack-compose-audit/evals/evals.json | 2 +-
skills/jetpack-compose-audit/references/scoring.md | 6 +++---
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/skills/compose-agent/references/performance.md b/skills/compose-agent/references/performance.md
index 79627be..e145213 100644
--- a/skills/compose-agent/references/performance.md
+++ b/skills/compose-agent/references/performance.md
@@ -15,7 +15,7 @@ On Kotlin `2.0.20+` with Compose Compiler `1.5.4+`, **Strong Skipping Mode is on
What still defeats skipping under SSM:
1. **Fresh collection / fresh object literals in the call site.** `listOf(...)`, `mapOf(...)`, `MyUiModel(...)` built at the call site recompute a new identity on every recomposition. Structural equality may save you, but construction churn still costs.
-2. **Fresh non-lambda objects built in the composable body** — a `MyUiModel(...)` or wrapper allocated inline and passed down (an extension of 1). **Not lambdas:** SSM wraps every lambda passed to a composable in `remember`, *including ones with unstable captures*, so a plain callback does not defeat skipping — don't manually `remember` callbacks to "fix" recomposition (see *Lambdas In Composables* and *Optimizations That Do Nothing*). Manual remembering only matters SSM-off or on a `@DontMemoize` / `@NonSkippableComposable` path.
+2. **Fresh non-lambda objects built in the composable body** — a `MyUiModel(...)` or wrapper allocated inline and passed down (an extension of 1). **Not lambdas:** SSM wraps every lambda passed to a composable in `remember`, *including ones with unstable captures*, so a plain callback does not defeat skipping — don't manually `remember` callbacks to "fix" recomposition (see *Lambdas In Composables* and *Optimizations That Do Nothing*). Manual remembering only matters SSM-off or on a `@DontMemoize` path.
3. **Broken `equals()` on parameters.** If a data class overrides `equals()` incorrectly or is a plain class without `equals`, skipping fails for the wrong reason.
4. **Explicit `@NonSkippableComposable` / `@DontMemoize`** on hot paths.
@@ -93,7 +93,7 @@ Layout(content = { Text(label); Text(value) }) { (labelM, valueM), constraints -
}
```
-If a child genuinely needs the parent's constraints, `BoxWithConstraints` / `SubcomposeLayout` is fine — that exposes *parent constraints*, not a sibling's measured size. Do not round-trip a measured size through composition-read state.
+Writing a measured size from a layout callback is only a problem when something reads it **in composition**. If the value is consumed *exclusively* in a later layout/draw pass (`Modifier.layout { }`, `Modifier.drawBehind { }`, `graphicsLayer { }`), the write stays downstream and there is no loop — no custom `Layout` needed. And if a child genuinely needs the parent's constraints, `BoxWithConstraints` / `SubcomposeLayout` is fine — that exposes *parent constraints*, not a sibling's measured size. Do not round-trip a measured size through composition-read state.
**Mutating a snapshot collection in the composition body (composition-phase self-invalidation — not cross-phase).** A `mutableStateListOf` / `mutableStateMapOf` (or `toMutableStateList()` / `toMutableStateMap()`) mutated (`add`, `put`, `putAll`, `clear`, `[k] =`, `+=`) inside a `@Composable` body that also reads it invalidates the composition that produced it:
@@ -114,7 +114,7 @@ Mutate snapshot state from an event handler, `LaunchedEffect`, or a state holder
These *look* like recomposition fixes but change nothing. Don't write them, and don't leave them behind as "optimized":
- `remember(index) { isFirstRow(index) }` — a pure, cheap function of its own key. Same inputs, no skipping benefit; inline it. Only `remember` genuinely expensive work keyed on real inputs.
-- Wrapping a callback in `remember` to "stabilize" it **under Strong Skipping** — the compiler already auto-memoizes lambdas passed to composables, *including those with unstable captures*. That lever only matters SSM-off or on a `@DontMemoize` / `@NonSkippableComposable` path.
+- Wrapping a callback in `remember` to "stabilize" it **under Strong Skipping** — the compiler already auto-memoizes lambdas passed to composables, *including those with unstable captures*. That lever only matters SSM-off or on a `@DontMemoize` path.
- Identity-caching a read-only derived map to preserve reference equality — `remember(keys)` on the inputs is enough and won't serve stale data.
- Hoisting state up without stabilizing the values passed back down — a fresh unstable instance each recomposition still defeats skipping on the child.
@@ -163,7 +163,7 @@ With SSM on, Compose wraps **every lambda passed to a composable** in `remember`
Two cases where manual remembering still matters:
-1. **Strong Skipping is off, or the lambda is on a `@DontMemoize` / `@NonSkippableComposable` path.** There the compiler does not wrap the lambda — `remember` it (and stabilize its captures) if a profiler shows identity-based churn.
+1. **Strong Skipping is off, or the lambda is on a `@DontMemoize` path.** There the compiler does not wrap the lambda — `remember` it (and stabilize its captures) if a profiler shows identity-based churn.
2. **Expensive derivations inside lambdas.** If the lambda itself is cheap but allocates a large structure, that allocation happens on every call. Move the allocation outside.
## Expensive Work In Composition
diff --git a/skills/jetpack-compose-audit/evals/evals.json b/skills/jetpack-compose-audit/evals/evals.json
index d8e7d9b..04b5042 100644
--- a/skills/jetpack-compose-audit/evals/evals.json
+++ b/skills/jetpack-compose-audit/evals/evals.json
@@ -171,7 +171,7 @@
"Do not credit `remember(index) { index == 0 }` — it wraps a pure, cheap expression keyed on its own input and saves nothing.",
"Do not list either pattern in Prioritized Fixes, and do not deduct for removing them.",
"If recommending anything, cite runtime recomposition counts or compiler reports as proof rather than the presence of the pattern.",
- "Note that the `remember`-the-callback advice only applies with Strong Skipping OFF or on `@NonSkippableComposable` / `@DontMemoize` paths."
+ "Note that the `remember`-the-callback advice only applies with Strong Skipping OFF or on a `@DontMemoize` path."
]
},
{
diff --git a/skills/jetpack-compose-audit/references/scoring.md b/skills/jetpack-compose-audit/references/scoring.md
index 3be4f75..540c56d 100644
--- a/skills/jetpack-compose-audit/references/scoring.md
+++ b/skills/jetpack-compose-audit/references/scoring.md
@@ -106,7 +106,7 @@ Deduct for:
- repeated broad recomposition smells across screens/components → [stability](https://developer.android.com/develop/ui/compose/performance/stability)
- raw `List`/`Map`/`Set` parameters on widely reused composables when the rest of the codebase has the immutable-collections dependency available — deduct when Strong Skipping is OFF (unstable params block skipping outright), or when Strong Skipping is ON but the collection is rebuilt per recomposition in source (e.g. `listOf(a, b)` / `mapOf(...)` in a composable body, a getter that allocates, or `.toList()` / `.filter { }` on every call). Under Strong Skipping without observable churn, do not deduct → [stability](https://developer.android.com/develop/ui/compose/performance/stability)
- unstable composable params whose `equals()` is expensive, allocating, or semantically broken — for example, a plain `class Foo(...)` with identity equality passed to a reusable composable, a `data class` wrapping a large collection (deep `equals` on every recomposition), or a `data class` with mutable fields (stale skip results). Under Strong Skipping, every recomposition runs `equals()` on each unstable param to decide whether to skip; expensive equality can make "skipping" as costly as recomposing, and broken equality makes skipping silently wrong → [strong skipping](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping), [stability](https://developer.android.com/develop/ui/compose/performance/stability)
-- instance-recreation churn in source: `listOf(...)` / `mapOf(...)` / `setOf(...)` / anonymous `object` literals / `MyParams(...)` allocated inside a hot composable body and passed as a param. The value is `!=` to the prior recomposition's value by default, so both vanilla skipping and Strong Skipping's `==` gate fail, forcing the callee to re-run body-and-children. Hoist into `remember(...)`, a state holder, or a `@Composable` caller further up. **Bare lambda literals are exempt under Strong Skipping** — the compiler auto-memoizes lambdas passed to composables, so do not treat a plain `onClick = { ... }` as churn or recommend wrapping it in `remember` (that is a false lead — see the guard below); SSM memoizes lambdas *even with unstable captures*, so a lambda churns only SSM-**off** or on a `@DontMemoize` / `@NonSkippableComposable` path → [stability](https://developer.android.com/develop/ui/compose/performance/stability), [strong skipping](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping)
+- instance-recreation churn in source: `listOf(...)` / `mapOf(...)` / `setOf(...)` / anonymous `object` literals / `MyParams(...)` allocated inside a hot composable body and passed as a param. The value is `!=` to the prior recomposition's value by default, so both vanilla skipping and Strong Skipping's `==` gate fail, forcing the callee to re-run body-and-children. Hoist into `remember(...)`, a state holder, or a `@Composable` caller further up. **Bare lambda literals are exempt under Strong Skipping** — the compiler auto-memoizes lambdas passed to composables, so do not treat a plain `onClick = { ... }` as churn or recommend wrapping it in `remember` (that is a false lead — see the guard below); SSM memoizes lambdas *even with unstable captures*, so a lambda churns only SSM-**off** or on a `@DontMemoize` path → [stability](https://developer.android.com/develop/ui/compose/performance/stability), [strong skipping](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping)
- `mutableStateOf` where the typed factory exists (autoboxing) → [state](https://developer.android.com/develop/ui/compose/state)
- `derivedStateOf { ... }` whose block does not actually read any `State` object (meaning it will never invalidate, and the overhead of `derivedStateOf` is wasted) → [side-effects](https://developer.android.com/develop/ui/compose/side-effects)
- `@NonSkippableComposable` / `@DontMemoize` opt-outs without a justifying comment → [strong skipping](https://developer.android.com/develop/ui/compose/performance/stability/strongskipping)
@@ -173,7 +173,7 @@ Under Strong Skipping, all restartable composables become skippable and lambdas
Under Strong Skipping the more informative evidence is no longer "how many unstable classes are there" but:
-- **Instance-recreation churn in source** — `listOf(a, b)`, `mapOf(...)`, anonymous `object` literals, or `MyParams(a, b)` allocated inside a composable body and passed as a param. The SSM `==` check fails on every recomposition and the callee re-runs regardless of skippability. (Plain **lambda** literals are auto-memoized by SSM *even with unstable captures* — exclude them here; a lambda only churns SSM-off or on a `@DontMemoize` / `@NonSkippableComposable` path.)
+- **Instance-recreation churn in source** — `listOf(a, b)`, `mapOf(...)`, anonymous `object` literals, or `MyParams(a, b)` allocated inside a composable body and passed as a param. The SSM `==` check fails on every recomposition and the callee re-runs regardless of skippability. (Plain **lambda** literals are auto-memoized by SSM *even with unstable captures* — exclude them here; a lambda only churns SSM-off or on a `@DontMemoize` path.)
- **`equals()` cost and correctness on unstable params** — SSM calls `equals()` on each unstable param every recomposition. Expensive equality (`data class` wrapping a large collection, custom `equals` that walks a graph) can make skipping as costly as recomposing; broken equality (identity equality on a plain `class`, mutable fields inside a `data class`) makes skipping silently wrong.
- **Runtime recomposition counts** (Layout Inspector, `Recomposer` debug output, Compose UI tests with `Modifier.testTag` + recomposition counters). When available these beat `skippable%` as the primary signal under SSM.
- **`@NonSkippableComposable` / `@DontMemoize` opt-outs** — effectively the only way to see a "not skippable" named composable under SSM; each instance should carry a justifying comment and appear on hot paths only with cause.
@@ -192,7 +192,7 @@ Changes that *look* like recomposition fixes but do not reduce recomposition cou
| Identity/instance cache for a read-only derived map (`remember { }` returning a cached map to preserve reference equality) | Can serve stale overlays; `remember(keys)` on the actual inputs is enough and correct | `remember(keys) { derive() }` — key on the inputs, not on identity |
| `mutableIntStateOf` + a layout modifier applied to **both** the measured row and its sibling | The sibling still reads the size **in composition** unless it is measure-only; boxing was never the problem | Make the sibling read measured size in layout/draw (`Modifier.layout { }`), or hoist the shared value out of the round-trip |
| Forcing `assertRecompositionCount(Exactly(1))` on **both** rows in a focus-move / selection test | One row often *correctly* recomposes 0 times; the assertion encodes a wrong expectation, not a fix | Assert the count each row should actually have; investigate only rows that exceed it |
-| Manually `remember`-ing callbacks **under Strong Skipping** (Kotlin 2.0.20+ default) to "stabilize" them | SSM auto-memoizes lambdas already — *including those with unstable captures*; wrapping them in `remember` adds bookkeeping for no skipping benefit | Nothing — SSM has it covered. Only matters SSM-**off** or on a `@DontMemoize` / `@NonSkippableComposable` path: then hoist **and** stabilize, or pass a method reference |
+| Manually `remember`-ing callbacks **under Strong Skipping** (Kotlin 2.0.20+ default) to "stabilize" them | SSM auto-memoizes lambdas already — *including those with unstable captures*; wrapping them in `remember` adds bookkeeping for no skipping benefit | Nothing — SSM has it covered. Only matters SSM-**off** or on a `@DontMemoize` path: then hoist **and** stabilize, or pass a method reference |
**Strong Skipping caveat for the last row:** the "fresh lambda defeats skipping" reasoning only holds when SSM is **off**, or the lambda sits on a `@DontMemoize` / `@NonSkippableComposable` opt-out path. On the default SSM track, manual `remember` on the callback is itself the no-op — SSM memoizes lambdas *even with unstable captures*, so do not recommend it, and do not deduct for its absence. Decide which compiler track applies (see the Strong Skipping Mode Check) before scoring this one.