Repository: cashapp/redwood
Pull request #471 (2022-10-21 10:08:04)

Refactor Widget.Children to support accessing child layout modifiers.
Changes `Widget.Children` to support accessing + being notified about changes to child modifiers.

I left `LayoutModifier`s as part of `Widget` for the moment as it's not clear how we plan to extend layout modifiers in the future (e.g. [IDs](https://github.com/cashapp/redwood/issues/371)).

Not super satisfied with the design so looking for feedback!

colinrtwhite: @JakeWharton My main concern was around using `MutableListChildren` inside the other children implementations, but on second look this is probably ok.

Also I kept going back and forth on whether widgets should have access to their own layout modifiers. At the moment I'm leaning toward no, but didn't want to make that big change without discussing it. My thoughts:

- Only scoped layout modifiers currently make sense because they have to be explicitly handled by the parent.
- If we want to have unscoped layout modifiers they probably shouldn't be handled by the widget that they're applied on. Instead, we probably need some kind of `UnscopedLayoutModifierApplier<T>` interface that's provided to the `WidgetFactory` and handles applying a `LayoutModifier` on `T` at a global level. In this case `LayoutModifier`s still don't need to exist on `Widget`.

Either way I think I'll leave that change for a separate PR since my main goal is to unblock the per-item `padding`/`alignment` modifiers in `Column` and `Row` so Jesse can start using them in the Activity and Money tabs.

Patch:
diff --git a/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeColumn.kt b/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeColumn.kt
index a44c9cc95..615adbe75 100644
--- a/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeColumn.kt
+++ b/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeColumn.kt
@@ -32,9 +32,7 @@ internal class ComposeColumn : Column<@Composable () -> Unit> {
 
   override val value: @Composable () -> Unit get() = container.composable
 
-  override var layoutModifiers: LayoutModifier
-    get() = container.layoutModifiers
-    set(value) { container.layoutModifiers = value }
+  override var layoutModifiers: LayoutModifier = LayoutModifier
 
   override fun padding(padding: Padding) {
     container.padding(padding)
diff --git a/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeFlexContainer.kt b/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeFlexContainer.kt
index 1ee474099..360254bc1 100644
--- a/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeFlexContainer.kt
+++ b/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeFlexContainer.kt
@@ -29,7 +29,6 @@ import androidx.compose.ui.layout.MeasureResult
 import androidx.compose.ui.layout.MeasureScope
 import androidx.compose.ui.layout.Placeable
 import androidx.compose.ui.unit.Constraints
-import app.cash.redwood.LayoutModifier
 import app.cash.redwood.flexcontainer.AlignItems
 import app.cash.redwood.flexcontainer.FlexContainer
 import app.cash.redwood.flexcontainer.FlexDirection
@@ -57,8 +56,6 @@ internal class ComposeFlexContainer(private val direction: FlexDirection) {
 
   var modifier: Modifier by mutableStateOf(Modifier)
 
-  var layoutModifiers: LayoutModifier = LayoutModifier
-
   fun padding(padding: Padding) {
     container.padding = padding.toSpacing()
     recomposeTick++
diff --git a/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeRow.kt b/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeRow.kt
index dabca0f91..469fdbbc1 100644
--- a/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeRow.kt
+++ b/redwood-layout-composeui/src/main/kotlin/app/cash/redwood/layout/composeui/ComposeRow.kt
@@ -32,9 +32,7 @@ internal class ComposeRow : Row<@Composable () -> Unit> {
 
   override val value: @Composable () -> Unit get() = container.composable
 
-  override var layoutModifiers: LayoutModifier
-    get() = container.layoutModifiers
-    set(value) { container.layoutModifiers = value }
+  override var layoutModifiers: LayoutModifier = LayoutModifier
 
   override fun padding(padding: Padding) {
     container.padding(padding)
diff --git a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewColumn.kt b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewColumn.kt
index 83dedfc6d..c35b78ead 100644
--- a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewColumn.kt
+++ b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewColumn.kt
@@ -33,9 +33,7 @@ internal class ViewColumn(context: Context) : Column<View> {
 
   override val value: View get() = container.view
 
-  override var layoutModifiers: LayoutModifier
-    get() = container.layoutModifiers
-    set(value) { container.layoutModifiers = value }
+  override var layoutModifiers: LayoutModifier = LayoutModifier
 
   override fun padding(padding: Padding) {
     container.padding(padding)
diff --git a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewFlexContainer.kt b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewFlexContainer.kt
index 1b028ce30..ad5abca53 100644
--- a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewFlexContainer.kt
+++ b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewFlexContainer.kt
@@ -20,7 +20,6 @@ import android.view.View
 import android.view.ViewGroup
 import android.widget.HorizontalScrollView
 import android.widget.ScrollView as VerticalScrollView
-import app.cash.redwood.LayoutModifier
 import app.cash.redwood.flexcontainer.AlignItems
 import app.cash.redwood.flexcontainer.FlexContainer
 import app.cash.redwood.flexcontainer.FlexDirection
@@ -58,18 +57,16 @@ internal class ViewFlexContainer(context: Context, direction: FlexDirection) {
   val view: View get() = scrollView
 
   val children: Widget.Children<View> = MutableListChildren(
-    onUpdate = { views ->
+    onUpdate = { children ->
       container.items.clear()
       hostView.removeAllViews()
-      views.forEach {
-        container.items += it.asItem()
-        hostView.addView(it)
+      children.forEach {
+        container.items += it.value.asItem()
+        hostView.addView(it.value)
       }
     },
   )
 
-  var layoutModifiers: LayoutModifier = LayoutModifier
-
   fun padding(padding: Padding) {
     container.padding = padding.toSpacing()
     invalidate()
diff --git a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewRow.kt b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewRow.kt
index 54d2c53cd..2bed29ddb 100644
--- a/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewRow.kt
+++ b/redwood-layout-view/src/main/kotlin/app/cash/redwood/layout/view/ViewRow.kt
@@ -33,9 +33,7 @@ internal class ViewRow(context: Context) : Row<View> {
 
   override val value: View get() = container.view
 
-  override var layoutModifiers: LayoutModifier
-    get() = container.layoutModifiers
-    set(value) { container.layoutModifiers = value }
+  override var layoutModifiers: LayoutModifier = LayoutModifier
 
   override fun padding(padding: Padding) {
     container.padding(padding)
diff --git a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolApplier.kt b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolApplier.kt
index bf3117065..91951c422 100644
--- a/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolApplier.kt
+++ b/redwood-protocol-compose/src/commonMain/kotlin/app/cash/redwood/protocol/compose/ProtocolApplier.kt
@@ -189,17 +189,11 @@ internal class ProtocolApplier(
     val current = current as DiffProducingChildrenWidget
     val children = current._children
 
-    // TODO We should not have to track this and send it as part of the protocol.
-    //  Ideally this would be entirely encapsulated on the display-side with additional bookkeeping.
-    //  For now, we track it here and send it in the protocol as a simple solution.
-    val removedIds = ArrayList<Id>(count)
     for (i in index until index + count) {
-      removedIds.add(children[i].id)
+      nodes.remove(children[i].id)
     }
-
-    nodes.keys.removeAll(removedIds)
     children.remove(index, count)
-    diffAppender.append(ChildrenDiff.Remove(current.id, current.tag, index, count, removedIds))
+    diffAppender.append(ChildrenDiff.Remove(current.id, current.tag, index, count))
   }
 
   override fun move(from: Int, to: Int, count: Int) {
diff --git a/redwood-protocol-widget/build.gradle b/redwood-protocol-widget/build.gradle
index 1364073ab..4b9c01fac 100644
--- a/redwood-protocol-widget/build.gradle
+++ b/redwood-protocol-widget/build.gradle
@@ -26,3 +26,12 @@ kotlin {
 android {
   namespace 'app.cash.redwood.protocol.widget'
 }
+
+spotless {
+  kotlin {
+    targetExclude(
+      // Apache 2-licensed files from AOSP.
+      "src/commonMain/kotlin/app/cash/redwood/protocol/widget/utils.kt",
+    )
+  }
+}
diff --git a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolDisplay.kt b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolDisplay.kt
index 35bf40ef4..9bdfb82f1 100644
--- a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolDisplay.kt
+++ b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/ProtocolDisplay.kt
@@ -27,57 +27,70 @@ import app.cash.redwood.widget.Widget
 import kotlinx.serialization.json.JsonArray
 
 public class ProtocolDisplay<T : Any>(
-  container: Widget.Children<T>,
+  private val container: Widget.Children<T>,
   private val factory: DiffConsumingWidget.Factory<T>,
   private val eventSink: EventSink,
 ) : DiffSink {
   private val root: DiffConsumingWidget<T> = ProtocolDisplayRoot(container)
-  private val widgets = mutableMapOf(Id.Root to root)
+  private val nodes = mutableMapOf(Id.Root to Node(root, Id.Root, container))
 
   override fun sendDiff(diff: Diff) {
     for (childrenDiff in diff.childrenDiffs) {
-      val widget = checkNotNull(widgets[childrenDiff.id]) {
-        "Unknown widget ID ${childrenDiff.id}"
-      }
-      val children = widget.children(childrenDiff.tag) ?: continue
+      val node = node(childrenDiff.id)
+      val children = node.widget.children(childrenDiff.tag) ?: continue
 
       when (childrenDiff) {
         is ChildrenDiff.Insert -> {
           val childWidget = factory.create(childrenDiff.kind) ?: continue
-          widgets[childrenDiff.childId] = childWidget
-          children.insert(childrenDiff.index, childWidget.value)
+          children.insert(childrenDiff.index, childWidget)
+          nodes[childrenDiff.childId] = Node(childWidget, childrenDiff.id, children)
+          node.childIds.add(childrenDiff.index, childrenDiff.childId)
         }
         is ChildrenDiff.Move -> {
           children.move(childrenDiff.fromIndex, childrenDiff.toIndex, childrenDiff.count)
+          node.childIds.move(childrenDiff.fromIndex, childrenDiff.toIndex, childrenDiff.count)
         }
         is ChildrenDiff.Remove -> {
           children.remove(childrenDiff.index, childrenDiff.count)
-
-          @Suppress("ConvertArgumentToSet") // Bad advice as the collection is iterated.
-          widgets.keys.removeAll(childrenDiff.removedIds)
+          for (i in childrenDiff.index until childrenDiff.index + childrenDiff.count) {
+            nodes.remove(node.childIds[i])
+          }
+          node.childIds.remove(childrenDiff.index, childrenDiff.count)
         }
         ChildrenDiff.Clear -> {
           children.clear()
-          widgets.clear()
-          widgets[Id.Root] = root
+          node.childIds.clear()
+          nodes.clear()
+          nodes[Id.Root] = Node(root, Id.Root, container)
         }
       }
     }
 
     for (layoutModifier in diff.layoutModifiers) {
-      val widget = checkNotNull(widgets[layoutModifier.id]) {
-        "Unknown widget ID ${layoutModifier.id}"
-      }
-      widget.updateLayoutModifier(layoutModifier.elements)
+      val node = node(layoutModifier.id)
+      val childIndex = node(node.parentId).childIds.indexOf(layoutModifier.id)
+      node.widget.updateLayoutModifier(layoutModifier.elements)
+      node.parentChildren.onLayoutModifierUpdated(childIndex)
     }
 
     for (propertyDiff in diff.propertyDiffs) {
-      val widget = checkNotNull(widgets[propertyDiff.id]) {
-        "Unknown widget ID ${propertyDiff.id}"
-      }
-      widget.apply(propertyDiff, eventSink)
+      node(propertyDiff.id).widget.apply(propertyDiff, eventSink)
     }
   }
+
+  private fun node(id: Id): Node<T> {
+    return checkNotNull(nodes[id]) { "Unknown widget ID $id" }
+  }
+}
+
+private class Node<T : Any>(
+  val widget: DiffConsumingWidget<T>,
+  val parentId: Id,
+  val parentChildren: Widget.Children<T>,
+) {
+  private var _childIds: MutableList<Id>? = null
+  val childIds: MutableList<Id>
+    get() = _childIds ?: mutableListOf<Id>().also { _childIds = it }
 }
 
 private class ProtocolDisplayRoot<T : Any>(
diff --git a/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/utils.kt b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/utils.kt
new file mode 100644
index 000000000..aab753f85
--- /dev/null
+++ b/redwood-protocol-widget/src/commonMain/kotlin/app/cash/redwood/protocol/widget/utils.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2019 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 app.cash.redwood.protocol.widget
+
+internal fun <T> MutableList<T>.move(fromIndex: Int, toIndex: Int, count: Int) {
+  val dest = if (fromIndex > toIndex) toIndex else toIndex - count
+  if (count == 1) {
+    if (fromIndex == toIndex + 1 || fromIndex == toIndex - 1) {
+      // Adjacent elements, perform swap to avoid backing array manipulations.
+      val fromEl = get(fromIndex)
+      val toEl = set(toIndex, fromEl)
+      set(fromIndex, toEl)
+    } else {
+      val fromEl = removeAt(fromIndex)
+      add(dest, fromEl)
+    }
+  } else {
+    val subView = subList(fromIndex, fromIndex + count)
+    val subCopy = subView.toMutableList()
+    subView.clear()
+    addAll(dest, subCopy)
+  }
+}
+
+internal fun <T> MutableList<T>.remove(index: Int, count: Int) {
+  if (count == 1) {
+    removeAt(index)
+  } else {
+    subList(index, index + count).clear()
+  }
+}
diff --git a/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/protocol.kt b/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/protocol.kt
index b9360ded1..ee37597b0 100644
--- a/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/protocol.kt
+++ b/redwood-protocol/src/commonMain/kotlin/app/cash/redwood/protocol/protocol.kt
@@ -117,14 +117,7 @@ public sealed class ChildrenDiff {
     override val tag: UInt,
     val index: Int,
     val count: Int,
-    val removedIds: List<Id>,
-  ) : ChildrenDiff() {
-    init {
-      require(count == removedIds.size) {
-        "Count $count != Removed ID list size ${removedIds.size}"
-      }
-    }
-  }
+  ) : ChildrenDiff()
 
   public companion object {
     public const val RootChildrenTag: UInt = 1U
diff --git a/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/LayoutModifier.kt b/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/LayoutModifier.kt
index d791bfda2..2c1b1019c 100644
--- a/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/LayoutModifier.kt
+++ b/redwood-runtime/src/commonMain/kotlin/app/cash/redwood/LayoutModifier.kt
@@ -18,6 +18,9 @@ package app.cash.redwood
 
 import androidx.compose.runtime.Stable
 
+/**
+ * An ordered, immutable collection of elements that change how a widget is laid out.
+ */
 @Stable
 public interface LayoutModifier {
 
diff --git a/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt b/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
index 9d854ad3c..bf2e2a38f 100644
--- a/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
+++ b/redwood-treehouse/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
@@ -46,10 +46,10 @@ public class TreehouseUIKitView<T : Any>(
     }
 
   override val children: Widget.Children<*> =
-    MutableListChildren { newViews ->
+    MutableListChildren { children ->
       @Suppress("UNCHECKED_CAST") // cinterop loses the generic.
       (view.subviews as List<UIView>).forEach(UIView::removeFromSuperview)
-      newViews.forEach(view::addSubview)
+      children.forEach { view.addSubview(it.value) }
     }
 
   private val mutableHostConfiguration = MutableStateFlow(HostConfiguration())
diff --git a/redwood-widget-compose/build.gradle b/redwood-widget-compose/build.gradle
index a51a19d7b..febef9447 100644
--- a/redwood-widget-compose/build.gradle
+++ b/redwood-widget-compose/build.gradle
@@ -15,3 +15,12 @@ kotlin {
     }
   }
 }
+
+spotless {
+  kotlin {
+    targetExclude(
+      // Apache 2-licensed files from AOSP.
+      "src/commonMain/kotlin/app/cash/redwood/widget/compose/utils.kt",
+    )
+  }
+}
diff --git a/redwood-widget-compose/src/commonMain/kotlin/app/cash/redwood/widget/compose/ComposeWidgetChildren.kt b/redwood-widget-compose/src/commonMain/kotlin/app/cash/redwood/widget/compose/ComposeWidgetChildren.kt
index 33cd4e26a..c60b141b9 100644
--- a/redwood-widget-compose/src/commonMain/kotlin/app/cash/redwood/widget/compose/ComposeWidgetChildren.kt
+++ b/redwood-widget-compose/src/commonMain/kotlin/app/cash/redwood/widget/compose/ComposeWidgetChildren.kt
@@ -17,55 +17,45 @@ package app.cash.redwood.widget.compose
 
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.mutableStateListOf
+import app.cash.redwood.LayoutModifier
 import app.cash.redwood.widget.Widget
 
 public class ComposeWidgetChildren : Widget.Children<@Composable () -> Unit> {
-  private val children = mutableStateListOf<@Composable () -> Unit>()
+  private val layoutModifiers = mutableStateListOf<LayoutModifier>()
+
+  private val _widgets = mutableStateListOf<Widget<@Composable () -> Unit>>()
+  public val widgets: List<Widget<@Composable () -> Unit>> get() = _widgets
 
   @Composable
   public fun render() {
-    for (child in children) {
-      child()
+    for (index in _widgets.indices) {
+      // Observe the layout modifier so we recompose if it changes.
+      layoutModifiers[index]
+      _widgets[index].value()
     }
   }
 
-  override fun insert(index: Int, widget: @Composable () -> Unit) {
-    children.add(index, widget)
+  override fun insert(index: Int, widget: Widget<@Composable () -> Unit>) {
+    _widgets.add(index, widget)
+    layoutModifiers.add(index, widget.layoutModifiers)
   }
 
   override fun move(fromIndex: Int, toIndex: Int, count: Int) {
-    /*
-     * Copyright 2019 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.
-     */
-    val dest = if (fromIndex > toIndex) toIndex else toIndex - count
-    if (count == 1) {
-      val element = children.removeAt(fromIndex)
-      children.add(dest, element)
-    } else {
-      val subView = children.subList(fromIndex, fromIndex + count)
-      val subCopy = subView.toMutableList()
-      subView.clear()
-      children.addAll(dest, subCopy)
-    }
+    _widgets.move(fromIndex, toIndex, count)
+    layoutModifiers.move(fromIndex, toIndex, count)
   }
 
   override fun remove(index: Int, count: Int) {
-    children.removeRange(index, index + count)
+    _widgets.remove(index, count)
+    layoutModifiers.remove(index, count)
   }
 
   override fun clear() {
-    children.clear()
+    _widgets.clear()
+    layoutModifiers.clear()
+  }
+
+  override fun onLayoutModifierUpdated(index: Int) {
+    layoutModifiers.set(index, _widgets[index].layoutModifiers)
   }
 }
diff --git a/redwood-widget-compose/src/commonMain/kotlin/app/cash/redwood/widget/compose/utils.kt b/redwood-widget-compose/src/commonMain/kotlin/app/cash/redwood/widget/compose/utils.kt
new file mode 100644
index 000000000..d1686caf2
--- /dev/null
+++ b/redwood-widget-compose/src/commonMain/kotlin/app/cash/redwood/widget/compose/utils.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2019 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 app.cash.redwood.widget.compose
+
+internal fun <T> MutableList<T>.move(fromIndex: Int, toIndex: Int, count: Int) {
+  val dest = if (fromIndex > toIndex) toIndex else toIndex - count
+  if (count == 1) {
+    if (fromIndex == toIndex + 1 || fromIndex == toIndex - 1) {
+      // Adjacent elements, perform swap to avoid backing array manipulations.
+      val fromEl = get(fromIndex)
+      val toEl = set(toIndex, fromEl)
+      set(fromIndex, toEl)
+    } else {
+      val fromEl = removeAt(fromIndex)
+      add(dest, fromEl)
+    }
+  } else {
+    val subView = subList(fromIndex, fromIndex + count)
+    val subCopy = subView.toMutableList()
+    subView.clear()
+    addAll(dest, subCopy)
+  }
+}
+
+internal fun <T> MutableList<T>.remove(index: Int, count: Int) {
+  if (count == 1) {
+    removeAt(index)
+  } else {
+    subList(index, index + count).clear()
+  }
+}
diff --git a/redwood-widget/build.gradle b/redwood-widget/build.gradle
index 525f6041d..2f14752d2 100644
--- a/redwood-widget/build.gradle
+++ b/redwood-widget/build.gradle
@@ -29,3 +29,12 @@ kotlin {
 android {
   namespace 'app.cash.redwood.widget'
 }
+
+spotless {
+  kotlin {
+    targetExclude(
+      // Apache 2-licensed files from AOSP.
+      "src/commonMain/kotlin/app/cash/redwood/widget/utils.kt",
+    )
+  }
+}
diff --git a/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/ViewGroupChildren.kt b/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/ViewGroupChildren.kt
index b1563fea2..ea1a3fbc7 100644
--- a/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/ViewGroupChildren.kt
+++ b/redwood-widget/src/androidMain/kotlin/app/cash/redwood/widget/ViewGroupChildren.kt
@@ -18,12 +18,20 @@ package app.cash.redwood.widget
 import android.view.View
 import android.view.ViewGroup
 
-public class ViewGroupChildren(private val parent: ViewGroup) : Widget.Children<View> {
-  override fun insert(index: Int, widget: View) {
-    parent.addView(widget, index)
+public class ViewGroupChildren(
+  private val parent: ViewGroup,
+) : Widget.Children<View> {
+  private val _widgets = MutableListChildren<View>()
+  public val widgets: List<Widget<View>> get() = _widgets
+
+  override fun insert(index: Int, widget: Widget<View>) {
+    _widgets.insert(index, widget)
+    parent.addView(widget.value, index)
   }
 
   override fun move(fromIndex: Int, toIndex: Int, count: Int) {
+    _widgets.move(fromIndex, toIndex, count)
+
     val views = Array(count) { offset ->
       parent.getChildAt(fromIndex + offset)
     }
@@ -40,10 +48,16 @@ public class ViewGroupChildren(private val parent: ViewGroup) : Widget.Children<
   }
 
   override fun remove(index: Int, count: Int) {
+    _widgets.remove(index, count)
     parent.removeViews(index, count)
   }
 
   override fun clear() {
+    _widgets.clear()
     parent.removeAllViews()
   }
+
+  override fun onLayoutModifierUpdated(index: Int) {
+    _widgets.onLayoutModifierUpdated(index)
+  }
 }
diff --git a/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/MutableListChildren.kt b/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/MutableListChildren.kt
index 3cbf4f7ab..8c9247d57 100644
--- a/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/MutableListChildren.kt
+++ b/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/MutableListChildren.kt
@@ -15,86 +15,33 @@
  */
 package app.cash.redwood.widget
 
-import kotlin.jvm.JvmOverloads
-
 /**
  * A [MutableList] that is also a [Widget.Children].
  *
  * @param list Optional existing [MutableList] instance to wrap.
  * @param onUpdate Optional callback invoked when contents change via the [Widget.Children] API.
  */
-public class MutableListChildren<T : Any>
-@JvmOverloads constructor(
-  private val list: MutableList<T> = mutableListOf(),
-  private val onUpdate: (List<T>) -> Unit = {},
-) : Widget.Children<T>, MutableList<T> by list {
+public class MutableListChildren<T : Any>(
+  private val list: MutableList<Widget<T>> = mutableListOf(),
+  private val onUpdate: (List<Widget<T>>) -> Unit = {},
+) : Widget.Children<T>, MutableList<Widget<T>> by list {
   /** @param onUpdate Callback invoked when contents change via the [Widget.Children] API. */
-  public constructor(onUpdate: (List<T>) -> Unit) : this(mutableListOf(), onUpdate = onUpdate)
+  public constructor(onUpdate: (List<Widget<T>>) -> Unit) : this(mutableListOf(), onUpdate = onUpdate)
+
+  public val children: List<Widget<T>> get() = list
 
-  override fun insert(index: Int, widget: T) {
+  override fun insert(index: Int, widget: Widget<T>) {
     list.add(index, widget)
     onUpdate(list)
   }
 
   override fun move(fromIndex: Int, toIndex: Int, count: Int) {
-    /*
-     * Copyright 2019 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.
-     */
-    val dest = if (fromIndex > toIndex) toIndex else toIndex - count
-    if (count == 1) {
-      if (fromIndex == toIndex + 1 || fromIndex == toIndex - 1) {
-        // Adjacent elements, perform swap to avoid backing array manipulations.
-        val fromEl = list.get(fromIndex)
-        val toEl = list.set(toIndex, fromEl)
-        list.set(fromIndex, toEl)
-      } else {
-        val fromEl = list.removeAt(fromIndex)
-        list.add(dest, fromEl)
-      }
-    } else {
-      val subView = list.subList(fromIndex, fromIndex + count)
-      val subCopy = subView.toMutableList()
-      subView.clear()
-      list.addAll(dest, subCopy)
-    }
-
+    list.move(fromIndex, toIndex, count)
     onUpdate(list)
   }
 
   override fun remove(index: Int, count: Int) {
-    /*
-     * Copyright 2019 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.
-     */
-    if (count == 1) {
-      list.removeAt(index)
-    } else {
-      list.subList(index, index + count).clear()
-    }
-
+    list.remove(index, count)
     onUpdate(list)
   }
 
@@ -102,4 +49,8 @@ public class MutableListChildren<T : Any>
     list.clear()
     onUpdate(list)
   }
+
+  override fun onLayoutModifierUpdated(index: Int) {
+    onUpdate(list)
+  }
 }
diff --git a/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/Widget.kt b/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/Widget.kt
index 4f62fcab4..31bdb5d7b 100644
--- a/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/Widget.kt
+++ b/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/Widget.kt
@@ -24,6 +24,9 @@ public interface Widget<T : Any> {
    */
   public val value: T
 
+  /**
+   * A collection of elements that change how a widget is laid out.
+   */
   public var layoutModifiers: LayoutModifier
 
   /**
@@ -39,7 +42,7 @@ public interface Widget<T : Any> {
    */
   public interface Children<T : Any> {
     /** Insert child [widget] at [index]. */
-    public fun insert(index: Int, widget: T)
+    public fun insert(index: Int, widget: Widget<T>)
 
     /**
      * Move [count] child widgets from [fromIndex] to [toIndex].
@@ -56,5 +59,8 @@ public interface Widget<T : Any> {
 
     /** Remove all child widgets. */
     public fun clear()
+
+    /** Indicates that the [LayoutModifier] for the widget at [index] has changed. */
+    public fun onLayoutModifierUpdated(index: Int)
   }
 }
diff --git a/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/utils.kt b/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/utils.kt
new file mode 100644
index 000000000..de1ffe4ea
--- /dev/null
+++ b/redwood-widget/src/commonMain/kotlin/app/cash/redwood/widget/utils.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2019 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 app.cash.redwood.widget
+
+internal fun <T> MutableList<T>.move(fromIndex: Int, toIndex: Int, count: Int) {
+  val dest = if (fromIndex > toIndex) toIndex else toIndex - count
+  if (count == 1) {
+    if (fromIndex == toIndex + 1 || fromIndex == toIndex - 1) {
+      // Adjacent elements, perform swap to avoid backing array manipulations.
+      val fromEl = get(fromIndex)
+      val toEl = set(toIndex, fromEl)
+      set(fromIndex, toEl)
+    } else {
+      val fromEl = removeAt(fromIndex)
+      add(dest, fromEl)
+    }
+  } else {
+    val subView = subList(fromIndex, fromIndex + count)
+    val subCopy = subView.toMutableList()
+    subView.clear()
+    addAll(dest, subCopy)
+  }
+}
+
+internal fun <T> MutableList<T>.remove(index: Int, count: Int) {
+  if (count == 1) {
+    removeAt(index)
+  } else {
+    subList(index, index + count).clear()
+  }
+}
diff --git a/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/HTMLElementChildren.kt b/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/HTMLElementChildren.kt
index f9cbbe5b7..43bfc2f6f 100644
--- a/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/HTMLElementChildren.kt
+++ b/redwood-widget/src/jsMain/kotlin/app/cash/redwood/widget/HTMLElementChildren.kt
@@ -22,13 +22,20 @@ import org.w3c.dom.get
 public class HTMLElementChildren(
   private val parent: HTMLElement,
 ) : Widget.Children<HTMLElement> {
-  override fun insert(index: Int, widget: HTMLElement) {
+  private val _widgets = MutableListChildren<HTMLElement>()
+  public val widgets: List<Widget<HTMLElement>> get() = _widgets
+
+  override fun insert(index: Int, widget: Widget<HTMLElement>) {
+    _widgets.insert(index, widget)
+
     // Null element returned when index == childCount causes insertion at end.
     val current = parent.children[index]
-    parent.insertBefore(widget, current)
+    parent.insertBefore(widget.value, current)
   }
 
   override fun move(fromIndex: Int, toIndex: Int, count: Int) {
+    _widgets.move(fromIndex, toIndex, count)
+
     val elements = Array(count) {
       val element = parent.children[fromIndex] as HTMLElement
       parent.removeChild(element)
@@ -48,12 +55,19 @@ public class HTMLElementChildren(
   }
 
   override fun remove(index: Int, count: Int) {
+    _widgets.remove(index, count)
+
     repeat(count) {
       parent.removeChild(parent.children[index]!!)
     }
   }
 
   override fun clear() {
+    _widgets.clear()
     parent.clear()
   }
+
+  override fun onLayoutModifierUpdated(index: Int) {
+    _widgets.onLayoutModifierUpdated(index)
+  }
 }
diff --git a/samples/counter/ios/app/Podfile.lock b/samples/counter/ios/app/Podfile.lock
index beaf95e40..5e2e7271b 100644
--- a/samples/counter/ios/app/Podfile.lock
+++ b/samples/counter/ios/app/Podfile.lock
@@ -9,7 +9,7 @@ EXTERNAL SOURCES:
     :path: "../shared"
 
 SPEC CHECKSUMS:
-  shared: 30eb6b0f455099e90a67ab7b5566fd0cc45b5e8f
+  shared: 3995166f6f59d263c37ee34d61043893c0781a11
 
 PODFILE CHECKSUM: d9da0ad36b550eead22a7e5e29c5f4cd1402e5b4
 
diff --git a/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/CounterViewControllerDelegate.kt b/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/CounterViewControllerDelegate.kt
index e1b4a6a78..5f214ffd4 100644
--- a/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/CounterViewControllerDelegate.kt
+++ b/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/CounterViewControllerDelegate.kt
@@ -47,10 +47,10 @@ class CounterViewControllerDelegate(
       onEvent = { NSLog("RedwoodEvent: $it") },
     )
 
-    val children = MutableListChildren { newViews ->
+    val children = MutableListChildren { children ->
       @Suppress("UNCHECKED_CAST") // cinterop loses the generic.
       (root.subviews as List<UIView>).forEach(UIView::removeFromSuperview)
-      newViews.forEach(root::addArrangedSubview)
+      children.forEach { root.addArrangedSubview(it.value) }
     }
     val factory = DiffConsumingSunspotWidgetFactory(IosSunspotNodeFactory)
     val display = ProtocolDisplay(
diff --git a/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/sunspot/IosSunspotBox.kt b/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/sunspot/IosSunspotBox.kt
index f09d46d39..93b38b8d6 100644
--- a/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/sunspot/IosSunspotBox.kt
+++ b/samples/counter/ios/shared/src/commonMain/kotlin/example/ios/sunspot/IosSunspotBox.kt
@@ -33,9 +33,9 @@ class IosSunspotBox(
 ) : SunspotBox<UIView> {
   override var layoutModifiers: LayoutModifier = LayoutModifier
 
-  override val children = MutableListChildren { newViews ->
+  override val children = MutableListChildren { children ->
     @Suppress("UNCHECKED_CAST") // cinterop loses the generic.
     (value.subviews as List<UIView>).forEach(UIView::removeFromSuperview)
-    newViews.forEach(value::addArrangedSubview)
+    children.forEach { value.addArrangedSubview(it.value) }
   }
 }
diff --git a/samples/emoji-search/ios/app/EmojiSearchApp/ChildrenBinding.swift b/samples/emoji-search/ios/app/EmojiSearchApp/ChildrenBinding.swift
index 8484cebd8..078ee436e 100644
--- a/samples/emoji-search/ios/app/EmojiSearchApp/ChildrenBinding.swift
+++ b/samples/emoji-search/ios/app/EmojiSearchApp/ChildrenBinding.swift
@@ -30,8 +30,8 @@ class ChildrenBinding: Redwood_widgetWidgetChildren {
         update(storage)
     }
 
-    func insert(index: Int32, widget: Any) {
-        storage.insert(widget as! UIView, at: Int(index))
+    func insert(index: Int32, widget: Redwood_widgetWidget) {
+        storage.insert(widget.value as! UIView, at: Int(index))
         update(storage)
     }
 
@@ -54,4 +54,7 @@ class ChildrenBinding: Redwood_widgetWidgetChildren {
         (0..<count).forEach { _ in storage.remove(at: Int(index)) }
         update(storage)
     }
+
+    func onLayoutModifierUpdated(index: Int32) {
+    }
 }


Test patch:
diff --git a/redwood-layout-composeui/src/test/kotlin/app/cash/redwood/layout/composeui/ComposeFlexContainerTest.kt b/redwood-layout-composeui/src/test/kotlin/app/cash/redwood/layout/composeui/ComposeFlexContainerTest.kt
index a58a23caf..d3668cc21 100644
--- a/redwood-layout-composeui/src/test/kotlin/app/cash/redwood/layout/composeui/ComposeFlexContainerTest.kt
+++ b/redwood-layout-composeui/src/test/kotlin/app/cash/redwood/layout/composeui/ComposeFlexContainerTest.kt
@@ -24,10 +24,12 @@ import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.unit.sp
 import app.cash.paparazzi.DeviceConfig.Companion.PIXEL_6
 import app.cash.paparazzi.Paparazzi
+import app.cash.redwood.LayoutModifier
 import app.cash.redwood.flexcontainer.AlignItems
 import app.cash.redwood.flexcontainer.FlexDirection
 import app.cash.redwood.flexcontainer.JustifyContent
 import app.cash.redwood.layout.api.Padding
+import app.cash.redwood.widget.Widget
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -120,7 +122,11 @@ class ComposeFlexContainerTest(
           modifier = Modifier.background(Color.Green),
         )
       }
-      container.children.insert(index, composable)
+      val widget = object : Widget<@Composable () -> Unit> {
+        override val value = composable
+        override var layoutModifiers: LayoutModifier = LayoutModifier
+      }
+      container.children.insert(index, widget)
     }
 
     paparazzi.snapshot {
diff --git a/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewFlexContainerTest.kt b/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewFlexContainerTest.kt
index b14da7d54..dab53e9e1 100644
--- a/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewFlexContainerTest.kt
+++ b/redwood-layout-view/src/test/kotlin/app/cash/redwood/layout/view/ViewFlexContainerTest.kt
@@ -17,13 +17,16 @@ package app.cash.redwood.layout.view
 
 import android.graphics.Color
 import android.graphics.drawable.ColorDrawable
+import android.view.View
 import android.widget.TextView
 import app.cash.paparazzi.DeviceConfig.Companion.PIXEL_6
 import app.cash.paparazzi.Paparazzi
+import app.cash.redwood.LayoutModifier
 import app.cash.redwood.flexcontainer.AlignItems
 import app.cash.redwood.flexcontainer.FlexDirection
 import app.cash.redwood.flexcontainer.JustifyContent
 import app.cash.redwood.layout.api.Padding
+import app.cash.redwood.widget.Widget
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -115,7 +118,11 @@ class ViewFlexContainerTest(
         setTextColor(Color.BLACK)
         text = item
       }
-      container.children.insert(index, view)
+      val widget = object : Widget<View> {
+        override val value = view
+        override var layoutModifiers: LayoutModifier = LayoutModifier
+      }
+      container.children.insert(index, widget)
     }
 
     paparazzi.snapshot(container.view)
diff --git a/redwood-protocol/src/commonTest/kotlin/app/cash/redwood/protocol/ProtocolTest.kt b/redwood-protocol/src/commonTest/kotlin/app/cash/redwood/protocol/ProtocolTest.kt
index 7992242ed..e9d89556e 100644
--- a/redwood-protocol/src/commonTest/kotlin/app/cash/redwood/protocol/ProtocolTest.kt
+++ b/redwood-protocol/src/commonTest/kotlin/app/cash/redwood/protocol/ProtocolTest.kt
@@ -17,7 +17,6 @@ package app.cash.redwood.protocol
 
 import kotlin.test.Test
 import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
 import kotlinx.serialization.KSerializer
 import kotlinx.serialization.json.Json
 import kotlinx.serialization.json.JsonNull
@@ -48,7 +47,7 @@ class ProtocolTest {
         ChildrenDiff.Clear,
         ChildrenDiff.Insert(Id(1U), 2U, Id(3U), 4, 5),
         ChildrenDiff.Move(Id(1U), 2U, 3, 4, 5),
-        ChildrenDiff.Remove(Id(1U), 2U, 3, 4, listOf(Id(5U), Id(6U), Id(7U), Id(8U))),
+        ChildrenDiff.Remove(Id(1U), 2U, 3, 4),
       ),
       layoutModifiers = listOf(
         LayoutModifiers(
@@ -73,7 +72,7 @@ class ProtocolTest {
       """["clear",{}],""" +
       """["insert",{"id":1,"tag":2,"childId":3,"kind":4,"index":5}],""" +
       """["move",{"id":1,"tag":2,"fromIndex":3,"toIndex":4,"count":5}],""" +
-      """["remove",{"id":1,"tag":2,"index":3,"count":4,"removedIds":[5,6,7,8]}]""" +
+      """["remove",{"id":1,"tag":2,"index":3,"count":4}]""" +
       """],"layoutModifiers":[""" +
       """{"id":1,"elements":[[1,{}]]}""" +
       """],"propertyDiffs":[""" +
@@ -93,12 +92,6 @@ class ProtocolTest {
     assertJsonRoundtrip(Diff.serializer(), model, json)
   }
 
-  @Test fun removeCountMustMatchListSize() {
-    assertFailsWith<IllegalArgumentException>("Count 4 != Removed ID list size 3") {
-      ChildrenDiff.Remove(Id(1U), 2U, 3, 4, listOf(Id(5U), Id(6U), Id(7U)))
-    }
-  }
-
   private fun <T> assertJsonRoundtrip(serializer: KSerializer<T>, model: T, json: String) {
     assertEquals(json, format.encodeToString(serializer, model))
     assertEquals(model, format.decodeFromString(serializer, json))
diff --git a/redwood-widget/src/commonTest/kotlin/app/cash/redwood/widget/AbstractWidgetChildrenTest.kt b/redwood-widget/src/commonTest/kotlin/app/cash/redwood/widget/AbstractWidgetChildrenTest.kt
index 2b2d247ea..6f39ace77 100644
--- a/redwood-widget/src/commonTest/kotlin/app/cash/redwood/widget/AbstractWidgetChildrenTest.kt
+++ b/redwood-widget/src/commonTest/kotlin/app/cash/redwood/widget/AbstractWidgetChildrenTest.kt
@@ -15,6 +15,7 @@
  */
 package app.cash.redwood.widget
 
+import app.cash.redwood.LayoutModifier
 import kotlin.test.Test
 import kotlin.test.assertEquals
 
@@ -148,4 +149,14 @@ abstract class AbstractWidgetChildrenTest<T : Any> {
     children.clear()
     assertEquals(listOf(), names())
   }
+
+  protected fun <T : Any> Widget.Children<T>.insert(index: Int, widget: T) {
+    insert(
+      index = index,
+      widget = object : Widget<T> {
+        override val value: T = widget
+        override var layoutModifiers: LayoutModifier = LayoutModifier
+      },
+    )
+  }
 }
diff --git a/redwood-widget/src/commonTest/kotlin/app/cash/redwood/widget/MutableListChildrenTest.kt b/redwood-widget/src/commonTest/kotlin/app/cash/redwood/widget/MutableListChildrenTest.kt
index 1a8a8f0a2..30cf2a209 100644
--- a/redwood-widget/src/commonTest/kotlin/app/cash/redwood/widget/MutableListChildrenTest.kt
+++ b/redwood-widget/src/commonTest/kotlin/app/cash/redwood/widget/MutableListChildrenTest.kt
@@ -22,7 +22,7 @@ class MutableListChildrenTest : AbstractWidgetChildrenTest<String>() {
   private var updateCount = Int.MIN_VALUE
   override val children = MutableListChildren<String> { updateCount++ }
   override fun widget(name: String) = name
-  override fun names() = children.toList()
+  override fun names() = children.map { it.value }
 
   @Test fun insertCallsUpdate() {
     updateCount = 0