Merge "Fix immediate invalidations for moved movable content" into androidx-main
diff --git a/compose/runtime/runtime/integration-tests/src/androidAndroidTest/kotlin/androidx/compose/runtime/AndroidMovableContentTests.kt b/compose/runtime/runtime/integration-tests/src/androidAndroidTest/kotlin/androidx/compose/runtime/AndroidMovableContentTests.kt
new file mode 100644
index 0000000..c17130f
--- /dev/null
+++ b/compose/runtime/runtime/integration-tests/src/androidAndroidTest/kotlin/androidx/compose/runtime/AndroidMovableContentTests.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import kotlin.test.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class AndroidMovableContentTests : BaseComposeTest() {
+    @get:Rule
+    override val activityRule = makeTestActivityRule()
+
+    @Test
+    fun testMovableContentParameterInBoxWithConstraints() {
+        var state by mutableStateOf(false)
+        var lastSeen: Boolean? = null
+        val content = movableContentOf { parameter: Boolean ->
+            val content = @Composable {
+                lastSeen = parameter
+            }
+            Container(content)
+        }
+
+        // Infrastructure to avoid throwing a failure on the UI thread.
+        val failureReasons = mutableListOf<String>()
+        fun failed(reason: String) {
+            failureReasons.add(reason)
+        }
+
+        fun phase(description: String): Phase {
+            return object : Phase {
+                override fun expect(actual: Any?): Result {
+                    return object : Result {
+                        override fun toEqual(expected: Any?) {
+                            if (expected != actual) {
+                                failed("$description, expected $actual to be $expected")
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        compose {
+            if (state) {
+                content(true)
+            } else {
+                BoxWithConstraints {
+                    content(false)
+                }
+            }
+        }.then {
+            phase("In initial composition").expect(lastSeen).toEqual(state)
+            state = true
+        }.then {
+            phase("When setting state to true").expect(lastSeen).toEqual(state)
+            state = false
+        }.then {
+            phase("When setting state to false").expect(lastSeen).toEqual(state)
+        }.done()
+
+        assertEquals("", failureReasons.joinToString())
+    }
+}
+
+@Composable
+fun Container(content: @Composable () -> Unit) {
+    content()
+}
+
+interface Phase {
+    fun expect(actual: Any?): Result
+}
+
+interface Result {
+    fun toEqual(expected: Any?)
+}
\ No newline at end of file
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 af9f3bc..3c0412e 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
@@ -2674,6 +2674,7 @@
 
     internal fun tryImminentInvalidation(scope: RecomposeScopeImpl, instance: Any?): Boolean {
         val anchor = scope.anchor ?: return false
+        val slotTable = reader.table
         val location = anchor.toIndexFor(slotTable)
         if (isComposing && location >= reader.currentGroup) {
             // if we are invalidating a scope that is going to be traversed during this
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 165ab21..83dc817e 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
@@ -933,15 +933,24 @@
             scope.defaultsInvalid = true
         }
         val anchor = scope.anchor
-        if (anchor == null || !slotTable.ownsAnchor(anchor) || !anchor.valid)
-            return InvalidationResult.IGNORED // The scope has not yet entered the composition
-        if (!anchor.valid)
+        if (anchor == null || !anchor.valid)
             return InvalidationResult.IGNORED // The scope was removed from the composition
+        if (!slotTable.ownsAnchor(anchor)) {
+            // The scope might be owned by the delegate
+            val delegate = synchronized(lock) { invalidationDelegate }
+            if (delegate?.tryImminentInvalidation(scope, instance) == true)
+                return InvalidationResult.IMMINENT // The scope was owned by the delegate
+
+            return InvalidationResult.IGNORED // The scope has not yet entered the composition
+        }
         if (!scope.canRecompose)
             return InvalidationResult.IGNORED // The scope isn't able to be recomposed/invalidated
         return invalidateChecked(scope, anchor, instance)
     }
 
+    private fun tryImminentInvalidation(scope: RecomposeScopeImpl, instance: Any?): Boolean =
+        isComposing && composer.tryImminentInvalidation(scope, instance)
+
     private fun invalidateChecked(
         scope: RecomposeScopeImpl,
         anchor: Anchor,
@@ -959,7 +968,7 @@
                 } else null
             }
             if (delegate == null) {
-                if (isComposing && composer.tryImminentInvalidation(scope, instance)) {
+                if (tryImminentInvalidation(scope, instance)) {
                     // The invalidation was redirected to the composer.
                     return InvalidationResult.IMMINENT
                 }
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 24a3119..d5ad0d4 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,8 +32,10 @@
 import kotlin.test.assertNotEquals
 import kotlin.test.assertSame
 import kotlin.test.assertTrue
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 
 @Stable
+@OptIn(ExperimentalCoroutinesApi::class)
 class MovableContentTests {
 
     @Test
@@ -1470,6 +1472,39 @@
         assertEquals(2, hashList.size)
         assertEquals(hashList[0], hashList[1])
     }
+
+    @Test
+    fun parameterPassingThroughDeferredSubcompose() = compositionTest {
+        var state by mutableStateOf(false)
+        var lastSeen: Boolean? = null
+        val content = movableContentOf { parameter: Boolean ->
+            Container {
+                lastSeen = parameter
+            }
+        }
+
+        compose {
+            if (state) {
+                content(true)
+            } else {
+                DeferredSubcompose {
+                    content(state)
+                }
+            }
+        }
+
+        testCoroutineScheduler.advanceTimeBy(5_000)
+
+        assertEquals(state, lastSeen)
+
+        repeat(5) {
+            state = !state
+
+            expectChanges()
+
+            assertEquals(state, lastSeen, "Failed in iteration $it")
+        }
+    }
 }
 
 @Composable
@@ -1593,7 +1628,6 @@
         composition.setContent(content)
     }
     DisposableEffect(Unit) {
-
         onDispose { composition.dispose() }
     }
 }
@@ -1622,14 +1656,14 @@
     }
 
     override fun onForgotten() {
-        check(count > 0) { "Abandoned or forgotten mor times than remembered" }
+        check(count > 0) { "Abandoned or forgotten more times than remembered" }
         forgottenCount++
         count--
         if (count == 0) died = true
     }
 
     override fun onAbandoned() {
-        check(count > 0) { "Abandoned or forgotten mor times than remembered" }
+        check(count > 0) { "Abandoned or forgotten more times than remembered" }
         abandonedCount++
         count--
         if (count == 0) died = true