Repository: cashapp/redwood
Pull request #858 (2023-03-17 21:14:53)

New Content interface and implementation
This is a major step towards breaking the dependency from TreehouseView onto Zipline etc.

With this change TreehouseView no longer knows about TreehouseApp, and no longer needs a renderTo() call.

I'm not particularly happy with the 'readyForContent' variable name or the ReadyForContentChangeListener interface name. I want something that represents attached state on both platforms.

There's a possible follow-up to this change to simplify the RealBinding and LoadingBinding types. These are still necessary, but the new structure reduces how much stuff they need to own.

https://github.com/cashapp/redwood/issues/541


Patch:
diff --git a/redwood-treehouse-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt b/redwood-treehouse-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt
index cac1c1864..8da69caae 100644
--- a/redwood-treehouse-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt
+++ b/redwood-treehouse-composeui/src/commonMain/kotlin/app/cash/redwood/treehouse/composeui/TreehouseContent.kt
@@ -18,6 +18,7 @@ package app.cash.redwood.treehouse.composeui
 import androidx.compose.foundation.isSystemInDarkTheme
 import androidx.compose.foundation.layout.Box
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
@@ -27,7 +28,8 @@ import app.cash.redwood.treehouse.TreehouseApp
 import app.cash.redwood.treehouse.TreehouseContentSource
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.TreehouseView.CodeListener
-import app.cash.redwood.treehouse.TreehouseView.OnStateChangeListener
+import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener
+import app.cash.redwood.treehouse.bindWhenReady
 import app.cash.redwood.widget.compose.ComposeWidgetChildren
 import kotlinx.coroutines.flow.MutableStateFlow
 
@@ -43,26 +45,25 @@ public fun <A : AppService> TreehouseContent(
   )
 
   val rememberedCodeListener = rememberUpdatedState(codeListener)
-  val rememberedContent = rememberUpdatedState(contentSource)
   val treehouseView = remember(widgetSystem) {
     object : TreehouseView<A> {
       override val codeListener get() = rememberedCodeListener.value
-      override var stateChangeListener: OnStateChangeListener<A>? = null
-      override val boundContentSource: TreehouseContentSource<A> get() = rememberedContent.value
       override val children = ComposeWidgetChildren()
       override val hostConfiguration = MutableStateFlow(hostConfiguration)
       override val widgetSystem = widgetSystem
+      override val readyForContent = true
+      override var readyForContentChangeListener: ReadyForContentChangeListener<A>? = null
       override fun reset() = children.remove(0, children.widgets.size)
     }
   }
   LaunchedEffect(treehouseView, hostConfiguration) {
     treehouseView.hostConfiguration.value = hostConfiguration
   }
-  LaunchedEffect(treehouseApp, treehouseView) {
-    treehouseApp.renderTo(treehouseView)
-  }
-  LaunchedEffect(treehouseView, contentSource) {
-    treehouseView.stateChangeListener?.onStateChanged(treehouseView)
+  DisposableEffect(treehouseView, contentSource) {
+    val closeable = contentSource.bindWhenReady(treehouseView, treehouseApp)
+    onDispose {
+      closeable.close()
+    }
   }
 
   Box {
diff --git a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
index 449f14dc3..00d578cf5 100644
--- a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
+++ b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/TreehouseWidgetView.kt
@@ -24,7 +24,7 @@ import android.view.View
 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
 import android.widget.FrameLayout
 import app.cash.redwood.treehouse.TreehouseView.CodeListener
-import app.cash.redwood.treehouse.TreehouseView.OnStateChangeListener
+import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener
 import app.cash.redwood.widget.ViewGroupChildren
 import app.cash.redwood.widget.Widget
 import kotlin.DeprecationLevel.ERROR
@@ -49,28 +49,19 @@ public class TreehouseWidgetView<A : AppService>(
   ) : this(context, widgetSystem)
 
   public override var codeListener: CodeListener = CodeListener()
-  public override var stateChangeListener: OnStateChangeListener<A>? = null
+
+  override var readyForContentChangeListener: ReadyForContentChangeListener<A>? = null
     set(value) {
-      check(value != null) { "Views cannot be unbound from a listener at this time" }
-      check(field == null) { "View already bound to a listener" }
+      check(value == null || field == null) { "View already bound to a listener" }
       field = value
     }
 
-  private var contentSource: TreehouseContentSource<A>? = null
-
   /**
    * Like [View.isAttachedToWindow]. We'd prefer that property but it's false until
    * [onAttachedToWindow] returns and true until [onDetachedFromWindow] returns.
    */
-  private var immediateIsAttachedToWindow = false
-
-  override val boundContentSource: TreehouseContentSource<A>?
-    get() {
-      return when {
-        immediateIsAttachedToWindow -> contentSource
-        else -> null
-      }
-    }
+  override var readyForContent: Boolean = false
+    private set
 
   private val _children = ViewGroupChildren(this)
   override val children: Widget.Children<View> get() = _children
@@ -88,21 +79,16 @@ public class TreehouseWidgetView<A : AppService>(
     removeAllViews()
   }
 
-  public fun setContentSource(contentSource: TreehouseContentSource<A>) {
-    this.contentSource = contentSource
-    stateChangeListener?.onStateChanged(this)
-  }
-
   override fun onAttachedToWindow() {
     super.onAttachedToWindow()
-    immediateIsAttachedToWindow = true
-    stateChangeListener?.onStateChanged(this)
+    readyForContent = true
+    readyForContentChangeListener?.onReadyForContentChanged(this)
   }
 
   override fun onDetachedFromWindow() {
     super.onDetachedFromWindow()
-    immediateIsAttachedToWindow = false
-    stateChangeListener?.onStateChanged(this)
+    readyForContent = false
+    readyForContentChangeListener?.onReadyForContentChanged(this)
   }
 
   override fun onConfigurationChanged(newConfig: Configuration) {
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/Content.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/Content.kt
new file mode 100644
index 000000000..3685ac566
--- /dev/null
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/Content.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * 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.treehouse
+
+/**
+ * A UI built as an interactive widget tree, that may or may not be actively running, or bound to an
+ * on-screen display.
+ *
+ * This content may be bound and unbound multiple times. For example, if its target view is
+ * obscured, or removed it may be unbound, only to be rebound later to the same view or another
+ * view. Whether state of the content is retained between bind and unbind cycles is an
+ * implementation detail. In practice this will work similarly to refreshing a webpage: changes
+ * applied using the content are retained but widget state like scroll positions and selection
+ * bounds are lost.
+ *
+ * Calling [bind] may not immediately yield a ready widget tree; the content source may require
+ * work to prepare its UI such as downloading code or asynchronous calls to a worker thread.
+ *
+ * Content must be unbound after use.
+ */
+public interface Content<A : AppService> {
+  /**
+   * It is an error to bind multiple views simultaneously.
+   *
+   * This function may only be invoked on [TreehouseDispatchers.ui].
+   */
+  public fun bind(view: TreehouseView<A>)
+
+  /**
+   * Calling [unbind] without a bound view is safe.
+   *
+   * This function may only be invoked on [TreehouseDispatchers.ui].
+   */
+  public fun unbind()
+}
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ContentBinding.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ContentBinding.kt
new file mode 100644
index 000000000..912528048
--- /dev/null
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ContentBinding.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * 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.treehouse
+
+import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener
+import okio.Closeable
+
+/**
+ * Binds this content to [view] whenever the view is
+ * [ready for content][TreehouseView.readyForContent]. The content will bind and unbind as this view
+ * is attached and detached from the UI.
+ *
+ * Returns a closeable that unbinds from the content and stops tracking the ready state.
+ */
+public fun <A : AppService> Content<A>.bindWhenReady(view: TreehouseView<A>): Closeable {
+  val listener = ReadyForContentChangeListener<A> {
+    if (view.readyForContent) {
+      bind(view)
+    } else {
+      unbind()
+    }
+  }
+
+  view.readyForContentChangeListener = listener
+  listener.onReadyForContentChanged(view)
+
+  return object : Closeable {
+    override fun close() {
+      unbind()
+      view.readyForContentChangeListener = null
+    }
+  }
+}
+
+public fun <A : AppService> TreehouseContentSource<A>.bindWhenReady(
+  view: TreehouseView<A>,
+  app: TreehouseApp<A>,
+): Closeable {
+  val content = app.createContent(this)
+  return content.bindWhenReady(view)
+}
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
index bfc5a79cb..02a6368e9 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
@@ -40,11 +40,11 @@ import okio.Path
  */
 public class TreehouseApp<A : AppService> private constructor(
   private val factory: Factory,
-  private val appScope: CoroutineScope,
+  internal val appScope: CoroutineScope,
   public val spec: Spec<A>,
 ) {
   public val dispatchers: TreehouseDispatchers = factory.dispatchers
-  private val eventPublisher: EventPublisher = factory.eventPublisher
+  internal val eventPublisher: EventPublisher = factory.eventPublisher
 
   private var started = false
 
@@ -52,13 +52,14 @@ public class TreehouseApp<A : AppService> private constructor(
   private var closed = false
 
   /** Only accessed on [TreehouseDispatchers.ui]. */
-  private var ziplineSession: ZiplineSession<A>? = null
+  internal var ziplineSession: ZiplineSession<A>? = null
 
   /**
-   * Keys are views currently attached on-screen with non-null contents.
+   * Contents that this app is currently responsible for.
+   *
    * Only accessed on [TreehouseDispatchers.ui].
    */
-  private val bindings = mutableMapOf<TreehouseView<A>, Binding>()
+  internal val boundContents = mutableListOf<TreehouseContent<A>>()
 
   /**
    * Returns the current zipline attached to this host, or null if Zipline hasn't loaded yet. The
@@ -70,20 +71,15 @@ public class TreehouseApp<A : AppService> private constructor(
   public val zipline: Zipline?
     get() = ziplineSession?.zipline
 
-  private val stateChangeListener = TreehouseView.OnStateChangeListener { view ->
-    bind(view, ziplineSession, codeChanged = false)
-  }
-
   /**
-   * Connect this app to drive the content for [view].
+   * Create content for [source].
    *
    * Calls to this function will [start] this app if it isn't already started.
    */
-  public fun renderTo(view: TreehouseView<A>) {
+  public fun createContent(source: TreehouseContentSource<A>): Content<A> {
     start()
 
-    view.stateChangeListener = stateChangeListener
-    stateChangeListener.onStateChanged(view)
+    return TreehouseContent(this, source)
   }
 
   /**
@@ -178,9 +174,8 @@ public class TreehouseApp<A : AppService> private constructor(
 
       next.startFrameClock()
 
-      val viewsToRebind = bindings.keys.toTypedArray() // Defensive copy 'cause bind() mutates.
-      for (treehouseView in viewsToRebind) {
-        bind(treehouseView, next, codeChanged = true)
+      for (content in boundContents) {
+        content.receiveZiplineSession(next, codeChanged = true)
       }
 
       if (previous != null) {
@@ -193,58 +188,6 @@ public class TreehouseApp<A : AppService> private constructor(
     }
   }
 
-  /** This function may only be invoked on [TreehouseDispatchers.zipline]. */
-  private fun bind(
-    view: TreehouseView<A>,
-    ziplineSession: ZiplineSession<A>?,
-    codeChanged: Boolean,
-  ) {
-    dispatchers.checkUi()
-
-    // Make sure we're tracking this view, so we can update it when the code changes.
-    val contentSource = view.boundContentSource
-    val previous = bindings[view]
-    if (!codeChanged && previous is RealBinding<*> && contentSource == previous.contentSource) {
-      return // Nothing has changed.
-    }
-
-    val next = when {
-      // We have content and code. Launch the treehouse UI.
-      contentSource != null && ziplineSession != null -> {
-        RealBinding(
-          app = this@TreehouseApp,
-          appScope = appScope,
-          eventPublisher = eventPublisher,
-          contentSource = contentSource,
-          session = ziplineSession,
-          view = view,
-        ).apply {
-          start(ziplineSession, view)
-        }
-      }
-
-      // We have content but no code. Keep track of it for later.
-      contentSource != null -> {
-        LoadingBinding.also {
-          if (previous == null) {
-            view.codeListener.onInitialCodeLoading()
-          }
-        }
-      }
-
-      // No content.
-      else -> null
-    }
-
-    // Replace the previous binding, if any.
-    when {
-      next != null -> bindings[view] = next
-      else -> bindings.remove(view)
-    }
-
-    previous?.cancel()
-  }
-
   /** This function may only be invoked on [TreehouseDispatchers.zipline]. */
   public fun cancel() {
     dispatchers.checkZipline()
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseContent.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseContent.kt
new file mode 100644
index 000000000..8d6872a48
--- /dev/null
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseContent.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2023 Square, Inc.
+ *
+ * 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.treehouse
+
+internal class TreehouseContent<A : AppService>(
+  private val treehouseApp: TreehouseApp<A>,
+  private val source: TreehouseContentSource<A>,
+) : Content<A> {
+  private val dispatchers = treehouseApp.dispatchers
+
+  private var view: TreehouseView<A>? = null
+  private var binding: Binding? = null
+
+  override fun bind(view: TreehouseView<A>) {
+    treehouseApp.dispatchers.checkUi()
+
+    // Binding the bound view does nothing. This is necessary so that listeners don't need to
+    // independently track the bound/unbound state.
+    if (this.view == view) return
+
+    require(this.view == null)
+    this.view = view
+
+    treehouseApp.boundContents += this
+    receiveZiplineSession(treehouseApp.ziplineSession, false)
+  }
+
+  override fun unbind() {
+    treehouseApp.dispatchers.checkUi()
+
+    if (view == null) return // unbind() is idempotent.
+
+    treehouseApp.boundContents.remove(this)
+    binding?.cancel()
+    view = null
+    binding = null
+  }
+
+  /** This function may only be invoked on [TreehouseDispatchers.ui]. */
+  internal fun receiveZiplineSession(
+    ziplineSession: ZiplineSession<A>?,
+    codeChanged: Boolean,
+  ) {
+    dispatchers.checkUi()
+
+    // Make sure we're tracking this view, so we can update it when the code changes.
+    val view = this.view!!
+    val previous = binding
+    if (!codeChanged && previous is RealBinding<*>) {
+      return // Nothing has changed.
+    }
+
+    val next = when {
+      // We have content and code. Launch the treehouse UI.
+      ziplineSession != null -> {
+        RealBinding(
+          app = treehouseApp,
+          appScope = treehouseApp.appScope,
+          eventPublisher = treehouseApp.eventPublisher,
+          contentSource = source,
+          session = ziplineSession,
+          view = view,
+        ).apply {
+          start(ziplineSession, view)
+        }
+      }
+
+      // We have content but no code. Keep track of it for later.
+      else -> {
+        LoadingBinding.also {
+          if (previous == null) {
+            view.codeListener.onInitialCodeLoading()
+          }
+        }
+      }
+    }
+
+    // Replace the previous binding, if any.
+    binding = next
+
+    previous?.cancel()
+  }
+}
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
index 56628db59..7b3f35f63 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseView.kt
@@ -22,22 +22,19 @@ import kotlinx.coroutines.flow.StateFlow
 import kotlinx.serialization.json.Json
 
 public interface TreehouseView<A : AppService> {
-  /** This is the actual content, or null if not attached to the screen. */
-  public val boundContentSource: TreehouseContentSource<A>?
   public val children: Widget.Children<*>
   public val hostConfiguration: StateFlow<HostConfiguration>
   public val widgetSystem: WidgetSystem<A>
   public val codeListener: CodeListener
-  public var stateChangeListener: OnStateChangeListener<A>?
+  public val readyForContent: Boolean
+  public var readyForContentChangeListener: ReadyForContentChangeListener<A>?
 
   /** Invoked when new code is loaded. This should at minimum clear all [children]. */
   public fun reset()
 
-  public fun interface OnStateChangeListener<A : AppService> {
-    /**
-     * Called when [TreehouseView.boundContentSource] has changed.
-     */
-    public fun onStateChanged(view: TreehouseView<A>)
+  public fun interface ReadyForContentChangeListener<A : AppService> {
+    /** Called when [TreehouseView.readyForContent] has changed. */
+    public fun onReadyForContentChanged(view: TreehouseView<A>)
   }
 
   public fun interface WidgetSystem<A : AppService> {
diff --git a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
index 4b66e88e6..600248d5d 100644
--- a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
+++ b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/TreehouseUIKitView.kt
@@ -16,7 +16,7 @@
 package app.cash.redwood.treehouse
 
 import app.cash.redwood.treehouse.TreehouseView.CodeListener
-import app.cash.redwood.treehouse.TreehouseView.OnStateChangeListener
+import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener
 import app.cash.redwood.widget.UIViewChildren
 import app.cash.redwood.widget.Widget
 import kotlin.DeprecationLevel.ERROR
@@ -46,22 +46,15 @@ public class TreehouseUIKitView<A : AppService>(
 
   public val view: UIView = RootUiView(this)
   public override var codeListener: CodeListener = CodeListener()
-  public override var stateChangeListener: OnStateChangeListener<A>? = null
+
+  override var readyForContentChangeListener: ReadyForContentChangeListener<A>? = null
     set(value) {
-      check(value != null) { "Views cannot be unbound from a listener at this time" }
-      check(field == null) { "View already bound to a listener" }
+      check(value == null || field == null) { "View already bound to a listener" }
       field = value
     }
 
-  private var contentSource: TreehouseContentSource<A>? = null
-
-  override val boundContentSource: TreehouseContentSource<A>?
-    get() {
-      return when {
-        view.superview != null -> contentSource
-        else -> null
-      }
-    }
+  override val readyForContent: Boolean
+    get() = view.superview != null
 
   private val _children = UIViewChildren(view)
   override val children: Widget.Children<UIView> get() = _children
@@ -80,13 +73,8 @@ public class TreehouseUIKitView<A : AppService>(
     (view.subviews as List<UIView>).forEach(UIView::removeFromSuperview)
   }
 
-  public fun setContent(contentSource: TreehouseContentSource<A>) {
-    this.contentSource = contentSource
-    stateChangeListener?.onStateChanged(this)
-  }
-
   internal fun superviewChanged() {
-    stateChangeListener?.onStateChanged(this)
+    readyForContentChangeListener?.onReadyForContentChanged(this)
   }
 
   internal fun updateHostConfiguration() {
diff --git a/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyColumn.kt b/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyColumn.kt
index 07244299a..6b1e40627 100644
--- a/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyColumn.kt
+++ b/redwood-treehouse-lazylayout-uiview/src/commonMain/kotlin/app/cash/redwood/treehouse/lazylayout/uiview/UIViewLazyColumn.kt
@@ -22,6 +22,7 @@ import app.cash.redwood.treehouse.TreehouseContentSource
 import app.cash.redwood.treehouse.TreehouseUIKitView
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.ZiplineTreehouseUi
+import app.cash.redwood.treehouse.bindWhenReady
 import app.cash.redwood.treehouse.lazylayout.api.LazyListIntervalContent
 import app.cash.redwood.treehouse.lazylayout.widget.LazyColumn
 import platform.Foundation.NSIndexPath
@@ -72,13 +73,11 @@ private class TableViewDataSource<A : AppService>(
 
   override fun tableView(tableView: UITableView, cellForRowAtIndexPath: NSIndexPath): UITableViewCell {
     val treehouseView = TreehouseUIKitView(widgetSystem)
-    treehouseApp.renderTo(treehouseView)
-    treehouseView.setContent(
-      CellContentSource(
-        intervals[cellForRowAtIndexPath.section.toInt()].itemProvider,
-        cellForRowAtIndexPath.row.toInt(),
-      ),
+    val cellContentSource = CellContentSource<A>(
+      intervals[cellForRowAtIndexPath.section.toInt()].itemProvider,
+      cellForRowAtIndexPath.row.toInt(),
     )
+    cellContentSource.bindWhenReady(treehouseView, treehouseApp)
     return TableViewCell(treehouseView.view)
   }
 }
diff --git a/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyColumn.kt b/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyColumn.kt
index 6561437b4..96f82d9d5 100644
--- a/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyColumn.kt
+++ b/redwood-treehouse-lazylayout-view/src/main/kotlin/app/cash/redwood/treehouse/lazylayout/view/ViewLazyColumn.kt
@@ -29,10 +29,13 @@ import androidx.recyclerview.widget.RecyclerView
 import app.cash.redwood.LayoutModifier
 import app.cash.redwood.treehouse.AppService
 import app.cash.redwood.treehouse.TreehouseApp
+import app.cash.redwood.treehouse.TreehouseContentSource
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.TreehouseWidgetView
+import app.cash.redwood.treehouse.bindWhenReady
 import app.cash.redwood.treehouse.lazylayout.api.LazyListIntervalContent
 import app.cash.redwood.treehouse.lazylayout.widget.LazyColumn
+import okio.Closeable
 
 private data class LazyContentItem(
   val index: Int,
@@ -83,26 +86,30 @@ internal class ViewLazyColumn<A : AppService>(
       val container = FrameLayout(parent.context).apply {
         layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, contentHeight)
       }
-      return ViewHolder(container, treehouseApp, widgetSystem)
+      return ViewHolder(container, widgetSystem)
     }
 
     override fun onBindViewHolder(holder: ViewHolder<A>, position: Int) {
       val itemContent = currentList[position]
-      holder.treehouseWidgetView.setContentSource {
+      val itemContentSource = TreehouseContentSource<A> {
         itemContent.item.get(itemContent.index)
       }
+
+      holder.widgetContentBinding?.close()
+      holder.widgetContentBinding = itemContentSource.bindWhenReady(
+        view = holder.treehouseWidgetView,
+        app = treehouseApp,
+      )
     }
   }
 
   private class ViewHolder<A : AppService>(
     container: FrameLayout,
-    treehouseApp: TreehouseApp<A>,
     widgetSystem: TreehouseView.WidgetSystem<A>,
   ) : RecyclerView.ViewHolder(container) {
+    var widgetContentBinding: Closeable? = null
     val treehouseWidgetView = TreehouseWidgetView(container.context, widgetSystem)
       .apply {
-        treehouseApp.renderTo(this)
-
         layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
           gravity = Gravity.CENTER_HORIZONTAL
         }
diff --git a/samples/emoji-search/android-views/src/main/java/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt b/samples/emoji-search/android-views/src/main/java/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt
index 652bd2272..3042f1392 100644
--- a/samples/emoji-search/android-views/src/main/java/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt
+++ b/samples/emoji-search/android-views/src/main/java/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt
@@ -25,6 +25,7 @@ import app.cash.redwood.treehouse.TreehouseAppFactory
 import app.cash.redwood.treehouse.TreehouseContentSource
 import app.cash.redwood.treehouse.TreehouseView
 import app.cash.redwood.treehouse.TreehouseWidgetView
+import app.cash.redwood.treehouse.bindWhenReady
 import app.cash.redwood.treehouse.lazylayout.view.ViewRedwoodTreehouseLazyLayoutWidgetFactory
 import app.cash.zipline.loader.ManifestVerifier
 import app.cash.zipline.loader.asZiplineHttpClient
@@ -70,8 +71,7 @@ class EmojiSearchActivity : ComponentActivity() {
 
     setContentView(
       TreehouseWidgetView(this, widgetSystem).apply {
-        treehouseApp.renderTo(this)
-        setContentSource(treehouseContentSource)
+        treehouseContentSource.bindWhenReady(this, treehouseApp)
       },
     )
   }
diff --git a/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/exposed.kt b/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/exposed.kt
index e4904dd1d..c32beb156 100644
--- a/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/exposed.kt
+++ b/samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/exposed.kt
@@ -19,14 +19,18 @@ package com.example.redwood.emojisearch.ios
 
 import app.cash.redwood.LayoutModifier
 import app.cash.redwood.layout.uiview.UIViewRedwoodLayoutWidgetFactory
+import app.cash.redwood.treehouse.AppService
+import app.cash.redwood.treehouse.Content
 import app.cash.redwood.treehouse.TreehouseUIKitView
 import app.cash.redwood.treehouse.TreehouseView
+import app.cash.redwood.treehouse.bindWhenReady
 import app.cash.redwood.treehouse.lazylayout.uiview.UIViewRedwoodTreehouseLazyLayoutWidgetFactory
 import com.example.redwood.emojisearch.widget.EmojiSearchDiffConsumingNodeFactory
 import com.example.redwood.emojisearch.widget.EmojiSearchWidgetFactories
 import com.example.redwood.emojisearch.widget.EmojiSearchWidgetFactory
 import okio.ByteString
 import okio.ByteString.Companion.toByteString
+import okio.Closeable
 import platform.Foundation.NSData
 
 // Used to export types to Objective-C / Swift.
@@ -46,3 +50,8 @@ fun exposedTypes(
 fun byteStringOf(data: NSData): ByteString = data.toByteString()
 
 fun layoutModifier(): LayoutModifier = LayoutModifier
+
+fun <A : AppService> bindWhenReady(
+  content: Content<A>,
+  view: TreehouseView<A>,
+): Closeable = content.bindWhenReady(view)
diff --git a/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift b/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift
index d873cf3ab..bf6f4bffb 100644
--- a/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift
+++ b/samples/emoji-search/ios-uikit/EmojiSearchApp/EmojiSearchViewController.swift
@@ -37,9 +37,8 @@ class EmojiSearchViewController : UIViewController {
         let treehouseApp = emojiSearchLauncher.createTreehouseApp()
         let widgetSystem = EmojiSearchWidgetSystem()
         let treehouseView = Redwood_treehouse_hostTreehouseUIKitView<Presenter_treehouseEmojiSearchPresenter>(widgetSystem: widgetSystem)
-        treehouseApp.renderTo(view: treehouseView)
-        treehouseView.setContent(contentSource: EmojiSearchContent())
-
+        let content = treehouseApp.createContent(source: EmojiSearchContent())
+        ExposedKt.bindWhenReady(content: content, view: treehouseView)
         view = treehouseView.view
     }
 }


Test patch:
diff --git a/redwood-treehouse-host/src/androidInstrumentedTest/kotlin/app/cash/redwood/treehouse/TreehouseWidgetViewTest.kt b/redwood-treehouse-host/src/androidInstrumentedTest/kotlin/app/cash/redwood/treehouse/TreehouseWidgetViewTest.kt
index 3b0b6b818..15a4e8529 100644
--- a/redwood-treehouse-host/src/androidInstrumentedTest/kotlin/app/cash/redwood/treehouse/TreehouseWidgetViewTest.kt
+++ b/redwood-treehouse-host/src/androidInstrumentedTest/kotlin/app/cash/redwood/treehouse/TreehouseWidgetViewTest.kt
@@ -54,9 +54,9 @@ class TreehouseWidgetViewTest {
     val activity = Robolectric.buildActivity(Activity::class.java).resume().visible().get()
     val parent = activity.findViewById<ViewGroup>(android.R.id.content)
     val layout = TreehouseWidgetView(context, throwingWidgetSystem)
-    val listener = CountingStateChangeListener()
+    val listener = CountingReadyForContentChangeListener()
 
-    layout.stateChangeListener = listener
+    layout.readyForContentChangeListener = listener
     assertEquals(0, listener.count)
 
     parent.addView(layout)
@@ -66,16 +66,6 @@ class TreehouseWidgetViewTest {
     assertEquals(2, listener.count)
   }
 
-  @Test fun contentSendsStateChange() {
-    val layout = TreehouseWidgetView(context, throwingWidgetSystem)
-    val listener = CountingStateChangeListener()
-    layout.stateChangeListener = listener
-    assertEquals(0, listener.count)
-
-    layout.setContentSource { throw UnsupportedOperationException() }
-    assertEquals(1, listener.count)
-  }
-
   @Test fun resetClearsUntrackedChildren() {
     val layout = TreehouseWidgetView(context, throwingWidgetSystem)
 
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CountingStateChangeListener.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CountingReadyForContentChangeListener.kt
similarity index 73%
rename from redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CountingStateChangeListener.kt
rename to redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CountingReadyForContentChangeListener.kt
index 3987d6c68..62f5b0af7 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CountingStateChangeListener.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/CountingReadyForContentChangeListener.kt
@@ -15,10 +15,12 @@
  */
 package app.cash.redwood.treehouse
 
-class CountingStateChangeListener : TreehouseView.OnStateChangeListener<Nothing> {
+import app.cash.redwood.treehouse.TreehouseView.ReadyForContentChangeListener
+
+class CountingReadyForContentChangeListener : ReadyForContentChangeListener<Nothing> {
   var count = 0
 
-  override fun onStateChanged(view: TreehouseView<Nothing>) {
+  override fun onReadyForContentChanged(view: TreehouseView<Nothing>) {
     count++
   }
 }
diff --git a/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIKitViewTest.kt b/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIKitViewTest.kt
index 2fba23373..6347e48d9 100644
--- a/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIKitViewTest.kt
+++ b/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/TreehouseUIKitViewTest.kt
@@ -50,9 +50,9 @@ class TreehouseUIKitViewTest {
   @Test fun attachAndDetachSendsStateChange() {
     val parent = UIView()
     val layout = TreehouseUIKitView(throwingWidgetSystem)
-    val listener = CountingStateChangeListener()
+    val listener = CountingReadyForContentChangeListener()
 
-    layout.stateChangeListener = listener
+    layout.readyForContentChangeListener = listener
     assertEquals(0, listener.count)
 
     parent.addSubview(layout.view)
@@ -62,16 +62,6 @@ class TreehouseUIKitViewTest {
     assertEquals(2, listener.count)
   }
 
-  @Test fun contentSendsStateChange() {
-    val layout = TreehouseUIKitView(throwingWidgetSystem)
-    val listener = CountingStateChangeListener()
-    layout.stateChangeListener = listener
-    assertEquals(0, listener.count)
-
-    layout.setContent { throw UnsupportedOperationException() }
-    assertEquals(1, listener.count)
-  }
-
   @Test fun resetClearsUntrackedChildren() {
     val layout = TreehouseUIKitView(throwingWidgetSystem)