Lesson 1
Movable content carries identity across locations
Trace how movableContentOf extracts remembered state, nodes, and invalidations, then inserts them at a new call site.
Evidence legend: Durable marks a stable mental model; Version-specific marks behavior tied to the AndroidX pin below; Experimental marks a changing or transitional path.
A remember value normally follows its position in one composition. What if a screen changes from a row to a column, but the editor, focus, or scroll state must follow the content rather than the old branch? movableContentOf makes that relocation explicit: the call creates a tracked content identity, and a later call can claim the state and nodes that left another location.
Extraction is more than moving a lambda
movableContentOf is marked @RememberInComposition; its returned lambda asks the current composer to insertMovableContent with an internal MovableContent holder and its parameter (MovableContent.kt#L34-L64). That holder gives each tracked lambda an identity even when two holders wrap the same function. The identity is not “the latest lambda object”; it is the runtime’s movable-content record at a composition location.
During apply, extractMovableContentAtCurrent creates a new slot table and removes the content from the source table. Its nodes leave their old parent, pending invalidations are extracted, and the resulting MovableContentStateReference retains content, parameters, source composition, slots, anchor, locals, nested references, and invalidations. A paired reference later represents insertion at the destination (Composer.kt#L1437-L1582, MovableContent.kt#L280-L311).
The composer documents two insertion cases: a paired reference moves released state from another location or composition; an unpaired reference creates new state for content that has no previous state (Composer.kt#L374-L398). After insertion, the destination composes over the moved group so changed parameters and new code are reconciled. The move is therefore a storage-and-node transfer, not a promise that the destination never executes composable code.
flowchart LR
A[Call leaves source] --> B[Extract slot state and nodes]
B --> C[Transfer pending invalidations]
C --> D[Insert at destination]
D --> E[Recompose over moved state]The pinned controls make this concrete. testMovableContentSharesState moves content from Row to Column and asserts the same remembered object; movableContentPreservesNodes asserts the same marker node and live remember observers. movableContentInvalidatedWhileDeleted covers link and gap composers when invalidation arrives during temporary absence (MovableContentTests.kt#L48-L133, MovableContentTests.kt#L1834-L1910).
Move, dispose, and recompose are different
| Event | State result | Node result | Invalidation result |
|---|---|---|---|
| Movable call leaves and a new call claims it | Remembered state can transfer | Existing nodes can transfer | Pending scopes move to the new owner |
| Ordinary content is removed | Slots are forgotten; observers may be notified | Nodes are removed | Old scopes no longer belong to active content |
| Same content is invalidated in place | Existing remembered values remain | Existing nodes are updated | Scope is scheduled for recomposition |
Durable
A historical release note says unclaimed movable state is eventually removed and remember observers are notified; it is not retained forever (Compose Runtime 1.2.0-alpha03). Composition.dispose() is the stronger terminal operation: it clears the hierarchy and releases composition resources (Composition.kt#L72-L90).
Reproduce the boundary
At an AndroidX checkout pinned to cc1caf65677fc10a8ce8116eba46e716f4cef222, run:
./gradlew :compose:runtime:runtime:desktopTest \
--tests 'androidx.compose.runtime.MovableContentTests.testMovableContentSharesState' \
--tests 'androidx.compose.runtime.MovableContentTests.movableContentPreservesNodes' \
--tests 'androidx.compose.runtime.MovableContentTests.movableContentInvalidatedWhileDeleted_linkComposer' \
--tests 'androidx.compose.runtime.MovableContentTests.movableContentInvalidatedWhileDeleted_gapComposer'
Controls: switch branch location, retain node identity, then invalidate while the old call is deleted. Expected: the remembered object and marker node survive relocation, and invalidation is not lost during the gap. Limit: mock-applier tests do not specify animation, saveable state, or custom-applier policy. This procedure was not executed in this content edit.
Misconceptions and check
- “
movableContentOfis just a stable lambda.” Its runtime holder participates in extraction and insertion; a stable lambda alone does not transfer slot storage. - “A move means no recomposition.” The destination composes over the moved state; preservation of identity is not suppression of all execution.
- “Temporary absence is disposal.” The runtime can hold movable state long enough for a new call to claim it; permanent unclaimed state is different.
Predict the result before looking at the test: if the same movableContentOf value is called in a different branch, which should remain identical—the remembered state object, the emitted marker node, or neither? The pinned controls answer: both state and node identity remain, while their parent location changes.
Evidence ledger
| Claim | Evidence | Label |
|---|---|---|
| Extraction transfers slot state, nodes, and pending invalidations | Composer.kt#L1437-L1582 | Version-specific |
| Relocation preserves a remembered object and node in pinned tests | MovableContentTests.kt#L48-L133 | Version-specific |
| Unclaimed state is eventually forgotten | Compose Runtime 1.2.0-alpha03 | Durable |
Freshness
Refresh when movable-content extraction, pairing, invalidation transfer, or the gap/link composer implementations change. Keep preservation claims tied to the pinned tests; do not generalize them to arbitrary custom appliers or saveable state.
Finished this lesson?
Your progress stays only in this browser.