A comprehensive study of how the Compose compiler determines type stability for recomposition optimization. All details that Optimize App Performance By Mastering Stability in Jetpack Compose and compose-performance couldn't take in.
Jetpack Compose Mechanisms takes you from "how to use Compose" into "how Compose actually works," tracing the AOSP source line by line through the compiler, runtime, and UI layers beneath every Composable, with practical, production-ready examples from the author's own Compose tooling and libraries. It then ties all three layers together into deep, real-world performance tuning, from stability inference to the skip decision. Fully updated for Kotlin 2.4.0 and Compose Compiler 2.4.0.
- Compose Compiler Stability Inference System
- π Sponsors
- π Jetpack Compose Mechanisms Book
- Table of Contents
- Chapter 1: Foundations
- Chapter 2: Stability Type System
- Chapter 3: The Inference Algorithm
- Chapter 4: Implementation Mechanisms
- Chapter 5: Case Studies
- Chapter 6: Configuration and Tooling
- Chapter 7: Advanced Topics
- Chapter 8: Compiler Analysis System
- Conclusion
- π Manifest Android Interview
- ποΈ Dove Letter
- Find this repository useful? β€οΈ
- License
The Compose compiler implements a stability inference system to enable recomposition optimization. This system analyzes types at compile time to determine whether their values can be safely compared for equality during recomposition.
The inference process involves analyzing type declarations, examining field properties, and tracking stability through generic type parameters. The results inform the runtime whether to skip recomposition when parameter values remain unchanged.
A type is considered stable when it satisfies three conditions:
- Immutability: The observable state of an instance does not change after construction
- Equality semantics: Two instances with equal observable state are equal via
equals() - Change notification: If the type contains observable mutable state, all state changes trigger composition invalidation
These properties allow the runtime to make optimization decisions based on value comparison.
When a composable function receives parameters, the runtime determines whether to execute the function body:
@Composable
fun UserProfile(user: User) {
// Function body
}The decision process:
- Compare the new
uservalue with the previous value - If equal and the type is stable, skip recomposition
- If different or unstable, execute the function body
Without stability information, the runtime has to recompose on every invocation, whether or not the parameters changed. That was the whole story until strong skipping landed, which section 1.4 covers.
Stability inference affects recomposition in three ways:
Skipping: a composable whose parameter values did not change can be skipped instead of executed. Which parameters count as unchanged depends on stability, so this is where the inference pays off.
Comparison Propagation: the compiler passes what it knows about a parameter down to child composable calls through the $changed mask, so a value already proven unchanged is not compared again further down the tree.
Comparison Strategy: the runtime picks structural equality (equals()) for stable types and referential equality (===) for unstable ones. Section 1.4 follows this thread, because since strong skipping became the default this is the part that still decides the outcome.
Consider this example:
// Unstable parameter type: an interface with unknown stability
@Composable
fun ExpensiveList(items: List<String>) {
// List is an interface, so it has Unknown stability
// Comparison falls back to the instance
}
// Stable parameter type: an immutable collection
@Composable
fun ExpensiveList(items: ImmutableList<String>) {
// ImmutableList is in KnownStableConstructs
// Comparison uses equals()
}
// The expression and the type are two different questions
@Composable
fun Caller() {
// listOf() is a known stable function, so this expression is stable
val items = listOf("a", "b")
// but the parameter type is still List, which is Unknown
ExpensiveList(items)
}List and MutableList are both interfaces, so both have Unknown stability. To get a stable parameter, use one of:
ImmutableListfrom kotlinx.collections.immutable, which is registered inKnownStableConstructskotlin.collections.Listadded to your stability configuration file- The
@Stableannotation on the class that holds the list
Everything above describes the classic skipping rule. It was narrower than it is usually remembered: a parameter only blocked skipping when it was used, unstable, and required, which is exactly the condition the body transformer still carries for builds that turn the flag off:
if (
!FeatureFlag.StrongSkipping.enabled &&
isUsed &&
isUnstable &&
isRequired
) {
// if it is a used + unstable parameter with no default expression and we are
// not in strong skipping mode, the fn will _never_ skip
mightSkip = false
}An unstable parameter with a default, or one the body never reads, was always fine. Strong skipping removes the condition entirely, and it has been on by default since the compiler shipped FeatureFlag.StrongSkipping with default = true:
enum class FeatureFlag(val featureName: String, val default: Boolean) {
StrongSkipping("StrongSkipping", default = true),
IntrinsicRemember("IntrinsicRemember", default = true),
OptimizeNonSkippingGroups("OptimizeNonSkippingGroups", default = true),
PausableComposition("PausableComposition", default = true),
;
}With strong skipping on, a restartable composable is skippable regardless of what its parameters are, unless it opts out. Stability no longer decides whether the function can skip. It decides how each parameter is compared:
- A stable parameter is compared with
Composer.changed(), which uses structural equality (equals()) - An unstable parameter is compared with
Composer.changedInstance(), which uses referential equality (===)
Strong skipping also memoizes lambdas that capture unstable values, which previously blocked lambda reuse.
So stability still matters, just for a different reason. A data class that is unstable will be compared by identity, and a fresh instance built on every recomposition will never compare equal, so the child recomposes every time even though its contents are identical. To opt a composable out of skipping entirely, annotate it with @NonSkippableComposable.
The compiler represents stability through a sealed class hierarchy defined in Stability.kt:
sealed class Stability {
class Certain(val stable: Boolean) : Stability()
class Runtime(val declaration: IrClass) : Stability()
class Unknown(val declaration: IrClass) : Stability()
class Parameter(val parameter: IrTypeParameter) : Stability()
class Combined(val elements: List<Stability>) : Stability()
}Each subtype represents a different category of stability information available to the compiler.
This type represents stability the compiler can settle completely at compile time.
Structure:
class Certain(val stable: Boolean) : Stability()The stable field indicates whether the type is definitely stable (true) or definitely unstable (false).
Examples:
// Certain(stable = true)
class Point(val x: Int, val y: Int)
// Certain(stable = false)
class Counter(var count: Int)Usage Conditions:
- Primitive types (
Int,Long,Boolean, etc.) StringandUnit- Function types (
FunctionN,KFunctionN) - Classes with only stable
valproperties - Classes with a non delegated
varproperty that has a backing field (immediately unstable) - Classes marked with
@Stableor@Immutableannotations
Implementation: See Stability.kt for the knownStable() extension function.
This type indicates that stability must be checked at runtime by reading a generated $stable field.
Structure:
class Runtime(val declaration: IrClass) : Stability()The declaration references the class whose stability requires runtime determination.
Generated Code Example:
// Source code
class Box<T>(val value: T)
// Compiler generated code
@StabilityInferred(parameters = 0b1)
class Box<T>(val value: T) {
// a synthetic static final int placed directly on the class,
// not inside a companion object
val $stable: Int = 0
}The StabilityInferred KDoc in the Compose runtime describes the field the same way: "there will be a synthetic static final int $stable added to the class."
When Applied:
- Classes compiled in a separate module, which arrive as external stubs carrying an
@StabilityInferredbitmask - Public or internal classes declared in a different file than the one being compiled, on JVM
Only those two. A generic class in the same file resolves to Stability.Parameter instead, covered in 2.5.
Runtime Behavior:
The $stable field holds the class's own contribution. The call site combines it with the stability of the type arguments the bitmask selects:
Box<Int> // $stable contributes 0, Int is stable -> stable
Box<MutableList<Int>> // $stable contributes 0, MutableList is not -> unstableImplementation: See Stability.kt (the Runtime handling in StabilityInferencer.stabilityOf) and ClassStabilityTransformer.kt (the generated $stable field).
This type represents cases where the compiler cannot determine stability.
Structure:
class Unknown(val declaration: IrClass) : Stability()Examples:
interface Repository {
fun getData(): String
}
class Screen(val source: Repository)
// Repository has Unknown stabilityUsage Conditions:
- Interface types, since the implementation is not known
openandabstractclasses, which seed asUnknownbecause a subclass could add unstable state
Runtime Behavior:
Unknown is the one stability that cannot be expressed at runtime. isExpressible() returns false for it, so there is no $stable read to emit and no bit to set, and the class falls back to Unstable at the use site. Comparison then goes through changedInstance, which uses ===.
Implementation: See Stability.kt (the Stability.Unknown branch in StabilityInferencer.stabilityOf).
This type represents stability that depends on a generic type parameter.
Structure:
class Parameter(val parameter: IrTypeParameter) : Stability()Example:
class Wrapper<T>(val value: T)
// ^^^^^^^^^^^^
// Stability depends on T
// Instantiation examples:
Wrapper<Int> // Stable (Int is stable)
Wrapper<Counter> // Unstable (Counter from 2.2 is unstable)Resolution Process:
When analyzing Wrapper<Int>:
- Identify
value: ThasStability.Parameter(T) - Substitute
TwithInt - Evaluate
stabilityOf(Int)=Stable - Result:
Wrapper<Int>is stable
Implementation: See Stability.kt for type parameter handling (the isTypeParameter() branch and applyTypeParameterMask).
This type aggregates multiple stability factors from different sources.
Structure:
class Combined(val elements: List<Stability>) : Stability()Examples:
class Complex<T, U>(
val primitive: Int, // Certain(stable = true)
val param1: T, // Parameter(T)
val param2: U // Parameter(U)
)
// Stable + Stable = Stable
// Stable + Parameter(T) = Parameter(T)
// Parameter(T) + Parameter(U) = Combined([Parameter(T), Parameter(U)])Combination Rules:
The compiler combines stabilities using the plus operator (defined in Stability.kt):
operator fun plus(other: Stability): Stability = when {
other is Certain -> if (other.stable) this else other
this is Certain -> if (stable) other else this
else -> Combined(listOf(this, other))
}Worked examples:
Stable + Stable = Stable
Stable + Unstable = Unstable
Unstable + Stable = Unstable
Stable + Parameter = Parameter // a stable Certain is absorbed
Parameter + Parameter = Combined([Parameter, Parameter])
Runtime + Parameter = Combined([Runtime, Parameter])The key observation: adding a stable Certain returns the other operand unchanged, so only genuinely uncertain factors (Parameter, Runtime, Unknown) accumulate into a Combined. A Combined is only produced when neither operand is Certain.
Key Property: unstable stability dominates all combinations. A single unstable component makes the entire result unstable.
The Compose compiler follows a systematic decision tree when determining stability. This tree represents the actual logic flow implemented in the compiler.
βββββββββββββββββββββββββββββββββββ
β Start: Analyze a type β
ββββββββββββββ¬βββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββ
β Error or dynamic type? ββββYesβββ [UNSTABLE]
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Unit, primitive, String, or ββββYesβββ [STABLE]
β a function type? β
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Type parameter? ββββYesβββ substitute, else
ββββββββββββββ¬βββββββββββββββββββββ [PARAMETER]
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Nullable? ββββYesβββ analyze the
ββββββββββββββ¬βββββββββββββββββββββ non null type
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Value class? ββββYesβββ marker β [STABLE],
β (multi field, then inline) β else analyze the
ββββββββββββββ¬βββββββββββββββββββββ underlying types
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Now analyze the class β
ββββββββββββββ¬βββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββ
β Already being analyzed? ββββYesβββ [UNSTABLE]
β (cycle) β
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Has a stable marked descendant?ββββYesβββ [STABLE]
β (@Stable, @Immutable, any β
β @StableMarker annotation, β
β or a known stable marker) β
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Enum class or enum entry? ββββYesβββ [STABLE]
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Object (singleton)? ββββYesβββ [STABLE]
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Primitive, or a Protobuf type? ββββYesβββ [STABLE]
β (GeneratedMessage/Lite) β
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β In KnownStableConstructs? ββββYesβββ [STABLE] combined with
β (Pair, Triple, ImmutableListβ¦) β the masked type params
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Matches the stability config? ββββYesβββ [STABLE] combined with
β (stability_config.conf) β the masked type params
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β External Java stub? ββββYesβββ [UNSTABLE]
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Interface? ββββYesβββ [UNKNOWN]
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β External stub with no ββββYesβββ [UNSTABLE]
β @StabilityInferred bitmask? β
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β JVM, public or internal, and ββββYesβββ [RUNTIME] combined with
β declared in a different file? β every type parameter
β (incremental compilation) β (mask = null)
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β External stub? ββββYesβββ [RUNTIME] combined with
β (separately compiled module) β the masked type params
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Seed stability: β
β final class β STABLE β
β non final β UNKNOWN β
ββββββββββββββ¬βββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββ
β Any non delegated var ββββYesβββ [UNSTABLE]
β property with a backing field? β
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Combine every backing field β
β type, then the superclass β
β (dropped if it is Unknown) β
ββββββββββββββ¬βββββββββββββββββββββ
β
βΌ
[STABLE / UNSTABLE / COMBINED / UNKNOWN]
When analyzing generic types, the compiler follows an additional decision path:
βββββββββββββββββββββββββββββββββββ
β Generic Type: Class<T1, T2> β
ββββββββββββββ¬βββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββ
β Base class stable? ββββNoββββ [UNSTABLE]
ββββββββββββββ¬βββββββββββββββββββββ
β Yes
βΌ
βββββββββββββββββββββββββββββββββββ
β Has stability bitmask? ββββNoββββ Analyze each
β (from KnownStableConstructs β type parameter
β or external config) β individually
ββββββββββββββ¬βββββββββββββββββββββ
β Yes
βΌ
βββββββββββββββββββββββββββββββββββ
β For each type parameter Ti: β
β Is bit i set in bitmask? ββββNoββββ Ti doesn't affect
ββββββββββββββ¬βββββββββββββββββββββ stability
β Yes
βΌ
βββββββββββββββββββββββββββββββββββ
β Check stability of actual β
β type argument for Ti βββββ Combine all results
βββββββββββββββββββββββββββββββββββ
For expressions (used in default parameters and composable bodies):
βββββββββββββββββββββββββββββββββββ
β Expression to analyze β
ββββββββββββββ¬βββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββ
β Is expression type stable? ββββYesβββ [STABLE]
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Is it a constant (IrConst)? ββββYesβββ [STABLE]
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Is it a stable function call? ββββYesβββ Check function
β (listOf, mapOf, etc.) β type parameters
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Is it a val reference? ββββYesβββ Check initializer
β (a val, not a var) β stability
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β A local delegated property ββββYesβββ [STABLE]
β reference? β
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
βββββββββββββββββββββββββββββββββββ
β Is it a composite with all ββββYesβββ [STABLE]
β stable subexpressions? β
ββββββββββββββ¬βββββββββββββββββββββ
β No
βΌ
Fall back to the type's stability
Note the last box. An expression the analysis cannot say anything extra about does not become unstable, it keeps whatever its type already said. Expression analysis only ever improves on the type result, never worsens it.
1. Early Exit Conditions:
- Primitives, String, Unit, and function types are immediately stable
- Stability annotations override all other checks, including the value class branches
- Enums, both classes and entries, are always stable because their instances are singletons and their state is fixed after initialization
- Objects are always stable, since there is exactly one instance and identity comparison always holds
2. Interface Handling:
- Interfaces return
Unknownstability because implementations can vary - Exception: Interfaces with
@Stablemarker are trusted
3. External Types:
- Java types default to unstable, since Java has no
valguarantee - Protobuf types are special cased as stable, because generated messages present an immutable API
- External Kotlin modules use
@StabilityInferredbitmasks, and an external stub without one is unstable
4. Member Analysis:
- The seed stability is
Stableforfinalclasses andUnknown(declaration)for non final (openorabstract) classes - Any non delegated
varproperty makes the entire class unstable - Delegated properties are analyzed through their delegate type instead
- Superclass stability is combined into the result, but an
Unknownsuperclass result is dropped, so an open superclass does not poison the subclass
5. Generic Type Resolution:
- The bitmask encodes which type parameters affect stability
- At most 32 type parameters are considered, since the mask is an
Int - Type arguments are substituted and analyzed recursively
This decision tree is implemented across several functions in the compiler, with the main entry point being StabilityInferencer.stabilityOf().
The algorithm follows the decision tree above. StabilityInferencer carries three pieces of state through the whole recursion, and each phase below makes more sense once you know what they are:
substitutions: a map from type parameter symbol to the type argument currently bound to it, grown as the walk descends through generic typescurrentlyAnalyzing: the set of symbols on the current analysis stack, which is what stops a recursive type from recursing foreveranalysisEntryFile: the file that started this whole request, which decides whether a class can be inferred concretely or has to fall back to a runtime read
Results are cached in cache, keyed by SymbolForAnalysis, but only when the declaration lives in analysisEntryFile. A result that depended on which file asked the question cannot be reused by a different question.
The algorithm short circuits as soon as it can settle stability definitively, so most types never reach the full member walk.
The compiler first checks for common stable types:
when {
type is IrErrorType -> Stability.Unstable
type is IrDynamicType -> Stability.Unstable
type.isUnit() ||
type.isPrimitiveType() ||
type.isFunctionOrKFunction() ||
type.isSyntheticComposableFunction() ||
type.isString() -> Stability.Stable
}Primitive Types:
- Numeric:
Byte,Short,Int,Long,Float,Double - Boolean:
Boolean - Character:
Char
Function Types:
- Standard functions:
Function0,Function1, ...,FunctionN - Kotlin functions:
KFunction0,KFunction1, ...,KFunctionN - Composable functions:
ComposableFunction0, etc.
For generic type parameters:
type.isTypeParameter() -> {
val classifier = type.classifierOrFail
val arg = substitutions[classifier]
val symbol = SymbolForAnalysis(classifier, emptyList(), analysisEntryFile)
if (arg != null && symbol !in currentlyAnalyzing) {
stabilityOf(arg, substitutions, currentlyAnalyzing + symbol, analysisEntryFile)
} else {
Stability.Parameter(classifier.owner as IrTypeParameter)
}
}Substitution Example:
class Container<T>(val item: T)
// Analyzing Container<Int>
// T is IrTypeParameter
// substitutions map: {T: Int}
// Result: stabilityOf(Int) = StableNullable types defer to their non null counterpart:
type.isNullable() -> stabilityOf(
type.makeNotNull(),
substitutions,
currentlyAnalyzing,
analysisEntryFile
)Examples:
Int?β analyzeIntβ StableUser?β analyzeUserβ depends on User structure
The IR models a value class with one of two representations, and the compiler checks them in that order. FullValueClassRepresentation holds a list of underlying properties. Every multi field value class has one, and so does a single property value class on a target that does not use the JVM inline layout. isFullValueClassType() tests for it:
type.isFullValueClassType() -> {
val valueClassDeclaration = type.getClass()
?: error("Failed to resolve the class definition of full value class type $type")
if (valueClassDeclaration.hasStableMarker()) {
Stability.Stable
} else {
val primaryProperties = valueClassDeclaration.valueClassRepresentation
?.underlyingPropertyNamesToTypes
?: return Stability.Unstable // is abstract value class
primaryProperties
.map { (_, type) -> stabilityOf(type, substitutions, currentlyAnalyzing, analysisEntryFile) }
.let { Stability.Combined(it) }
}
}Every underlying property contributes, and the results are folded into a Combined. An abstract value class has no valueClassRepresentation at all, so it falls back to Unstable.
InlineClassRepresentation holds exactly one property, and isInlineClassType() tests for it. That branch unwraps to the single underlying type:
type.isInlineClassType() -> {
val inlineClassDeclaration = type.getClass()
?: error("Failed to resolve the class definition of inline type $type")
if (inlineClassDeclaration.hasStableMarker()) {
Stability.Stable
} else {
stabilityOf(
type = getInlineClassUnderlyingType(
inlineClassDeclaration,
treatCompatibleFullValueClassesAsInline = false
),
substitutions = substitutions,
currentlyAnalyzing = currentlyAnalyzing,
analysisEntryFile
)
}
}The treatCompatibleFullValueClassesAsInline = false argument is what keeps the two branches apart. With true, getInlineClassUnderlyingType reinterprets a compatible full value class, meaning one with exactly one underlying property and no superclass, as an inline class. The full value class branch above has already handled that case, so passing false leaves the inline branch dealing only with genuine inline classes.
Examples:
@JvmInline
value class UserId(val value: Int)
// Checks: stabilityOf(Int) = Stable
@JvmInline
value class Token(val value: String)
// Checks: stabilityOf(String) = Stable
@JvmInline
value class Range(val start: Int, val end: Int)
// Multi field value class, so FullValueClassRepresentation
// Checks both underlying types: Combined([Stable, Stable])
@JvmInline
@Stable
value class SpecialId(val list: MutableList<Int>)
// @Stable marker overrides underlying type analysis
// Result: Stable (by annotation)A stable marker short circuits both branches, so the annotation wins over whatever the underlying types say.
To prevent infinite recursion with recursive types:
if (currentlyAnalyzing.contains(fullSymbol))
return Stability.UnstableExample:
class Node(val value: Int, val next: Node?)
// Analysis trace:
// 1. stabilityOf(Node) β add to currentlyAnalyzing
// 2. Check field: value: Int β Stable
// 3. Check field: next: Node? β unwrap nullable
// 4. Check field: next: Node β CYCLE DETECTED
// 5. Return UnstableThis conservative approach ensures termination while potentially marking some stable recursive types as unstable.
Quick checks for annotated or special types:
if (declaration.hasStableMarkedDescendant()) return Stability.Stable
if (declaration.isEnumClass || declaration.isEnumEntry) return Stability.Stable
if (declaration.isObject) return Stability.Stable
if (declaration.defaultType.isPrimitiveType()) return Stability.Stable
if (declaration.isProtobufType()) return Stability.StableStable Markers:
@Stableannotation@Immutableannotation- Annotations marked with
@StableMarker - Annotations listed in
KnownStableConstructs.stableMarkers
That last entry exists because some annotations outside Compose carry the same guarantee but cannot be annotated with @StableMarker:
val stableMarkers = setOf(
ClassId(
FqName("com.google.errorprone.annotations"),
Name.identifier("Immutable")
)
)The check itself resolves the annotation class and tests both paths:
private fun IrAnnotation.isStableMarker(): Boolean {
val owner = annotationClass?.owner ?: return false
return owner.hasAnnotation(ComposeFqNames.StableMarker) ||
owner.classId in KnownStableConstructs.stableMarkers
}hasStableMarkedDescendant() then walks the supertypes, so a class inherits the marker from any annotated ancestor other than Any.
Enum Handling:
All enum classes and enum entries are considered stable because:
- Enum instances are singletons (referential equality works)
- Enum state is immutable after initialization
- Enum equality is based on identity
Object Handling:
object declarations (singletons) are always stable. There is exactly one instance, so identity comparison is always valid regardless of the object's members.
Protobuf Detection:
private fun IrClass.isProtobufType(): Boolean {
if (!isFinalClass) return false
val directParentClassName = superTypes
.lastOrNull { !it.isInterface() }
?.classOrNull?.owner?.fqNameWhenAvailable?.toString()
return directParentClassName == "com.google.protobuf.GeneratedMessageLite" ||
directParentClassName == "com.google.protobuf.GeneratedMessage"
}Generated protobuf classes are marked stable despite potentially containing mutable implementation details.
The compiler maintains a registry of known stable types:
val stableTypes = mapOf(
Pair::class.qualifiedName!! to 0b11,
Triple::class.qualifiedName!! to 0b111,
Comparator::class.qualifiedName!! to 0b1,
Result::class.qualifiedName!! to 0b1,
ClosedRange::class.qualifiedName!! to 0b1,
ClosedFloatingPointRange::class.qualifiedName!! to 0b1,
// Guava
"com.google.common.collect.ImmutableList" to 0b1,
"com.google.common.collect.ImmutableEnumMap" to 0b11,
"com.google.common.collect.ImmutableMap" to 0b11,
"com.google.common.collect.ImmutableEnumSet" to 0b1,
"com.google.common.collect.ImmutableSet" to 0b1,
// Kotlinx immutable
"kotlinx.collections.immutable.ImmutableCollection" to 0b1,
"kotlinx.collections.immutable.ImmutableList" to 0b1,
"kotlinx.collections.immutable.ImmutableSet" to 0b1,
"kotlinx.collections.immutable.ImmutableMap" to 0b11,
"kotlinx.collections.immutable.PersistentCollection" to 0b1,
"kotlinx.collections.immutable.PersistentList" to 0b1,
"kotlinx.collections.immutable.PersistentSet" to 0b1,
"kotlinx.collections.immutable.PersistentMap" to 0b11,
// Dagger
"dagger.Lazy" to 0b1,
// Coroutines
EmptyCoroutineContext::class.qualifiedName!! to 0,
// Java types
BigInteger::class.qualifiedName!! to 0,
BigDecimal::class.qualifiedName!! to 0,
Locale::class.qualifiedName!! to 0,
)The integer value represents a bitmask indicating which type parameters affect stability (covered in Chapter 4). A mask of 0 means the type is stable regardless of its type arguments (e.g. BigInteger, Locale).
Users can provide configuration files declaring types as stable:
if (declaration.isExternalStableType()) {
val baseStability = Stability.Stable
return baseStability.applyTypeParameterMask(
mask = externalTypeMatcherCollection
.maskForName(declaration.fqNameWhenAvailable) ?: 0,
typeParameters = typeParameters,
substitutions,
analyzing,
analysisEntryFile,
)
}The same applyTypeParameterMask helper used for KnownStableConstructs (Phase 7) combines the base stability with the stability of the type arguments selected by the configured bitmask. Configuration file format is covered in Chapter 6.
Stability inference has to hold up under incremental compilation, which is separated by file. If the compiler inferred concrete stability for a class declared in another file, a later edit to that file could silently invalidate the result without recompiling the dependents. To avoid that, a class that is part of the public or internal API and lives in a different file than the one that started the analysis is forced to use runtime stability: the value of its generated $stable field is read at runtime instead of being decided at compile time.
Before that check, an external stub with no bitmask at all is rejected outright:
if (declaration.origin == IrDeclarationOrigin.IR_EXTERNAL_DECLARATION_STUB &&
declaration.stabilityParamBitmask() == null
) {
return Stability.Unstable
}The order matters. A class compiled without the Compose compiler has no $stable field, so returning Runtime for it would emit a read of a field that does not exist. Catching the missing bitmask first is what keeps that from happening.
// `analysisEntryFile` is the file containing the element that started this
// stabilityOf() call tree; `fileContainingDeclaration` is where `declaration` lives.
val forcedToUseRuntimeStability = isTargetJvm &&
(declaration.visibility.isPublicAPI ||
declaration.visibility == DescriptorVisibilities.INTERNAL) &&
(fileContainingDeclaration == null || fileContainingDeclaration != analysisEntryFile)
if (forcedToUseRuntimeStability) {
if (typeParameters.isEmpty()) {
return Stability.Runtime(declaration)
} else {
val baseStability = Stability.Runtime(declaration)
return baseStability.applyTypeParameterMask(
mask = null, // null = consider every type parameter
typeParameters = typeParameters,
substitutions,
analyzing,
analysisEntryFile,
)
}
}
// Classes that come from a separately compiled module arrive as external stubs.
// Their stability is encoded in the @StabilityInferred bitmask.
if (declaration.origin == IrDeclarationOrigin.IR_EXTERNAL_DECLARATION_STUB) {
val mask = declaration.stabilityParamBitmask() ?: return Stability.Unstable
val baseStability = Stability.Runtime(declaration)
return baseStability.applyTypeParameterMask(
mask,
typeParameters = typeParameters,
substitutions,
analyzing,
analysisEntryFile,
)
}Key points:
- The decision is driven by the file the declaration lives in, through
analysisEntryFile, not by a "current module" check. That is what makes the result safe under incremental compilation. Stability.Runtime(declaration)means "emit a read ofdeclaration.$stableat runtime". It is combined with the stability of the relevant type arguments throughapplyTypeParameterMask.- Under
forcedToUseRuntimeStabilitythe mask isnull, so every type parameter is taken into account. The compiler has no bitmask to consult yet, since the class is being compiled in this same module, so it assumes the worst. For a genuine external stub the recorded@StabilityInferredbitmask is used instead. - An external stub with no
@StabilityInferredbitmask, such as a third party class compiled without the Compose compiler, isUnstable. - The check only applies when
isTargetJvmis true. Other targets have no static field to read, which is why Chapter 4 describes a separate scheme for Native and JS.
Because an external stub has no IrFile parent, fileContainingDeclaration is null for it, and the file comparison is true. So on JVM a public class from another module takes this branch rather than the external stub branch below, and its recorded bitmask is never read. Chapter 5.4 walks through what that looks like at a call site.
Note: the order of checks in the source is Java stub, then interface, then external stub without a bitmask, then
forcedToUseRuntimeStability, then external stub. Phases 10 and 11 below actually execute before this runtime stability logic.
if (declaration.origin == IrDeclarationOrigin.IR_EXTERNAL_JAVA_DECLARATION_STUB) {
return Stability.Unstable
}Java types default to unstable because:
- Java allows unrestricted mutability
- There is no equivalent of Kotlin's
valguarantee - The Java standard library carries no stability annotations
if (declaration.isInterface) {
// `Stability.Unknown` is always used for interfaces because stability bitmasks
// aren't populated for them.
return Stability.Unknown(declaration)
}An interface has no implementation to inspect, and ClassStabilityTransformer skips interfaces, so there is no $stable field to fall back on either. That is why the result is Unknown rather than Runtime.
For concrete classes in the current module:
var stability = if (declaration.modality == Modality.FINAL) {
Stability.Stable
} else {
Stability.Unknown(declaration)
}
for (member in declaration.declarations) {
when (member) {
is IrProperty -> {
member.backingField?.let {
if (member.isVar && !member.isDelegated) return Stability.Unstable
stability += stabilityOf(it.type, substitutions, analyzing, analysisEntryFile)
}
}
is IrField -> {
stability += stabilityOf(member.type, substitutions, analyzing, analysisEntryFile)
}
}
}
declaration.superClass?.let {
val superClassStability = stabilityOf(it, substitutions, analyzing, analysisEntryFile)
if (superClassStability !is Stability.Unknown) {
stability += superClassStability
}
}
return stabilityKey Points:
- The seed is
Stableonly forfinalclasses. A non finalopenorabstractclass seeds asUnknown(declaration), since an unknown subclass could add unstable state - Any non delegated
varproperty immediately returnsUnstable - Combine the stability of every backing field type
- Include superclass stability, but only when it is not
Unknown, since anUnknownsuperclass result is dropped rather than propagated - Use the
+operator for combination (see 2.6)
Beyond type stability, the compiler analyzes expression stability:
fun stabilityOf(expr: IrExpression, fileContainingDependent: IrFile?): Stability {
// look at type first. if type is stable, whole expression is
val stability = stabilityOf(expr.type, fileContainingDependent)
if (stability.knownStable()) return stability
return when (expr) {
is IrConst -> Stability.Stable
is IrCall -> stabilityOf(expr, stability, fileContainingDependent)
is IrGetValue -> /* analyze variable */
is IrLocalDelegatedPropertyReference -> Stability.Stable
is IrComposite -> /* analyze all statements */
else -> stability
}
}The type is checked first, and a stable type ends it there. Everything below only runs when the type alone was not enough.
Literal constants are always stable:
val x = 42 // IrConst(42) β Stable
val s = "text" // IrConst("text") β Stable
val b = true // IrConst(true) β StableThe compiler checks known stable functions:
private fun stabilityOf(expr: IrCall, baseStability: Stability): Stability {
val function = expr.symbol.owner
val fqName = function.kotlinFqName
return when (val mask = KnownStableConstructs.stableFunctions[fqName.asString()]) {
null -> baseStability
0 -> Stability.Stable
else -> Stability.Combined(/* check type arguments */)
}
}Known Stable Functions:
val stableFunctions = mapOf(
"kotlin.collections.emptyList" to 0,
"kotlin.collections.listOf" to 0b1,
"kotlin.collections.listOfNotNull" to 0b1,
"kotlin.collections.mapOf" to 0b11,
"kotlin.collections.emptyMap" to 0,
"kotlin.collections.setOf" to 0b1,
"kotlin.collections.emptySet" to 0,
"kotlin.to" to 0b11,
// Kotlinx immutable
"kotlinx.collections.immutable.immutableListOf" to 0b1,
"kotlinx.collections.immutable.immutableSetOf" to 0b1,
"kotlinx.collections.immutable.immutableMapOf" to 0b11,
"kotlinx.collections.immutable.persistentListOf" to 0b1,
"kotlinx.collections.immutable.persistentSetOf" to 0b1,
"kotlinx.collections.immutable.persistentMapOf" to 0b11,
)A reference to a local val inherits its initializer's stability. A var cannot, since the value may have been reassigned since:
is IrGetValue -> {
val owner = expr.symbol.owner
if (owner is IrVariable && !owner.isVar) {
owner.initializer?.let { stabilityOf(it, fileContainingDependent) } ?: stability
} else {
stability
}
}This is where expression analysis earns its place, because the type on its own would say nothing useful:
val items = listOf("a", "b") // listOf is in stableFunctions β Stable
val alias = items // IrGetValue over a val β reads the initializer β Stable
var mutable = listOf("a", "b")
val alias2 = mutable // IrGetValue over a var β falls back to List β UnknownThe check is limited to IrVariable, so it applies to local variables only. A property read goes through a getter and is handled by the IrCall branch instead.
Generic types use bitmasks to encode type parameter dependencies.
Each type parameter is represented by a single bit:
- Bit N = 1: Type parameter N affects stability
- Bit N = 0: Type parameter N does not affect stability
The mask is an Int, so only the first 32 type parameters are represented. applyTypeParameterMask skips anything at index 32 or higher.
Examples, using the masks KnownStableConstructs records for the standard library types:
Pair::class.qualifiedName!! to 0b11
// Bit 0: A affects stability
// Bit 1: B affects stability
Triple::class.qualifiedName!! to 0b111
// Bit 0: A affects stability
// Bit 1: B affects stability
// Bit 2: C affects stability
Result::class.qualifiedName!! to 0b1
// Bit 0: T affects stability
Locale::class.qualifiedName!! to 0
// No type parameters, and stable unconditionallyA class the compiler infers itself carries the same encoding in its @StabilityInferred(parameters = ...) annotation.
Past 32 the encoding quietly breaks down. ClassStabilityTransformer builds the mask with parameterMask or (0b1 shl index) and never caps index, so 0b1 shl 32 wraps back to bit 0. A class with 33 type parameters where all of them matter compiles to @StabilityInferred(parameters = -1). One where only the 33rd matters compiles to parameters = 1, which reads back as "the first type parameter". And a known stable class with 33 parameters gets parameters = 0, because the known stable bit is guarded by symbols.size < 32. This is a corner nobody hits in practice, but it is worth knowing the mask is not defined past 32 rather than merely truncated.
ClassStabilityTransformer sets one extra bit, at index typeParameters.size, when the class turned out to be stable on its own:
if (stability.knownStable() && symbols.size < 32) {
parameterMask = parameterMask or (0b1 shl symbols.size)
}A class is either known stable or it is not, so this bit never coexists with parameter bits. These are the values the compiler actually emits, taken from its own golden tests:
class EmptyClass
// @StabilityInferred(parameters = 1)
// no type parameters, known stable, so bit 0 is the known stable bit
class SingleParamProp<T>(val p1: T)
// @StabilityInferred(parameters = 1)
// bit 0 means T affects stability
class SingleParamNonProp<T>(p1: T) { val p2 = p1.hashCode() }
// @StabilityInferred(parameters = 2)
// T is never stored, so the class is known stable and bit 1 is set
class DoubleParamSingleProp<T, V>(val p1: T, p2: V) { val p3 = p2.hashCode() }
// @StabilityInferred(parameters = 1)
// only T is stored, so only bit 0 is set
class X<T>(val p1: List<T>)
// @StabilityInferred(parameters = 0)
// List is an interface, so the class is unstable and no bit is setFor a class with no type parameters the rule collapses to a single bit: parameters = 1 means stable, parameters = 0 means not.
This is implemented by the Stability.applyTypeParameterMask extension function. Note that mask is nullable. A null mask means "consider every type parameter", which is what the incremental compilation Runtime path passes, while a concrete mask selects parameters one bit at a time.
private fun Stability.applyTypeParameterMask(
mask: Int?,
typeParameters: List<IrTypeParameter>,
substitutions: Map<IrTypeParameterSymbol, IrTypeArgument>,
currentlyAnalyzing: Set<SymbolForAnalysis>,
analysisEntryFile: IrFile?,
): Stability {
return when {
mask == 0 || typeParameters.isEmpty() -> this
else -> this + Stability.Combined(
typeParameters.mapIndexedNotNull { index, irTypeParameter ->
if (index >= 32) return@mapIndexedNotNull null
if (mask == null || mask and (0b1 shl index) != 0) {
val sub = substitutions[irTypeParameter.symbol]
if (sub != null)
stabilityOf(sub, substitutions, currentlyAnalyzing, analysisEntryFile)
else
Stability.Parameter(irTypeParameter)
} else null
}
)
}
}Process:
- For each type parameter at index I (capped at 32)
- If
maskisnull, or bit I is set inmask, include that parameter - When included, add the stability of the substituted type argument (or
Parameterif not yet substituted) - Otherwise, ignore that parameter
For JVM targets, makeStabilityField() builds a $stable property whose backing field is static, final, and annotated with @JvmField. The field sits directly on the class, not inside a companion object:
// Source
class Stable(val bar: Int)
class Unstable(var bar: Int)
// Transformed IR, as printed by the compiler's own golden tests
@StabilityInferred(parameters = 1)
class Stable(val bar: Int) {
val %stable: Int = 0
}
@StabilityInferred(parameters = 0)
class Unstable(var bar: Int) {
val %stable: Int = 8
}The @JvmField annotation tells JvmPropertiesLowering to skip the getter and rewrite reads as direct field accesses, so from Java the field is plain Stable.$stable.
Stability Values:
enum class StabilityBits(val bits: Int) {
UNSTABLE(0b100),
STABLE(0b000);
fun bitsForSlot(slot: Int): Int = bits shl (1 + slot * 3)
}bitsForSlot positions the stability bits for a given parameter slot. The $stable field stored on a class uses slot 0, so UNSTABLE becomes 0b100 shl 1 = 0b1000 = 8. That is where the 8 above comes from.
For Native and JS targets, buildStabilityPropNonJvm() puts everything at the package level instead, because there is no static field slot to hang it on. Three declarations are generated, all named from the class FQN with dots replaced by underscores:
- A private backing field named
<mangled fqName>$stable - A property named
<mangled fqName>$stablepropthat owns the field - A separate getter function named
<mangled fqName>$stableprop_getter, registered as metadata visible
// Generated for Native and JS, for a class com.example.Box
private val `com_example_Box$stable`: Int = /* computed */
// Registered via metadataDeclarationRegistrar.registerFunctionAsMetadataVisible(...)
@Deprecated(
level = DeprecationLevel.HIDDEN,
message = "Synthetic declaration generated by the Compose compiler. Please do not use."
)
// @HiddenFromObjC is added only on Native targets
fun `com_example_Box$stableprop_getter`(): Int = `com_example_Box$stable`Rationale:
A separate getter function is used instead of a plain field getter because registerFunctionAsMetadataVisible does not work for a field getter, and there is no API to register properties as metadata visible. Making the getter metadata visible is what lets another module read the stability value at all.
Reading the value back goes through getRuntimeStabilityValue(), which looks the getter up by name in the dependency's metadata. When the getter is missing, the dependency was built with an older plugin, and the raw field cannot be trusted: on Native there is no guarantee the package initializer has run, so the field may still hold uninitialized data. The one exception is a dependency whose compiler version string starts with 1.9, where the field was emitted as a constant and can be read directly. Everything else is treated as Unstable, and ClassStabilityTransformer collects those classes and reports a COMPOSE_CONFIGURATION_WARNING listing them, advising an upgrade to avoid extra recompositions.
private fun IrAnnotationContainer.stabilityParamBitmask(): Int? =
annotations.findAnnotation(ComposeFqNames.StabilityInferred)?.getConstArgument("parameters")The annotation carries a single integer parameter, declared in the Compose runtime as StabilityInferred(val parameters: Int), and the lookup reads it by name.
val annotation = IrAnnotationImpl(
UNDEFINED_OFFSET,
UNDEFINED_OFFSET,
StabilityInferredClass.defaultType,
StabilityInferredClass.constructors.first(),
typeArgumentsCount = 0,
constructorTypeArgumentsCount = 0,
origin = null
).also {
it.arguments[0] = irConst(parameterMask)
}
if (cls.hasFirDeclaration()) {
context.metadataDeclarationRegistrar.addMetadataVisibleAnnotationsToElement(
cls,
annotation,
)
} else {
cls.annotations += annotation
}The annotation is created as an IrAnnotationImpl, which replaced the IrConstructorCallImpl earlier compiler versions used. A class that has a FIR declaration behind it gets the annotation attached as metadata visible, so it survives into the module's metadata and can be read from another module. Classes without one, such as declarations synthesized later in the pipeline, get the annotation added to the IR node directly.
The field itself is only emitted for declarations another module could see:
if (cls.visibility.isPublicAPI || cls.visibility == DescriptorVisibilities.INTERNAL) {
cls.addStabilityMarkerField(stableExpr)
}visitClass skips the whole transform for enums, enum entries, interfaces, annotation classes, anonymous objects, expect declarations, inner classes, file classes, companions, inline class types, and anything neither public nor internal.
Before using stability for code generation, the compiler normalizes it:
fun Stability.normalize(): Stability {
when (this) {
is Stability.Certain,
is Stability.Parameter,
is Stability.Runtime,
is Stability.Unknown,
-> return this
is Stability.Combined -> { /* normalize */ }
}
val parameters = mutableSetOf<IrTypeParameterSymbol>()
val parts = mutableListOf<Stability>()
val stack = mutableListOf<Stability>(this)
while (stack.isNotEmpty()) {
when (val stability: Stability = stack.removeAt(stack.size - 1)) {
is Stability.Combined -> {
stack.addAll(stability.elements)
}
is Stability.Certain -> {
if (!stability.stable)
return Stability.Unstable
}
is Stability.Parameter -> {
if (stability.parameter.symbol !in parameters) {
parameters.add(stability.parameter.symbol)
parts.add(stability)
}
}
is Stability.Runtime -> parts.add(stability)
is Stability.Unknown -> { /* ignore */ }
}
}
return Stability.Combined(parts)
}Normalization Operations:
- Flatten Combined: Recursively expand nested
Combinedinstances - Remove Unknown:
Unknownelements are discarded (treated as uncertain) - Deduplicate Parameters: Keep only unique type parameters
- Short circuit on Unstable: return immediately if any
Certain(false)is found - Collect Runtime and Parameter: Preserve these for runtime checks
Result Types:
Stability.Unstableif any component is unstableStability.Combined([...])with deduplicated elements otherwise
Dropping Unknown is what makes an open class usable. Recall from Phase 12 that a non final class seeds as Unknown(declaration). Without normalization that seed would sit in the result forever and every open class would be unstable. Instead:
open class Parent(val b: B)
class Child : Parent(B())Parent seeds Unknown(Parent), picks up Runtime(B) from its field, and normalizes to Combined([Runtime(B)]). The compiler emits:
@StabilityInferred(parameters = 0)
open class Parent(val b: B) {
val %stable: Int = B.%stable
}
@StabilityInferred(parameters = 0)
class Child : Parent {
val %stable: Int = B.%stable
}Both classes defer to B at runtime. Child gets there a different way: its superclass result is a Combined, not a bare Unknown, so the Phase 12 rule does not drop it, and normalization strips the Unknown out of it afterwards. The drop in Phase 12 only fires when the superclass resolves to Unknown on its own.
When nothing expressible survives, irStableExpression returns null for the remaining Unknown and the class falls back to UNSTABLE. That is the path class X<T>(val p1: List<T>) takes.
val x: Int = 42
// Analysis: type.isPrimitiveType() β true
// Result: Stability.Certain(stable = true)All primitive numeric types follow the same pattern.
val s: String = "text"
// Analysis: type.isString() β true
// Result: Stability.Certain(stable = true)String receives special treatment due to its immutability guarantees.
val f: (Int) -> String = { it.toString() }
// Analysis: type.isFunctionOrKFunction() β true
// Result: Stability.Certain(stable = true)Function types are stable because:
- Function references are immutable
- Capturing lambdas capture immutable values (or create new closures)
- Function equality is defined by reference
data class User(
val id: Int,
val name: String
)
// Analysis:
// 1. Not in fast path
// 2. No annotations
// 3. Not in known constructs
// 4. Field analysis:
// - id: Int β Stable
// - name: String β Stable
// 5. Combine: Stable + Stable = Stable
// Result: Stability.Certain(stable = true)class Counter(
var count: Int
)
// Analysis:
// 1. Field analysis:
// - count is var β immediate return
// Result: Stability.Certain(stable = false)class Mixed(
val stable: String,
var unstable: Int
)
// Analysis:
// 1. Field analysis:
// - stable: String β Stable
// - unstable is var β immediate return
// Result: Stability.Certain(stable = false)A var makes the class unstable only when it has a backing field and is not delegated. A var with a custom getter and setter and no backing field never reaches the check, and the compiler's golden output confirms it: class NonBackingFieldUnstableVarProp { var p1: Unstable get() { TODO() } set(value) { } } compiles to @StabilityInferred(parameters = 1) with $stable = 0. Section 7.3 covers the delegated case.
class Box<T>(val value: T)
// Analysis:
// 1. Field analysis:
// - value: T β Stability.Parameter(T)
// 2. Seed is Stable (final class), and Stable + Parameter(T) returns Parameter(T)
// 3. Generate annotation: @StabilityInferred(parameters = 0b1)
// Result: Stability.Parameter(T), not wrapped in a Combined
// Instantiation:
val intBox: Box<Int>
// Substitute T β Int
// stabilityOf(Int) = Stable
// Result: Stable
val counterBox: Box<Counter>
// Substitute T β Counter
// stabilityOf(Counter) = Unstable (from 5.2)
// Result: Unstableclass Pair<A, B>(val first: A, val second: B)
// Analysis:
// 1. Field analysis:
// - first: A β Parameter(A)
// - second: B β Parameter(B)
// 2. Generate annotation: @StabilityInferred(parameters = 0b11)
// Result: Combined([Parameter(A), Parameter(B)])
// Instantiation:
val pair: Pair<Int, String>
// Substitute A β Int, B β String
// stabilityOf(Int) = Stable
// stabilityOf(String) = Stable
// Result: StableA generic field whose type is itself generic is where the inference most often surprises people:
class Container<T>(val items: List<T>)
// Analysis:
// 1. Field analysis:
// - items: List<T>
// - List is an interface, so it never reaches member analysis
// - Result: Unknown(List)
// 2. Stable + Unknown(List) = Unknown(List)
// 3. Unknown is not expressible, so there is no runtime check to emit
// Result: @StabilityInferred(parameters = 0), $stable = 8 (unstable)List is not in KnownStableConstructs, and it is an interface, so the type argument is never even examined. Container<String> is unstable for the same reason Container<Counter> is. The compiler's own golden test records exactly this for class X<T>(val p1: List<T>).
Swapping in a type the compiler does know changes the outcome:
import kotlinx.collections.immutable.ImmutableList
class Container<T>(val items: ImmutableList<T>)
// Analysis:
// 1. Field analysis:
// - items: ImmutableList<T>
// - "kotlinx.collections.immutable.ImmutableList" to 0b1 in KnownStableConstructs
// - bit 0 is set, so the type argument is checked
// - T has no substitution here β Parameter(T)
// 2. Result: Combined([Parameter(T)])
// Instantiation:
val container: Container<String>
// Substitute T β String, stabilityOf(String) = Stable
// Result: Stable// Library module (compiled separately)
@StabilityInferred(parameters = 0b1)
class LibraryBox<T>(val value: T) {
val $stable: Int = 0 // synthetic static final field on the class
}
// Your module
@Composable
fun UseLibraryBox(box: LibraryBox<StableClass>) { /* ... */ }
// Analysis:
// 1. LibraryBox is public and lives in another file, and the target is JVM
// 2. forcedToUseRuntimeStability is true -> Stability.Runtime(LibraryBox)
// 3. mask is null, so every type argument is folded in
// 4. stabilityOf(StableClass) -> Runtime(StableClass)
// Result, after normalize(): Combined([Runtime(LibraryBox), Runtime(StableClass)])At the call site that becomes a single expression, which the compiler's golden tests show verbatim:
UseLibraryBox(LibraryBox(StableClass()), %composer, LibraryBox.%stable or StableClass.%stable)There is a detail here worth stopping on. On JVM the recorded @StabilityInferred bitmask is not what selected the type argument. forcedToUseRuntimeStability is checked before the external stub branch, and it passes mask = null, so every type argument is folded in regardless of what the bitmask says. The compiler's own golden output makes this visible: SingleParamNonProp is compiled with @StabilityInferred(parameters = 2), meaning no type parameter affects its stability, and the call site still emits SingleParamNonProp.%stable or StableClass.%stable.
The recorded bitmask is consulted on the branch below it, which is reached when the target is not JVM, or when the class is neither public nor internal. The extra type arguments on JVM cost a few or operations and can only make a result more conservative, never less, which is the trade the compiler takes for incremental safety.
// Third party library (no Compose compiler)
class ThirdPartyType(val data: String)
// Your module
fun useThirdParty(obj: ThirdPartyType) {
// Analysis:
// 1. ThirdPartyType is external
// 2. No @StabilityInferred annotation
// 3. stabilityParamBitmask() returns null
// Result: Stability.Unstable
}interface Repository {
fun getData(): String
}
class Screen(val repo: Repository)
// Analysis of Repository:
// 1. Repository is interface
// 2. Unknown implementations
// Result: Stability.Unknown(Repository)
// Analysis of Screen:
// 1. Seed is Stable (final class)
// 2. Field repo: Repository β Unknown(Repository)
// 3. Stable + Unknown returns the Unknown unchanged
// Result: Stability.Unknown(Repository)
// At the use site: not expressible, so comparison falls back to changedInstanceabstract class BaseViewModel {
abstract val state: String
}
class Screen(val viewModel: BaseViewModel)
// Analysis:
// 1. BaseViewModel is abstract (not an interface), so it reaches member analysis
// 2. modality != FINAL -> seed stability is Unknown(BaseViewModel)
// 3. `abstract val state` has no backing field, so no member contributes
// Result: Stability.Unknown(BaseViewModel)A non final class seeds as Unknown. Concrete val properties with backing fields are still combined in, but the Unknown seed keeps the overall result uncertain unless something resolves it.
This trace assumes BaseViewModel and Screen are in the same file. On JVM, a public BaseViewModel in a different file never reaches member analysis at all: Phase 9 intercepts it and returns Runtime instead.
@Stable
interface StableRepository {
fun getData(): String
}
class Screen(val repo: StableRepository)
// Analysis of StableRepository:
// 1. Check annotations
// 2. Has @Stable marker
// Result: Stability.Certain(stable = true)
// Analysis of Screen:
// 1. Field repo: StableRepository β Stable
// Result: Stableopen class Base(val id: Int)
class Derived(val name: String) : Base(0)
// Analysis of Base:
// 1. Base is open, so modality != FINAL β seed is Unknown(Base)
// 2. Field id: Int β Stable
// 3. Unknown(Base) + Stable = Unknown(Base)
// Result: Stability.Unknown(Base)
// Analysis of Derived:
// 1. Derived is final β seed is Stable
// 2. Field name: String β Stable
// 3. Check superclass: Base β Unknown, so it is dropped
// Result: Stability.Certain(stable = true)An open superclass resolving to Unknown is dropped rather than combined in. Without that rule every subclass of an open class would inherit the uncertainty of a class that is perfectly stable on its own.
open class Base(var state: Int)
class Derived(val data: String) : Base(0)
// Analysis of Base:
// 1. Field state is var
// Result: Unstable
// Analysis of Derived:
// 1. Field data: String β Stable
// 2. Check superclass: Base β Unstable
// 3. Combine: Stable + Unstable = Unstable
// Result: Stability.Certain(stable = false)The unstable superclass makes all derived classes unstable.
Declares that a type's public API is stable:
@Stable
class MutableCounter(private var count: Int) {
fun increment() {
count++
// Must trigger recomposition
}
override fun equals(other: Any?): Boolean {
return other is MutableCounter && count == other.count
}
}Contract:
- Public API appears immutable (private var is internal)
equals()implements structural equality- State changes trigger composition invalidation
Warning: Incorrect usage violates runtime assumptions.
Stronger guarantee than @Stable:
@Immutable
class ImmutableData(val value: String)Contract:
- All observable state is truly immutable
- No mutable fields (even private)
equals()implements structural equality
Both annotations mark types as stable for recomposition skipping, and there is one difference in how the compiler treats them.
Stability Inference Treatment
Both annotations are processed identically through hasStableMarker():
fun IrAnnotationContainer.hasStableMarker(): Boolean =
annotations.any { it.isStableMarker() }Both result in:
- The same stability inference,
Stability.Certain(stable = true) - The same
$stablefield, emitted as the constantSTABLE - The same recomposition skipping behavior
Neither gets a @StabilityInferred annotation. ClassStabilityTransformer.visitClass returns as soon as it sees a marker:
if (declaration.hasStableMarker()) {
metrics.recordClass(declaration, marked = true, stability = Stability.Stable)
cls.addStabilityMarkerField(irConst(STABLE))
return cls
}There is nothing to infer, so there is no bitmask to record. The compiler's golden output shows exactly that, a @Stable class carrying val %stable: Int = 0 and no @StabilityInferred line.
Static Expression Optimization (Key Difference)
@Immutable has special treatment for static expression detection.
private fun IrConstructorCall.isStatic(fileContainingDependent: IrFile?): Boolean {
// special case constructors of inline classes as static if their underlying
// value is static.
if (type.isInlineClassType()) {
return stabilityInferencer.stabilityOf(
type.unboxInlineClass(), fileContainingDependent
).knownStable() &&
arguments[0]?.isStatic(fileContainingDependent) == true
}
// If a type is immutable, then calls to its constructor are static if all of
// the provided arguments are static.
if (symbol.owner.parentAsClass.hasAnnotation(ComposeFqNames.Immutable)) {
return areAllArgumentsStatic(fileContainingDependent)
}
return false
}Practical Impact:
@Immutable
data class ImmutablePoint(val x: Int, val y: Int)
@Stable
data class StablePoint(val x: Int, val y: Int)
@Composable
fun Chart(
// @static in the compiler report: the constructor call is evaluated once
origin: ImmutablePoint = ImmutablePoint(0, 0),
// @dynamic: the call is re-evaluated whenever the default is taken
anchor: StablePoint = StablePoint(0, 0),
) { /* ... */ }What staticness buys:
- Default parameters: a
@staticdefault is hoisted out of the defaults group, so the composable does not re-evaluate it on every composition. The-composables.txtreport tags every default as@staticor@dynamic, which makes the difference easy to see. - Intrinsic remember: a static argument is known not to change, so the comparison it would need can be folded away instead of costing a slot.
- Lambda memoization: a lambda that only captures static values has nothing that can change, so it does not need a
rememberwrapper keyed on its captures.
Summary Table:
| Aspect | @Stable | @Immutable |
|---|---|---|
| Stability inference | Stable | Stable |
| Recomposition skipping | Enabled | Enabled |
| Constructor staticness | No | Yes (with static args) |
| Lambda capture optimization | Standard | Enhanced |
| Semantic contract | Allows private mutability | Truly immutable |
The compiler treats @Immutable as the stronger guarantee, and that extra guarantee buys compile time evaluation of constructor calls, which then feeds static expression detection and lambda memoization.
Create custom stability markers:
@StableMarker
annotation class MyStable
@MyStable
class CustomType(val data: String)
// Treated as @StableCreate a stability configuration file, stability_config.conf. StabilityConfigParser reads it line by line, skipping blank lines and lines that start with //. A comment after a pattern is a parse error, not a comment:
// Single class
com.example.ExternalType
// Package wildcard
com.example.models.**
// Single segment wildcard
com.example.*.data
// Generic parameter inclusion
com.example.Container<*>
// Generic parameter exclusion
com.example.Wrapper<*,_>
// Mixed generic parameters
com.example.Complex<*,_,*>
Wildcard Rules:
*: matches a single package segment**: matches multiple package segments<*>: the generic parameter affects stability<_>: the generic parameter is ignored for stability
A pattern matches more than the class named. FqNameMatcherCollection.matches checks the class's own FQN and every supertype FQN, so listing a base class or an interface makes every type that extends it stable too:
fun matches(name: FqName?, superTypes: List<IrType>): Boolean {
// ...
return matcherTree.findFirstPositiveMatcher(name) != null ||
superTypeNames.any { matcherTree.findFirstPositiveMatcher(it) != null }
}That is convenient for a sealed hierarchy and a trap for a broad interface. Patterns are stored in a character trie keyed on the pattern text up to its first wildcard, so a large configuration file does not slow compilation down much. The class KDoc describes the tree as keyed by package segment, but MutableMatcherTree walks one Char at a time.
Generic Parameter Encoding:
Container<*,_,*>
β β β
β β ββ Bit 2 set (third param matters)
β ββββ Bit 1 clear (second param ignored)
ββββββ Bit 0 set (first param matters)
// Bitmask: 0b101 = 5
Pass the configuration file to the composeCompiler block of the Compose compiler Gradle plugin:
composeCompiler {
stabilityConfigurationFiles.addAll(
rootProject.layout.projectDirectory.file("stability_config.conf"),
)
}The older singular stabilityConfigurationFile property still exists, but it is deprecated at DeprecationLevel.ERROR and is scheduled for removal in Kotlin 2.5.0, so new builds should use stabilityConfigurationFiles.
The same block carries the compiler's feature flags. Each flag has a default in the plugin, and the Gradle option only records a deviation from it:
enum class FeatureFlag(val featureName: String, val default: Boolean) {
StrongSkipping("StrongSkipping", default = true),
IntrinsicRemember("IntrinsicRemember", default = true),
OptimizeNonSkippingGroups("OptimizeNonSkippingGroups", default = true),
PausableComposition("PausableComposition", default = true),
;
}To turn one off, pass its disabled form:
composeCompiler {
featureFlags = setOf(ComposeFeatureFlag.PausableComposition.disabled())
}Which flags you can still reach from the DSL has narrowed. In ComposeFeatureFlags, StrongSkipping and IntrinsicRemember are marked DeprecationLevel.ERROR with "This flag is now enabled by default and will be removed with Kotlin 2.5.0", so writing ComposeFeatureFlag.StrongSkipping.disabled() no longer compiles. OptimizeNonSkippingGroups and PausableComposition are only WARNING and remain usable.
Strong skipping is therefore not something you opt out of any more. If you need one composable to keep running, @NonSkippableComposable is the supported way.
The older single purpose options, enableStrongSkippingMode, enableIntrinsicRemember, and enableNonSkippingGroupOptimization, are likewise deprecated at DeprecationLevel.ERROR alongside stabilityConfigurationFile.
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}reportsDestination produces <module>-classes.txt, <module>-composables.txt, and <module>-composables.csv, plus <module>-composables.log when the compiler logged anything. metricsDestination produces <module>-module.json. In the prefix, dots in the module name become underscores and angle brackets are removed.
<module>-classes.txt: class stability analysis, one entry per class, each field marked stable, unstable, or runtime. Inferred classes also get a <runtime stability> line showing the expression used to resolve stability at runtime:
stable class com.example.User {
stable val id: Int
stable val name: String
<runtime stability> = Stable
}
unstable class com.example.Counter {
unstable var count: Int
<runtime stability> = Unstable
}
runtime class com.example.Box {
runtime val value: T
<runtime stability> = Parameter(T)
}
A class marked with @Stable or @Immutable is printed without the <runtime stability> line, since nothing was inferred.
<module>-composables.txt: composable function analysis, printed in pseudo Kotlin. Each parameter sits on its own line with no separator, and each default expression is tagged @static or @dynamic:
restartable skippable fun com.example.Image(
unstable bitmap: ImageBitmap
stable contentDescription: String?
stable modifier: Modifier? = @static <expression>
stable count: Int = @static 0
stable label: String? = @dynamic <expression>
)
Only an IrConst or an IrGetValue is printed literally. Everything else prints as the token <expression>, so most non trivial defaults show up that way and the @static or @dynamic tag is the part carrying the information.
restartable marks a function that can serve as a recomposition scope, and skippable marks one that can be skipped when its arguments compare equal. The two are related but separate. A function has to be restartable to be skippable, and shouldBeRestartable() already rules out inline functions, functions with a non Unit return type, @NonRestartableComposable, and functions with explicit groups. Among what is left, a restartable entry with no skippable usually means @NonSkippableComposable, since strong skipping makes the rest skippable by default.
<module>-composables.csv: the same per function data in a form you can drop into a spreadsheet.
<module>-module.json: module wide counters, useful mostly as a number to track across builds:
{
"skippableComposables": 53,
"restartableComposables": 60,
"readonlyComposables": 1,
"totalComposables": 100,
"restartGroups": 60,
"totalGroups": 139,
"staticArguments": 25,
"certainArguments": 138,
"knownStableArguments": 377,
"knownUnstableArguments": 25,
"unknownStableArguments": 24,
"totalArguments": 426,
"markedStableClasses": 8,
"inferredStableClasses": 28,
"inferredUnstableClasses": 0,
"inferredUncertainClasses": 0,
"effectivelyStableClasses": 36,
"totalClasses": 36,
"memoizedLambdas": 40,
"singletonLambdas": 6,
"singletonComposableLambdas": 4,
"composableLambdas": 49,
"totalLambdas": 81,
"featureFlags": {
"StrongSkipping": true,
"IntrinsicRemember": true,
"OptimizeNonSkippingGroups": true,
"PausableComposition": true
}
}The ratio between certainArguments and totalArguments tells you how much stability metadata is actually reaching composable calls, which is usually the most actionable number in the file.
Problem:
data class UserState(var loading: Boolean)
// Unstable due to varSolution:
data class UserState(val loading: Boolean)
// StableProblem:
class ViewModel(val items: MutableList<String>)
// MutableList is an interface, so it resolves to Unknown,
// which makes the enclosing class uncertainSolution:
import kotlinx.collections.immutable.ImmutableList
class ViewModel(val items: ImmutableList<String>)
// ImmutableList is stable (in KnownStableConstructs)Alternative (requires configuration):
class ViewModel(val items: List<String>)
// List is an interface with Unknown stability by default
// To make it stable, add to stability_config.conf:
// kotlin.collections.ListDeclaring kotlin.collections.List stable is a promise you make on behalf of every list in the module, including the MutableList instances that are also List. It is only safe if you never mutate a list after handing it to a composable.
Problem:
interface DataSource { }
@Composable
fun Screen(source: DataSource) {
// DataSource has Unknown stability
// Falls back to instance comparison
}Solution A: Add @Stable
@Stable
interface DataSource { }Solution B: Use concrete type
@Composable
fun Screen(source: ConcreteDataSource) {
// Concrete class can have known stability
}Problem:
// Third party library without Compose support
class LibraryClass(val data: String)
@Composable
fun Display(obj: LibraryClass) {
// LibraryClass marked unstable
}Solution:
Add to stability_config.conf:
com.thirdparty.LibraryClass
Problem:
open class MutableBase(var state: Int)
class DerivedData(val name: String) : MutableBase(0)
// Unstable due to base classSolution:
Restructure to avoid mutable inheritance:
open class Base(val id: Int)
class DerivedData(val name: String, val state: Int) : Base(0)
// Stable if all fields are stableprivate fun IrSimpleType.substitutionMap(): Map<IrTypeParameterSymbol, IrTypeArgument> {
val cls = classOrNull ?: return emptyMap()
val params = cls.owner.typeParameters.map { it.symbol }
val args = arguments
return params.zip(args).filter { (param, arg) ->
param != (arg as? IrSimpleType)?.classifier
}.toMap()
}Example:
class Container<T, U>(val first: T, val second: U)
// Analyzing Container<Int, String>
// typeParameters = [T, U]
// arguments = [Int, String]
// substitutionMap = {T: Int, U: String}When analyzing Container<Int, String>:
// Field: first: T
stabilityOf(T, substitutions = {T: Int, U: String})
// Lookup T in substitutions β Int
// Result: stabilityOf(Int) = Stable
// Field: second: U
stabilityOf(U, substitutions = {T: Int, U: String})
// Lookup U in substitutions β String
// Result: stabilityOf(String) = Stableclass Outer<T>(val inner: Inner<T>)
class Inner<U>(val value: U)
// Analyzing Outer<Int>
// 1. Field inner: Inner<T>
// 2. Inner has type parameter U
// 3. U is substituted with T
// 4. T is substituted with Int
// 5. Result: stabilityOf(Int) = Stabledata class SymbolForAnalysis(
val symbol: IrClassifierSymbol,
val typeParameters: List<IrTypeArgument?>,
// The file containing the element that initiated this stabilityOf request tree.
// Two identical symbols analyzed from different entry files are distinct keys,
// which is what keeps the per file caching and runtime stability behavior correct.
val analysisEntryFile: IrFile?,
)
// The public overload builds the key
val fullSymbol = SymbolForAnalysis(symbol, typeArguments, analysisEntryFile)
// and the private overload it delegates to checks it, first thing
if (currentlyAnalyzing.contains(symbol))
return Stability.UnstableThe currentlyAnalyzing set tracks the analysis stack to detect cycles. The analysisEntryFile is part of the key because the same type can resolve to different stability depending on which file initiated the analysis (see Phase 9).
class Node(val value: Int, val next: Node?)
// Analysis trace:
// stabilityOf(Node, currentlyAnalyzing = {})
// analyzing = {Node}
// field: value: Int β Stable
// field: next: Node?
// unwrap nullable
// stabilityOf(Node, currentlyAnalyzing = {Node})
// CYCLE DETECTED: Node in currentlyAnalyzing
// return UnstableSome recursive types that could be stable are marked unstable:
class TreeNode(val value: Int, val left: TreeNode?, val right: TreeNode?)
// Could be stable (immutable structure)
// Marked unstable due to cycle detectionThis conservative approach ensures algorithm termination.
Detection:
private fun IrClass.isProtobufType(): Boolean {
if (!isFinalClass) return false
val directParentClassName = superTypes
.lastOrNull { !it.isInterface() }
?.classOrNull?.owner?.fqNameWhenAvailable?.toString()
return directParentClassName == "com.google.protobuf.GeneratedMessageLite" ||
directParentClassName == "com.google.protobuf.GeneratedMessage"
}Rationale:
Generated protobuf classes use internal mutability for builder patterns but present an immutable API. The compiler treats them as stable based on their parent class.
if (member.isVar && !member.isDelegated)
return Stability.UnstableA delegated var escapes the immediate Unstable return, and the backing field it does have holds the delegate, not the value. So the class inherits the delegate's stability:
@Stable
class StableDelegate { /* getValue, setValue */ }
class UnstableDelegate {
var value: Int = 0
/* getValue, setValue */
}
class StableDelegateProp {
var p1 by StableDelegate()
}
// @StabilityInferred(parameters = 1), $stable = 0
class UnstableDelegateProp {
var p1 by UnstableDelegate()
}
// @StabilityInferred(parameters = 0), $stable = 8This is what makes by mutableStateOf(...) work. MutableState is @Stable, so a var delegated to it leaves the enclosing class stable, and the runtime still learns about writes because the state object notifies composition itself.
Both value class branches start with the same check:
if (inlineClassDeclaration.hasStableMarker()) {
Stability.Stable
}A marker therefore overrides whatever the underlying types say:
@JvmInline
@Stable
value class Wrapper(val list: MutableList<Int>)
// Underlying type (MutableList) is unstable
// But @Stable annotation overrides
// Result: Stable (developer responsibility)ClassStabilityTransformer skips cls.defaultType.isInlineClassType(), and that predicate resolves with treatCompatibleFullValueClassesAsInline = false. So a @JvmInline value class gets no $stable field of its own and is resolved by unwrapping at each use site. A multi field value class is not excluded by that check, so it goes through the transform like an ordinary class and does get @StabilityInferred and a $stable field.
Stability inference is one half of what the Compose plugin does. The other half is a set of checkers that validate how composable functions are declared and called. Both halves used to be split across two frontends, and the older one is now gone: there is no k1 package in the plugin anymore, and with it went BindingTrace, WritableSlice, and TypeResolutionInterceptorExtension. Everything the frontend does today runs on FIR, and everything the backend does runs on IR.
The plugin needs to write down what it learned in one phase and read it back in a later one. It uses a different mechanism on each side of the compiler.
lower/ComposePluginAttributes.kt
In the IR phase, metadata is attached directly to IR nodes through delegated attribute and flag properties:
internal var IrExpression.isStaticExpression: Boolean by irFlag(copyByDefault = true)
internal var IrExpression.isStaticFunctionExpression: Boolean by irFlag(copyByDefault = true)
internal var IrElement.isComposableSingleton: Boolean by irFlag(copyByDefault = true)
internal var IrElement.isComposableSingletonClass: Boolean by irFlag(copyByDefault = true)
internal var IrElement.durableFunctionKey: KeyInfo? by irAttribute(copyByDefault = true)
internal var IrElement.hasTransformedLambda: Boolean by irFlag(copyByDefault = true)
internal var IrFunction.functionMetrics: FunctionMetrics? by irAttribute(copyByDefault = true)What each one carries:
- isStaticExpression: marks expressions whose value can be computed once instead of on every composition
- durableFunctionKey: stores the stable identity of a function, which is what makes hot reload possible
- isComposableSingleton and isComposableSingletonClass: mark composable lambdas that were hoisted into singletons
- functionMetrics: the per function record that ends up in the compiler reports
They read and write as plain properties on the node, so an analysis pass does expr.isStaticExpression = true and a later lowering does if (expr.isStaticExpression) { ... }. No lookup table sits in between. copyByDefault = true means the attribute survives when an IR node is copied, which matters because several lowerings rebuild functions wholesale.
The frontend has no trace to record into. When a checker needs to remember something between calls, it stores it in a session component instead:
internal class ComposableTargetSessionStorage(session: FirSession) : FirExtensionSessionComponent(session) {
// parent links, lambda to expression links, and a cache of LazyScheme per FirElement
}
private val FirSession.composableTargetSessionStorage by FirSession.sessionComponentAccessor<ComposableTargetSessionStorage>()The component is registered with the rest of the plugin's FIR extensions and lives for the duration of the session. Applier inference is the only part of the frontend that needs this; the call and declaration checkers are stateless.
k2/ComposableCallChecker.kt
This file holds two checkers, one for calls and one for property reads, both wired into the FIR checker infrastructure:
object ComposablePropertyAccessExpressionChecker : FirPropertyAccessExpressionChecker(MppCheckerKind.Common)
object ComposableFunctionCallChecker : FirFunctionCallChecker(MppCheckerKind.Common)The function call checker dispatches on what the callee turned out to be:
context(context: CheckerContext, reporter: DiagnosticReporter)
override fun check(expression: FirFunctionCall) {
val calleeFunction = expression.calleeReference.toResolvedFunctionSymbol()
?: return
// K2 propagates annotation from the fun interface method to the constructor.
// https://youtrack.jetbrains.com/issue/KT-47708.
if (calleeFunction.origin == FirDeclarationOrigin.SamConstructor) return
if (calleeFunction.isComposable(context.session)) {
checkComposableCall(expression, calleeFunction, context, reporter)
} else if (calleeFunction.callableId.isInvoke()) {
checkInvoke(expression, context, reporter)
}
}The question the checker has to answer is whether the call sits inside something composable. It answers it by walking outward from the call site through CheckerContext.containingElements, which is the stack of FIR elements the checker is currently nested in. There is no PSI involved: the compiler stopped generating PSI outside the IDE, and the last PSI condition was removed from this checker along with it.
private inline fun CheckerContext.visitCurrentScope(
visitInlineLambdaParameter: (FirValueParameter) -> Unit,
visitAnonymousFunction: (FirAnonymousFunction) -> Unit = {},
visitFunction: (FirFunction) -> Unit = {},
visitTryExpression: (FirTryExpression, FirElement) -> Unit = { _, _ -> },
visitFunctionCall: (FirFunctionCall) -> Unit = {},
) {
for ((elementIndex, element) in containingElements.withIndex().reversed()) {
when (element) {
is FirAnonymousFunction -> {
if (element.inlineStatus == InlineStatus.Inline) {
findValueParameterForLambdaAtIndex(elementIndex)?.let(visitInlineLambdaParameter)
}
visitAnonymousFunction(element)
if (element.inlineStatus != InlineStatus.Inline) return
}
is FirFunction -> {
visitFunction(element)
return
}
is FirTryExpression -> { /* ... */ }
is FirFunctionCall -> visitFunctionCall(element)
// ...
is FirDeclaration -> return
}
}
}Two details make this work. The walk goes reversed(), so it visits the innermost element first and moves outward. And the function is inline, so a bare return inside one of the callbacks returns from the enclosing checker function, not just from the loop. That is how the checker says "this call is fine, stop looking" without threading a result value back out.
An Inline lambda is transparent: the walk passes straight through it, because a composable call inside an inline lambda executes in the caller's scope. NoInline and CrossInline lambdas stop the walk, because their body may run at any time.
Everything else falls into the final is FirDeclaration -> return branch, which ends the walk without finding a composable scope. A few element kinds are listed above it precisely so they do not end the walk: FirProperty and FirValueParameter, because the call may have come from an initializer or a default value; FirAnonymousObject and FirAnonymousInitializer; and a FirField whose origin is Synthetic.DelegateField, which FIR creates for constructor delegation.
checkComposableCall runs the walk with five callbacks, and each one handles a rule:
- Zero argument
key: a call toandroidx.compose.runtime.keywith a single argument has a group key but no body, which is always a mistake. ReportsKEY_CALL_WITH_NO_ARGUMENTS. @DisallowComposableCallslambdas: if the enclosing inline lambda's parameter type carries the annotation, reportsCAPTURED_COMPOSABLE_INVOCATION.- Composable scopes: a lambda whose function type kind is
ComposableFunction, or a function annotated@Composable, ends the check successfully. - try blocks: reports
ILLEGAL_TRY_CATCH_AROUND_COMPOSABLE. - runCatching: reports
ILLEGAL_RUN_CATCHING_AROUND_COMPOSABLE. - Fall through: if the walk finished without finding a composable scope, reports
COMPOSABLE_INVOCATION.
The try check is narrower than its name suggests. Composable calls are legal inside catch and finally, and only the try block itself is rejected:
visitTryExpression = { tryExpression, container ->
// Only report an error if the composable call happens inside of the `try`
// block. Composable calls are allowed inside of `catch` and `finally` blocks.
if (container !is FirCatch && tryExpression.finallyBlock != container) {
reporter.reportOn(
tryExpression.source,
ComposeErrors.ILLEGAL_TRY_CATCH_AROUND_COMPOSABLE,
context
)
}
}container is the child of the try expression through which the walk arrived, which is what lets the checker tell the three blocks apart. The reason for the rule is that composition state is written as the composable executes. An exception thrown mid execution leaves the slot table partly updated, and the catch block would then be running against a composition that no longer matches the code that produced it. runCatching is rejected for the same reason, since it is a try in disguise.
@ReadOnlyComposable promises that the body only performs read operations on the composer, which lets the compiler emit no group around it at all. Calling a normal composable from one would break that promise, so checkComposableFunction carries the call site source down and reports it:
if (function.hasComposableAnnotation(session)) {
if (function.hasReadOnlyComposableAnnotation(session) && nonReadOnlyCallInsideFunction != null) {
reporter.reportOn(nonReadOnlyCallInsideFunction, NONREADONLY_CALL_IN_READONLY_COMPOSABLE, context)
}
return ComposableCheckForScopeStatus.STOP
}The source is only non null when the callee is itself not readonly, so a readonly composable calling another readonly composable passes.
A composable property is a getter, not a field, and a delegated composable property has extra limits:
if (function is FirPropertyAccessor && function.propertySymbol.hasDelegate) {
if (function.propertySymbol.isVar) {
reporter.reportOn(function.source, COMPOSE_INVALID_DELEGATE, context)
} else if (function.propertySymbol is FirRegularPropertySymbol) {
// Only local variables can be implicitly composable, for top-level or
// class-level declarations we require an explicit annotation.
reporter.reportOn(function.propertySymbol.source, COMPOSABLE_EXPECTED, context)
}
return ComposableCheckForScopeStatus.STOP
}A var delegate needs setValue, and a composable setValue has nowhere to run, so it is rejected outright. A val delegate at top level or class level has to be annotated rather than inferred, because its type is part of the declaration's public shape.
checkInvoke handles the inverse problem. When an inline function's lambda parameter is invoked from inside a @DisallowComposableCalls lambda, that restriction has to propagate to the parameter, otherwise a composable call could sneak through the invocation:
val param = (expression.dispatchReceiver as? FirPropertyAccessExpression)
?.calleeReference
?.toResolvedValueParameterSymbol()
?: return
if (param.resolvedReturnTypeRef.hasDisallowComposableCallsAnnotation(context.session) ||
!param.containingDeclarationSymbol.let { it is FirCallableSymbol && it.isInline }
) {
return
}If the parameter is not already annotated, the checker reports MISSING_DISALLOW_COMPOSABLE_CALLS_ANNOTATION naming both the parameter that needs the annotation and the one that imposed the restriction.
k2/ComposableFunctionChecker.kt is a FirFunctionChecker, and it runs the override checks before it even asks whether the function is composable:
- Override consistency: an override must match its parent on composability, otherwise
FirErrors.CONFLICTING_OVERLOADS. - Applier scheme on overrides: when both are composable,
!override.toScheme().canOverride(declaration.symbol.toScheme())reportsCOMPOSE_APPLIER_DECLARATION_MISMATCH. - expect and actual: a mismatch between the expect declaration and its actual reports
MISMATCHED_COMPOSABLE_IN_EXPECT_ACTUAL.
The rest applies only to @Composable declarations:
- suspend:
COMPOSABLE_SUSPEND_FUN. A suspend function can resume anywhere, and composition has to stay on the composition thread with its slot table positioned where it left off. - main:
COMPOSABLE_FUN_MAIN. There is no composer to pass in at the process entry point. - Default parameter values on open and abstract functions: these need runtime support, so they are gated on language version through
ComposeLanguageFeature, which declaresDefaultParametersInAbstractFunctions(LanguageVersion.KOTLIN_2_1)andDefaultParametersInOpenFunctions(LanguageVersion.KOTLIN_2_2). Below the required version they reportABSTRACT_COMPOSABLE_DEFAULT_PARAMETER_VALUEorOPEN_COMPOSABLE_DEFAULT_PARAMETER_VALUE. A dependency compiled before the support existed reports the warningDEPRECATED_OPEN_COMPOSABLE_DEFAULT_PARAMETER_VALUE. setValueoperator: a composablesetValuereportsCOMPOSE_INVALID_DELEGATE, matching the delegate rule in the call checker.
k2/ComposablePropertyChecker.kt holds two checkers. The first runs on any property whose getter or setter is annotated:
if (declaration.isVar) {
reporter.reportOn(declaration.source, ComposeErrors.COMPOSABLE_VAR, context)
}
if (declaration.hasBackingField) {
reporter.reportOn(declaration.source, ComposeErrors.COMPOSABLE_PROPERTY_BACKING_FIELD, context)
}Both come down to the same thing: a composable property is a function that runs during composition, so there is nothing for a field to hold and nothing for a setter to do.
The second, ComposablePropertyReferenceChecker, reports COMPOSABLE_PROPERTY_REFERENCE for a ::property reference to a non delegated composable property. A property reference produces a KProperty object whose getter would have to be invoked outside composition.
k2/ComposableAnnotationChecker.kt is a FirResolvedTypeRefChecker. It catches @Composable written on something that is not a function type:
if (typeRef !is FirErrorTypeRef && !typeRef.coneType.isComposableFunction(session)) {
reporter.reportOn(composableAnnotation.source, COMPOSABLE_INAPPLICABLE_TYPE, typeRef.coneType, context)
}It makes one exception, for the synthetic array type FIR creates around a vararg parameter, since the annotation there belongs to the element type.
k2/ComposeErrors.kt declares 24 diagnostic factories, and the severity comes from which builder they use: error0 through error4 produce errors, warning0 through warning4 produce warnings. The split is worth knowing, because the applier diagnostics are the ones people most often expect to fail a build:
| Diagnostic | Severity |
|---|---|
COMPOSABLE_INVOCATION |
Error |
COMPOSABLE_EXPECTED |
Error |
CAPTURED_COMPOSABLE_INVOCATION |
Error |
NONREADONLY_CALL_IN_READONLY_COMPOSABLE |
Error |
ILLEGAL_TRY_CATCH_AROUND_COMPOSABLE |
Error |
ILLEGAL_RUN_CATCHING_AROUND_COMPOSABLE |
Error |
MISSING_DISALLOW_COMPOSABLE_CALLS_ANNOTATION |
Error |
COMPOSABLE_SUSPEND_FUN |
Error |
COMPOSABLE_FUN_MAIN |
Error |
COMPOSABLE_VAR |
Error |
COMPOSABLE_PROPERTY_BACKING_FIELD |
Error |
COMPOSABLE_PROPERTY_REFERENCE |
Error |
COMPOSE_INVALID_DELEGATE |
Error |
COMPOSABLE_INAPPLICABLE_TYPE |
Error |
KEY_CALL_WITH_NO_ARGUMENTS |
Error |
MISMATCHED_COMPOSABLE_IN_EXPECT_ACTUAL |
Error |
OPEN_COMPOSABLE_DEFAULT_PARAMETER_VALUE |
Error |
ABSTRACT_COMPOSABLE_DEFAULT_PARAMETER_VALUE |
Error |
COMPOSE_APPLIER_CALL_MISMATCH |
Warning |
COMPOSE_APPLIER_PARAMETER_MISMATCH |
Warning |
COMPOSE_APPLIER_DECLARATION_MISMATCH |
Warning |
DEPRECATED_OPEN_COMPOSABLE_DEFAULT_PARAMETER_VALUE |
Warning |
COMPOSE_CONFIGURATION_ERROR |
Error, no source |
COMPOSE_CONFIGURATION_WARNING |
Warning, no source |
CONFLICTING_OVERLOADS does not appear here because it is not a Compose diagnostic. The plugin reuses FirErrors.CONFLICTING_OVERLOADS from the Kotlin compiler itself. COMPOSE_CONFIGURATION_WARNING is the sourceless factory used for plugin level messages, including the non JVM stability warning from Chapter 4.
A composable function does not produce UI directly. It emits nodes into whatever applier the composition was started with, and an Android UI node means nothing to a canvas or a terminal renderer. The applier target system tracks which applier each composable expects, and reports when two that disagree meet.
inference/Scheme.kt models a target as a sealed Item with two implementations, both top level rather than nested:
sealed class Item
class Token(val value: String) : Item()
class Open(
val index: Int,
val constraints: Constraints = Constraints.UNRESTRICTED,
override val isUnspecified: Boolean = false,
) : Item()A Token is a target that is already decided, carrying the fully qualified name of a marker annotation. An Open is a target still to be determined. Its index is what ties positions together: two Open items with the same non negative index have to resolve to the same token, while a negative index means the item is independent of every other. Constraints narrows an open target to a set of allowed tokens, which is how a function that declares more than one acceptable target is represented.
A Scheme is the target of a declaration plus the schemes of its composable lambda parameters and result:
class Scheme(
val target: Item,
val parameters: List<Scheme> = emptyList(),
val result: Scheme? = null,
val anyParameters: Boolean = false,
) {
fun canOverride(other: Scheme): Boolean =
alphaRename().simpleCanOverride(other.alphaRename())
}equals, hashCode, and canOverride all compare modulo alpha renaming, so [0, [0]] and [2, [2]] are the same scheme. What matters is which positions share an index, not which numbers were used. canOverride is what ComposableFunctionChecker calls when validating an override.
The debug form is [target, parameter, parameter: result]. Serialization into the @ComposableInferredTarget annotation is separate and produces three strings:
data class SerializedScheme(val scheme: String, val positional: String, val indexed: String)The extra two carry the constraint sets, which the main string has no room for.
ApplierInferencer is generic over the node and type representation, so the same engine serves both the FIR frontend and the IR backend:
class ApplierInferencer<Type, Node>(
private val typeAdapter: TypeAdapter<Type>,
private val nodeAdapter: NodeAdapter<Type, Node>,
private val lazySchemeStorage: LazySchemeStorage<Node>,
private val errorReporter: ErrorReporter<Node>,
)It exposes visitVariable, visitCall, and toFinalScheme. Callers supply four adapters: TypeAdapter reads a declared scheme off a type, NodeAdapter navigates containers and parameter positions, LazySchemeStorage caches partially resolved schemes, and ErrorReporter receives conflicts.
The algorithm is unification, the same technique type inference uses:
- Read the declared scheme from
@ComposableTargetand@ComposableInferredTarget, or start fully open when there is none. - Convert each scheme to
CallBindings, a tree ofBindingobjects mirroring the scheme's shape. - Unify the call's bindings with the callee's, position by position.
- When an open binding meets a token, bind it. When two open bindings meet, merge them so they resolve together.
Bindings keeps unified bindings in a circular list and always merges the smaller group into the larger, which keeps the work bounded as a call graph grows. Binding fails when the intersected constraints allow nothing:
fun unify(a: Binding, b: Binding): BooleanFailure comes back as false and reaches the user through ErrorReporter. Nothing is thrown, and no substitution map is built: the whole state lives in Bindings and LazyScheme.
Nothing in the compiler hardcodes a target name. A token is either the applier string of a @ComposableTarget annotation, or the fully qualified name of an annotation class that is itself annotated @ComposableTargetMarker. androidx.compose.ui.UiComposable is just what the UI library happens to call its marker; the compiler never mentions it.
fun FirCallableSymbol<*>.schemeItem(): Item {
val targets = targetsFromAnnotations()
val explicitOpen = compositionOpenTarget()
return when {
targets.size == 1 -> Token(targets.first())
targets.size > 1 -> Open(explicitOpen ?: -1, constraints = Constraints.restrictedTo(targets))
explicitOpen != null -> Open(explicitOpen)
else -> Open(-1, isUnspecified = true)
}
}The annotations all live in androidx.compose.runtime: ComposableTarget(applier), ComposableOpenTarget(index), ComposableInferredTarget(scheme), ComposableInferredTargetConstraints(positional, indexed), and ComposableTargetMarker(description). That description is what turns a token back into readable text in a diagnostic.
k2/ComposableTargetChecker.kt is a FirFunctionCallChecker that runs the inferencer over each composable call:
override fun check(expression: FirFunctionCall) {
val calleeFunction = expression.calleeReference.toResolvedCallableSymbol() ?: return
if (calleeFunction.isComposable(context.session)) {
updateParents(context)
val infer = FirApplierInferencer(context, reporter)
val call = inferenceNodeOf(expression, context)
val target = callableInferenceNodeOf(expression, calleeFunction, context)
// ... map arguments to inference nodes ...
infer.visitCall(call, target, arguments)
}
}Its ErrorReporter turns the failed token sets into prose using each marker's description, and reports one of two diagnostics:
COMPOSE_APPLIER_CALL_MISMATCH: "Calling a {1} composable function where a {0} composable was expected"COMPOSE_APPLIER_PARAMETER_MISMATCH: "A {1} composable parameter was provided where a {0} composable was expected"
Example Error:
@Composable
@ComposableTarget("androidx.compose.ui.UiComposable")
fun UiButton(text: String, content: @Composable () -> Unit) { /* ... */ }
@Composable
@ComposableTarget("com.example.CustomApplier")
fun CustomWidget() { /* ... */ }
@Composable
fun Screen() {
UiButton("Click") {
CustomWidget() // COMPOSE_APPLIER_CALL_MISMATCH
}
}All three applier diagnostics are warnings rather than errors. A mismatch usually is a real bug, but inference here spans the whole call graph, and a library that never declared a target can pull an unrelated build into a conflict it cannot fix. Reporting without blocking is the compromise.
The frontend has no interceptor rewriting lambda descriptors anymore. @Composable function types are a first class function type kind in FIR, contributed by an extension:
class ComposableFunctionTypeKindExtension(session: FirSession) : FirFunctionTypeKindExtension(session) {
override fun FunctionTypeKindRegistrar.registerKinds() {
registerKind(ComposableFunction, KComposableFunction)
}
}
object ComposableFunction : FunctionTypeKind(
FqName("androidx.compose.runtime.internal"),
"ComposableFunction",
ComposeClassIds.Composable,
isReflectType = false,
isInlineable = true,
) {
override val prefixForTypeRender: String get() = "@Composable"
override fun reflectKind(): FunctionTypeKind = KComposableFunction
}Because the kind is registered with the type system, @Composable () -> Unit is a distinct type rather than a function type with an annotation on it. Ordinary type inference then does the work that used to need an interceptor:
// Expected type is @Composable () -> Unit, so the lambda is inferred
// with the ComposableFunction kind and composable calls are allowed inside it
val content: @Composable () -> Unit = {
Text("Hello")
}
Column(
content = {
Text("Item 1")
Text("Item 2")
}
)This is also the check ComposableCallChecker performs when it asks whether a lambda opens a composable scope: function.typeRef.coneType.functionTypeKind(context.session) === ComposableFunction.
One compatibility detail is worth noting. The kind sets serializeAsFunctionWithAnnotationUntil, so composable function types are written into metadata as plain function types carrying @Composable. That keeps libraries built by the current compiler readable by older plugin versions.
βββββββββββββββββββββββββββ
β 1. PARSING β
β Source to light tree β
βββββββββββββ¬ββββββββββββββ
β
βββββββββββββΌββββββββββββββ
β 2. FIR RESOLUTION β
β Types, symbols, and β
β composable type kinds β
βββββββββββββ¬ββββββββββββββ
β
βββββββββββββΌββββββββββββββ
β 3. FIR CHECKERS β
β ββββββββββββββββββββ β
β β Annotation check β β
β β Function check β β
β β Property check β β
β β Call check β β
β β Target check β β
β ββββββββββββββββββββ β
βββββββββββββ¬ββββββββββββββ
β
βββββββββββββΌββββββββββββββ
β 4. FIR2IR β
β Build the IR tree β
βββββββββββββ¬ββββββββββββββ
β
βββββββββββββΌββββββββββββββ
β 5. IR ANALYSIS β
β Stability inference β
β Static detection β
βββββββββββββ¬ββββββββββββββ
β
βββββββββββββΌββββββββββββββ
β 6. IR LOWERING β
β Transform composables β
βββββββββββββββββββββββββββ
Everything the frontend contributes is registered in one place:
class ComposeFirExtensionRegistrar : FirExtensionRegistrar() {
override fun ExtensionRegistrarContext.configurePlugin() {
+::ComposableFunctionTypeKindExtension
+::ComposeFirCheckersExtension
+::ComposableTargetSessionStorage
registerDiagnosticContainers(ComposeErrors)
}
}ComposeFirCheckersExtension is where each checker is attached to its slot:
override val declarationCheckers = object : DeclarationCheckers() {
override val functionCheckers = setOf(ComposableFunctionChecker)
override val propertyCheckers = setOf(ComposablePropertyChecker)
}
override val typeCheckers = object : TypeCheckers() {
override val resolvedTypeRefCheckers = setOf(ComposableAnnotationChecker)
}
override val expressionCheckers = object : ExpressionCheckers() {
override val functionCallCheckers = setOf(ComposableFunctionCallChecker, ComposableTargetChecker)
override val propertyAccessExpressionCheckers = setOf(ComposablePropertyAccessExpressionChecker)
override val callableReferenceAccessCheckers = setOf(ComposablePropertyReferenceChecker)
}There is no declaration generator, supertype generator, or status transformer. The frontend only inspects and reports; every declaration the plugin synthesizes, including the $stable field, is created in the IR phase.
FIR checkers to session components:
// During applier target checking
session.composableTargetSessionStorage.storeLazyScheme(node, lazyScheme)IR analysis to IR attributes:
// During static expression analysis
expression.isStaticExpression = isStatic
// During key generation
function.durableFunctionKey = keyInfoIR lowering reading attributes back:
// During composable transformation
val isStatic = expr.isStaticExpression
val key = function.durableFunctionKeyclass MainActivity {
fun onCreate() {
Text("Hello") // ERROR: COMPOSABLE_INVOCATION
}
@Composable
fun Content() {
Text("Hello") // OK: inside a composable function
runBlocking {
Text("Error") // ERROR: COMPOSABLE_INVOCATION
}
LaunchedEffect(Unit) {
Text("Error") // ERROR: the block is suspend, not composable
}
try {
Text("Error") // ERROR: ILLEGAL_TRY_CATCH_AROUND_COMPOSABLE
} catch (e: Exception) {
Text("Fine") // OK: catch blocks are allowed
}
}
}Analysis flow for the first call:
ComposableFunctionCallCheckerresolvesTextand finds it composablevisitCurrentScopewalks outward from the call throughcontainingElements- It reaches
onCreate, aFirFunctionwithout@Composable, socheckComposableFunctionreportsCOMPOSABLE_EXPECTEDon the declaration and returnsCONTINUE - The walk ends, and
checkComposableCallreportsCOMPOSABLE_INVOCATIONon the call
The developer sees two diagnostics for one mistake, one pointing at the call and one at the function that should have been annotated.
The runBlocking and LaunchedEffect cases end the same way for a different reason. Neither function is inline, so their lambdas arrive with an inlineStatus other than Inline. Their function type kind is not ComposableFunction either, so the walk visits the lambda, finds nothing composable about it, and the non inline status stops it right there. Suspend lambdas are never special cased anywhere in the checker; they simply fail the composable type kind test like any other ordinary lambda.
inline fun <T> withoutComposables(
@DisallowComposableCalls block: () -> T
): T = block()
@Composable
fun Screen() {
withoutComposables {
val data = loadData() // OK
Text(data) // ERROR: CAPTURED_COMPOSABLE_INVOCATION
}
}Analysis flow:
- The walk from
Textreaches the lambda, which isInline findValueParameterForLambdaAtIndexmaps the lambda back toblockthrough the resolved argument listblock's type carries@DisallowComposableCallsCAPTURED_COMPOSABLE_INVOCATIONis reported, naming bothblockandwithoutComposables
The rule exists because an inline lambda passed to a function like this may be stored and invoked later, outside composition, where there is no composer to call into.
class Foo(var value: Int = 0)
@Composable
fun Test(x: Int) {
A(x)
}
@Composable
fun Test(x: Foo) {
used(x)
}Stability analysis:
Intis a primitive, soCertain(stable = true)Foohas avarproperty, soCertain(stable = false), and it gets@StabilityInferred(parameters = 0)with$stable = 8
Generated code, as the compiler's golden tests record it:
fun Test(x: Int, %composer: Composer?, %changed: Int) {
%composer = %composer.startRestartGroup(<>)
val %dirty = %changed
if (%changed and 0b0110 == 0) {
%dirty = %dirty or if (%composer.changed(x)) 0b0100 else 0b0010
}
if (%composer.shouldExecute(%dirty and 0b0011 != 0b0010, %dirty and 0b0001)) {
A(x, %composer, 0b1110 and %dirty)
} else {
%composer.skipToGroupEnd()
}
%composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
Test(x, %composer, updateChangedFlags(%changed or 0b0001))
}
}fun Test(x: Foo, %composer: Composer?, %changed: Int) {
%composer = %composer.startRestartGroup(<>)
val %dirty = %changed
if (%changed and 0b0110 == 0) {
%dirty = %dirty or if (%composer.changedInstance(x)) 0b0100 else 0b0010
}
if (%composer.shouldExecute(%dirty and 0b0011 != 0b0010, %dirty and 0b0001)) {
used(x)
} else {
%composer.skipToGroupEnd()
}
%composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
Test(x, %composer, updateChangedFlags(%changed or 0b0001))
}
}The two bodies have the same shape. Both open a restart group, both compute a %dirty mask, both gate the body on shouldExecute, and both skip to the end of the group when nothing changed. There is exactly one difference: the stable parameter goes through %composer.changed(x) and the unstable one through %composer.changedInstance(x).
That single call is where all of stability inference lands. changed compares with equals(), so an equal value skips even when it is a different instance. changedInstance compares with ===, so a rebuilt object never compares equal and the body runs again. A data class that is unstable does not lose the ability to skip; it loses the ability to match, which in a screen that rebuilds its state on every emission amounts to the same cost.
shouldExecute came in with pausable composition. Its first argument is whether any used parameter differs from last time, and its second is %dirty and 1, the low bit that says the scope was restarted rather than reached normally. That second argument is what lets a paused composition resume a scope it had already decided to skip. When FeatureFlag.PausableComposition is off, or the runtime on the classpath is too old to have the function, irShouldExecute falls back to the older form:
irOrOr(
parametersChanged,
irNot(irIsSkipping())
)One more branch depends on the flags. With strong skipping disabled, a function that has both unstable parameters and default values gets an extra guard, defaultParam.irHasAnyProvidedAndUnstable(unstableMask), forcing execution whenever an unstable parameter was actually passed. Strong skipping makes that guard unnecessary, since changedInstance already handles those parameters, so with the default configuration it is never generated.
Most day to day work with stability comes down to two habits. Read the -classes.txt report before you guess, because the compiler will tell you exactly which field made a class unstable and the answer is often a var you forgot about or an interface typed property. And when you reach for a fix, prefer giving the compiler something it can infer, a val of a type it already knows, over declaring a type stable in the configuration file, since that declaration is a promise the compiler cannot check.
What is worth carrying away is that stability was never really about skipping. Since strong skipping became the default, every restartable composable can skip. Stability decides how the runtime compares a parameter, equals() or ===, and everything in this repository, the bitmasks, the $stable field, the file scoped runtime fallback, exists to answer that one question as precisely as separate compilation allows. Once you read it that way, an unstable class stops being a thing that blocks an optimization and becomes a thing that compares by identity, which is a far easier property to reason about in your own code.
If you want more on Compose performance, check out the compose-performance repository.
Manifest Android Interview is a comprehensive guide designed to enhance your Android development expertise through 108 interview questions with detailed answers, 162 additional practical questions, and 50+ "Pro Tips for Mastery" sections. The interview questions primarily focus on Android development, including the Framework, UI, Jetpack Libraries, and Business Logic, as well as Jetpack Compose, covering Fundamentals, Runtime, and UI.
If you're eager to dive deeper into Kotlin and Android, explore Dove Letter, a private subscription repository where you can learn, discuss, and share knowledge. To get more details about this unique opportunity, check out the Learn Kotlin and Android With Dove Letter article.
Support it by joining stargazers for this repository. β
Also, follow me on GitHub for my next creations! π€©
Designed and developed by 2025 skydoves (Jaewoong Eum)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
