min
04/07/2026, 9:40 AMRecomposer.composing runs every time we compose, which means each composition pass takes place in a snapshot. I understand that the snapshot has observers which records which states are read/written during the pass. But what happens when the event handler of a button for example updates a state? By the time such user input is taken, we’re no longer in a nested snapshot with the aforementioned observers. Who notices what when how and does what when how which achieves what?min
04/07/2026, 9:49 AMCoroutineScope(AndroidUiDispatcher.Main).launch {
channel.consumeEach {
sent.set(false)
Snapshot.sendApplyNotifications()
}
}
Snapshot.registerGlobalWriteObserver {
if (sent.compareAndSet(false, true)) {
channel.trySend(Unit)
}
}
I’m aware that GlobalSnapshotManager observes all writes, but it just advances the global snapshot and does nothing else. How does this lead to a recomposition?min
04/07/2026, 9:57 AMprivate suspend fun recompositionRunner(
block: suspend CoroutineScope.(parentFrameClock: MonotonicFrameClock) -> Unit
) {
val parentFrameClock = coroutineContext.monotonicFrameClock
withContext(broadcastFrameClock) {
// Enforce mutual exclusion of callers; register self as current runner
val callingJob = coroutineContext.job
registerRunnerJob(callingJob)
// Observe snapshot changes and propagate them to known composers only from
// this caller's dispatcher, never working with the same composer in parallel.
// unregisterApplyObserver is called as part of the big finally below
val unregisterApplyObserver =
Snapshot.registerApplyObserver { changed, _ ->
synchronized(stateLock) {
if (_state.value >= State.Idle) {
val snapshotInvalidations = snapshotInvalidations
changed.fastForEach {
if (
it is StateObjectImpl &&
!it.isReadIn(ReaderKind.Composition)
) {
// continue if we know that state is never read in
// composition
return@fastForEach
}
snapshotInvalidations.add(it)
}
deriveStateLocked()
} else null
}
?.resume(Unit)
}
addRunning(recomposerInfo)
try {
// Invalidate all registered composers when we start since we weren't observing
// snapshot changes on their behalf. Assume anything could have changed.
knownCompositions().fastForEach { it.invalidateAll() }
coroutineScope { block(parentFrameClock) }
} finally {
unregisterApplyObserver.dispose()
synchronized(stateLock) {
if (runnerJob === callingJob) {
runnerJob = null
}
deriveStateLocked()
}
removeRunning(recomposerInfo)
}
}
}
I think it’s the write observer is meant to notify this apply observer, but I still don’t understand anything beyond that.Zach Klippenstein (he/him) [MOD]
04/07/2026, 1:29 PMrunRecomposeAndApplyChanges). It recomposes all the recompose scopes that were invalidated. A scope can be invalidated either because it read some state that was changed (and that change was observed by the apply observer), or explicitly via a RecomposeScope object. Recomposing one scope can invalidate more, so it just keeps looping until there are no more invalidated scopes left. Then it commits all those changes, updates modifiers, and starts effects before giving control back to the frame scheduler (Choreographer on Android) to do other phases like layout and drawing.min
04/09/2026, 12:10 PMthis.resolveParentCompositionContext() in AbstractComposeView.ensureCompositionCreated creates a new Recomposer if necessary. The factory used to create the Recomposer for the view tree installs a lifecycle observer, which calls recomposer.runRecomposeAndApplyChanges() on create. That‘s what seems to lead to the snapshot apply observer. It seems to entail storing the continuation of the coroutine in this.workContinuation to resume it later based on some internal state. I’ve not managed to get the full picture yet. Could you please share some insights I should keep in mind while I look into it?