Lesson 4
Mutation policies decide equivalence
Compare structural, referential, never-equal, and custom merge policies at the snapshot state boundary.
Status: A mutation policy defines the state layer’s notion of equivalence and optional conflict merge. That role is Durable . The stock implementations and exact merge path are Version-specific at the pinned revision.
Outcome
Given two assignments or two competing snapshot writes, you should be able to predict when a policy treats them as equivalent and when it permits a merge.
Equivalence is a state decision
SnapshotMutationPolicy<T> has two operations. equivalent(a, b) answers whether a new value should count as a change. merge(previous, current, applied) optionally produces a value when two snapshots changed the same state object (SnapshotMutationPolicy.kt).
The policy is consulted inside SnapshotMutableStateImpl before a setter creates a writable record. If equivalent(old, new) is true, the setter does not write a new value. The same policy is also consulted during a conflict: if the parent’s current value and the applying value are equivalent, that collision need not produce a different result; otherwise the policy’s merge can return a new record value or null (SnapshotState.kt).
flowchart TD
Write[State write] --> Equal{Equivalent?}
Equal -->|Yes| NoRecord[No state change]
Equal -->|No| Record[Writable record]
Record --> Apply[Snapshot apply]
Apply --> Conflict{Competing write?}
Conflict -->|No| Publish[Publish]
Conflict -->|Yes| Merge[Policy merge]
Merge -->|Value| Publish
Merge -->|Null| Reject[Reject apply]This is deliberately narrower than saying “the policy controls recomposition.” It controls whether the snapshot state value is considered changed and how a collision can be merged. Composition and other observers may then receive different notifications, but their scheduling is a later layer.
The three stock policies
| Policy | equivalent(a, b) | Useful prediction |
|---|---|---|
| Structural | a == b | Equal data-class or collection values can be treated as unchanged |
| Referential | a === b | A new instance counts as different even when equals says equal |
| Never equal | always false | Every assignment counts as a change; competing writes cannot merge by equivalence |
The pinned SnapshotMutationPolicy.kt defines all three. mutableStateOf uses structural equality by default (SnapshotState.kt). The choice is part of the state holder, not a comparison you can safely infer from the value’s Kotlin type.
A custom policy can merge a counter or combine disjoint fields. Its merge receives the value the applying snapshot started from, the parent/current value, and the applying value. Returning null means the conflict cannot be resolved. A merge function must be conservative: it should return a value only when combining those three states preserves the type’s invariant.
Reproduce policy decisions
This is a pinned upstream-test procedure, not an experiment run for this lesson. At AndroidX revision cc1caf65677fc10a8ce8116eba46e716f4cef222, run:
./gradlew :compose:runtime:runtime:desktopTest \
--tests 'androidx.compose.runtime.snapshots.SnapshotTests.changingAnEqualityPolicyStateToItsCurrentValueIsNotConsideredAChange' \
--tests 'androidx.compose.runtime.snapshots.SnapshotTests.changingANeverEqualPolicyStateToItsCurrentValueIsConsideredAChange' \
--tests 'androidx.compose.runtime.snapshots.SnapshotTests.stateUsingNeverEqualPolicyCannotBeMerged' \
--tests 'androidx.compose.runtime.snapshots.SnapshotTests.statesWithStructuralEqualityPolicyMerge'
The control is the equality-policy test: assigning the same referential value produces no change. Replace the policy with neverEqualPolicy(); the same assignment is now considered a change. For the merge control, let both snapshots write structurally equal values; both applies should succeed. For the mutation, use two distinct values with neverEqualPolicy(); the second apply should throw SnapshotApplyConflictException. Interpretation limit: these tests establish state-layer equivalence and conflict outcomes. They do not establish that a composition body runs, that a frame is requested, or that a policy is appropriate for mutable objects whose contents change in place.
Misconceptions
- “A policy is just a setter optimization.” It also participates in snapshot conflict resolution.
- “Structural equality means immutable data.”
==can say two mutable containers are equal while later in-place mutation bypasses a state assignment. - “Never equal means every value is visibly different everywhere.” It marks state writes as changed; downstream observers still have their own mechanics.
- “A merge function can always choose the latest value.” It must preserve the state type’s semantics, not just avoid an exception.
Check yourself
Two snapshots begin with Value(1, 2) and both write a new Value(2, 2). Which stock policy can regard the second apply as equivalent, and what changes if the policy is referential?
Source notes
| Claim | Direct evidence | Status |
|---|---|---|
equivalent suppresses equal writes and merge resolves conflicts | SnapshotMutationPolicy.kt | Durable
Version-specific |
| Stock policy semantics are structural, referential, and never-equal | SnapshotMutationPolicy.kt | Version-specific |
mutableStateOf defaults to structural equality | SnapshotState.kt | Version-specific |
| Policy behavior is asserted by snapshot tests | SnapshotTests.kt | Version-specific |
Freshness
Refresh this lesson when stock policies, setter equivalence, or mergeRecords changes. The current release notes include a recent fix for a merge-policy previous-value bug; use the release notes and the pinned SnapshotTests.testMergePolicy before trusting a future merge trace.
Finished this lesson?
Your progress stays only in this browser.