optimizing-lazy-layouts — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited optimizing-lazy-layouts (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Lazy layouts compose only what's visible, but two things still cost: re-composition of items that should have been reused (missing key), and per-item allocation that compounds with scroll velocity (missing contentType, modifier chains created inside items { }). Both have a one-line fix. This skill teaches Claude how to apply that fix correctly and to validate that item composables are themselves skippable. Prefetch tuning is a separate concern — see ../configuring-lazy-prefetch/SKILL.md.
LazyColumn, LazyRow, LazyVerticalGrid, or LazyHorizontalGrid.Modifier.animateItem() was added but no animation runs on inserts or removals.unstable/non-skippable, or @TraceRecomposition shows item composables recomposing on every scroll tick.../configuring-lazy-prefetch/SKILL.md.List<Foo>, Flow<Foo>, a domain var) → first run ../../stability/diagnosing-compose-stability/SKILL.md and then ../../stability/stabilizing-compose-types/SKILL.md.state.value in Composition phase, recomposing the row every frame → use ../../recomposition/deferring-state-reads/SKILL.md.firstVisibleItemIndex == 0) is the hot path → use ../../recomposition/choosing-derivedstateof/SKILL.md.Modifier.animateItem() (the GA replacement for the experimental animateItemPlacement).org.jetbrains.kotlin.plugin.compose applied. Strong Skipping is on by default; non-skippable item composables become amplified at scroll speed.../../measurement/generating-baseline-profiles/SKILL.md when ready to measure.LazyListScope.items(list), items(count), itemsIndexed(list), and the LazyGridScope equivalents. For each, decide: does each element have a stable identity that outlives a single composition? If yes — and it almost always does — supply key = { it.id } using a server-side stable ID. MUST NOT use the list index, UUID.randomUUID() evaluated per emission, or hashCode() of a mutable object.// WRONG
LazyColumn { items(snacks) { snack -> SnackRow(snack) } }
// WRONG because: index-based identity → insert/remove discards composition state and breaks animateItem().// RIGHT
LazyColumn {
items(
items = snacks,
key = { it.id },
contentType = { it::class },
) { snack ->
SnackRow(snack, Modifier.animateItem())
}
}contentType as a recycled slot, the cached composition is reused; otherwise it is discarded and rebuilt. For homogeneous lists Compose infers a single content type and contentType is optional. For mixed feeds (cards, headers, ads, carousels, dividers) MUST supply a stable type discriminator.../../stability/diagnosing-compose-stability/SKILL.md. If the item composable accepts an unstable parameter, no amount of key/contentType work will help — the row recomposes on every scroll-driven snapshot tick anyway. Fix with ../../stability/stabilizing-compose-types/SKILL.md before tuning further.BorderStroke instances built inside the lambda are reallocated each pass. Hoist constants and remember-based caches above the LazyColumn or to the call site. Modifier chains are themselves cheap because Compose deduplicates them structurally — hoist a Modifier only when profiling proves it matters.key. The animation runs on inserts, removals, and reorders; without key the animation cannot bind to identity and silently no-ops. The default fade-in / fade-out / placement spring is usually correct; tune with fadeInSpec, fadeOutSpec, placementSpec only when the design system requires it.painterResource(...), MaterialTheme.colorScheme.surface, RoundedCornerShape(...) resolutions on every item composition add up. Hoist to the screen-level composable and pass down, or remember once at the LazyColumn parent.key// WRONG
LazyColumn {
items(snacks) { snack -> SnackRow(snack) }
}
// WRONG because: items default to index-based identity. On insert/remove/reorder, every position past the change point has a different "identity", composition state and scroll-restoration are lost, and Modifier.animateItem() has nothing to animate from.// RIGHT
LazyColumn {
items(snacks, key = { it.id }) { snack -> SnackRow(snack) }
}// WRONG
items(snacks, key = { UUID.randomUUID() }) { snack -> SnackRow(snack) }
// WRONG because: a fresh key on every recomposition guarantees the cached composition is discarded every time — strictly worse than no key.// WRONG
items(snacks, key = { it.hashCode() }) { snack -> SnackRow(snack) }
// WRONG because: hashCode() of a mutable type changes when fields mutate, breaking identity continuity for the same logical item.// RIGHT
items(snacks, key = { it.id }) { snack -> SnackRow(snack) }contentType// WRONG
items(feed, key = { it.id }) { item ->
when (item) {
is FeedItem.Card -> CardRow(item)
is FeedItem.Ad -> AdRow(item)
is FeedItem.Header -> HeaderRow(item)
}
}
// WRONG because: cached compositions of one type are discarded when scrolled into a different type's slot — every row crossing a type boundary is a fresh build instead of a recycled update.// RIGHT
items(
items = feed,
key = { it.id },
contentType = { it::class },
) { item ->
when (item) {
is FeedItem.Card -> CardRow(item)
is FeedItem.Ad -> AdRow(item)
is FeedItem.Header -> HeaderRow(item)
}
}Modifier.animateItem() without a stable key// WRONG
items(snacks) { snack ->
SnackRow(snack, Modifier.animateItem())
}
// WRONG because: animateItem() binds animation state to the item's key. With no key, identity is index-based, so an insert at position 0 looks like every-row-changed and nothing animates correctly.// RIGHT
items(snacks, key = { it.id }) { snack ->
SnackRow(snack, Modifier.animateItem())
}// WRONG
items(snacks, key = { it.id }) { snack ->
val placeholder = painterResource(R.drawable.snack_placeholder)
val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
Card(border = border) {
AsyncImage(snack.imageUrl, placeholder = placeholder)
}
}
// WRONG because: painterResource resolution and BorderStroke allocation happen on every item composition; at high scroll velocity these compound into measurable allocation pressure.// RIGHT
@Composable
fun SnackList(snacks: ImmutableList<Snack>) {
val placeholder = painterResource(R.drawable.snack_placeholder)
val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
LazyColumn {
items(snacks, key = { it.id }, contentType = { it::class }) { snack ->
Card(border = border) {
AsyncImage(snack.imageUrl, placeholder = placeholder)
}
}
}
}Note: Compose deduplicates structurally-equal Modifier chains internally, so reallocating Modifier.fillMaxWidth().padding(16.dp) per item is a micro-optimization. Hoist a Modifier only when profiling identifies it as the bottleneck — premature remember { Modifier.… } adds noise without measurable benefit.
// WRONG
@Composable
fun SnackRow(snack: Snack, tags: List<String>) { /* ... */ }
// Caller:
items(snacks, key = { it.id }) { snack ->
SnackRow(snack, tags = snack.tags)
}
// WRONG because: List<String> is an unstable parameter under inference; every scroll-driven recomposition recomposes the row body even though the snack didn't change.// RIGHT
@Immutable
data class Snack(val id: Long, val name: String, val tags: ImmutableList<String>)
@Composable
fun SnackRow(snack: Snack) { /* ... */ }
items(snacks, key = { it.id }, contentType = { it::class }) { snack ->
SnackRow(snack)
}Cross-reference: ../../stability/stabilizing-compose-types/SKILL.md.
LazyVerticalGrid with mixed spans// RIGHT — keys + contentType apply to grids identically
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(
items = feed,
key = { it.id },
contentType = { it::class },
span = { item -> if (item is FeedItem.Header) GridItemSpan(maxLineSpan) else GridItemSpan(1) },
) { item ->
when (item) {
is FeedItem.Header -> HeaderRow(item, Modifier.animateItem())
is FeedItem.Card -> CardCell(item, Modifier.animateItem())
}
}
}key for every items(...) block where item identity outlives a single composition (effectively: every list backed by domain objects).UUID.randomUUID() evaluated per emission, MUST NOT use hashCode() of a mutable object.contentType for heterogeneous lists (cards + headers + ads, etc.). Use a stable type discriminator such as it::class or a sealed enum.Modifier.animateItem() without a stable key — the animation silently no-ops.../../stability/diagnosing-compose-stability/SKILL.md before blaming the lazy layout. An unstable item parameter cancels every gain from key/contentType.items { } in extra inline composable wrappers (Row { items { } }) hoping to "force" skippability — Row/Column/Box are NOT restartable/skippable to begin with (skydoves hot take #3).../configuring-lazy-prefetch/SKILL.md for high-velocity scroll surfaces only after item-level fixes are in place.Modifier.animateItem() runs the expected fade and placement animation on inserts and removals.@TraceRecomposition on the item composable shows recompositions only on real state changes, not on every scroll-driven invalidation.composables.txt) shows the item composable as restartable skippable with all parameters stable or runtime.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.