avoiding-subcomposition-pitfalls — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited avoiding-subcomposition-pitfalls (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.
SubcomposeLayout runs its content's composition during the measure pass, not during the parent's composition pass. That trades extra layout cost for the ability to use measured constraints inside the children's composition. BoxWithConstraints, material3.Scaffold, and the lazy layouts are all built on top of it, so the cost is invisible until it nests or lands inside a hot path. This skill teaches Claude how to detect the misuse, how to replace SubcomposeLayout with a cheaper primitive when its power is not needed, and how to keep it efficient when it is.
BoxWithConstraints purely to read maxWidth / maxHeight and pick between a few composables, and the screen feels heavy at first frame or on configuration change (rotation, IME show).BoxWithConstraints or Scaffold is nested inside another BoxWithConstraints or Scaffold, multiplying subcomposition cost.BoxWithConstraints sits inside a LazyColumn/LazyRow item and the developer reports scroll jank that did not exist before the wrap was added.SubcomposeLayout block but only uses constraints to compute final positions, never to drive child composition.Compose:applyChanges or measure-phase composition counts that scale with parent constraint changes (rotation, drag-to-resize, animated container size).SubcomposeLayout's measurePolicy block allocates a fresh composable lambda inside subcompose(slotId) { … } on every measurement. AndroidX maintains an internal lint check named ComposableLambdaInMeasurePolicy (in compose/lint/internal-lint-checks/) that flags exactly this pattern on its own codebase; published APIs like BoxWithConstraints, material.Scaffold, and material3.TabRow suppress that check because the trade-off is fundamental to their public contract. App-side code that reproduces the pattern pays the same cost without the suppression rationale.SubcomposeLayout use case and there is no cheaper primitive. Keep SubcomposeLayout; tune it (see Pattern: Tuning a justified SubcomposeLayout).LazyColumn item but the cause is unstable parameters, not subcomposition. Diagnose with ../../stability/diagnosing-compose-stability/SKILL.md first.../deferring-state-reads/SKILL.md.../../lists/configuring-lazy-prefetch/SKILL.md.../deferring-state-reads/references/three-phases.md first.../../measurement/testing-compose-in-release-mode/SKILL.md covers why debug builds lie.../../measurement/tracing-recompositions-at-runtime/SKILL.md covers wiring androidx.compose.runtime:runtime-tracing so that measure-phase work shows up in system traces.SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse: Int) factory and pausable subcomposition on the lazy-list path. Both ship in the current androidx.compose.ui:ui / androidx.compose.foundation:foundation artifacts; check the release notes at https://developer.android.com/jetpack/androidx/releases/compose-ui and https://developer.android.com/jetpack/androidx/releases/compose-foundation if pinning to an older release.BoxWithConstraints, Scaffold, SubcomposeLayout, and any custom @Composable that delegates to one. The material3.Scaffold and foundation.layout.BoxWithConstraints are both implemented with SubcomposeLayout; their cost is identical.BoxWithConstraints that reads maxWidth only to pass it to a child as a parameter is wasting the subcomposition machinery. A BoxWithConstraints that picks between two completely different @Composable trees based on maxWidth < 600.dp is using it correctly.Modifier.layout { measurable, constraints -> … } or Modifier.onSizeChanged { … } instead. Both run during the layout phase without spinning up a new composition.Scaffolds nested, or a BoxWithConstraints inside a Scaffold slot that itself contains another BoxWithConstraints, multiplies measure-phase composition. Hoist the outer one to the screen root and use ordinary Box / Column / Row for the inner branches.LazyColumn already knows its constraints) or use Modifier.fillMaxWidth().onSizeChanged { … } for one-time sizing.n previously-used slots so the next subcomposition reuses their slot table and node subtree instead of paying for a fresh composition. The factory SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse: Int) ships in androidx.compose.ui.layout.PrecomposedSlotHandle; the next subcompose(slotId, …) during measurement skips the composition step. This is exactly how the lazy layouts spread item composition across frames.androidx.compose.runtime:runtime-tracing and confirm that Compose:recompose and Compose:applyChanges no longer fire during scroll or resize ticks at the rate of the parent's constraint changes. The measured screen should show subcomposition only on actual structural change (e.g. rotation), not on every frame.BoxWithConstraints used to read size — replace with Modifier.onSizeChanged// WRONG
@Composable
fun ParallaxHeader(scrollOffset: () -> Float) {
BoxWithConstraints {
val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
Image(
painter = painterResource(R.drawable.hero),
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.graphicsLayer {
translationX = scrollOffset() * widthPx * 0.2f
},
contentDescription = null,
)
}
}
// WRONG because: BoxWithConstraints subcomposes its content during the measure phase. Each
// new constraint (rotation, IME show, animated parent size) re-runs that subcomposition just
// to read maxWidth, when a Modifier.onSizeChanged would have given the same value with no
// extra composition.// RIGHT
@Composable
fun ParallaxHeader(scrollOffset: () -> Float) {
var widthPx by remember { mutableStateOf(0f) }
Image(
painter = painterResource(R.drawable.hero),
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.onSizeChanged { widthPx = it.width.toFloat() }
.graphicsLayer {
translationX = scrollOffset() * widthPx * 0.2f
},
contentDescription = null,
)
}onSizeChanged runs in the layout phase (no subcomposition); the underlying onRemeasured is invoked on every layout pass and forwards to the user callback when the measured size differs from the previous size. The graphicsLayer block runs in Draw with the latest widthPx and the latest scroll offset.
BoxWithConstraints to pick a layout — keep it but hoist it// WRONG
@Composable
fun ProductCard(product: Product) {
BoxWithConstraints {
if (maxWidth < 360.dp) {
CompactProductCard(product)
} else {
WideProductCard(product)
}
}
}
@Composable
fun ProductGrid(products: List<Product>) {
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(products) { ProductCard(it) }
}
}
// WRONG because: every grid cell wraps its content in BoxWithConstraints. Each cell pays
// for a subcomposition during the grid's measure pass. With 200 cells visible across a long
// scroll session, that is 200 redundant subcompositions on every measurement.// RIGHT
@Composable
fun ProductGrid(products: List<Product>) {
BoxWithConstraints {
val isCompact = maxWidth / 2 < 360.dp
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(products) { product ->
if (isCompact) CompactProductCard(product) else WideProductCard(product)
}
}
}
}The single BoxWithConstraints at the grid root subcomposes once per parent constraint change. The decision flows into every item as a plain Boolean, which is stable and skippable.
Scaffold — collapse to one// WRONG
@Composable
fun ProfileScreen(...) {
Scaffold(topBar = { TopBar(...) }) { outer ->
Scaffold(
modifier = Modifier.padding(outer),
bottomBar = { ProfileTabBar(...) },
) { inner ->
ProfileContent(modifier = Modifier.padding(inner))
}
}
}
// WRONG because: each Scaffold is a SubcomposeLayout with its own measure-phase composition.
// Nesting them doubles that cost on every frame whose constraints change (IME show, rotation,
// nav bar inset change).// RIGHT
@Composable
fun ProfileScreen(...) {
Scaffold(
topBar = { TopBar(...) },
bottomBar = { ProfileTabBar(...) },
) { padding ->
ProfileContent(modifier = Modifier.padding(padding))
}
}Scaffold already supports a top bar, bottom bar, snackbar host, and FAB in a single subcomposition. Use the slots provided rather than nesting another Scaffold.
SubcomposeLayoutWhen a screen genuinely needs measure-time access to constraints (a tab strip whose tabs size by their own content while sharing the row's max width, a lookahead-driven shared element container, a custom popover that aligns to a measured anchor), keep SubcomposeLayout but tune the state.
@Composable
fun BadgeRow(badges: List<Badge>) {
val slotState = remember {
SubcomposeLayoutState(SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse = 8))
}
SubcomposeLayout(state = slotState) { constraints ->
val measurables = subcompose(slotId = "badges") {
badges.fastForEach { Badge(it) }
}
val placeables = measurables.map { it.measure(constraints) }
val width = placeables.sumOf { it.width }
val height = placeables.maxOf { it.height }
layout(width, height) {
var x = 0
placeables.fastForEach { p ->
p.place(x, 0)
x += p.width
}
}
}
}SubcomposeSlotReusePolicy(8) lets the layout retain up to eight previously-composed slots. When a slot id reappears (e.g. a badge enters the screen again after exiting), the layout reuses the existing slot table instead of rerunning composition. The lazy layouts use a similar mechanism with their own policy implementation.
For predictable upcoming slots, call state.precompose(slotId, content) on an earlier frame and dispose the returned PrecomposedSlotHandle if the slot is not needed after all. The next subcompose(slotId, …) during measurement reuses the precomposed content with no composition step.
LazyListScope.item { } containing BoxWithConstraints// WRONG
LazyColumn {
items(rows) { row ->
BoxWithConstraints {
val isWide = maxWidth > 600.dp
RowContent(row, isWide)
}
}
}
// WRONG because: each row item subcomposes during the LazyColumn's measure pass. Scroll the
// list and every newly-visible item pays for a fresh subcomposition. On a 60Hz feed this is
// the difference between smooth and dropped frames.// RIGHT
@Composable
fun RowFeed(rows: List<Row>) {
BoxWithConstraints {
val isWide = maxWidth > 600.dp
LazyColumn {
items(rows, key = { it.id }) { row -> RowContent(row, isWide) }
}
}
}The single BoxWithConstraints at the feed root subcomposes only when the feed itself is remeasured. The per-row item is a plain RowContent call with a stable Boolean parameter, which strong skipping handles.
BoxWithConstraints only to read maxWidth/maxHeight for a Modifier-level effect. Use Modifier.onSizeChanged { … } or Modifier.layout { … } instead.BoxWithConstraints inside a LazyColumn/LazyRow/LazyVerticalGrid item. Hoist the constraint read to the lazy layout's parent and pass the resolved value down.Scaffold inside another Scaffold. Use the slots (topBar, bottomBar, snackbarHost, floatingActionButton) the outer Scaffold already exposes.SubcomposeLayout when the children's composition genuinely depends on a measured value (parent constraints, sibling size). The point is to use the right tool, not to ban SubcomposeLayout.SubcomposeSlotReusePolicy to SubcomposeLayoutState whenever slots come and go. The default no-op policy disposes slots eagerly and pays full composition cost on every reappearance.state.precompose(slotId, content) on a previous frame and let the measure-pass subcompose reuse the result.Modifier.onSizeChanged { … } over reading maxWidth in a BoxWithConstraints body when the value only feeds a graphicsLayer { }, drawBehind { }, or other Layout/Draw-phase consumer.androidx.compose.runtime:runtime-tracing shows that Compose:recompose and Compose:applyChanges no longer fire on every measurement of the affected subtree. They should fire only on real structural change (rotation, navigation, list item insert).BoxWithConstraints does not increment per parent constraint change.SubcomposeLayout, the subcompose(slotId) { … } content lambda is hoisted into a remember-stable reference (or is cheap and intentional, with a one-line comment explaining why a fresh lambda per measurement is safe here). This is the pattern AndroidX's internal ComposableLambdaInMeasurePolicy lint enforces on the AndroidX codebase.BoxWithConstraints blocks inside LazyColumn/LazyRow/LazyVerticalGrid items.Scaffold is nested inside another Scaffold in the migrated file.SubcomposeLayout, a SubcomposeSlotReusePolicy is passed to SubcomposeLayoutState, OR the comment explicitly notes that all slots are permanent and reuse is unnecessary.SubcomposeLayout reference: https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#SubcomposeLayout(androidx.compose.ui.Modifier,kotlin.Function2)SubcomposeLayout.kt: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.ktBoxWithConstraints.kt: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/BoxWithConstraints.ktScaffold.kt (uses SubcomposeLayout for the chrome layout): https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Scaffold.kt../deferring-state-reads/references/three-phases.md — three-phase model background that motivates "do not run composition during measure".../../lists/configuring-lazy-prefetch/SKILL.md — the lazy layouts use precompose and pausable subcomposition; the same APIs are available to custom SubcomposeLayout users.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.