Clear lookaheadPassDelegate when layoutNode is moved out of a LookaheadScope

When lookahead root is set to null, clear the lookahead pass delegate.
This can happen when lookaheadScope is removed in one of the parents of a
given LayoutNode, or more likely when movableContent moves from a parent
in a LookaheadScope to a parent not in any LookaheadScope.

Fixes: 389826380
Test: new test added
Change-Id: Ib6b505684a5ae5af4e53963221238342c4db3951
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/LookaheadDelegatesTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/LookaheadDelegatesTest.kt
new file mode 100644
index 0000000..cb5de6d
--- /dev/null
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/layout/LookaheadDelegatesTest.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2025 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.ui.layout
+
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.movableContentOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.AndroidOwnerExtraAssertionsRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class LookaheadDelegatesTest {
+    @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
+
+    @get:Rule val excessiveAssertions = AndroidOwnerExtraAssertionsRule()
+
+    @Test
+    fun testResetLookaheadPassDelegate() {
+        var placeChild by mutableStateOf(true)
+        var useLookaheadScope by mutableStateOf(true)
+        rule.setContent {
+            val movableContent = remember {
+                movableContentOf {
+                    Row(Modifier.padding(5.dp).requiredSize(200.dp)) {
+                        Box(Modifier.size(100.dp))
+                        Box(Modifier.size(100.dp))
+                        if (!useLookaheadScope) {
+                            Box(Modifier.size(100.dp))
+                        }
+                    }
+                }
+            }
+            Box(
+                Modifier.layout { m, c ->
+                    m.measure(c).run {
+                        layout(width, height) {
+                            if (placeChild) {
+                                place(0, 0)
+                            }
+                        }
+                    }
+                }
+            ) {
+                // Move moveableContent from a parent in LookaheadScope to a parent that is not
+                // in a LookaheadScope.
+                if (useLookaheadScope) {
+                    Box { LookaheadScope { movableContent() } }
+                } else {
+                    movableContent()
+                }
+            }
+        }
+
+        rule.waitForIdle()
+        placeChild = false
+        useLookaheadScope = !useLookaheadScope
+        rule.waitForIdle()
+
+        placeChild = true
+        rule.waitForIdle()
+    }
+}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
index 5f60779..c644a2c 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNode.kt
@@ -118,6 +118,12 @@
                 if (newRoot != null) {
                     layoutDelegate.ensureLookaheadDelegateCreated()
                     forEachCoordinatorIncludingInner { it.ensureLookaheadDelegateCreated() }
+                } else {
+                    // When lookahead root is set to null, clear the lookahead pass delegate.
+                    // This can happen when lookaheadScope is removed in one of the parents, or
+                    // more likely when movableContent moves from a parent in a LookaheadScope to
+                    // a parent not in a LookaheadScope.
+                    layoutDelegate.clearLookaheadDelegate()
                 }
                 invalidateMeasurements()
             }
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt
index 53d3cdb..e8b9d20 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt
@@ -368,6 +368,10 @@
         measurePassDelegate.childDelegatesDirty = true
         lookaheadPassDelegate?.let { it.childDelegatesDirty = true }
     }
+
+    fun clearLookaheadDelegate() {
+        lookaheadPassDelegate = null
+    }
 }
 
 /**