Lesson 4
Groups, restart scopes, and source keys
Connect control-flow boundaries to restart callbacks, durable keys, and historical group elision.
Status: Groups mark protocol boundaries that let the runtime reconcile control flow or restart a region. That mental model is Durable . Which calls are emitted, and which groups are elided, are Version-specific .
Outcome
You should be able to explain three different generated structures:
- a function-level restart group;
- a replace group around a conditional branch; and
- no group around a non-restartable function whose surrounding control flow is already safe.
They solve different reconciliation problems.
Control flow needs a boundary
Consider:
@Composable
fun Panel(showDetails: Boolean) {
Header()
if (showDetails) Details() else Summary()
Footer()
}
The if can change which calls happen, so the runtime needs a boundary saying “this region may be replaced.” The current Composer contract says startReplaceGroup may insert, remove, or replace a group, but not move it.
A function-level restart group answers a different question: “Can this region run again from its saved callback?” Its boundary is startRestartGroup/endRestartGroup.
flowchart TD
Function[Composable function] --> Restart[startRestartGroup]
Restart --> Body[Function body]
Body --> Branch[Conditional control flow]
Branch --> Replace[startReplaceGroup]
Replace --> Arm[One selected branch]
Arm --> EndReplace[endReplaceGroup]
EndReplace --> EndRestart[endRestartGroup]The diagram is a protocol sketch. It does not claim that every visible source statement receives a group.
Restart scopes and update callbacks
At the pinned revision, most restartable declarations start a restart group, generate skip checks and a body, end the group, and install an update callback when the runtime returns one. The compiler documents this in ComposableFunctionBodyTransformer.kt. ScopeUpdateScope accepts a composer and force integer; RecomposeScopeImpl.kt stores the block.
The captured golden output makes the connection concrete:
%composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
C(%composer, updateChangedFlags(%changed or 0b0001))
}
The callback is a recipe for re-entering the region, not a second rendered tree. Its force-bit and changed-flag expression are compiler details.
Source keys are identity signals, not global IDs
The runtime documents group keys as compiler-generated source-location values. The compiler combines a function key with element offsets and element-kind distinctions in ComposableFunctionBodyTransformer.kt. DurableFunctionKeyTransformer.kt builds a semantic path that usually survives identity-preserving edits.
“Durable” does not mean globally unique or application-owned. The Composer docs say the composite key hash is likely, not guaranteed, unique. Sibling position, control flow, and explicit data keys still matter; Lesson 5 separates those data keys.
Why call groups changed
The historical design note records an evolution: early implementations put groups at call sites; later designs moved groups into functions and added control-flow groups, so unconditional calls could be disambiguated by order. It then proposed removing groups for non-restartable functions, which need no restart boundary.
That is history, not a timeless specification. Current goldens show the optimization: testGrouplessProperty.txt has a composable getter with no restart group, while testIfStatementGroups.txt keeps a replace group for a conditional branch. Treat group counts as Version-specific
.
Experiment: compile three shapes
Compile three fixtures: a normal restartable composable, the same body with @NonRestartableComposable, and an if with calls in both branches.
Expected: the normal function has a restart callback; the non-restartable shape need not; the conditional shape has a branch boundary. Control: add a composable call to a condition or loop. Limit: a golden proves only its flags and inputs.
Misconceptions
- “Every composable call gets one permanent group.” Some calls are elided.
- “Restart and replace groups are interchangeable.” One re-enters a scope; the other reconciles non-moving flow.
- “A source key is a global UI ID.” It participates in local identity.
Check yourself
Why can an unconditional call survive without a call-site group after control-flow groups are introduced? What additional boundary is still required when the function itself must be restarted on a state read?
Evidence notes
| Claim | Direct evidence | Status |
|---|---|---|
| Replace groups bound conditional content that may be replaced but not moved | Composer.kt | Durable
Version-specific |
| Restart groups store a callback used to re-enter a scope | Composer.kt and RecomposeScopeImpl.kt | Durable
Version-specific |
| Durable function keys are semantic-path hashes that usually survive equivalent edits | DurableFunctionKeyTransformer.kt | Version-specific |
| Call groups have changed historically and non-restartable groups can be optimized away | Historical design note, current compiler goldens, and Gerrit changes 2928806 and 2918728 | Version-specific |
Freshness
Refresh this lesson when compiler control-flow lowering, durable-key computation, restart callback storage, or group-elision flags change. Recheck the historical note against current tests; do not promote its proposal into a current guarantee.
Finished this lesson?
Your progress stays only in this browser.