diff --git a/compose/koject-compose-core/build.gradle.kts b/compose/koject-compose-core/build.gradle.kts index b2e6cd2e..b442fdc4 100644 --- a/compose/koject-compose-core/build.gradle.kts +++ b/compose/koject-compose-core/build.gradle.kts @@ -28,6 +28,7 @@ kotlin { dependencies { api(project(":koject-core")) implementation(compose.runtime) + implementation(libs.kotlinx.coroutines.core) } } diff --git a/compose/koject-compose-core/src/androidMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.android.kt b/compose/koject-compose-core/src/androidMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.android.kt index 2ade463f..0fe27c4e 100644 --- a/compose/koject-compose-core/src/androidMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.android.kt +++ b/compose/koject-compose-core/src/androidMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.android.kt @@ -6,18 +6,25 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import com.moriatsushi.koject.ExperimentalKojectApi import com.moriatsushi.koject.component.ComponentExtras +import kotlinx.coroutines.CoroutineScope @OptIn(ExperimentalKojectApi::class) internal actual class ComposeComponentExtras( @ComposeContext val context: Context, -) : ComponentExtras + private val coroutineScopeHolder: CoroutineScopeHolder, +) : ComponentExtras { + @ComposeCoroutineScope + actual val coroutineScope: CoroutineScope + get() = coroutineScopeHolder.coroutineScope +} @PublishedApi @Composable internal actual fun composeComponentExtras(): ComposeComponentExtras { val context = LocalContext.current + val coroutineScopeHolder = rememberCoroutineScopeHolder() return remember(context) { - ComposeComponentExtras(context) + ComposeComponentExtras(context, coroutineScopeHolder) } } diff --git a/compose/koject-compose-core/src/commonMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt b/compose/koject-compose-core/src/commonMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt index 98a4fa88..c433e928 100644 --- a/compose/koject-compose-core/src/commonMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt +++ b/compose/koject-compose-core/src/commonMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt @@ -3,9 +3,13 @@ package com.moriatsushi.koject.compose import androidx.compose.runtime.Composable import com.moriatsushi.koject.ExperimentalKojectApi import com.moriatsushi.koject.component.ComponentExtras +import kotlinx.coroutines.CoroutineScope @OptIn(ExperimentalKojectApi::class) -internal expect class ComposeComponentExtras : ComponentExtras +internal expect class ComposeComponentExtras : ComponentExtras { + @ComposeCoroutineScope + val coroutineScope: CoroutineScope +} @Composable @PublishedApi diff --git a/compose/koject-compose-core/src/commonMain/kotlin/com/moriatsushi/koject/compose/ComposeCoroutineScope.kt b/compose/koject-compose-core/src/commonMain/kotlin/com/moriatsushi/koject/compose/ComposeCoroutineScope.kt new file mode 100644 index 00000000..9c1bd3df --- /dev/null +++ b/compose/koject-compose-core/src/commonMain/kotlin/com/moriatsushi/koject/compose/ComposeCoroutineScope.kt @@ -0,0 +1,13 @@ +package com.moriatsushi.koject.compose + +import androidx.compose.runtime.rememberCoroutineScope +import com.moriatsushi.koject.Qualifier +import kotlinx.coroutines.CoroutineScope + +/** + * Get a Composable [CoroutineScope] that the same as [rememberCoroutineScope]. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +@MustBeDocumented +annotation class ComposeCoroutineScope diff --git a/compose/koject-compose-core/src/commonMain/kotlin/com/moriatsushi/koject/compose/CoroutineScopeHolder.kt b/compose/koject-compose-core/src/commonMain/kotlin/com/moriatsushi/koject/compose/CoroutineScopeHolder.kt new file mode 100644 index 00000000..569fc29e --- /dev/null +++ b/compose/koject-compose-core/src/commonMain/kotlin/com/moriatsushi/koject/compose/CoroutineScopeHolder.kt @@ -0,0 +1,45 @@ +package com.moriatsushi.koject.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.remember +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel + +internal interface CoroutineScopeHolder { + val coroutineScope: CoroutineScope +} + +private class CoroutineScopeHolderImpl( + private val coroutineContext: CoroutineContext, +) : CoroutineScopeHolder, RememberObserver { + private var _coroutineScope: CoroutineScope? = null + override val coroutineScope: CoroutineScope + get() = _coroutineScope ?: CoroutineScope(coroutineContext).also { + _coroutineScope = it + } + + override fun onRemembered() { + // no op + } + + override fun onForgotten() { + _coroutineScope?.cancel() + } + + override fun onAbandoned() { + _coroutineScope?.cancel() + } +} + +@Composable +internal fun rememberCoroutineScopeHolder(): CoroutineScopeHolder { + return remember { + CoroutineScopeHolderImpl( + Job() + Dispatchers.Main.immediate, + ) + } +} diff --git a/compose/koject-compose-core/src/desktopMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.desktop.kt b/compose/koject-compose-core/src/desktopMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.desktop.kt index b6126279..f3097fc1 100644 --- a/compose/koject-compose-core/src/desktopMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.desktop.kt +++ b/compose/koject-compose-core/src/desktopMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.desktop.kt @@ -1,16 +1,25 @@ package com.moriatsushi.koject.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.moriatsushi.koject.ExperimentalKojectApi import com.moriatsushi.koject.component.ComponentExtras +import kotlinx.coroutines.CoroutineScope @OptIn(ExperimentalKojectApi::class) -internal actual class ComposeComponentExtras : ComponentExtras - -private val instance = ComposeComponentExtras() +internal actual class ComposeComponentExtras( + private val coroutineScopeHolder: CoroutineScopeHolder, +) : ComponentExtras { + @ComposeCoroutineScope + actual val coroutineScope: CoroutineScope + get() = coroutineScopeHolder.coroutineScope +} @PublishedApi @Composable internal actual fun composeComponentExtras(): ComposeComponentExtras { - return instance + val coroutineScopeHolder = rememberCoroutineScopeHolder() + return remember { + ComposeComponentExtras(coroutineScopeHolder) + } } diff --git a/compose/koject-compose-core/src/jsMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt b/compose/koject-compose-core/src/jsMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt index b6126279..f3097fc1 100644 --- a/compose/koject-compose-core/src/jsMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt +++ b/compose/koject-compose-core/src/jsMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt @@ -1,16 +1,25 @@ package com.moriatsushi.koject.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.moriatsushi.koject.ExperimentalKojectApi import com.moriatsushi.koject.component.ComponentExtras +import kotlinx.coroutines.CoroutineScope @OptIn(ExperimentalKojectApi::class) -internal actual class ComposeComponentExtras : ComponentExtras - -private val instance = ComposeComponentExtras() +internal actual class ComposeComponentExtras( + private val coroutineScopeHolder: CoroutineScopeHolder, +) : ComponentExtras { + @ComposeCoroutineScope + actual val coroutineScope: CoroutineScope + get() = coroutineScopeHolder.coroutineScope +} @PublishedApi @Composable internal actual fun composeComponentExtras(): ComposeComponentExtras { - return instance + val coroutineScopeHolder = rememberCoroutineScopeHolder() + return remember { + ComposeComponentExtras(coroutineScopeHolder) + } } diff --git a/compose/koject-compose-core/src/nativeMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt b/compose/koject-compose-core/src/nativeMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt index b6126279..f3097fc1 100644 --- a/compose/koject-compose-core/src/nativeMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt +++ b/compose/koject-compose-core/src/nativeMain/kotlin/com/moriatsushi/koject/compose/ComposeComponentExtras.kt @@ -1,16 +1,25 @@ package com.moriatsushi.koject.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import com.moriatsushi.koject.ExperimentalKojectApi import com.moriatsushi.koject.component.ComponentExtras +import kotlinx.coroutines.CoroutineScope @OptIn(ExperimentalKojectApi::class) -internal actual class ComposeComponentExtras : ComponentExtras - -private val instance = ComposeComponentExtras() +internal actual class ComposeComponentExtras( + private val coroutineScopeHolder: CoroutineScopeHolder, +) : ComponentExtras { + @ComposeCoroutineScope + actual val coroutineScope: CoroutineScope + get() = coroutineScopeHolder.coroutineScope +} @PublishedApi @Composable internal actual fun composeComponentExtras(): ComposeComponentExtras { - return instance + val coroutineScopeHolder = rememberCoroutineScopeHolder() + return remember { + ComposeComponentExtras(coroutineScopeHolder) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5299a964..73d845ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ compose-multiplatform = "1.3.1" kotlin = "1.8.10" kotlin-compile-testing = "1.5.0" kotlinpoet = "1.12.0" +kotlinx-coroutines = "1.6.4" ksp = "1.8.10-1.0.9" ktlint = "0.48.1" mockito-kotlin = "4.1.0" @@ -33,6 +34,7 @@ androidx-test-core = { group = "androidx.test", name = "core-ktx", version.ref = androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidx-test-ext-junit" } kotlin-compile-testing-ksp = { group = "com.github.tschuchortdev", name = "kotlin-compile-testing-ksp", version.ref = "kotlin-compile-testing" } kotlinpoet-ksp = { group = "com.squareup", name = "kotlinpoet-ksp", version.ref = "kotlinpoet" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } ksp-processor-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" } mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito-kotlin" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } diff --git a/integration-test/compose/build.gradle.kts b/integration-test/compose/build.gradle.kts index 4dd95f19..4519ee18 100644 --- a/integration-test/compose/build.gradle.kts +++ b/integration-test/compose/build.gradle.kts @@ -22,6 +22,7 @@ kotlin { dependencies { api(project(":compose:koject-compose-core")) implementation(compose.runtime) + implementation(libs.kotlinx.coroutines.core) } } diff --git a/integration-test/compose/src/commonMain/kotlin/com/moriatsushi/koject/integrationtest/compose/Types.kt b/integration-test/compose/src/commonMain/kotlin/com/moriatsushi/koject/integrationtest/compose/Types.kt index d8c51b93..1a212c06 100644 --- a/integration-test/compose/src/commonMain/kotlin/com/moriatsushi/koject/integrationtest/compose/Types.kt +++ b/integration-test/compose/src/commonMain/kotlin/com/moriatsushi/koject/integrationtest/compose/Types.kt @@ -3,6 +3,8 @@ package com.moriatsushi.koject.integrationtest.compose import com.moriatsushi.koject.Provides import com.moriatsushi.koject.Singleton import com.moriatsushi.koject.compose.ComposeComponent +import com.moriatsushi.koject.compose.ComposeCoroutineScope +import kotlinx.coroutines.CoroutineScope @Provides class CommonClass @@ -20,3 +22,10 @@ class ForComposeClass class ForComposeHolder( value: ForComposeClass, ) + +@ComposeComponent +@Provides +class ForComposeWithCoroutineScope( + @ComposeCoroutineScope + val scope: CoroutineScope, +) diff --git a/integration-test/compose/src/jsTest/kotlin/com/moriatsushi/koject/integrationtest/compose/ComposeCoroutineScopeTest.kt b/integration-test/compose/src/jsTest/kotlin/com/moriatsushi/koject/integrationtest/compose/ComposeCoroutineScopeTest.kt new file mode 100644 index 00000000..3de1ed5c --- /dev/null +++ b/integration-test/compose/src/jsTest/kotlin/com/moriatsushi/koject/integrationtest/compose/ComposeCoroutineScopeTest.kt @@ -0,0 +1,51 @@ +package com.moriatsushi.koject.integrationtest.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.moriatsushi.koject.Koject +import com.moriatsushi.koject.compose.rememberInject +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.isActive +import org.jetbrains.compose.web.testutils.ComposeWebExperimentalTestsApi +import org.jetbrains.compose.web.testutils.runTest + +@OptIn(ComposeWebExperimentalTestsApi::class) +class ComposeCoroutineScopeTest { + @Test + fun successInject() = Koject.runTest { + runTest { + var value: ForComposeWithCoroutineScope? = null + + composition { + value = rememberInject() + } + + assertNotNull(value) + } + } + + @Test + fun cancelScope_whenHidden() = Koject.runTest { + runTest { + var visible by mutableStateOf(true) + var value: ForComposeWithCoroutineScope? = null + + composition { + if (visible) { + value = rememberInject() + } + } + + assertTrue(value!!.scope.isActive) + + visible = false + waitForRecompositionComplete() + + assertFalse(value!!.scope.isActive) + } + } +} diff --git a/integration-test/compose/src/jvmTest/kotlin/com/moriatsushi/koject/integrationtest/compose/ComposeCoroutineScopeTest.kt b/integration-test/compose/src/jvmTest/kotlin/com/moriatsushi/koject/integrationtest/compose/ComposeCoroutineScopeTest.kt new file mode 100644 index 00000000..4ec55ed4 --- /dev/null +++ b/integration-test/compose/src/jvmTest/kotlin/com/moriatsushi/koject/integrationtest/compose/ComposeCoroutineScopeTest.kt @@ -0,0 +1,51 @@ +package com.moriatsushi.koject.integrationtest.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.moriatsushi.koject.Koject +import com.moriatsushi.koject.compose.rememberInject +import com.moriatsushi.koject.integrationtest.compose.junit.RunWith +import com.moriatsushi.koject.integrationtest.compose.junit.UITestRunner +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.isActive +import org.junit.Rule +import org.junit.Test + +@RunWith(UITestRunner::class) +class ComposeCoroutineScopeTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun successInject() = Koject.runTest { + var value: ForComposeWithCoroutineScope? = null + + composeTestRule.setContent { + value = rememberInject() + } + + assertNotNull(value) + } + + @Test + fun cancelScope_whenHidden() = Koject.runTest { + var visible by mutableStateOf(true) + var value: ForComposeWithCoroutineScope? = null + + composeTestRule.setContent { + if (visible) { + value = rememberInject() + } + } + + assertTrue(value!!.scope.isActive) + + visible = false + composeTestRule.waitForIdle() + + assertFalse(value!!.scope.isActive) + } +}