Skip to content

Commit 880c005

Browse files
authored
Add runtime libraries compatibility check for Compose projects (#5485)
Add runtime libraries compatibility check for Compose projects Fixes [CMP-9288](https://youtrack.jetbrains.com/issue/CMP-9288) Check compose libraries compatibility <img width="1083" height="264" alt="image" src="https://github.com/user-attachments/assets/4b321025-7b76-4a8e-a1f8-12a7ddabbd56" /> To disable the check there is a new gradle property: `org.jetbrains.compose.library.compatibility.check.disable` ## Testing - Added integration tests to verify version mismatches and override behavior. ## Release Notes ### Features - Gradle Plugin - Add a compatibility check for runtime libraries to ensure consistency with the expected Compose version.
1 parent 1b40a6d commit 880c005

File tree

10 files changed

+315
-3
lines changed

10 files changed

+315
-3
lines changed

gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ abstract class ComposePlugin : Plugin<Project> {
5252

5353
project.configureWebCompatibility()
5454

55+
project.configureRuntimeLibrariesCompatibilityCheck()
56+
5557
project.afterEvaluate {
5658
configureDesktop(project, desktopExtension)
5759
project.configureWeb(composeExtension)
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package org.jetbrains.compose
2+
3+
import org.gradle.api.DefaultTask
4+
import org.gradle.api.Project
5+
import org.gradle.api.artifacts.result.ResolvedComponentResult
6+
import org.gradle.api.provider.Property
7+
import org.gradle.api.provider.ProviderFactory
8+
import org.gradle.api.provider.SetProperty
9+
import org.gradle.api.tasks.Input
10+
import org.gradle.api.tasks.TaskAction
11+
import org.jetbrains.compose.desktop.application.internal.ComposeProperties
12+
import org.jetbrains.compose.internal.KOTLIN_JVM_PLUGIN_ID
13+
import org.jetbrains.compose.internal.KOTLIN_MPP_PLUGIN_ID
14+
import org.jetbrains.compose.internal.kotlinJvmExt
15+
import org.jetbrains.compose.internal.mppExt
16+
import org.jetbrains.compose.internal.utils.dependsOn
17+
import org.jetbrains.compose.internal.utils.joinLowerCamelCase
18+
import org.jetbrains.compose.internal.utils.provider
19+
import org.jetbrains.compose.internal.utils.registerOrConfigure
20+
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
21+
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget
22+
import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget
23+
import javax.inject.Inject
24+
25+
internal fun Project.configureRuntimeLibrariesCompatibilityCheck() {
26+
plugins.withId(KOTLIN_MPP_PLUGIN_ID) {
27+
mppExt.targets.configureEach { target -> target.configureRuntimeLibrariesCompatibilityCheck() }
28+
}
29+
plugins.withId(KOTLIN_JVM_PLUGIN_ID) {
30+
kotlinJvmExt.target.configureRuntimeLibrariesCompatibilityCheck()
31+
}
32+
}
33+
34+
private fun KotlinTarget.configureRuntimeLibrariesCompatibilityCheck() {
35+
val target = this
36+
if (
37+
target.platformType == KotlinPlatformType.common ||
38+
target.platformType == KotlinPlatformType.androidJvm ||
39+
(target.platformType == KotlinPlatformType.jvm && target !is KotlinJvmTarget) //new AGP
40+
) {
41+
return
42+
}
43+
compilations.configureEach { compilation ->
44+
val runtimeDependencyConfigurationName = if (target.platformType != KotlinPlatformType.native) {
45+
compilation.runtimeDependencyConfigurationName
46+
} else {
47+
compilation.compileDependencyConfigurationName
48+
} ?: return@configureEach
49+
val config = project.configurations.getByName(runtimeDependencyConfigurationName)
50+
51+
val task = project.tasks.registerOrConfigure<RuntimeLibrariesCompatibilityCheck>(
52+
joinLowerCamelCase("check", target.name, compilation.name, "composeLibrariesCompatibility"),
53+
) {
54+
expectedVersion.set(composeVersion)
55+
projectPath.set(project.path)
56+
configurationName.set(runtimeDependencyConfigurationName)
57+
runtimeDependencies.set(provider { config.incoming.resolutionResult.allComponents })
58+
}
59+
compilation.compileTaskProvider.dependsOn(task)
60+
}
61+
}
62+
63+
internal abstract class RuntimeLibrariesCompatibilityCheck : DefaultTask() {
64+
private companion object {
65+
val librariesForCheck = listOf(
66+
"org.jetbrains.compose.foundation:foundation",
67+
"org.jetbrains.compose.ui:ui"
68+
)
69+
}
70+
71+
@get:Inject
72+
protected abstract val providers: ProviderFactory
73+
74+
@get:Input
75+
abstract val expectedVersion: Property<String>
76+
77+
@get:Input
78+
abstract val projectPath: Property<String>
79+
80+
@get:Input
81+
abstract val configurationName: Property<String>
82+
83+
@get:Input
84+
abstract val runtimeDependencies: SetProperty<ResolvedComponentResult>
85+
86+
init {
87+
onlyIf {
88+
!ComposeProperties.disableLibraryCompatibilityCheck(providers).get()
89+
}
90+
}
91+
92+
@TaskAction
93+
fun run() {
94+
val expectedRuntimeVersion = expectedVersion.get()
95+
val foundLibs = runtimeDependencies.get().filter { component ->
96+
component.moduleVersion?.let { lib -> lib.group + ":" + lib.name } in librariesForCheck
97+
}
98+
val problems = foundLibs.mapNotNull { component ->
99+
val module = component.moduleVersion ?: return@mapNotNull null
100+
if (module.version == expectedRuntimeVersion) return@mapNotNull null
101+
ProblemLibrary(module.group + ":" + module.name, module.version)
102+
}
103+
104+
if (problems.isNotEmpty()) {
105+
logger.warn(
106+
getMessage(
107+
projectPath.get(),
108+
configurationName.get(),
109+
problems,
110+
expectedRuntimeVersion
111+
)
112+
)
113+
}
114+
}
115+
116+
private data class ProblemLibrary(val name: String, val version: String)
117+
118+
private fun getMessage(
119+
projectName: String,
120+
configurationName: String,
121+
problemLibs: List<ProblemLibrary>,
122+
expectedVersion: String
123+
): String = buildString {
124+
appendLine("w: Compose Multiplatform runtime dependencies' versions don't match with plugin version.")
125+
problemLibs.forEach { lib ->
126+
appendLine(" expected: '${lib.name}:$expectedVersion'")
127+
appendLine(" actual: '${lib.name}:${lib.version}'")
128+
appendLine()
129+
}
130+
appendLine("This may lead to compilation errors or unexpected behavior at runtime.")
131+
appendLine("Such version mismatch might be caused by dependency constraints in one of the included libraries.")
132+
val taskName = if (projectName.isNotEmpty() && !projectName.endsWith(":")) "$projectName:dependencies" else "${projectName}dependencies"
133+
appendLine("You can inspect resulted dependencies tree via `./gradlew $taskName --configuration ${configurationName}`.")
134+
appendLine("See more details in Gradle documentation: https://docs.gradle.org/current/userguide/viewing_debugging_dependencies.html#sec:listing-dependencies")
135+
appendLine()
136+
appendLine("Please update Compose Multiplatform Gradle plugin's version or align dependencies' versions to match the current plugin version.")
137+
}
138+
}

gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ComposeProjectProperties.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ internal object ComposeProperties {
2828
internal const val SYNC_RESOURCES_PROPERTY = "compose.ios.resources.sync"
2929
internal const val DISABLE_HOT_RELOAD = "org.jetbrains.compose.hot.reload.disable"
3030
internal const val DISABLE_RESOURCE_CONTENT_HASH_GENERATION = "org.jetbrains.compose.resources.content.hash.generation.disable"
31+
internal const val DISABLE_LIBRARY_COMPATIBILITY_CHECK = "org.jetbrains.compose.library.compatibility.check.disable"
3132

3233
fun isVerbose(providers: ProviderFactory): Provider<Boolean> =
3334
providers.valueOrNull(VERBOSE).toBooleanProvider(false)
@@ -68,6 +69,9 @@ internal object ComposeProperties {
6869
fun disableResourceContentHashGeneration(providers: ProviderFactory): Provider<Boolean> =
6970
providers.valueOrNull(DISABLE_RESOURCE_CONTENT_HASH_GENERATION).toBooleanProvider(false)
7071

72+
fun disableLibraryCompatibilityCheck(providers: ProviderFactory): Provider<Boolean> =
73+
providers.valueOrNull(DISABLE_LIBRARY_COMPATIBILITY_CHECK).toBooleanProvider(false)
74+
7175
//providers.valueOrNull works only with root gradle.properties
7276
fun dontSyncResources(project: Project): Provider<Boolean> =
7377
project.findLocalOrGlobalProperty(SYNC_RESOURCES_PROPERTY).map { it == "false" }

gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/projectExtensions.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import org.gradle.util.GradleVersion
1313
import org.jetbrains.compose.ComposeExtension
1414
import org.jetbrains.compose.web.WebExtension
1515
import org.jetbrains.kotlin.gradle.dsl.KotlinJsProjectExtension
16+
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
1617
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
1718

1819
internal val Project.composeExt: ComposeExtension?
@@ -27,6 +28,12 @@ internal val Project.mppExt: KotlinMultiplatformExtension
2728
internal val Project.mppExtOrNull: KotlinMultiplatformExtension?
2829
get() = extensions.findByType(KotlinMultiplatformExtension::class.java)
2930

31+
internal val Project.kotlinJvmExt: KotlinJvmProjectExtension
32+
get() = kotlinJvmExtOrNull ?: error("Could not find KotlinJvmProjectExtension ($project)")
33+
34+
internal val Project.kotlinJvmExtOrNull: KotlinJvmProjectExtension?
35+
get() = extensions.findByType(KotlinJvmProjectExtension::class.java)
36+
3037
internal val Project.kotlinJsExtOrNull: KotlinJsProjectExtension?
3138
get() = extensions.findByType(KotlinJsProjectExtension::class.java)
3239

gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ComposeResources.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import org.gradle.util.GradleVersion
66
import org.jetbrains.compose.desktop.application.internal.ComposeProperties
77
import org.jetbrains.compose.internal.KOTLIN_JVM_PLUGIN_ID
88
import org.jetbrains.compose.internal.KOTLIN_MPP_PLUGIN_ID
9-
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
9+
import org.jetbrains.compose.internal.kotlinJvmExt
1010
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
1111
import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin
1212
import org.jetbrains.kotlin.gradle.plugin.extraProperties
@@ -60,8 +60,7 @@ private fun Project.onKgpApplied(config: Provider<ResourcesExtension>, kgp: Kotl
6060
}
6161

6262
internal fun Project.onKotlinJvmApplied(config: Provider<ResourcesExtension>) {
63-
val kotlinExtension = project.extensions.getByType(KotlinJvmProjectExtension::class.java)
64-
configureJvmOnlyResources(kotlinExtension, config)
63+
configureJvmOnlyResources(kotlinJvmExt, config)
6564
}
6665

6766
internal fun Project.onAgpApplied(block: (pluginId: String) -> Unit) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package org.jetbrains.compose.test.tests.integration
2+
3+
import org.jetbrains.compose.desktop.application.internal.ComposeProperties
4+
import org.jetbrains.compose.internal.utils.OS
5+
import org.jetbrains.compose.internal.utils.currentOS
6+
import org.jetbrains.compose.test.utils.GradlePluginTestBase
7+
import org.jetbrains.compose.test.utils.checks
8+
import org.jetbrains.compose.test.utils.modify
9+
import kotlin.test.Test
10+
11+
class RuntimeLibrariesCompatibilityCheckTest : GradlePluginTestBase() {
12+
13+
@Test
14+
fun correctConfigurationDoesntPrintWarning(): Unit = with(
15+
testProject("misc/compatibilityLibCheck")
16+
) {
17+
val logMsg = "w: Compose Multiplatform runtime dependencies' versions don't match with plugin version."
18+
gradle("assembleAndroidMain").checks {
19+
check.logDoesntContain("checkAndroidMainComposeLibrariesCompatibility")
20+
check.logDoesntContain(logMsg)
21+
}
22+
gradle("metadataMainClasses").checks {
23+
check.logDoesntContain("checkMetadataMainComposeLibrariesCompatibility")
24+
check.logDoesntContain(logMsg)
25+
}
26+
gradle("jvmMainClasses").checks {
27+
check.taskSuccessful(":checkJvmMainComposeLibrariesCompatibility")
28+
check.logDoesntContain(logMsg)
29+
}
30+
gradle("jvmTestClasses").checks {
31+
check.taskSuccessful(":checkJvmMainComposeLibrariesCompatibility")
32+
check.taskSuccessful(":checkJvmTestComposeLibrariesCompatibility")
33+
check.logDoesntContain(logMsg)
34+
}
35+
gradle("wasmJsMainClasses").checks {
36+
check.taskSuccessful(":checkWasmJsMainComposeLibrariesCompatibility")
37+
check.logDoesntContain(logMsg)
38+
}
39+
40+
if (currentOS == OS.MacOS) {
41+
gradle("compileKotlinIosSimulatorArm64").checks {
42+
check.taskSuccessful(":checkIosSimulatorArm64MainComposeLibrariesCompatibility")
43+
check.logDoesntContain(logMsg)
44+
}
45+
}
46+
47+
file("build.gradle.kts").modify {
48+
it.replace(
49+
"api(\"org.jetbrains.compose.ui:ui:${defaultTestEnvironment.composeVersion}\")",
50+
"api(\"org.jetbrains.compose.ui:ui\") { version { strictly(\"1.9.3\") } }"
51+
)
52+
}
53+
val msg = buildString {
54+
appendLine("w: Compose Multiplatform runtime dependencies' versions don't match with plugin version.")
55+
appendLine(" expected: 'org.jetbrains.compose.ui:ui:${defaultTestEnvironment.composeVersion}'")
56+
appendLine(" actual: 'org.jetbrains.compose.ui:ui:1.9.3'")
57+
}
58+
gradle("jvmMainClasses").checks {
59+
check.taskSuccessful(":checkJvmMainComposeLibrariesCompatibility")
60+
check.logContains(msg)
61+
}
62+
gradle("wasmJsMainClasses").checks {
63+
check.taskSuccessful(":checkWasmJsMainComposeLibrariesCompatibility")
64+
check.logContains(msg)
65+
}
66+
67+
if (currentOS == OS.MacOS) {
68+
gradle("compileKotlinIosSimulatorArm64").checks {
69+
check.taskSuccessful(":checkIosSimulatorArm64MainComposeLibrariesCompatibility")
70+
check.logContains(msg)
71+
}
72+
}
73+
val disableProperty = ComposeProperties.DISABLE_LIBRARY_COMPATIBILITY_CHECK
74+
gradle("jvmMainClasses", "-P${disableProperty}=true").checks {
75+
check.taskSkipped(":checkJvmMainComposeLibrariesCompatibility")
76+
check.logDoesntContain(msg)
77+
}
78+
}
79+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
plugins {
2+
id("com.android.kotlin.multiplatform.library")
3+
id("org.jetbrains.kotlin.multiplatform")
4+
id("org.jetbrains.kotlin.plugin.compose")
5+
id("org.jetbrains.compose")
6+
}
7+
8+
kotlin {
9+
androidLibrary {
10+
namespace = "org.company.app"
11+
compileSdk = 35
12+
minSdk = 23
13+
androidResources.enable = true
14+
}
15+
16+
jvm()
17+
18+
js { browser() }
19+
wasmJs { browser() }
20+
21+
iosX64()
22+
iosArm64()
23+
iosSimulatorArm64()
24+
25+
sourceSets {
26+
commonMain.dependencies {
27+
api("org.jetbrains.compose.runtime:runtime:COMPOSE_VERSION_PLACEHOLDER")
28+
api("org.jetbrains.compose.ui:ui:COMPOSE_VERSION_PLACEHOLDER")
29+
api("org.jetbrains.compose.foundation:foundation:COMPOSE_VERSION_PLACEHOLDER")
30+
}
31+
}
32+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#Gradle
2+
org.gradle.jvmargs=-Xmx4G
3+
org.gradle.caching=true
4+
org.gradle.configuration-cache=true
5+
org.gradle.daemon=true
6+
org.gradle.parallel=true
7+
8+
#Kotlin
9+
kotlin.code.style=official
10+
kotlin.daemon.jvmargs=-Xmx4G
11+
kotlin.native.binary.gc=cms
12+
kotlin.incremental.wasm=true
13+
14+
#Android
15+
android.useAndroidX=true
16+
android.nonTransitiveRClass=true
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
pluginManagement {
2+
repositories {
3+
mavenLocal()
4+
gradlePluginPortal()
5+
google()
6+
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
7+
}
8+
plugins {
9+
id("com.android.kotlin.multiplatform.library").version("AGP_VERSION_PLACEHOLDER")
10+
id("com.android.application").version("AGP_VERSION_PLACEHOLDER")
11+
id("org.jetbrains.kotlin.multiplatform").version("KOTLIN_VERSION_PLACEHOLDER")
12+
id("org.jetbrains.kotlin.android").version("KOTLIN_VERSION_PLACEHOLDER")
13+
id("org.jetbrains.kotlin.jvm").version("KOTLIN_VERSION_PLACEHOLDER")
14+
id("org.jetbrains.kotlin.plugin.compose").version("KOTLIN_VERSION_PLACEHOLDER")
15+
id("org.jetbrains.compose").version("COMPOSE_GRADLE_PLUGIN_VERSION_PLACEHOLDER")
16+
}
17+
}
18+
dependencyResolutionManagement {
19+
repositories {
20+
mavenLocal()
21+
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
22+
mavenCentral()
23+
gradlePluginPortal()
24+
google()
25+
}
26+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package org.company.app
2+
3+
import androidx.compose.foundation.text.BasicText
4+
import androidx.compose.runtime.Composable
5+
6+
@Composable
7+
fun App() {
8+
BasicText("test")
9+
}

0 commit comments

Comments
 (0)