ViewCompose State Snapshots
1. Scope
This document defines snapshot semantics and usage constraints for the viewcompose-runtime state
system and the renderer-connected state owners published by viewcompose-ui-contract.
Goals:
- Provide consistent read/write semantics for
MutableState. - Define conflict handling for concurrent writes.
- Prevent the runtime from regressing to direct assignment without transactions.
2. Public API
mutableStateOf(value, policy)- default policy:
structuralEqualityPolicy()
- default policy:
SnapshotMutationPolicy<T>equivalent(a, b): determines whether a write is treated as unchanged;merge(previous, current, applied): merges a concurrent conflict;nullmeans the conflict cannot be merged.
SnapshottakeSnapshot()takeMutableSnapshot()withMutableSnapshot { ... }currentGlobalId()
MutableSnapshotenter { ... }apply()dispose()
RuntimeObservationobserveReads(onInvalidated) { ... }: creates one independently disposable dependency owner;prepareReplacement(previous) { ... }: reads candidate dependencies and returns an explicitcommit/abortreplacement transaction.
- Renderer-connected state
LazyListState: virtualized item position and layout information;ScrollState: eager-container logical offset, range, viewport, motion, and commands;PagerState: current, settled, target, offset, count, motion, capability, and commands.
3. Core semantics
MutableStateuses an MVCCStateRecordchain; a read selects the version visible to itsreadId.- Outside an explicit snapshot context,
state.value = xruns an internal autocommit transaction throughtakeMutableSnapshot + apply. MutableSnapshot.apply()publishes serially:- without conflict, it commits directly;
- with conflict, it calls
policy.merge(previous, current, applied); - if merge fails,
apply()returnsFailure.
- Read snapshots are isolated:
Snapshot.takeSnapshot().enter { ... }always reads the versions visible to that snapshot, regardless of later global commits. - Every
ComposerLitecomposition runs within a consistent read snapshot, so reads cannot drift within one pass. - The runtime tracks the
readIdof active snapshots. Commits retain versions required by active readers and prune history that is no longer visible after snapshots are released. - One successful global apply gathers affected
Observationinstances in stable unique order and invokes each at most once on the applying thread after runtime and state locks are released. Separate applies are never debounced together. A conflict or no-op apply emits no invalidation. - Framework-owned fields that form one public logical tuple use one existing mutable-snapshot
transaction. Writer serialization, including
synchronized, does not make separate commits atomically visible to snapshot readers. - A renderer-connected state publishes one immutable snapshot through normal
MutableState. Equal snapshots do not invalidate observation or listeners. - One connector is live at a time. Replacement captures the old connector's latest snapshot, clears its listener, and attaches the new connector. Disposal detaches it; stale commands cannot reach an abandoned native View.
ScrollState.scrollToretains a detached target and applies it to a newly attached eager host;animateScrollTois a detached no-op.PagerStatecommands are detached no-ops because the controlled pager declaration remains authoritative across recreation.- Eager horizontal offsets and pager indexes are logical in RTL. Native physical positions are a renderer detail and must not leak into the portable snapshot.
- Observation dependency replacement retains subscriptions shared with the committed dependency
set. Candidate-only dependencies temporarily subscribe the same
Observation, preserving at-most-once identity even when one Apply changes old and candidate dependencies together;commitswitches the authoritative set without an invalidation gap, whileabortreleases only candidate additions. Exactly one replacement may be prepared at a time, and every replacement requires one terminal operation. - A nested snapshot freezes pending values visible in its parent at creation. Later parent writes cannot alter that read view. Child apply compares per-state write identities with this frozen baseline; earlier parent writes are not conflicts, but later writes are. Applying to an applied or disposed parent is invalid. Read-only children also preserve buffered parent values.
- Derived caches distinguish mutable snapshot identities and local mutations. Read snapshots with identical committed history may share cache validity. Upstream subscriptions exist only while consumers observe the derived state; independent reads validate their view without retaining upstream subscriptions, and a new consumer reconnects before its read returns.
- Successful publication establishes the mutable snapshot's terminal state before notification. Delivery attempts every affected observation, including derived dependency paths, at most once per apply. Callback failures rethrow the first throwable with later failures suppressed; writes remain committed and cannot be retried as an unapplied transaction. Reentrant applies have their own notification batch.
- A failed derived calculation preserves its previous dependency subscriptions. Later applies remain invalidation opportunities even while the cached result is dirty.
4. Concurrency and conflict constraints
- Conflict detection uses state-record versions. A new record created after the transaction
readIdis a concurrent write. equivalent(a, b)decides only whether one assignment creates a record; it does not infer transaction concurrency.- Conflicts do not overwrite by default. A commit is allowed only when
mergesupplies a merged value. - Without merge support, including the default policy, a conflict fails and the caller decides whether to retry.
- Nested conflicts compare buffered write identities, not value equality. Returning a parent value to its earlier value still counts as a later write. A child captures only pending values; committed records remain shared under its pinned read ID. Capturing a parent with pending writes costs space proportional to that pending set, not to all live state objects.
5. Development constraints
- Do not add a runtime state-write path that bypasses snapshots.
- A new state container integrated with
RuntimeObservationmust implement snapshot visibility. - Any policy or conflict-semantic change must add concurrent-transaction and composition consistency tests.
- Call
dispose()onSnapshot/MutableSnapshotafter use, or close it throughuse, to avoid retaining historical versions indefinitely. - When adding several framework-owned observable fields, classify whether they are one invariant
or independent events. Group only the invariant writes in
Snapshot.withMutableSnapshotand add a test that reads the complete tuple from an invalidation callback. - A state connector change requires replacement, disposal, equal-snapshot, pending-command, and logical-RTL tests. State objects never own Android Views or perform platform configuration.
- A framework transaction that re-evaluates long-lived observed readers must use prepared dependency replacement. Disposing and recreating every subscription on each successful frame is both a race risk and recurring hot-path work.
6. Related documents
Global apply notifications temporarily leave the caller's snapshot context. They read committed
global state and may start independent transactions; the caller's previous context is restored in
finally. This also applies when apply() is invoked inside enter(): notification code can read
the commit, while subsequent caller reads in the terminal snapshot remain invalid.