From fafe0c37e16ebf3082d12d318fb44f68376245a8 Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Wed, 11 Mar 2026 14:37:15 +0100 Subject: [PATCH 1/4] Fix RUM-13135: Replace Modifier.semantics with custom ModifierNodeElement Root cause: The Datadog Kotlin Compiler Plugin prepends Modifier.semantics {} to every composable call's modifier chain. In certain Compose versions, the CoreSemanticsModifierNode implementation may participate in the layout measurement chain, causing constraint propagation issues in SubcomposeLayout- based components (e.g., Coil SubcomposeAsyncImage) inside LazyRow/LazyColumn. Changes: - Replace Modifier.semantics {} with custom DatadogSemanticsElement/Node - DatadogSemanticsNode explicitly implements only SemanticsModifierNode - Node is guaranteed to never interfere with layout constraints - DatadogSemanticsPropertyKey and public API remain unchanged Tests: 7 unit --- .../android/compose/DatadogModifier.kt | 66 +++++++-- .../compose/DatadogSemanticsElementTest.kt | 127 ++++++++++++++++++ 2 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt diff --git a/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt b/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt index 1cca6c4f23..a466ac5a84 100644 --- a/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt +++ b/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt @@ -8,11 +8,13 @@ package com.datadog.android.compose import androidx.compose.ui.Modifier +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.SemanticsModifierNode +import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver -import androidx.compose.ui.semantics.semantics import com.datadog.android.compose.internal.InstrumentationType import com.datadog.android.compose.internal.sendTelemetry @@ -28,7 +30,7 @@ import com.datadog.android.compose.internal.sendTelemetry */ fun Modifier.datadog(name: String, isImage: Boolean = false): Modifier { sendTelemetry(autoInstrumented = false, InstrumentationType.Semantics) - return this.datadogSemantics(name, isImage) + return this.then(DatadogSemanticsElement(name, isImage)) } /** @@ -37,16 +39,7 @@ fun Modifier.datadog(name: String, isImage: Boolean = false): Modifier { */ internal fun Modifier.instrumentedDatadog(name: String, isImage: Boolean): Modifier { sendTelemetry(autoInstrumented = true, InstrumentationType.Semantics) - return this.datadogSemantics(name, isImage) -} - -private fun Modifier.datadogSemantics(name: String, isImage: Boolean): Modifier { - return this.semantics { - this.datadog = name - if (isImage) { - this[SemanticsProperties.Role] = Role.Image - } - } + return this.then(DatadogSemanticsElement(name, isImage)) } internal val DatadogSemanticsPropertyKey: SemanticsPropertyKey = SemanticsPropertyKey( @@ -56,4 +49,51 @@ internal val DatadogSemanticsPropertyKey: SemanticsPropertyKey = Semanti } ) -private var SemanticsPropertyReceiver.datadog by DatadogSemanticsPropertyKey +internal var SemanticsPropertyReceiver.datadog by DatadogSemanticsPropertyKey + +/** + * A custom [ModifierNodeElement] that provides Datadog semantics without participating in + * layout measurement. This replaces the previous `Modifier.semantics {}` approach to avoid + * interference with layout constraint propagation in components like `SubcomposeLayout` + * (e.g., Coil's `SubcomposeAsyncImage`) inside `LazyRow`/`LazyColumn`. + * + * By implementing only [SemanticsModifierNode] (and not `LayoutModifierNode`), this modifier + * node is explicitly excluded from the layout measurement chain, ensuring it never modifies + * constraints passed to child composables. + */ +internal data class DatadogSemanticsElement( + val name: String, + val isImage: Boolean +) : ModifierNodeElement() { + override fun create(): DatadogSemanticsNode = DatadogSemanticsNode(name, isImage) + + override fun update(node: DatadogSemanticsNode) { + node.name = name + node.isImage = isImage + } + + override fun InspectorInfo.inspectableProperties() { + this.properties["name"] = name + this.properties["isImage"] = isImage + } +} + +/** + * A [SemanticsModifierNode] that attaches Datadog component metadata to the semantics tree. + * This node does NOT implement `LayoutModifierNode`, so it is never consulted during + * layout measurement and cannot modify constraints. + */ +internal class DatadogSemanticsNode( + var name: String, + var isImage: Boolean +) : Modifier.Node(), SemanticsModifierNode { + override val shouldMergeDescendantSemantics: Boolean get() = false + override val shouldClearDescendantSemantics: Boolean get() = false + + override fun SemanticsPropertyReceiver.applySemantics() { + this.datadog = name + if (isImage) { + this[SemanticsProperties.Role] = Role.Image + } + } +} diff --git a/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt b/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt new file mode 100644 index 0000000000..8a5c3075e5 --- /dev/null +++ b/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt @@ -0,0 +1,127 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.compose + +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.BoolForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +class DatadogSemanticsElementTest { + + @Test + fun `M create node with correct properties W create()`( + @StringForgery name: String, + @BoolForgery isImage: Boolean + ) { + // Given + val element = DatadogSemanticsElement(name, isImage) + + // When + val node = element.create() + + // Then + assertThat(node.name).isEqualTo(name) + assertThat(node.isImage).isEqualTo(isImage) + } + + @Test + fun `M update node properties W update()`( + @StringForgery initialName: String, + @StringForgery updatedName: String, + @BoolForgery initialIsImage: Boolean + ) { + // Given + val element = DatadogSemanticsElement(initialName, initialIsImage) + val node = element.create() + val updatedElement = DatadogSemanticsElement(updatedName, !initialIsImage) + + // When + updatedElement.update(node) + + // Then + assertThat(node.name).isEqualTo(updatedName) + assertThat(node.isImage).isEqualTo(!initialIsImage) + } + + @Test + fun `M be equal W same properties`( + @StringForgery name: String, + @BoolForgery isImage: Boolean + ) { + // Given + val element1 = DatadogSemanticsElement(name, isImage) + val element2 = DatadogSemanticsElement(name, isImage) + + // Then + assertThat(element1).isEqualTo(element2) + assertThat(element1.hashCode()).isEqualTo(element2.hashCode()) + } + + @Test + fun `M not be equal W different name`( + @StringForgery name1: String, + @StringForgery name2: String, + @BoolForgery isImage: Boolean + ) { + // Given + val element1 = DatadogSemanticsElement(name1, isImage) + val element2 = DatadogSemanticsElement(name2, isImage) + + // Then + if (name1 != name2) { + assertThat(element1).isNotEqualTo(element2) + } + } + + @Test + fun `M not be equal W different isImage`( + @StringForgery name: String + ) { + // Given + val element1 = DatadogSemanticsElement(name, true) + val element2 = DatadogSemanticsElement(name, false) + + // Then + assertThat(element1).isNotEqualTo(element2) + } + + @Test + fun `M node not merge descendants W shouldMergeDescendantSemantics`( + @StringForgery name: String, + @BoolForgery isImage: Boolean + ) { + // Given + val element = DatadogSemanticsElement(name, isImage) + val node = element.create() + + // Then + assertThat(node.shouldMergeDescendantSemantics).isFalse() + } + + @Test + fun `M node not clear descendants W shouldClearDescendantSemantics`( + @StringForgery name: String, + @BoolForgery isImage: Boolean + ) { + // Given + val element = DatadogSemanticsElement(name, isImage) + val node = element.create() + + // Then + assertThat(node.shouldClearDescendantSemantics).isFalse() + } +} From a5765876830c3741e15fefa45b8967dce21ea6f4 Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Wed, 11 Mar 2026 15:00:11 +0100 Subject: [PATCH 2/4] Fix ktlint: Format class and function signatures Apply ktlint standard:class-signature and standard:function-signature rules to DatadogSemanticsElement, DatadogSemanticsNode, and test methods. --- .../com/datadog/android/compose/DatadogModifier.kt | 13 +++++-------- .../android/compose/DatadogSemanticsElementTest.kt | 14 +++----------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt b/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt index a466ac5a84..5949d01259 100644 --- a/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt +++ b/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt @@ -61,10 +61,8 @@ internal var SemanticsPropertyReceiver.datadog by DatadogSemanticsPropertyKey * node is explicitly excluded from the layout measurement chain, ensuring it never modifies * constraints passed to child composables. */ -internal data class DatadogSemanticsElement( - val name: String, - val isImage: Boolean -) : ModifierNodeElement() { +internal data class DatadogSemanticsElement(val name: String, val isImage: Boolean) : + ModifierNodeElement() { override fun create(): DatadogSemanticsNode = DatadogSemanticsNode(name, isImage) override fun update(node: DatadogSemanticsNode) { @@ -83,10 +81,9 @@ internal data class DatadogSemanticsElement( * This node does NOT implement `LayoutModifierNode`, so it is never consulted during * layout measurement and cannot modify constraints. */ -internal class DatadogSemanticsNode( - var name: String, - var isImage: Boolean -) : Modifier.Node(), SemanticsModifierNode { +internal class DatadogSemanticsNode(var name: String, var isImage: Boolean) : + Modifier.Node(), + SemanticsModifierNode { override val shouldMergeDescendantSemantics: Boolean get() = false override val shouldClearDescendantSemantics: Boolean get() = false diff --git a/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt b/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt index 8a5c3075e5..3720994752 100644 --- a/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt +++ b/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt @@ -23,10 +23,7 @@ import org.mockito.junit.jupiter.MockitoExtension class DatadogSemanticsElementTest { @Test - fun `M create node with correct properties W create()`( - @StringForgery name: String, - @BoolForgery isImage: Boolean - ) { + fun `M create node with correct properties W create()`(@StringForgery name: String, @BoolForgery isImage: Boolean) { // Given val element = DatadogSemanticsElement(name, isImage) @@ -58,10 +55,7 @@ class DatadogSemanticsElementTest { } @Test - fun `M be equal W same properties`( - @StringForgery name: String, - @BoolForgery isImage: Boolean - ) { + fun `M be equal W same properties`(@StringForgery name: String, @BoolForgery isImage: Boolean) { // Given val element1 = DatadogSemanticsElement(name, isImage) val element2 = DatadogSemanticsElement(name, isImage) @@ -88,9 +82,7 @@ class DatadogSemanticsElementTest { } @Test - fun `M not be equal W different isImage`( - @StringForgery name: String - ) { + fun `M not be equal W different isImage`(@StringForgery name: String) { // Given val element1 = DatadogSemanticsElement(name, true) val element2 = DatadogSemanticsElement(name, false) From 6171e76b3dd0d27f3c2055a0ba603b34aef4a57f Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Wed, 11 Mar 2026 15:14:08 +0100 Subject: [PATCH 3/4] Fix detekt: Convert data class to regular class with manual equals/hashCode Convert DatadogSemanticsElement from data class to regular class to avoid detekt DataClassContainsFunctions violation. Manually implement equals() and hashCode() to maintain the same behavior for Compose modifier reuse. --- .../com/datadog/android/compose/DatadogModifier.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt b/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt index 5949d01259..95148095ff 100644 --- a/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt +++ b/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt @@ -61,7 +61,7 @@ internal var SemanticsPropertyReceiver.datadog by DatadogSemanticsPropertyKey * node is explicitly excluded from the layout measurement chain, ensuring it never modifies * constraints passed to child composables. */ -internal data class DatadogSemanticsElement(val name: String, val isImage: Boolean) : +internal class DatadogSemanticsElement(private val name: String, private val isImage: Boolean) : ModifierNodeElement() { override fun create(): DatadogSemanticsNode = DatadogSemanticsNode(name, isImage) @@ -74,6 +74,18 @@ internal data class DatadogSemanticsElement(val name: String, val isImage: Boole this.properties["name"] = name this.properties["isImage"] = isImage } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DatadogSemanticsElement) return false + return name == other.name && isImage == other.isImage + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + isImage.hashCode() + return result + } } /** From 3cff75cd37d4b8028d07291d5b8d988fdf4bda19 Mon Sep 17 00:00:00 2001 From: Marius Constantin Date: Wed, 11 Mar 2026 15:28:29 +0100 Subject: [PATCH 4/4] Style: Use multi-line signatures matching project conventions Revert ktlint auto-format to use multi-line class and function signatures that match the existing project style conventions (parameters on separate lines when there are 2+ parameters). --- .../com/datadog/android/compose/DatadogModifier.kt | 13 ++++++++----- .../android/compose/DatadogSemanticsElementTest.kt | 10 ++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt b/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt index 95148095ff..cc48fd9c42 100644 --- a/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt +++ b/integrations/dd-sdk-android-compose/src/main/kotlin/com/datadog/android/compose/DatadogModifier.kt @@ -61,8 +61,10 @@ internal var SemanticsPropertyReceiver.datadog by DatadogSemanticsPropertyKey * node is explicitly excluded from the layout measurement chain, ensuring it never modifies * constraints passed to child composables. */ -internal class DatadogSemanticsElement(private val name: String, private val isImage: Boolean) : - ModifierNodeElement() { +internal class DatadogSemanticsElement( + private val name: String, + private val isImage: Boolean +) : ModifierNodeElement() { override fun create(): DatadogSemanticsNode = DatadogSemanticsNode(name, isImage) override fun update(node: DatadogSemanticsNode) { @@ -93,9 +95,10 @@ internal class DatadogSemanticsElement(private val name: String, private val isI * This node does NOT implement `LayoutModifierNode`, so it is never consulted during * layout measurement and cannot modify constraints. */ -internal class DatadogSemanticsNode(var name: String, var isImage: Boolean) : - Modifier.Node(), - SemanticsModifierNode { +internal class DatadogSemanticsNode( + var name: String, + var isImage: Boolean +) : Modifier.Node(), SemanticsModifierNode { override val shouldMergeDescendantSemantics: Boolean get() = false override val shouldClearDescendantSemantics: Boolean get() = false diff --git a/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt b/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt index 3720994752..fc767cd6c7 100644 --- a/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt +++ b/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt @@ -23,7 +23,10 @@ import org.mockito.junit.jupiter.MockitoExtension class DatadogSemanticsElementTest { @Test - fun `M create node with correct properties W create()`(@StringForgery name: String, @BoolForgery isImage: Boolean) { + fun `M create node with correct properties W create()`( + @StringForgery name: String, + @BoolForgery isImage: Boolean + ) { // Given val element = DatadogSemanticsElement(name, isImage) @@ -55,7 +58,10 @@ class DatadogSemanticsElementTest { } @Test - fun `M be equal W same properties`(@StringForgery name: String, @BoolForgery isImage: Boolean) { + fun `M be equal W same properties`( + @StringForgery name: String, + @BoolForgery isImage: Boolean + ) { // Given val element1 = DatadogSemanticsElement(name, isImage) val element2 = DatadogSemanticsElement(name, isImage)