Merge "Introduce slots reuse logic in SubcomposeLayout" into androidx-main
diff --git a/compose/ui/ui/api/1.0.0-beta07.txt b/compose/ui/ui/api/1.0.0-beta07.txt
index bafada9..5bd98c8 100644
--- a/compose/ui/ui/api/1.0.0-beta07.txt
+++ b/compose/ui/ui/api/1.0.0-beta07.txt
@@ -1760,6 +1760,7 @@
   }
 
   public final class SubcomposeLayoutState {
+    ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
     ctor public SubcomposeLayoutState();
     method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt
index bafada9..5bd98c8 100644
--- a/compose/ui/ui/api/current.txt
+++ b/compose/ui/ui/api/current.txt
@@ -1760,6 +1760,7 @@
   }
 
   public final class SubcomposeLayoutState {
+    ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
     ctor public SubcomposeLayoutState();
     method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
diff --git a/compose/ui/ui/api/public_plus_experimental_1.0.0-beta07.txt b/compose/ui/ui/api/public_plus_experimental_1.0.0-beta07.txt
index b952190..7ccf817 100644
--- a/compose/ui/ui/api/public_plus_experimental_1.0.0-beta07.txt
+++ b/compose/ui/ui/api/public_plus_experimental_1.0.0-beta07.txt
@@ -1864,6 +1864,7 @@
   }
 
   public final class SubcomposeLayoutState {
+    ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
     ctor public SubcomposeLayoutState();
     method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
diff --git a/compose/ui/ui/api/public_plus_experimental_current.txt b/compose/ui/ui/api/public_plus_experimental_current.txt
index b952190..7ccf817 100644
--- a/compose/ui/ui/api/public_plus_experimental_current.txt
+++ b/compose/ui/ui/api/public_plus_experimental_current.txt
@@ -1864,6 +1864,7 @@
   }
 
   public final class SubcomposeLayoutState {
+    ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
     ctor public SubcomposeLayoutState();
     method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
diff --git a/compose/ui/ui/api/restricted_1.0.0-beta07.txt b/compose/ui/ui/api/restricted_1.0.0-beta07.txt
index b504acd..bd71677 100644
--- a/compose/ui/ui/api/restricted_1.0.0-beta07.txt
+++ b/compose/ui/ui/api/restricted_1.0.0-beta07.txt
@@ -1761,6 +1761,7 @@
   }
 
   public final class SubcomposeLayoutState {
+    ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
     ctor public SubcomposeLayoutState();
     method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt
index b504acd..bd71677 100644
--- a/compose/ui/ui/api/restricted_current.txt
+++ b/compose/ui/ui/api/restricted_current.txt
@@ -1761,6 +1761,7 @@
   }
 
   public final class SubcomposeLayoutState {
+    ctor public SubcomposeLayoutState(int maxSlotsToRetainForReuse);
     ctor public SubcomposeLayoutState();
     method public androidx.compose.ui.layout.SubcomposeLayoutState.PrecomposedSlotHandle precompose(Object? slotId, kotlin.jvm.functions.Function0<kotlin.Unit> content);
   }
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
index e7e9b98..88cd537 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
@@ -25,13 +25,17 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.background
 import androidx.compose.ui.draw.assertColor
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ImageBitmap
 import androidx.compose.ui.graphics.asAndroidBitmap
+import androidx.compose.ui.layout.RootMeasurePolicy.measure
 import androidx.compose.ui.platform.AndroidOwnerExtraAssertionsRule
 import androidx.compose.ui.platform.ComposeView
 import androidx.compose.ui.platform.LocalDensity
@@ -822,6 +826,185 @@
             assertThat(layoutCount).isEqualTo(1)
         }
     }
+
+    @Test
+    fun slotsKeptForReuse() {
+        val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val state = SubcomposeLayoutState(2)
+
+        composeItems(state, items)
+
+        rule.runOnIdle {
+            items.value = listOf(2, 3)
+        }
+
+        assertNodes(
+            exists = /*active*/ listOf(2, 3) + /*reusable*/ listOf(1, 4),
+            doesNotExist = /*disposed*/ listOf(0)
+        )
+    }
+
+    @Test
+    fun newSlotIsUsingReusedSlot() {
+        val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val state = SubcomposeLayoutState(2)
+
+        composeItems(state, items)
+
+        rule.runOnIdle {
+            items.value = listOf(2, 3)
+            // 1 and 4 are now in reusable buffer
+        }
+
+        rule.runOnIdle {
+            items.value = listOf(2, 3, 5)
+            // the last reusable slot (4) will be used for composing 5
+        }
+
+        assertNodes(
+            exists = /*active*/ listOf(2, 3, 5) + /*reusable*/ listOf(1),
+            doesNotExist = /*disposed*/ listOf(0, 4)
+        )
+    }
+
+    @Test
+    fun theSameSlotIsUsedWhileItIsInReusableList() {
+        val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val state = SubcomposeLayoutState(2)
+
+        composeItems(state, items)
+
+        rule.runOnIdle {
+            items.value = listOf(2, 3)
+            // 1 and 4 are now in reusable buffer
+        }
+
+        rule.runOnIdle {
+            items.value = listOf(2, 3, 1)
+            // slot 1 should be taken back from reusable
+        }
+
+        assertNodes(
+            exists = /*active*/ listOf(2, 3, 1) + /*reusable*/ listOf(4)
+        )
+    }
+
+    @Test
+    fun prefetchIsUsingReusableNodes() {
+        val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val state = SubcomposeLayoutState(2)
+
+        composeItems(state, items)
+
+        rule.runOnIdle {
+            items.value = listOf(2, 3)
+            // 1 and 4 are now in reusable buffer
+        }
+
+        rule.runOnIdle {
+            state.precompose(5) {
+                ItemContent(5)
+            }
+            // prefetch should take slot 4 from reuse
+        }
+
+        assertNodes(
+            exists = /*active*/ listOf(2, 3) + /*prefetch*/ listOf(5) + /*reusable*/ listOf(1)
+        )
+    }
+
+    @Test
+    fun prefetchSlotWhichIsInReusableList() {
+        val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val state = SubcomposeLayoutState(3)
+
+        composeItems(state, items)
+
+        rule.runOnIdle {
+            items.value = listOf(2)
+            // 1, 3 and 4 are now in reusable buffer
+        }
+
+        rule.runOnIdle {
+            state.precompose(3) {
+                ItemContent(3)
+            }
+            // prefetch should take slot 3 from reuse
+        }
+
+        assertNodes(
+            exists = /*active*/ listOf(2) + /*prefetch*/ listOf(3) + /*reusable*/ listOf(1, 4),
+            doesNotExist = listOf(0)
+        )
+    }
+
+    @Test
+    fun nothingIsReusedWhenMaxSlotsAre0() {
+        val items = mutableStateOf(listOf(0, 1, 2, 3, 4))
+        val state = SubcomposeLayoutState(0)
+
+        composeItems(state, items)
+
+        rule.runOnIdle {
+            items.value = listOf(2, 4)
+        }
+
+        assertNodes(
+            exists = listOf(2, 4),
+            doesNotExist = listOf(0, 1, 3)
+        )
+    }
+
+    @Test
+    fun reuse1Node() {
+        val items = mutableStateOf(listOf(0, 1, 2, 3))
+        val state = SubcomposeLayoutState(1)
+
+        composeItems(state, items)
+
+        rule.runOnIdle {
+            items.value = listOf(0, 1)
+        }
+
+        assertNodes(
+            exists = /*active*/ listOf(0, 1) + /*reusable*/ listOf(3),
+            doesNotExist = /*disposed*/ listOf(2)
+        )
+    }
+
+    private fun composeItems(
+        state: SubcomposeLayoutState,
+        items: MutableState<List<Int>>
+    ) {
+        rule.setContent {
+            SubcomposeLayout(state) { constraints ->
+                items.value.forEach {
+                    subcompose(it) {
+                        ItemContent(it)
+                    }.forEach {
+                        it.measure(constraints)
+                    }
+                }
+                layout(10, 10) {}
+            }
+        }
+    }
+
+    @Composable
+    private fun ItemContent(index: Int) {
+        Box(Modifier.fillMaxSize().testTag("$index"))
+    }
+
+    private fun assertNodes(exists: List<Int>, doesNotExist: List<Int> = emptyList()) {
+        exists.forEach {
+            rule.onNodeWithTag("$it")
+                .assertExists()
+        }
+        doesNotExist.forEach {
+            rule.onNodeWithTag("$it")
+                .assertDoesNotExist()
+        }
+    }
 }
 
 fun ImageBitmap.assertCenterPixelColor(expectedColor: Color) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt
index 2977afe..92a69d7 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt
@@ -135,9 +135,21 @@
 }
 
 /**
- * Contains the state used by [SubcomposeLayout].
+ * State used by [SubcomposeLayout].
+ *
+ * @param maxSlotsToRetainForReuse when non-zero the layout will keep active up to this count
+ * slots which we were used but not used anymore instead of disposing them. Later when you try to
+ * compose a new slot instead of creating a completely new slot the layout would reuse the
+ * previous slot which allows to do less work especially if the slot contents are similar.
  */
-class SubcomposeLayoutState {
+class SubcomposeLayoutState(
+    private val maxSlotsToRetainForReuse: Int
+) {
+    /**
+     * State used by [SubcomposeLayout].
+     */
+    constructor() : this(0)
+
     internal var compositionContext: CompositionContext? = null
 
     // Pre-allocated lambdas to update LayoutNode
@@ -151,15 +163,21 @@
     private val root: LayoutNode get() = requireNotNull(_root)
     private var currentIndex = 0
     private val nodeToNodeState = mutableMapOf<LayoutNode, NodeState>()
-    private val slodIdToNode = mutableMapOf<Any?, LayoutNode>()
+    // this map contains active slotIds (without precomposed or reusable nodes)
+    private val slotIdToNode = mutableMapOf<Any?, LayoutNode>()
     private val scope = Scope()
     private val precomposeMap = mutableMapOf<Any?, LayoutNode>()
 
     /**
-     * `root.foldedChildren` list contains all the active children (used during the last measure
-     * pass) plus n = `precomposedCount` nodes in the end of the list which were precomposed and
+     * `root.foldedChildren` list consist of:
+     * 1) all the active children (used during the last measure pass)
+     * 2) `reusableCount` nodes in the middle of the list which were active and stopped being
+     * used. now we keep them (up to `maxCountOfSlotsToReuse`) in order to reuse next time we
+     * will need to compose a new item
+     * 4) `precomposedCount` nodes in the end of the list which were precomposed and
      * are waiting to be used during the next measure passes.
      */
+    private var reusableCount = 0
     private var precomposedCount = 0
 
     internal fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable> {
@@ -168,12 +186,14 @@
             "subcompose can only be used inside the measure or layout blocks"
         }
 
-        val node = slodIdToNode.getOrPut(slotId) {
+        val node = slotIdToNode.getOrPut(slotId) {
             val precomposed = precomposeMap.remove(slotId)
             if (precomposed != null) {
                 check(precomposedCount > 0)
                 precomposedCount--
                 precomposed
+            } else if (reusableCount > 0) {
+                takeNodeFromReusables(slotId)
             } else {
                 createNodeAt(currentIndex)
             }
@@ -239,20 +259,64 @@
     }
 
     private fun disposeAfterIndex(currentIndex: Int) {
-        // this size is not including precomposed items in the end of the list
-        val activeChildrenSize = root.foldedChildren.size - precomposedCount
-        ignoreRemeasureRequests {
-            for (i in currentIndex until activeChildrenSize) {
-                disposeNode(root.foldedChildren[i])
-            }
-            root.removeAt(currentIndex, activeChildrenSize - currentIndex)
+        val precomposedNodesSectionStart = root.foldedChildren.size - precomposedCount
+        val reusableNodesSectionStart = maxOf(
+            currentIndex,
+            precomposedNodesSectionStart - maxSlotsToRetainForReuse
+        )
+
+        // keep up to maxCountOfSlotsToReuse last nodes to be reused later
+        reusableCount = precomposedNodesSectionStart - reusableNodesSectionStart
+        for (i in reusableNodesSectionStart until reusableNodesSectionStart + reusableCount) {
+            val node = root.foldedChildren[i]
+            val state = nodeToNodeState[node]!!
+            // remove them from slotIdToNode so they are not considered active
+            slotIdToNode.remove(state.slotId)
         }
+
+        // dispose the rest of the nodes
+        val nodesToDispose = reusableNodesSectionStart - currentIndex
+        if (nodesToDispose > 0) {
+            ignoreRemeasureRequests {
+                for (i in currentIndex until currentIndex + nodesToDispose) {
+                    disposeNode(root.foldedChildren[i])
+                }
+                root.removeAt(currentIndex, nodesToDispose)
+            }
+        }
+    }
+
+    private fun takeNodeFromReusables(slotId: Any?): LayoutNode {
+        check(reusableCount > 0)
+        val reusableNodesSectionEnd = root.foldedChildren.size - precomposedCount
+        val reusableNodesSectionStart = reusableNodesSectionEnd - reusableCount
+        var index = reusableNodesSectionStart
+        while (true) {
+            val node = root.foldedChildren[index]
+            val nodeState = nodeToNodeState.getValue(node)
+            if (nodeState.slotId == slotId) {
+                // we have a node with the same slotId
+                break
+            } else if (index == reusableNodesSectionEnd - 1) {
+                // it is the last available reusable node
+                nodeState.slotId = slotId
+                break
+            } else {
+                index++
+            }
+        }
+        if (index != reusableNodesSectionStart) {
+            // we need to rearrange the items
+            move(index, reusableNodesSectionStart, 1)
+        }
+        reusableCount--
+        return root.foldedChildren[reusableNodesSectionStart]
     }
 
     private fun disposeNode(node: LayoutNode) {
         val nodeState = nodeToNodeState.remove(node)!!
         nodeState.composition!!.dispose()
-        slodIdToNode.remove(nodeState.slotId)
+        slotIdToNode.remove(nodeState.slotId)
     }
 
     private fun createMeasurePolicy(
@@ -292,7 +356,7 @@
             it.composition!!.dispose()
         }
         nodeToNodeState.clear()
-        slodIdToNode.clear()
+        slotIdToNode.clear()
     }
 
     /**
@@ -309,10 +373,19 @@
      * @return [PrecomposedSlotHandle] instance which allows you to dispose the content.
      */
     fun precompose(slotId: Any?, content: @Composable () -> Unit): PrecomposedSlotHandle {
-        if (!slodIdToNode.containsKey(slotId)) {
+        if (!slotIdToNode.containsKey(slotId)) {
             val node = precomposeMap.getOrPut(slotId) {
-                createNodeAt(root.foldedChildren.size).also {
+                if (reusableCount > 0) {
+                    val node = takeNodeFromReusables(slotId)
+                    // now move this node to the end where we keep precomposed items
+                    val nodeIndex = root.foldedChildren.indexOf(node)
+                    move(nodeIndex, root.foldedChildren.size, 1)
                     precomposedCount++
+                    node
+                } else {
+                    createNodeAt(root.foldedChildren.size).also {
+                        precomposedCount++
+                    }
                 }
             }
             subcompose(node, slotId, content)
@@ -321,14 +394,21 @@
             override fun dispose() {
                 val node = precomposeMap.remove(slotId)
                 if (node != null) {
-                    ignoreRemeasureRequests {
-                        disposeNode(node)
-                        val itemIndex = root.foldedChildren.indexOf(node)
-                        check(itemIndex != -1)
-                        root.removeAt(itemIndex, 1)
-                        check(precomposedCount > 0)
-                        precomposedCount--
+                    val itemIndex = root.foldedChildren.indexOf(node)
+                    check(itemIndex != -1)
+                    if (reusableCount < maxSlotsToRetainForReuse) {
+                        val reusableNodesSectionStart =
+                            root.foldedChildren.size - precomposedCount - reusableCount
+                        move(itemIndex, reusableNodesSectionStart, 1)
+                        reusableCount++
+                    } else {
+                        ignoreRemeasureRequests {
+                            disposeNode(node)
+                            root.removeAt(itemIndex, 1)
+                        }
                     }
+                    check(precomposedCount > 0)
+                    precomposedCount--
                 }
             }
         }
@@ -350,7 +430,7 @@
         root.ignoreRemeasureRequests(block)
 
     private class NodeState(
-        val slotId: Any?,
+        var slotId: Any?,
         var content: @Composable () -> Unit,
         var composition: Composition? = null
     )
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index 5d9bd20..3fe9a9d 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -633,6 +633,8 @@
 Transforming pages\: *[0-9]+
 Rendering\: *[0-9]+
 WARNING\: unable to find what is referred to by
+@param maxSlotsToRetainForReuse
+in DClass SubcomposeLayoutState
 @param initVal
 in DClass AnimationVector[0-9]+D
 @param a,
@@ -966,4 +968,4 @@
 Execution optimizations have been disabled for task ':benchmark:benchmark\-macro:.*' to ensure correctness due to the following reasons:
 \- Gradle detected a problem with the following location: '\$OUT_DIR/androidx/benchmark/benchmark\-macro/build/generated/source/wire'\. Reason: Task ':benchmark:benchmark\-macro:.*' uses this output of task ':benchmark:benchmark\-macro:.*' without declaring an explicit or implicit dependency\. This can lead to incorrect results being produced, depending on what order the tasks are executed\. Please refer to https://docs\.gradle\.org/[0-9]+\.[0-9]+/userguide/validation_problems\.html\#implicit_dependency for more details about this problem\.
 # > Task :support-emoji-demos:lintDebug
-Error processing .* broken class file\? \(This feature requires ASM[0-9]+\)
+Error processing .* broken class file\? \(This feature requires ASM[0-9]+\)
\ No newline at end of file