AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified Apache-2.0 Self-run

Migrating To Modifier Node

skill-skydoves-compose-performance-skills-migrating-to-modifier-node · by skydoves

Use this skill to author new custom Jetpack Compose modifiers and migrate legacy ones from Modifier.composed { } to Modifier.Node + ModifierNodeElement<T>. Covers the persistent-node lifecycle (onAttach, onDetach, onReset, coroutineScope), the specialized node interfaces (DrawModifierNode, LayoutModifierNode, SemanticsModifierNode, PointerInputModifierNode, CompositionLocalConsumerModifierNode, L…

No reviews yet
0 installs
25 views
0.0% view→install

Install

$ agentstack add skill-skydoves-compose-performance-skills-migrating-to-modifier-node

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-skydoves-compose-performance-skills-migrating-to-modifier-node)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
3mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Migrating To Modifier Node? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Migrating to Modifier.Node — Persistent Nodes Over 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.

When to use this skill

  • A custom modifier currently uses Modifier.composed { } (search the module for Modifier.composed).
  • Authoring a new custom modifier from scratch — never start with composed { }.
  • A code review surfaces a composed { } factory.
  • The custom modifier needs a CoroutineScope (animation loop, debouncer), reads a CompositionLocal, participates in layout / drawing / pointer input, or tracks layout coordinates.
  • A @TraceRecomposition log shows the parent composable recomposing on every frame because a composed { } modifier is in the chain.

When NOT to use this skill

  • The "modifier" is actually a one-line composable wrapper that can stay a @Composable function — leave it alone.
  • The built-in modifier composition (Modifier.padding(...).clickable(...)) is sufficient, no custom node behavior needed.
  • The fix the developer needs is reordering an existing chain, not authoring a new node — see ../ordering-modifier-chains/SKILL.md.
  • The custom modifier reads a hot animation value via 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.

Prerequisites

  • Compose UI 1.5+Modifier.Node and the specialized node interfaces are stable here.
  • Kotlin 2.0+ with org.jetbrains.kotlin.plugin.compose applied. Strong Skipping is on by default; non-skippable modifiers compound at scroll velocity.
  • A release build for any final measurement (skydoves hot take #5: debug builds run interpreted and lie).
  • For the full per-interface override surface and lifecycle diagram, read references/modifier-node-anatomy.md before authoring anything beyond a DrawModifierNode.

Workflow

  • [ ] 1. Identify every Modifier.composed { } factory in the module. Grep for 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.
  • [ ] 2. Sketch the three pieces every Modifier.Node migration produces.
  • (a) The public extensionfun Modifier.foo(...): Modifier = this then FooElement(...). Same name and signature as the old composed { } factory.
  • (b) The ModifierNodeElement data class — holds the parameters; implements create() (called once on first apply) and update(node: T) (called on subsequent applies).
  • (c) The Modifier.Node subclass — holds mutable state (var fields), implements one or more specialized node interfaces, and runs lifecycle hooks (onAttach, onDetach, onReset).
  • [ ] 3. Make the Element a data class. The compiler-synthesized 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.
  • [ ] 4. Pick the right specialized node interface(s). A 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.

  • [ ] 5. Use the built-in coroutineScope for async work. Every 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.
  • [ ] 6. Trigger re-runs explicitly when needed. When you mutate node state from inside 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.
  • [ ] 7. Implement onAttach/onDetach/onReset for resource lifecycle. 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.
  • [ ] 8. Verify migration: no Modifier.composed { } remains. Re-grep the module. If any composed { } calls survive, list them with rationale; otherwise the migration is complete.

Patterns

Pattern: migrate 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() {
    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.

Pattern: coroutine work in a node (replace 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() {
    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.

Pattern: read a 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() {
    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.

Pattern: forgetting data class (the silent-stale-node bug)

// WRONG
private class CircleElement(val color: Color) : ModifierNodeElement() {
    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() {
    override fun create() = CircleNode(color)
    override fun update(node: CircleNode) { node.color = color }
}

Pattern: holding a reference to the calling composable

// 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() {
    override fun create() = HostingNode(tag)
    override fun update(node: HostingNode) { node.tag = tag }
}
private class HostingNode(var tag: String) : Modifier.Node() { /* ... */ }

Pattern: composing multiple behaviors with DelegatingNode

// RIGHT — one public modifier, three small nodes delegated under one element
private data class CardEffectsElement(
    val color: Color,
    val onClick: () -> Unit,
) : ModifierNodeElement() {
    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.

Specialized node interfaces

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.
  • LayoutAwareModifierNodeonPlaced(coordinates) / onRemeasured(size).
  • GlobalPositionAwareModifierNodeonGloballyPositioned(coordinates).
  • ObserverModifierNode — wrap state reads with observeReads { ... } and react in onObservedReadsChanged().
  • DelegatingNodedelegate(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).

Lifecycle (short form — full diagram in references)

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 cancelled

Mandatory rules

  • MUST prefer Modifier.Node for any new custom modifier — Modifier.composed { } is legacy.
  • MUST make ModifierNodeElement a data class so the synthesized equals()/hashCode() drive the diff. Plain class silently breaks update().
  • MUST override update(node: T) to mutate node state in place. MUST NOT recreate the node from update().
  • MUST release subscriptions, listeners, and external resources in onDetach(). coroutineScope is cancelled for you; manual resources are not.
  • MUST NOT hold a reference to the Composer, the calling composable, the parent composition, or any composition-scoped object inside a Modifier.Node.
  • MUST NOT allocate a new CoroutineScope in onAttach — use the built-in coroutineScope property.
  • MUST NOT recommend Modifier.composed { } for new code. (Repo-wide rule from SPEC §8.)
  • PREFERRED: specialized node interfaces (`D

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.