Fix missing invalidations while movable content is moving
If a recompose socpe was explicitly invalidated while the
content is owned by the recomposer the invaliation was ignored.
This the second part of a two part fix for 262514238
Fixes: 262514238
Test: ./gradlew :compose:r:r:tDUT
Change-Id: Ic487b8f797a2862effc07d2ef020d8461717940e
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
index 3c0412e..9968148 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
@@ -320,7 +320,7 @@
internal val composition: ControlledComposition,
internal val slotTable: SlotTable,
internal val anchor: Anchor,
- internal val invalidations: List<Pair<RecomposeScopeImpl, IdentityArraySet<Any>?>>,
+ internal var invalidations: List<Pair<RecomposeScopeImpl, IdentityArraySet<Any>?>>,
internal val locals: PersistentCompositionLocalMap
)
@@ -1865,13 +1865,7 @@
when (val previous = slots.set(groupSlotIndex, value)) {
is RememberObserver ->
rememberManager.forgetting(previous)
- is RecomposeScopeImpl -> {
- val composition = previous.composition
- if (composition != null) {
- previous.release()
- composition.pendingInvalidScopes = true
- }
- }
+ is RecomposeScopeImpl -> previous.release()
}
}
}
@@ -2770,11 +2764,7 @@
}
}
is RecomposeScopeImpl -> {
- val composition = data.composition
- if (composition != null) {
- composition.pendingInvalidScopes = true
- data.release()
- }
+ data.release()
reader.reposition(group)
recordSlotTableOperation { _, slots, _ ->
runtimeCheck(data == slots.slot(group, index)) {
@@ -3109,15 +3099,11 @@
// For all the anchors that moved, if the anchor is tracking a recompose
// scope, update it to reference its new composer.
- if (anchors.isNotEmpty()) {
- val toComposition = to.composition as CompositionImpl
- anchors.fastForEach { anchor ->
- // The recompose scope is always at slot 0 of a restart group.
- val recomposeScope = slots.slot(anchor, 0) as? RecomposeScopeImpl
- // Check for null as the anchor might not be for a recompose scope
- recomposeScope?.adoptedBy(toComposition)
- }
- }
+ RecomposeScopeImpl.adoptAnchoredScopes(
+ slots = slots,
+ anchors = anchors,
+ newOwner = to.composition as RecomposeScopeOwner
+ )
}
fromTable.read { reader ->
@@ -3700,7 +3686,7 @@
// composition before the new composition can be composed to receive it. When
// the new composition receives the state it must recompose over the state by
// calling invokeMovableContentLambda.
- slotTable.write { writer ->
+ val anchors = slotTable.write { writer ->
writer.beginInsert()
// This is the prefix created by invokeMovableContentLambda
@@ -3709,7 +3695,7 @@
writer.update(reference.parameter)
// Move the content into current location
- slots.moveTo(reference.anchor, 1, writer)
+ val anchors = slots.moveTo(reference.anchor, 1, writer)
// skip the group that was just inserted.
writer.skipGroup()
@@ -3718,8 +3704,63 @@
writer.endGroup()
writer.endInsert()
+
+ anchors
}
+
val state = MovableContentState(slotTable)
+ if (RecomposeScopeImpl.hasAnchoredRecomposeScopes(slotTable, anchors)) {
+ // Copy this into a local so the `movableContentRecomposeScopeOwner` captures
+ // only the value of `composition` not the composer not the composer's reference
+ // to the composition, which might change if we move to a composer per thread model
+ // instead of the current composer per composition, and the owner of the recompose
+ // scope is the composition, not the composer.
+ val composition = composition
+
+ // If any recompose scopes are invalidated while the movable content is outside
+ // a composition, ensure the reference is updated to contain the invalidation.
+ val movableContentRecomposeScopeOwner = object : RecomposeScopeOwner {
+ override fun invalidate(
+ scope: RecomposeScopeImpl,
+ instance: Any?
+ ): InvalidationResult {
+ // Try sending this to the original owner first.
+ val result = (composition as? RecomposeScopeOwner)?.invalidate(scope, instance)
+ ?: InvalidationResult.IGNORED
+
+ // If the original owner ignores this then we need to record it in the
+ // reference
+ if (result == InvalidationResult.IGNORED) {
+ reference.invalidations += scope to instance?.let {
+ IdentityArraySet<Any>().also { it.add(it) }
+ }
+ return InvalidationResult.SCHEDULED
+ }
+ return result
+ }
+
+ // The only reason [recomposeScopeReleased] is called is when the recompose scope is
+ // removed from the table. First, this never happens for content that is moving, and
+ // 2) even if it did the only reason we tell the composer is to clear tracking
+ // tables that contain this information which is not relevant here.
+ override fun recomposeScopeReleased(scope: RecomposeScopeImpl) {
+ // Nothing to do
+ }
+
+ // [recordReadOf] this is also something that would happen only during active
+ // recomposition which doesn't happened to a slot table that is moving.
+ override fun recordReadOf(value: Any) {
+ // Nothing to do
+ }
+ }
+ slotTable.write { writer ->
+ RecomposeScopeImpl.adoptAnchoredScopes(
+ slots = writer,
+ anchors = anchors,
+ newOwner = movableContentRecomposeScopeOwner
+ )
+ }
+ }
parentContext.movableContentStateReleased(reference, state)
}
@@ -4203,11 +4244,7 @@
rememberManager.forgetting(slot)
}
if (slot is RecomposeScopeImpl) {
- val composition = slot.composition
- if (composition != null) {
- composition.pendingInvalidScopes = true
- slot.release()
- }
+ slot.release()
}
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
index 83dc817e..bc7d310 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
@@ -348,7 +348,7 @@
private val applier: Applier<*>,
recomposeContext: CoroutineContext? = null
-) : ControlledComposition {
+) : ControlledComposition, RecomposeScopeOwner {
/**
* `null` if a composition isn't pending to apply.
* `Set<Any>` or `Array<Set<Any>>` if there are modifications to record
@@ -928,7 +928,7 @@
} else block()
}
- fun invalidate(scope: RecomposeScopeImpl, instance: Any?): InvalidationResult {
+ override fun invalidate(scope: RecomposeScopeImpl, instance: Any?): InvalidationResult {
if (scope.defaultsInScope) {
scope.defaultsInvalid = true
}
@@ -948,6 +948,10 @@
return invalidateChecked(scope, anchor, instance)
}
+ override fun recomposeScopeReleased(scope: RecomposeScopeImpl) {
+ pendingInvalidScopes = true
+ }
+
private fun tryImminentInvalidation(scope: RecomposeScopeImpl, instance: Any?): Boolean =
isComposing && composer.tryImminentInvalidation(scope, instance)
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt
index b5b8aac..8f72d6ad8 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt
@@ -19,6 +19,8 @@
import androidx.compose.runtime.collection.IdentityArrayIntMap
import androidx.compose.runtime.collection.IdentityArrayMap
import androidx.compose.runtime.collection.IdentityArraySet
+import androidx.compose.runtime.snapshots.fastAny
+import androidx.compose.runtime.snapshots.fastForEach
/**
* Represents a recomposable scope or section of the composition hierarchy. Can be used to
@@ -57,6 +59,12 @@
private const val SkippedFlag = 0x10
private const val RereadingFlag = 0x20
+internal interface RecomposeScopeOwner {
+ fun invalidate(scope: RecomposeScopeImpl, instance: Any?): InvalidationResult
+ fun recomposeScopeReleased(scope: RecomposeScopeImpl)
+ fun recordReadOf(value: Any)
+}
+
/**
* A RecomposeScope is created for a region of the composition that can be recomposed independently
* of the rest of the composition. The composer will position the slot table to the location
@@ -64,13 +72,12 @@
* [Composer.startRestartGroup] and is used to track how to restart the group.
*/
internal class RecomposeScopeImpl(
- composition: CompositionImpl?
+ owner: RecomposeScopeOwner?
) : ScopeUpdateScope, RecomposeScope {
private var flags: Int = 0
- var composition: CompositionImpl? = composition
- private set
+ private var owner: RecomposeScopeOwner? = owner
/**
* An anchor to the location in the slot table that start the group associated with this
@@ -83,7 +90,7 @@
* removed from the slot table. For example, if the scope is in the then clause of an if
* statement that later becomes false.
*/
- val valid: Boolean get() = composition != null && anchor?.valid ?: false
+ val valid: Boolean get() = owner != null && anchor?.valid ?: false
val canRecompose: Boolean get() = block != null
@@ -163,18 +170,19 @@
}
/**
- * Invalidate the group which will cause [composition] to request this scope be recomposed,
+ * Invalidate the group which will cause [owner] to request this scope be recomposed,
* and an [InvalidationResult] will be returned.
*/
fun invalidateForResult(value: Any?): InvalidationResult =
- composition?.invalidate(this, value) ?: InvalidationResult.IGNORED
+ owner?.invalidate(this, value) ?: InvalidationResult.IGNORED
/**
* Release the recompose scope. This is called when the recompose scope has been removed by the
* compostion because the part of the composition it was tracking was removed.
*/
fun release() {
- composition = null
+ owner?.recomposeScopeReleased(this)
+ owner = null
trackedInstances = null
trackedDependencies = null
}
@@ -183,18 +191,18 @@
* Called when the data tracked by this recompose scope moves to a different composition when
* for example, the movable content it is part of has moved.
*/
- fun adoptedBy(composition: CompositionImpl) {
- this.composition = composition
+ fun adoptedBy(owner: RecomposeScopeOwner) {
+ this.owner = owner
}
/**
- * Invalidate the group which will cause [composition] to request this scope be recomposed.
+ * Invalidate the group which will cause [owner] to request this scope be recomposed.
*
* Unlike [invalidateForResult], this method is thread safe and calls the thread safe
* invalidate on the composer.
*/
override fun invalidate() {
- composition?.invalidate(this, null)
+ owner?.invalidate(this, null)
}
/**
@@ -291,12 +299,12 @@
}
fun rereadTrackedInstances() {
- composition?.let { composition ->
+ owner?.let { owner ->
trackedInstances?.let { trackedInstances ->
rereading = true
try {
trackedInstances.forEach { value, _ ->
- composition.recordReadOf(value)
+ owner.recordReadOf(value)
}
} finally {
rereading = false
@@ -345,4 +353,26 @@
} else null
}
}
+
+ companion object {
+ internal fun adoptAnchoredScopes(
+ slots: SlotWriter,
+ anchors: List<Anchor>,
+ newOwner: RecomposeScopeOwner
+ ) {
+ if (anchors.isNotEmpty()) {
+ anchors.fastForEach { anchor ->
+ // The recompose scope is always at slot 0 of a restart group.
+ val recomposeScope = slots.slot(anchor, 0) as? RecomposeScopeImpl
+ // Check for null as the anchor might not be for a recompose scope
+ recomposeScope?.adoptedBy(newOwner)
+ }
+ }
+ }
+
+ internal fun hasAnchoredRecomposeScopes(slots: SlotTable, anchors: List<Anchor>) =
+ anchors.isNotEmpty() && anchors.fastAny {
+ slots.ownsAnchor(it) && slots.slot(slots.anchorIndex(it), 0) is RecomposeScopeImpl
+ }
+ }
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
index 1a46dc9..624d885e 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
@@ -599,6 +599,13 @@
return slots.toList().subList(start, end)
}
+ internal fun slot(group: Int, slotIndex: Int): Any? {
+ val start = groups.slotAnchor(group)
+ val end = if (group + 1 < groupsSize) groups.dataAnchor(group + 1) else slots.size
+ val len = end - start
+ return if (slotIndex in 0 until len) return slots[start + slotIndex] else Composer.Empty
+ }
+
override val compositionGroups: Iterable<CompositionGroup> get() = this
override fun iterator(): Iterator<CompositionGroup> =
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/MovableContentTests.kt b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/MovableContentTests.kt
index d5ad0d4..d3d1a87 100644
--- a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/MovableContentTests.kt
+++ b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/MovableContentTests.kt
@@ -32,10 +32,8 @@
import kotlin.test.assertNotEquals
import kotlin.test.assertSame
import kotlin.test.assertTrue
-import kotlinx.coroutines.ExperimentalCoroutinesApi
@Stable
-@OptIn(ExperimentalCoroutinesApi::class)
class MovableContentTests {
@Test
@@ -779,7 +777,6 @@
}
@Test
- @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
fun validateRecomposeScopesDoNotGetLost() = compositionTest {
var isHorizontal by mutableStateOf(false)
val displayValue = mutableStateOf(0)
@@ -809,7 +806,7 @@
isHorizontal = true
Snapshot.sendApplyNotifications()
- testCoroutineScheduler.advanceTimeBy(10)
+ advanceTimeBy(10)
displayValue.value++
expectChanges()
@@ -1493,7 +1490,7 @@
}
}
- testCoroutineScheduler.advanceTimeBy(5_000)
+ advanceTimeBy(5_000)
assertEquals(state, lastSeen)
@@ -1505,6 +1502,45 @@
assertEquals(state, lastSeen, "Failed in iteration $it")
}
}
+
+ @Test
+ fun stateChangesWhilePendingMove() = compositionTest {
+ var state = 0
+ var lastSeen: Int? = null
+ var deferred by mutableStateOf(false)
+ var scope: RecomposeScope? = null
+
+ val content = movableContentOf {
+ Container {
+ lastSeen = state
+ scope = currentRecomposeScope
+ }
+ }
+
+ compose {
+ if (deferred) {
+ DeferredSubcompose {
+ content()
+ }
+ SideEffect {
+ state++
+ scope?.invalidate()
+ }
+ } else {
+ content()
+ }
+ }
+
+ advanceTimeBy(5_000)
+
+ assertEquals(state, lastSeen)
+
+ deferred = true
+
+ advance()
+
+ assertEquals(state, lastSeen)
+ }
}
@Composable
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/mock/CompositionTest.kt b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/mock/CompositionTest.kt
index 0cd8df6..15e98c8 100644
--- a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/mock/CompositionTest.kt
+++ b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/mock/CompositionTest.kt
@@ -62,7 +62,7 @@
val changeCount = recomposer.changeCount
Snapshot.sendApplyNotifications()
if (recomposer.hasPendingWork) {
- testScheduler.advanceTimeBy(5_000)
+ advanceTimeBy(5_000)
check(ignorePendingWork || !recomposer.hasPendingWork) {
"Potentially infinite recomposition, still recomposing after advancing"
}
@@ -70,6 +70,8 @@
return recomposer.changeCount - changeCount
}
+ override fun advanceTimeBy(amount: Long) = testScheduler.advanceTimeBy(amount)
+
override fun advance(ignorePendingWork: Boolean) = advanceCount(ignorePendingWork) != 0L
override fun verifyConsistent() {
@@ -113,6 +115,11 @@
fun advanceCount(ignorePendingWork: Boolean = false): Long
/**
+ * Advance the clock by [amount] ms
+ */
+ fun advanceTimeBy(amount: Long)
+
+ /**
* Verify the composition is well-formed.
*/
fun verifyConsistent()