Skip to content

Commit f3d4741

Browse files
authored
Merge pull request #37 from skydoves/fix/sealed-subclass
Validate a subclass of a sealed type class
2 parents ba04206 + 0a3ddfa commit f3d4741

File tree

7 files changed

+119
-30
lines changed

7 files changed

+119
-30
lines changed

app/src/main/kotlin/com/skydoves/myapplication/MainActivity.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ import androidx.lifecycle.ViewModel
4949
import com.skydoves.myapplication.models.ImmutableData
5050
import com.skydoves.myapplication.models.MyClass2
5151
import com.skydoves.myapplication.models.NormalClass
52+
import com.skydoves.myapplication.models.NormalSealedClass
53+
import com.skydoves.myapplication.models.StableSealedClass
5254
import com.skydoves.myapplication.models.StableUser
5355
import com.skydoves.myapplication.models.UnstableUser
5456
import kotlinx.collections.immutable.ImmutableList
@@ -162,6 +164,8 @@ fun Icon(
162164
title: String,
163165
painter: Painter,
164166
users: List<StableUser>,
167+
normalSealedClass: NormalSealedClass.Normal,
168+
stableSealedClass: StableSealedClass.Stable,
165169
elevation: CardElevation = CardDefaults.cardElevation(),
166170
unstableUser: UnstableUser,
167171
) {

app/src/main/kotlin/com/skydoves/myapplication/models/Models.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ class StableClass(
7373
}
7474
}
7575

76+
sealed class NormalSealedClass {
77+
data class Normal(val names: List<String>) : NormalSealedClass()
78+
}
79+
80+
@Immutable
81+
sealed class StableSealedClass {
82+
data class Stable(val names: List<String>) : StableSealedClass()
83+
}
84+
7685
/**
7786
* Class with mixed stability properties.
7887
* Should be unstable due to mutableList.

app/stability/app.stability

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ public fun com.skydoves.myapplication.ActionButton(text: kotlin.String, onClick:
1414

1515
@Composable
1616
public fun com.skydoves.myapplication.Card(modifier: androidx.compose.ui.Modifier?, shape: androidx.compose.ui.graphics.Shape?, colors: androidx.compose.material3.CardColors?, elevation: androidx.compose.material3.CardElevation?, func: kotlin.Function2<androidx.compose.runtime.Composer, kotlin.Int, com.skydoves.myapplication.models.UnstableUser>, func2: kotlin.coroutines.SuspendFunction0<com.skydoves.myapplication.models.UnstableUser>, func3: kotlin.coroutines.SuspendFunction0<com.skydoves.myapplication.models.StableUser>, content: @[ExtensionFunctionType] kotlin.Function3<androidx.compose.foundation.layout.ColumnScope, androidx.compose.runtime.Composer, kotlin.Int, kotlin.Unit>): kotlin.Unit
17-
skippable: false
17+
skippable: true
1818
restartable: true
1919
params:
2020
- modifier: STABLE (marked @Stable or @Immutable)
2121
- shape: STABLE (marked @Stable or @Immutable)
2222
- colors: STABLE (marked @Stable or @Immutable)
2323
- elevation: STABLE (marked @Stable or @Immutable)
2424
- func: STABLE (function type)
25-
- func2: RUNTIME (requires runtime check)
26-
- func3: RUNTIME (requires runtime check)
25+
- func2: STABLE (function type)
26+
- func3: STABLE (function type)
2727
- content: STABLE (function type)
2828

2929
@Composable
@@ -40,10 +40,10 @@ public fun com.skydoves.myapplication.CorrectWithParameters(): kotlin.Unit
4040

4141
@Composable
4242
public fun com.skydoves.myapplication.CounterDisplay(count: com.skydoves.myapplication.MainViewModel): kotlin.Unit
43-
skippable: true
43+
skippable: false
4444
restartable: true
4545
params:
46-
- count: STABLE
46+
- count: RUNTIME (requires runtime check)
4747

4848
@Composable
4949
public fun com.skydoves.myapplication.GenericDisplay(item: T of com.skydoves.myapplication.GenericDisplay, fontWeight4: androidx.compose.ui.text.font.FontWeight, mySealed: com.skydoves.myapplication.MySealed, child2: com.skydoves.myapplication.MySealed.Child2, child3: com.skydoves.myapplication.MySealed.Child3, child4: com.skydoves.myapplication.MySealed.Child4, child: com.skydoves.myapplication.MySealed.Child, fontWeight: androidx.compose.ui.text.font.FontWeight, fontWeight2: androidx.compose.ui.text.font.FontWeight?, values: kotlin.String, value: kotlin.Int?, testValueClass: com.skydoves.myapplication.TestValueClass, textAlign: androidx.compose.ui.text.style.TextAlign, textAlign2: androidx.compose.ui.text.style.TextAlign?, displayText: kotlin.Function1<T of com.skydoves.myapplication.GenericDisplay, kotlin.String>): kotlin.Unit
@@ -52,28 +52,30 @@ public fun com.skydoves.myapplication.GenericDisplay(item: T of com.skydoves.mya
5252
params:
5353
- item: RUNTIME (requires runtime check)
5454
- fontWeight4: STABLE (marked @Stable or @Immutable)
55-
- mySealed: STABLE
56-
- child2: STABLE
57-
- child3: STABLE
55+
- mySealed: STABLE (class with no mutable properties)
56+
- child2: STABLE (class with no mutable properties)
57+
- child3: STABLE (class with no mutable properties)
5858
- child4: UNSTABLE (has mutable properties or unstable members)
59-
- child: STABLE
59+
- child: STABLE (class with no mutable properties)
6060
- fontWeight: STABLE (marked @Stable or @Immutable)
6161
- fontWeight2: STABLE (marked @Stable or @Immutable)
6262
- values: STABLE (String is immutable)
63-
- value: STABLE
64-
- testValueClass: STABLE
63+
- value: STABLE (class with no mutable properties)
64+
- testValueClass: STABLE (class with no mutable properties)
6565
- textAlign: STABLE (known stable type)
6666
- textAlign2: STABLE (known stable type)
6767
- displayText: STABLE (function type)
6868

6969
@Composable
70-
public fun com.skydoves.myapplication.Icon(title: kotlin.String, painter: androidx.compose.ui.graphics.painter.Painter, users: kotlin.collections.List<com.skydoves.myapplication.models.StableUser>, elevation: androidx.compose.material3.CardElevation?, unstableUser: com.skydoves.myapplication.models.UnstableUser): kotlin.Unit
70+
public fun com.skydoves.myapplication.Icon(title: kotlin.String, painter: androidx.compose.ui.graphics.painter.Painter, users: kotlin.collections.List<com.skydoves.myapplication.models.StableUser>, normalSealedClass: com.skydoves.myapplication.models.NormalSealedClass.Normal, stableSealedClass: com.skydoves.myapplication.models.StableSealedClass.Stable, elevation: androidx.compose.material3.CardElevation?, unstableUser: com.skydoves.myapplication.models.UnstableUser): kotlin.Unit
7171
skippable: false
7272
restartable: true
7373
params:
7474
- title: STABLE (String is immutable)
7575
- painter: RUNTIME (requires runtime check)
7676
- users: RUNTIME (requires runtime check)
77+
- normalSealedClass: RUNTIME (requires runtime check)
78+
- stableSealedClass: STABLE (class with no mutable properties)
7779
- elevation: STABLE (marked @Stable or @Immutable)
7880
- unstableUser: UNSTABLE (has mutable properties or unstable members)
7981

@@ -126,22 +128,22 @@ public fun com.skydoves.myapplication.StableUserCard(user: com.skydoves.myapplic
126128
skippable: true
127129
restartable: true
128130
params:
129-
- user: STABLE
131+
- user: STABLE (class with no mutable properties)
130132

131133
@Composable
132134
public fun com.skydoves.myapplication.Test(stableUser2: com.skydoves.myapplication.models.StableUser): kotlin.Unit
133135
skippable: true
134136
restartable: true
135137
params:
136-
- stableUser2: STABLE
138+
- stableUser2: STABLE (class with no mutable properties)
137139

138140
@Composable
139141
public fun com.skydoves.myapplication.Test(myClass2: com.skydoves.myapplication.models.MyClass2, normalClass: com.skydoves.myapplication.models.NormalClass, immutableList: kotlinx.collections.immutable.ImmutableList<kotlin.String>): kotlin.Unit
140142
skippable: false
141143
restartable: true
142144
params:
143145
- myClass2: UNSTABLE (has mutable properties or unstable members)
144-
- normalClass: STABLE
146+
- normalClass: STABLE (class with no mutable properties)
145147
- immutableList: STABLE (known stable type)
146148

147149
@Composable
@@ -161,10 +163,10 @@ public fun com.skydoves.myapplication.Test2(myClass2: kotlin.collections.List<ko
161163

162164
@Composable
163165
public fun com.skydoves.myapplication.Test3(myClass2: java.lang.StringBuilder): kotlin.Unit
164-
skippable: true
166+
skippable: false
165167
restartable: true
166168
params:
167-
- myClass2: STABLE
169+
- myClass2: UNSTABLE (mutable Java class)
168170

169171
@Composable
170172
public fun com.skydoves.myapplication.Test4(count: androidx.lifecycle.ViewModel): kotlin.Unit
@@ -217,7 +219,7 @@ public fun com.skydoves.myapplication.TrackedMixedParameters(title: kotlin.Strin
217219
params:
218220
- title: STABLE (String is immutable)
219221
- count: STABLE (primitive type)
220-
- user: STABLE
222+
- user: STABLE (class with no mutable properties)
221223

222224
@Composable
223225
public fun com.skydoves.myapplication.TrackedUnstableUserCard(user: com.skydoves.myapplication.models.UnstableUser): kotlin.Unit
@@ -231,7 +233,7 @@ public fun com.skydoves.myapplication.TrackedUserProfile(user: com.skydoves.myap
231233
skippable: true
232234
restartable: true
233235
params:
234-
- user: STABLE
236+
- user: STABLE (class with no mutable properties)
235237

236238
@Composable
237239
public fun com.skydoves.myapplication.UnstableUserCard(user: com.skydoves.myapplication.models.UnstableUser): kotlin.Unit

compose-stability-analyzer-idea/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ All notable changes to the IntelliJ IDEA plugin will be documented in this file.
1111
- Implemented `DefaultRecompositionLogger` for wasmJs using `println()` for browser console output
1212
- wasmJs target placed directly under common hierarchy (separate from skia group) for proper source set resolution
1313

14+
### Fixed
15+
- **Fixed sealed class stability inheritance** (Issue #31)
16+
- Abstract classes with `@Immutable` or `@Stable` annotations now correctly analyzed for stability
17+
- Sealed classes with `@Immutable`/`@Stable` now properly propagate stability to subclasses
18+
- Example: `@Immutable sealed class StableSealedClass` with `data class Stable(...)` subclass now correctly shows as STABLE instead of RUNTIME
19+
- IDE plugin now matches compiler plugin behavior for sealed class hierarchies
20+
1421
---
1522

1623
## [0.5.0] - 2025-11-08

compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/k2/KtStabilityInferencer.kt

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -368,14 +368,28 @@ internal class KtStabilityInferencer {
368368
}
369369

370370
// 19. Abstract classes - cannot determine (RUNTIME)
371+
// EXCEPT: Sealed classes with @Stable/@Immutable should be analyzed like regular classes
372+
// Issue #31: Sealed classes with @Immutable/@Stable should propagate stability to subclasses
371373
if (classSymbol.modality == KaSymbolModality.ABSTRACT) {
372-
return KtStability.Runtime(
373-
className = fqName ?: simpleName,
374-
reason = "Abstract class - actual implementation could be mutable",
375-
)
374+
// Check if this abstract class has @Stable or @Immutable annotation
375+
// If it does, it should be analyzed (not immediately returned as RUNTIME)
376+
val hasStabilityAnnotation = classSymbol.annotations.any { annotation ->
377+
val annotationFqName = annotation.classId?.asSingleFqName()?.asString()
378+
annotationFqName == "androidx.compose.runtime.Stable" ||
379+
annotationFqName == "androidx.compose.runtime.Immutable"
380+
}
381+
382+
// Only return RUNTIME if it doesn't have stability annotations
383+
if (!hasStabilityAnnotation) {
384+
return KtStability.Runtime(
385+
className = fqName ?: simpleName,
386+
reason = "Abstract class - actual implementation could be mutable",
387+
)
388+
}
389+
// Abstract classes with @Stable/@Immutable continue to property analysis
376390
}
377391

378-
// 20. Regular classes - analyze properties first before checking @StabilityInferred
392+
// 20. Regular classes (and sealed classes) - analyze properties first before checking @StabilityInferred
379393
val propertyStability = analyzeClassProperties(classSymbol, currentlyAnalyzing)
380394

381395
return when {
@@ -411,6 +425,31 @@ internal class KtStabilityInferencer {
411425
classSymbol: KaClassSymbol,
412426
currentlyAnalyzing: Set<KaClassLikeSymbol>,
413427
): KtStability {
428+
// Issue #31: Check if parent sealed class has @Immutable/@Stable
429+
val parentHasStabilityAnnotation = classSymbol.superTypes.any { superType ->
430+
val superClassSymbol = superType.expandedSymbol as? KaClassSymbol
431+
if (superClassSymbol != null) {
432+
// Check if superclass is sealed (has sealed subclasses)
433+
val isSealed = superClassSymbol.modality == KaSymbolModality.SEALED
434+
// Check if it has @Stable or @Immutable annotation
435+
val hasAnnotation = superClassSymbol.annotations.any { annotation ->
436+
val annotationFqName = annotation.classId?.asSingleFqName()?.asString()
437+
annotationFqName == "androidx.compose.runtime.Stable" ||
438+
annotationFqName == "androidx.compose.runtime.Immutable"
439+
}
440+
isSealed && hasAnnotation
441+
} else {
442+
false
443+
}
444+
}
445+
446+
if (parentHasStabilityAnnotation) {
447+
return KtStability.Certain(
448+
stable = true,
449+
reason = "Subclass of @Immutable/@Stable sealed class",
450+
)
451+
}
452+
414453
// Check superclass stability first
415454
val superClassStability = analyzeSuperclassStability(classSymbol, currentlyAnalyzing)
416455

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ androidGradlePlugin = "8.13.0"
1313
androidxActivity = "1.11.0"
1414
androidxComposeBom = "2025.10.01"
1515
jetbrains-compose = "1.9.2"
16-
compose-stability-analyzer = "0.4.2"
16+
compose-stability-analyzer = "0.5.0"
1717
spotless = "6.21.0"
1818

1919
[libraries]

stability-compiler/src/main/kotlin/com/skydoves/compose/stability/compiler/lower/StabilityAnalyzerTransformer.kt

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -395,20 +395,25 @@ public class StabilityAnalyzerTransformer(
395395
}
396396

397397
// 16. Abstract classes - cannot determine (RUNTIME)
398-
// EXCEPT sealed classes which are handled by property analysis
398+
// EXCEPT: sealed classes with @Stable/@Immutable annotations
399+
// Issue #31: @Immutable sealed classes should be trusted as stable
399400
if (clazz.modality == org.jetbrains.kotlin.descriptors.Modality.ABSTRACT) {
400-
// Check if this is a sealed class (sealed classes are abstract but stable if no mutable props)
401+
// Check if this is a sealed class (sealed classes are abstract but stable if annotated)
401402
val isSealed = try {
402403
clazz.sealedSubclasses.isNotEmpty()
403404
} catch (e: Exception) {
404405
false
405406
}
406407

407-
// Only mark as RUNTIME if it's NOT a sealed class
408-
if (!isSealed) {
408+
// Check if it has @Stable or @Immutable annotation
409+
val hasStabilityAnnotation = clazz.hasAnnotation(stableFqName) ||
410+
clazz.hasAnnotation(immutableFqName)
411+
412+
// Only mark as RUNTIME if it's NOT a sealed class AND doesn't have stability annotation
413+
if (!isSealed && !hasStabilityAnnotation) {
409414
return ParameterStability.RUNTIME
410415
}
411-
// Sealed classes continue to property analysis
416+
// Sealed classes and annotated abstract classes continue to property analysis
412417
}
413418

414419
// 18. Regular classes - analyze properties first before checking @StabilityInferred
@@ -434,6 +439,29 @@ public class StabilityAnalyzerTransformer(
434439
* Matches K2 implementation logic.
435440
*/
436441
private fun analyzeClassProperties(clazz: IrClass, fqName: String?): ParameterStability {
442+
// Issue #31: Check if parent sealed class has @Immutable/@Stable
443+
val parentHasStabilityAnnotation = clazz.superTypes.any { superType ->
444+
val superClassSymbol = superType.classOrNull
445+
if (superClassSymbol != null) {
446+
val superClass = superClassSymbol.owner
447+
// Check if superclass is sealed AND has stability annotation
448+
val isSealed = try {
449+
superClass.sealedSubclasses.isNotEmpty()
450+
} catch (e: Exception) {
451+
false
452+
}
453+
val hasAnnotation = superClass.hasAnnotation(stableFqName) ||
454+
superClass.hasAnnotation(immutableFqName)
455+
isSealed && hasAnnotation
456+
} else {
457+
false
458+
}
459+
}
460+
461+
if (parentHasStabilityAnnotation) {
462+
return ParameterStability.STABLE
463+
}
464+
437465
// Check superclass stability first (matches IDE plugin logic)
438466
val superClassStability = analyzeSuperclassStability(clazz)
439467

0 commit comments

Comments
 (0)