Minimal reproduction: Datadog Android Gradle plugin's Jetpack Compose auto-instrumentation
(composeInstrumentation = InstrumentationMode.AUTO) silently defeats Compose recomposition
skipping for any composable that receives a forwarded Modifier.
Reproduces on plugin 1.21.0 and 1.28.0 (latest at time of writing).
Kotlin 2.2.21, AGP 8.7.3, Compose BOM 2025.04.01. The Datadog SDK is never initialized —
the compile-time Modifier rewrite alone causes the defect.
A LazyColumn of 100 rows fed from a StateFlow. Every 500 ms exactly one row's value
changes. Rows receive their Modifier through a small chain of plain skippable composables
(Screen → RowItem → RowInner) — idiomatic modifier forwarding.
Each row detects wasted executions: its body re-ran while already composed, with data equal
to what it last rendered — exactly the work Compose skipping exists to eliminate. Initial
composition, scroll-into-view, and genuine data changes are all excluded (LazyColumn disposes
off-screen rows, resetting the row's remember), so the metric is scroll-proof. The header shows
a live verdict:
- skipping works → wasted per change = 0 (green)
- skipping defeated → wasted per change ≈ visible-row count (red)
# Variant A — instrumentation AUTO (the default here), applicationId com.example.ddkcprepro.auto
./gradlew :app:installDebug
# Variant B — instrumentation DISABLE, applicationId com.example.ddkcprepro.off
./gradlew :app:installDebug -PddOff=trueBoth install side by side. Open each and read the header after ~30 seconds.
| Build | wasted row executions per change |
|---|---|
AUTO (plugin 1.21.0) |
~17–21 (= visible rows) |
AUTO (plugin 1.28.0) |
~17–21 (= visible rows) |
DISABLE |
0.0 |
Every data change re-executes every visible row's body under AUTO; zero wasted executions with instrumentation disabled. The waste scales with visible-row count × update rate.
In AUTO mode the plugin's Kotlin compiler plugin rewrites every Modifier argument at every
composable call site:
// source:
RowInner(data = data, modifier = modifier)
// compiled:
RowInner(data = data, modifier = modifier.instrumentedDatadog("RowItem", false))instrumentedDatadog ends in:
private fun Modifier.datadogSemantics(name: String, isImage: Boolean): Modifier {
return this.semantics { // <- Function1 capturing (name, isImage)
this.datadog = name
if (isImage) { this[SemanticsProperties.Role] = Role.Image }
}
}Modifier.semantics {} produces an AppendedSemanticsElement whose equality is the equality of
its properties lambda. The lambda captures (name, isImage), so a new instance is
allocated on every execution of the call site, and lambda classes have no equals(). Result:
every time a parent recomposes, the child's modifier argument is a fresh, never-equal value.
Compose's skipping compares arguments by equality — one never-equal argument means the child
can never skip, regardless of how stable everything else is. The defeat cascades down the
entire forwarded-modifier chain.
This is invisible to normal tooling: compiler stability reports are written before IR rewriting, the source contains nothing to find, and there is no log or warning. Apps just do (visible-rows × update-rate) more composition work.
# AUTO build: > 0 injected call sites; DISABLE build: 0
dexdump -d app/build/outputs/apk/debug/app-debug.apk 2>/dev/null | grep -c "instrumentedDatadog"Layout Inspector's recomposition/skip columns show the same story: with AUTO, unchanged rows' recomposition counts climb in lockstep and skips stay ~0; with DISABLE, siblings pin at ~0 recompositions with climbing skips.
Implement the injected tag as an equality-stable ModifierNodeElement keyed on
(name, isImage) (data-class equality) instead of semantics {} around a fresh capturing
lambda. Two elements with equal (name, isImage) would then compare equal and skipping would
survive instrumentation.