Skip to main content

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:

  1. Provide consistent read/write semantics for MutableState.
  2. Define conflict handling for concurrent writes.
  3. Prevent the runtime from regressing to direct assignment without transactions.

2. Public API​

  1. mutableStateOf(value, policy)
    • default policy: structuralEqualityPolicy()
  2. SnapshotMutationPolicy<T>
    • equivalent(a, b): determines whether a write is treated as unchanged;
    • merge(previous, current, applied): merges a concurrent conflict; null means the conflict cannot be merged.
  3. Snapshot
    • takeSnapshot()
    • takeMutableSnapshot()
    • withMutableSnapshot { ... }
    • currentGlobalId()
  4. MutableSnapshot
    • enter { ... }
    • apply()
    • dispose()
  5. RuntimeObservation
    • observeReads(onInvalidated) { ... }: creates one independently disposable dependency owner;
    • prepareReplacement(previous) { ... }: reads candidate dependencies and returns an explicit commit/abort replacement transaction.
  6. 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​

  1. MutableState uses an MVCC StateRecord chain; a read selects the version visible to its readId.
  2. Outside an explicit snapshot context, state.value = x runs an internal autocommit transaction through takeMutableSnapshot + apply.
  3. MutableSnapshot.apply() publishes serially:
    • without conflict, it commits directly;
    • with conflict, it calls policy.merge(previous, current, applied);
    • if merge fails, apply() returns Failure.
  4. Read snapshots are isolated: Snapshot.takeSnapshot().enter { ... } always reads the versions visible to that snapshot, regardless of later global commits.
  5. Every ComposerLite composition runs within a consistent read snapshot, so reads cannot drift within one pass.
  6. The runtime tracks the readId of active snapshots. Commits retain versions required by active readers and prune history that is no longer visible after snapshots are released.
  7. One successful global apply gathers affected Observation instances 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.
  8. 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.
  9. A renderer-connected state publishes one immutable snapshot through normal MutableState. Equal snapshots do not invalidate observation or listeners.
  10. 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.
  11. ScrollState.scrollTo retains a detached target and applies it to a newly attached eager host; animateScrollTo is a detached no-op. PagerState commands are detached no-ops because the controlled pager declaration remains authoritative across recreation.
  12. 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.
  13. 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; commit switches the authoritative set without an invalidation gap, while abort releases only candidate additions. Exactly one replacement may be prepared at a time, and every replacement requires one terminal operation.
  14. 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.
  15. 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.
  16. 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.
  17. 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​

  1. Conflict detection uses state-record versions. A new record created after the transaction readId is a concurrent write.
  2. equivalent(a, b) decides only whether one assignment creates a record; it does not infer transaction concurrency.
  3. Conflicts do not overwrite by default. A commit is allowed only when merge supplies a merged value.
  4. Without merge support, including the default policy, a conflict fails and the caller decides whether to retry.
  5. 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​

  1. Do not add a runtime state-write path that bypasses snapshots.
  2. A new state container integrated with RuntimeObservation must implement snapshot visibility.
  3. Any policy or conflict-semantic change must add concurrent-transaction and composition consistency tests.
  4. Call dispose() on Snapshot/MutableSnapshot after use, or close it through use, to avoid retaining historical versions indefinitely.
  5. When adding several framework-owned observable fields, classify whether they are one invariant or independent events. Group only the invariant writes in Snapshot.withMutableSnapshot and add a test that reads the complete tuple from an invalidation callback.
  6. 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.
  7. 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.
  1. Architecture overview
  2. Performance
  3. Development workflow

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.