Skip to content

Commit 6bd3aa5

Browse files
committed
fix(compose): retain root span cache entries synchronously to avoid a dispose/remember race
Retaining inside a DisposableEffect deferred the retain to the effect-application phase, but Compose dispatches an outgoing composable's onDispose before an incoming composable's DisposableEffect in the same recomposition. Replacing a SentryTraced call under a given scopes (e.g. during navigation) could therefore have the outgoing call's release clear the cache before the incoming call's retain ran, so a later SentryTraced call under the same scopes found nothing cached and created a duplicate root span. Retain now happens inside remember instead, which runs synchronously during composition, before any effect-phase work for that frame - including another SentryTraced call's dispose. Release stays in DisposableEffect's onDispose since its timing no longer matters. Adds a regression test that swaps which keyed SentryTraced composable is mounted under the same scopes twice in a row, which reproduces the duplicate span without this fix.
1 parent 6702434 commit 6bd3aa5

2 files changed

Lines changed: 37 additions & 4 deletions

File tree

sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryComposeTracing.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,13 @@ public fun SentryTraced(
124124
) {
125125
val scopes = LocalSentryScopes.current ?: Sentry.getCurrentScopes()
126126
val rootSpans = LocalRootSpans.current
127-
DisposableEffect(rootSpans, scopes) {
128-
rootSpans.retain(scopes)
129-
onDispose { rootSpans.release(scopes) }
130-
}
127+
// Retain synchronously during composition (not inside a DisposableEffect) so it always runs
128+
// before any effect-phase work for this frame, including another SentryTraced call's dispose:
129+
// Compose runs the dispose of an outgoing node before the effects of an incoming one in the
130+
// same recomposition, so retaining from an effect could let the shared entry's refcount hit
131+
// zero (and get evicted) between an old and a new SentryTraced call sharing the same scopes.
132+
remember(rootSpans, scopes) { rootSpans.retain(scopes) }
133+
DisposableEffect(rootSpans, scopes) { onDispose { rootSpans.release(scopes) } }
131134
val parentCompositionSpan =
132135
getOrCreateParentSpan(rootSpans.compositionSpans, scopes, ::createCompositionParentSpan)
133136
val parentRenderingSpan =

sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import android.content.ComponentName
55
import androidx.activity.ComponentActivity
66
import androidx.compose.foundation.layout.Box
77
import androidx.compose.runtime.CompositionLocalProvider
8+
import androidx.compose.runtime.getValue
9+
import androidx.compose.runtime.key
10+
import androidx.compose.runtime.mutableStateOf
11+
import androidx.compose.runtime.setValue
812
import androidx.compose.ui.ExperimentalComposeUiApi
913
import androidx.compose.ui.test.junit4.createAndroidComposeRule
1014
import androidx.test.core.app.ApplicationProvider
@@ -117,4 +121,30 @@ class SentryTracedTest {
117121
assertEquals(1, txB.spans.count { it.operation == "ui.compose.composition" })
118122
assertEquals(1, txB.spans.count { it.operation == "ui.compose" })
119123
}
124+
125+
@Test
126+
fun `repeatedly replacing a traced composable under the same scopes keeps sharing the root span`() {
127+
// Each swap disposes the outgoing keyed composable and mounts a new one under the same
128+
// scopes within a single recomposition. Compose dispatches the outgoing composable's
129+
// onDispose before the incoming one's DisposableEffect runs, so a second swap is needed to
130+
// surface a root span cache that was cleared out from under a still-live retain.
131+
val scopes = newTracingScopes()
132+
val tx = scopes.startBoundTransaction("custom-scopes-tx")
133+
var step by mutableStateOf(0)
134+
135+
rule.setContent {
136+
CompositionLocalProvider(LocalSentryScopes provides scopes) {
137+
key(step) { SentryTraced(tag = "step-$step") { Box {} } }
138+
}
139+
}
140+
rule.waitForIdle()
141+
142+
step = 1
143+
rule.waitForIdle()
144+
145+
step = 2
146+
rule.waitForIdle()
147+
148+
assertEquals(1, tx.spans.count { it.operation == "ui.compose.composition" })
149+
}
120150
}

0 commit comments

Comments
 (0)