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..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 @@ -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,63 @@ 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 class DatadogSemanticsElement( + private val name: String, + private 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 + } + + 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 + } +} + +/** + * 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..fc767cd6c7 --- /dev/null +++ b/integrations/dd-sdk-android-compose/src/test/kotlin/com/datadog/android/compose/DatadogSemanticsElementTest.kt @@ -0,0 +1,125 @@ +/* + * 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() + } +}