Lesson 4

Groups, restart scopes, and source keys

Connect control-flow boundaries to restart callbacks, durable keys, and historical group elision.

4 min readUpdated Jul 11, 2026

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.

Diagram
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

ClaimDirect evidenceStatus
Replace groups bound conditional content that may be replaced but not movedComposer.ktDurable Version-specific
Restart groups store a callback used to re-enter a scopeComposer.kt and RecomposeScopeImpl.ktDurable Version-specific
Durable function keys are semantic-path hashes that usually survive equivalent editsDurableFunctionKeyTransformer.ktVersion-specific
Call groups have changed historically and non-restartable groups can be optimized awayHistorical design note, current compiler goldens, and Gerrit changes 2928806 and 2918728Version-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.