Lesson 7

Host composition and frames

Understand the process owner that coordinates composition, snapshot notifications, frame time, rendering, and cleanup.

4 min readUpdated Jul 17, 2026

Learning goal

Understand the process owner that coordinates composition, snapshot notifications, frame time, rendering, and cleanup.

The host is an orchestrator

Diagram
flowchart TD
    Content[AtlasContent] --> Composition
    StateWrite[Snapshot state write] --> Notify[Apply notifications]
    Notify --> Recomposer
    Clock[MonotonicFrameClock] --> Recomposer
    Recomposer -->|apply changes| Applier
    Applier --> Tree
    Applier -->|onEndChanges| RenderRequest
    RenderRequest --> Renderer
    Tree --> Renderer
    Renderer --> Surface
    DriverEnd[driver ends or critical work fails] --> TerminalState[terminal-state coordinator]
    TerminalState --> Lifecycle
    Lifecycle -->|dispose| Composition
    Lifecycle -->|cancel/join| Recomposer
    Lifecycle -->|cancel| Clock
    Lifecycle -->|cancel/join| OwnerJob[owner job]
    Lifecycle -->|dispose| Notify

The host does not decide layout or component behavior. It owns the lifetimes and scheduling relationships between those systems.

Required Runtime objects

val root = RootNode()
val ownerJob = SupervisorJob(parentJob)
val baseContext = ownerJob + ownerDispatcher
val frameClock = BroadcastFrameClock()
val scope = CoroutineScope(baseContext + frameClock)
val recomposer = Recomposer(baseContext)
val renderRequests = Channel<Unit>(Channel.CONFLATED)
val applier = AtlasApplier(root) { renderRequests.trySend(Unit) }
val composition = Composition(applier, recomposer)

fun launchCritical(
    block: suspend CoroutineScope.() -> Unit,
) = scope.launch {
    try {
        block()
    } catch (cancelled: CancellationException) {
        if (terminalState.isStopping) throw cancelled
        // Recomposer effect failure can cancel its runner and retain the
        // original failure as this cancellation's cause.
        terminateFailed(cancelled.cause ?: cancelled)
    } catch (failure: Throwable) {
        terminateFailed(failure) // record once, then cancel ownerJob
    }
}

val renderJob = launchCritical {
    for (ignored in renderRequests) {
        renderer.render(root)
    }
}

ownerDispatcher represents a sequential owner context supplied by the embedding application. parentJob can cancel the session, while the session-owned SupervisorJob prevents an uncaught child failure from canceling the caller before Atlas records it. Every critical loop runs through launchCritical; the terminal-state coordinator is marked as stopping before normal shutdown cancels Runtime work. An unexpected cancellation while it is not stopping—such as a LaunchedEffect failure canceling the Recomposer runner—is recorded as failure using the cancellation’s cause. terminateFailed then cancels sibling host work. The node tree must not be mutated by recomposition while another coroutine renders it concurrently.

Most importantly, the coroutine calling runRecomposeAndApplyChanges() must itself contain a MonotonicFrameClock:

launchCritical {
    recomposer.runRecomposeAndApplyChanges()
}

This works because scope was created with frameClock. Supplying a clock only to the Recomposer constructor while launching the runner in a clockless context is not sufficient.

Snapshot notification bridge

Snapshot writes outside an explicit mutable snapshot must be delivered to apply observers. A custom host commonly coalesces global-write callbacks before calling Snapshot.sendApplyNotifications().

private val snapshotWrites = Channel<Unit>(Channel.CONFLATED)

private val snapshotObserver = Snapshot.registerGlobalWriteObserver {
    snapshotWrites.trySend(Unit)
}

private fun launchSnapshotPump() = launchCritical {
    for (ignored in snapshotWrites) {
        Snapshot.sendApplyNotifications()
    }
}

This is conceptual host wiring: the observer callback may run where the write occurs, so it only signals the owner context. It should not mutate the node tree or poll continuously.

Frame driver boundary

fun interface FrameDriver {
    /** Returns null when this driver has ended normally. */
    suspend fun nextFrameNanos(): Long?
}

private fun launchFrames(
    frameDriver: FrameDriver,
    frameClock: BroadcastFrameClock,
) = launchCritical {
    while (isActive) {
        val timeNanos = frameDriver.nextFrameNanos()
            ?: return@launchCritical terminateClosed()
        frameClock.sendFrame(timeNanos)
    }
}

Frame times sent to withFrameNanos must be strictly increasing. A test driver can advance deterministically; an adapter can later connect this contract to a real surface lifecycle. A null result enters the same idempotent terminal-state coordinator used by shutdown and failures; merely returning from the frame child would leave its parent and sibling loops active.

Lifecycle skeleton

class AtlasHost(/* contracts injected here */) {
    // root, scope, clock, recomposer, applier, composition, observer

    suspend fun setContent(content: AtlasContent) =
        withContext(scope.coroutineContext) {
            composition.setContent(content)
        }

    suspend fun shutdown() {
        snapshotObserver.dispose()
        withContext(ownerDispatcher) {
            composition.dispose()
            recomposer.cancel()
            frameClock.cancel()
        }
        ownerJob.cancelAndJoin()
        recomposer.join()
    }
}

setContent, disposal, Applier mutation, layout, and rendering must all execute serially on the owner context. Cross-thread callbacks only enqueue work. A full implementation should make start/shutdown idempotence explicit, retain critical jobs, and surface their failure to its owner. Cancellation is cooperative, so shutdown waits rather than merely requesting cancellation; it should not hide runner failure behind an unconditional awaitCancellation().

Two clocks, one important distinction

Diagram
flowchart LR
    FrameOpportunity[frame opportunity] --> ComposeClock[Compose frame clock]
    ComposeClock --> Effects[withFrameNanos / animation]
    Applied[tree changes applied] --> RenderSchedule[render request]
    RenderSchedule --> Present[surface presentation]

They may share one driver, but they express different responsibilities:

  • the Compose clock resumes frame-aware coroutine work;
  • render scheduling decides when committed node state becomes a SurfaceFrame.

Deterministic test picture

Diagram
sequenceDiagram
    participant Test
    participant Driver
    participant FrameLoop
    participant Clock
    participant Recomposer
    participant Applier
    participant Recorder

    Test->>Driver: advance to 16 ms
    Driver-->>FrameLoop: nextFrameNanos returns
    FrameLoop->>Clock: sendFrame(16_000_000)
    Clock-->>Recomposer: resume parent withFrameNanos
    Recomposer->>Applier: apply pending changes
    Applier->>Recorder: request/render committed tree
    Test->>Recorder: inspect latest frame

Visual checkpoint

Draw the host diagram without looking. Use different colors for lifecycle ownership, Runtime work, and toolkit work. Ensure snapshot callbacks and frame callbacks enter through the owner context.

Review questions

  1. Why must the Recomposer runner’s context contain the frame clock?
  2. Why should the global-write observer only signal the owner context?
  3. How is a Compose frame clock different from rendering a surface frame?

Recap

The host owns lifetimes and scheduling.
The Recomposer owns recomposition coordination.
The Applier commits tree changes.
Atlas renders only committed tree state.

Sources

Finished this lesson?

Your progress stays only in this browser.