migrating-to-modifier-node — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited migrating-to-modifier-node (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.
composed { }Modifier.composed { } allocates a fresh composable scope per modifier per composition: it cannot be skipped, cannot be hoisted, and forces the parent to run on every recomposition. Modifier.Node is a persistent node diffed by ModifierNodeElement.equals() — created once on first apply, updated in place on subsequent applies. There is no per-recomposition allocation, no fresh composable scope, and no parent invalidation chain, which is why Android Developers describes the system as "designed from the ground up to be far more performant" than the legacy composed { } factory (see developer.android.com/develop/ui/compose/custom-modifiers). This skill teaches Claude how to author new modifiers as Modifier.Node and migrate legacy composed { } factories.
Modifier.composed { } (search the module for Modifier.composed).composed { }.composed { } factory.CoroutineScope (animation loop, debouncer), reads a CompositionLocal, participates in layout / drawing / pointer input, or tracks layout coordinates.@TraceRecomposition log shows the parent composable recomposing on every frame because a composed { } modifier is in the chain.@Composable function — leave it alone.Modifier.padding(...).clickable(...)) is sufficient, no custom node behavior needed.../ordering-modifier-chains/SKILL.md.Modifier.composed { } only to feed a value-form modifier underneath — the underlying issue is a wrong-phase state read; see ../../recomposition/deferring-state-reads/SKILL.md.Modifier.Node and the specialized node interfaces are stable here.org.jetbrains.kotlin.plugin.compose applied. Strong Skipping is on by default; non-skippable modifiers compound at scroll velocity.references/modifier-node-anatomy.md before authoring anything beyond a DrawModifierNode.Modifier.composed. Each match is one migration target. Note what the body does — remember, drawBehind, LaunchedEffect, a CompositionLocal read, a pointer handler — because that decides which specialized node interface(s) you need.fun Modifier.foo(...): Modifier = this then FooElement(...). Same name and signature as the old composed { } factory.create() (called once on first apply) and update(node: T) (called on subsequent applies).var fields), implements one or more specialized node interfaces, and runs lifecycle hooks (onAttach, onDetach, onReset).equals()/hashCode() are how Compose decides whether to call update() vs leave the node alone. MUST be data class. A plain class falls back to referential equality, the diff thinks every apply is a new modifier, and update() is never called — your node holds stale parameters silently.Modifier.Node is empty by itself; behavior comes from interfaces it implements. The common ones:| Interface | Use when the modifier needs to … |
|---|---|
DrawModifierNode | draw (replaces drawBehind/drawWithCache) |
LayoutModifierNode | measure/place (replaces layout { } and custom Layout) |
SemanticsModifierNode | contribute to accessibility |
PointerInputModifierNode | handle pointer / gesture input (replaces pointerInput) |
CompositionLocalConsumerModifierNode | read a CompositionLocal from inside the node |
LayoutAwareModifierNode | get notified when this node's size/coordinates change |
GlobalPositionAwareModifierNode | get notified about position in the window/root |
ObserverModifierNode | observe arbitrary state reads with a custom observer (observeReads { ... }) |
DelegatingNode | compose multiple node behaviors by delegating to child nodes |
TraversableNode | walk the modifier chain (parent/child traversal) |
A node MAY implement several interfaces at once — e.g. DrawModifierNode + CompositionLocalConsumerModifierNode + LayoutAwareModifierNode. For complex multi-behavior modifiers, PREFERRED: compose smaller DelegatingNode children rather than one mega-node implementing five interfaces.
Modifier.Node exposes a coroutineScope: CoroutineScope lazily tied to the node's attach/detach lifecycle. Launch animations, observers, debouncers there from onAttach(). MUST NOT create your own CoroutineScope inside onAttach — you will leak it past onDetach.update() or a coroutine and need a redraw / re-measure / re-place, call invalidateDraw(), invalidateMeasurement(), or invalidatePlacement(). By default, update() triggers an auto-invalidation; for fine control, override shouldAutoInvalidate = false and invalidate manually.onAttach runs when the node joins the tree; onDetach when it leaves; onReset when the node is reused (only relevant inside lazy layouts). MUST release listeners, observers, and external subscriptions in onDetach.composed { } calls survive, list them with rationale; otherwise the migration is complete.composed { drawBehind } to DrawModifierNode// WRONG (legacy)
fun Modifier.circle(color: Color): Modifier = composed {
val computed = remember(color) { color.copy(alpha = 0.5f) }
drawBehind { drawCircle(computed) }
}
// WRONG because: composed { } opens a fresh composable scope per parent recomposition; the modifier can never be skipped and forces the parent to recompose on every read it does inside.// RIGHT
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create(): CircleNode = CircleNode(color)
override fun update(node: CircleNode) { node.color = color }
}
private class CircleNode(var color: Color) : Modifier.Node(), DrawModifierNode {
override fun ContentDrawScope.draw() {
drawCircle(color.copy(alpha = 0.5f))
drawContent()
}
}
fun Modifier.circle(color: Color): Modifier = this then CircleElement(color)The data class Element gives equals() / hashCode() for free. When the caller passes the same color, equals() returns true and the node is left alone. When the color changes, update() mutates node.color in place — no allocation, no Composition invalidation in the parent.
composed { LaunchedEffect })// WRONG (legacy)
fun Modifier.pulse(period: Long): Modifier = composed {
val alpha = remember { Animatable(1f) }
LaunchedEffect(period) {
while (true) { alpha.animateTo(0.3f); alpha.animateTo(1f); delay(period) }
}
graphicsLayer { this.alpha = alpha.value }
}
// WRONG because: every parent recomposition allocates a new composable scope, and the LaunchedEffect's keying logic re-evaluates inside that scope.// RIGHT
private data class PulseElement(val period: Long) : ModifierNodeElement<PulseNode>() {
override fun create(): PulseNode = PulseNode(period)
override fun update(node: PulseNode) { node.period = period }
}
private class PulseNode(var period: Long) : Modifier.Node(), DrawModifierNode {
private var alpha by mutableFloatStateOf(1f)
override fun onAttach() {
coroutineScope.launch {
while (true) {
animate(1f, 0.3f) { value, _ -> alpha = value; invalidateDraw() }
animate(0.3f, 1f) { value, _ -> alpha = value; invalidateDraw() }
delay(period)
}
}
}
override fun ContentDrawScope.draw() {
drawContent()
drawRect(Color.Black.copy(alpha = 1f - alpha), blendMode = BlendMode.DstIn)
}
}
fun Modifier.pulse(period: Long): Modifier = this then PulseElement(period)The node's built-in coroutineScope is cancelled automatically on onDetach. No leak, no manual DisposableEffect.
CompositionLocal inside a node// WRONG (legacy)
fun Modifier.themedBorder(width: Dp): Modifier = composed {
val tokens = LocalThemeTokens.current
drawBehind { drawRect(tokens.outline, style = Stroke(width.toPx())) }
}
// WRONG because: every CompositionLocal read inside composed { } pins the modifier to a fresh scope per parent recomposition.// RIGHT
private data class ThemedBorderElement(val width: Dp) : ModifierNodeElement<ThemedBorderNode>() {
override fun create(): ThemedBorderNode = ThemedBorderNode(width)
override fun update(node: ThemedBorderNode) { node.width = width }
}
private class ThemedBorderNode(
var width: Dp,
) : Modifier.Node(), DrawModifierNode, CompositionLocalConsumerModifierNode {
override fun ContentDrawScope.draw() {
val tokens = currentValueOf(LocalThemeTokens)
drawContent()
drawRect(tokens.outline, style = Stroke(width.toPx()))
}
}
fun Modifier.themedBorder(width: Dp): Modifier = this then ThemedBorderElement(width)CompositionLocalConsumerModifierNode exposes currentValueOf(local) from inside any node callback. Reads are tracked by the Draw invalidation list, not by a Composition restart scope, so changing LocalThemeTokens redraws the node without recomposing the parent.
data class (the silent-stale-node bug)// WRONG
private class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create() = CircleNode(color)
override fun update(node: CircleNode) { node.color = color }
}
// WRONG because: not a data class -> equals() is referential -> every apply looks like a different element -> Compose tears down and recreates the node every time, OR the diff fails and update() is never called, leaving the node with the original color forever.// RIGHT
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create() = CircleNode(color)
override fun update(node: CircleNode) { node.color = color }
}// WRONG
private class HostingNode(val composer: Composer) : Modifier.Node() { /* ... */ }
// WRONG because: a Modifier.Node outlives any single composition pass; holding a Composer/composition-scoped object leaks it and invokes undefined behavior.// RIGHT — accept primitive/stable parameters; read CompositionLocals via CompositionLocalConsumerModifierNode if you need composition context.
private data class HostingElement(val tag: String) : ModifierNodeElement<HostingNode>() {
override fun create() = HostingNode(tag)
override fun update(node: HostingNode) { node.tag = tag }
}
private class HostingNode(var tag: String) : Modifier.Node() { /* ... */ }DelegatingNode// RIGHT — one public modifier, three small nodes delegated under one element
private data class CardEffectsElement(
val color: Color,
val onClick: () -> Unit,
) : ModifierNodeElement<CardEffectsNode>() {
override fun create() = CardEffectsNode(color, onClick)
override fun update(node: CardEffectsNode) {
node.update(color, onClick)
}
}
private class CardEffectsNode(
color: Color,
onClick: () -> Unit,
) : DelegatingNode() {
private val background = delegate(BackgroundNode(color))
private val click = delegate(ClickNode(onClick))
fun update(color: Color, onClick: () -> Unit) {
background.color = color
click.onClick = onClick
}
}DelegatingNode is the canonical way to assemble multi-behavior modifiers without one node implementing every interface. The delegated children share the host's lifecycle.
Cheat sheet — full override surface and "use when" guidance lives in references/modifier-node-anatomy.md:
DrawModifierNode — implement ContentDrawScope.draw(). Replaces drawBehind/drawWithCache for custom modifiers.LayoutModifierNode — implement MeasureScope.measure(...). Replaces Modifier.layout { }.SemanticsModifierNode — implement SemanticsPropertyReceiver.applySemantics().PointerInputModifierNode — implement onPointerEvent(...) and onCancelPointerInput().CompositionLocalConsumerModifierNode — exposes currentValueOf(local) inside any node callback.LayoutAwareModifierNode — onPlaced(coordinates) / onRemeasured(size).GlobalPositionAwareModifierNode — onGloballyPositioned(coordinates).ObserverModifierNode — wrap state reads with observeReads { ... } and react in onObservedReadsChanged().DelegatingNode — delegate(otherNode) to compose behaviors.TraversableNode — walk parents/children/descendants via the top-level extension functions on DelegatableNode: traverseAncestors(key, block), traverseChildren(key, block), traverseDescendants(key, block). The key parameter selects which traversable nodes participate; the descendants overload's block returns a TraverseDescendantsAction (continue / skip / cancel).ModifierNodeElement.create() // first apply only
↓
Modifier.Node.onAttach() // node joins the tree; coroutineScope becomes valid
↓ // (lives here across many parent recompositions)
ModifierNodeElement.update(node) // each subsequent apply with !equals previous
↓ // mutate node.var fields; auto-invalidates by default
Modifier.Node.onReset() // optional: lazy-layout reuse
↓
Modifier.Node.onDetach() // node leaves the tree; coroutineScope is cancelledModifier.Node for any new custom modifier — Modifier.composed { } is legacy.ModifierNodeElement<T> a data class so the synthesized equals()/hashCode() drive the diff. Plain class silently breaks update().update(node: T) to mutate node state in place. MUST NOT recreate the node from update().onDetach(). coroutineScope is cancelled for you; manual resources are not.Composer, the calling composable, the parent composition, or any composition-scoped object inside a Modifier.Node.CoroutineScope in onAttach — use the built-in coroutineScope property.Modifier.composed { } for new code. (Repo-wide rule from SPEC §8.)DrawModifierNode, LayoutModifierNode, …) over a bare Modifier.Node.DelegatingNode over implementing more than ~3 specialized interfaces on a single node.shouldAutoInvalidate = false and call invalidateDraw() / invalidateMeasurement() / invalidatePlacement() explicitly when fine-grained control matters; otherwise rely on the auto-invalidation triggered by update().grep -R "Modifier.composed" <module>/src returns zero matches in the migrated module.data class Element (search: class .*Element : ModifierNodeElement should be data class).composables.txt) shows the parent composables that consume the migrated modifier are now restartable skippable (no composed-induced non-skip).@TraceRecomposition (skydoves/compose-stability-analyzer) on the parent confirms in release + R8 + real device that the parent is not recomposed by the modifier's internal animation.onAttach (listeners, observers) are released in onDetach — verify with a leak canary pass.references/modifier-node-anatomy.md — full per-interface override surface, lifecycle diagram, DelegatingNode composition recipes, and manual-invalidation guidance.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.