Skip to content

Commit 9d13c7b

Browse files
committed
Provide MainScope in DI.derived
1 parent 44fa6ed commit 9d13c7b

File tree

8 files changed

+74
-40
lines changed

8 files changed

+74
-40
lines changed

.github/workflows/build.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
runs-on: macos-latest
1717
steps:
1818
- name: Checkout
19-
uses: actions/checkout@v4
19+
uses: actions/checkout@v5
2020
- name: Validate Gradle Wrapper
2121
uses: gradle/actions/wrapper-validation@v4
2222
- name: Install JDK

.github/workflows/docs.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
runs-on: macos-latest
2929
steps:
3030
- name: Checkout
31-
uses: actions/checkout@v4
31+
uses: actions/checkout@v5
3232
with:
3333
ref: ${{ inputs.ref }}
3434
- name: Install JDK

build-logic/build-logic-base/src/main/kotlin/com/ensody/buildlogic/AndroidExt.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ fun Project.setupAndroid(
3636
testOptions {
3737
// Needed for Robolectric
3838
unitTests {
39-
isIncludeAndroidResources = true
39+
// TODO: Remove this workaround for https://issuetracker.google.com/issues/411739086 once fixed in AGP
40+
isIncludeAndroidResources = listOf("androidUnitTest", "test").any { name ->
41+
val sourceSet = file("src/$name")
42+
sourceSet.exists() && sourceSet.walkTopDown().any { it.extension == "kt" }
43+
}
4044
}
4145
}
4246

build-logic/build-logic/src/main/kotlin/com/ensody/buildlogic/BuildLogicPlugin.kt

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,24 +65,18 @@ fun Project.setupBuildLogic(block: Project.() -> Unit) {
6565
implementation(rootLibs.findLibrary("junit").get())
6666
}
6767
}
68-
// testDebugUnitTest throws an error if there are no tests
69-
val hasTests = file("src").listFiles().orEmpty().any { sourceSet ->
70-
sourceSet.name.endsWith("Test") && sourceSet.walkTopDown().any { it.extension == "kt" }
71-
}
7268
tasks.register("testAll") {
7369
group = "verification"
74-
if (hasTests) {
75-
dependsOn(
76-
"testDebugUnitTest",
77-
"jvmTest",
78-
"iosSimulatorArm64Test",
79-
"iosX64Test",
80-
"macosArm64Test",
81-
"macosX64Test",
82-
"mingwX64Test",
83-
"linuxX64Test",
84-
)
85-
}
70+
dependsOn(
71+
"testDebugUnitTest",
72+
"jvmTest",
73+
"iosSimulatorArm64Test",
74+
"iosX64Test",
75+
"macosArm64Test",
76+
"macosX64Test",
77+
"mingwX64Test",
78+
"linuxX64Test",
79+
)
8680
}
8781
}
8882
if (extensions.findByType<KotlinBaseExtension>() != null) {

reactivestate-compose/src/composeMain/kotlin/com/ensody/reactivestate/compose/ViewModelExt.kt

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,22 +76,14 @@ public inline fun <reified T : Any?> onViewModel(
7676
WrapperViewModel { viewModelScope ->
7777
// The viewModelScope can't be used directly because we have to destroy and re-create the ViewModel whenever
7878
// the DI graph gets modified.
79-
val mutex = Mutex()
80-
var scope: CoroutineScope? = null
79+
var scopeRef: CoroutineScope? = null
8180
viewModelScope.invokeOnCompletion {
82-
mutex.withSpinLock {
83-
scope?.cancel()
84-
scope = null
85-
}
81+
scopeRef?.cancel()
8682
}
8783
DI.derived {
88-
val vmScope = MainScope()
89-
mutex.withSpinLock {
90-
scope?.cancel()
91-
scope = vmScope
92-
}
84+
scopeRef = scope
9385
ReactiveStateContext(
94-
vmScope + ContextualValRoot() + ContextualStateFlowStore.valued { InMemoryStateFlowStore(storage) },
86+
scope + ContextualValRoot() + ContextualStateFlowStore.valued { InMemoryStateFlowStore(storage) },
9587
this,
9688
).provider()
9789
}

reactivestate-core/src/commonMain/kotlin/com/ensody/reactivestate/DI.kt

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package com.ensody.reactivestate
22

33
import com.ensody.reactivestate.derived
4+
import kotlinx.coroutines.CoroutineScope
5+
import kotlinx.coroutines.MainScope
6+
import kotlinx.coroutines.cancel
47
import kotlinx.coroutines.flow.MutableStateFlow
58
import kotlinx.coroutines.flow.StateFlow
69
import kotlinx.coroutines.flow.getAndUpdate
10+
import kotlinx.coroutines.sync.Mutex
711
import kotlin.reflect.KClass
812
import com.ensody.reactivestate.derived as realDerived
913

@@ -111,11 +115,23 @@ public class DIImpl {
111115
public fun <T> derived(factory: DIResolver.() -> T): StateFlow<T> =
112116
derived(null, factory)
113117

114-
private fun <T> derived(klass: KClass<*>?, factory: DIResolver.() -> T): StateFlow<T> =
115-
realDerived {
116-
val resolver = DIResolverImpl(this, this@DIImpl, klass)
118+
private fun <T> derived(klass: KClass<*>?, factory: DIResolver.() -> T): StateFlow<T> {
119+
val mutex = Mutex()
120+
var scope: CoroutineScope? = null
121+
var lastResolver: DIResolverImpl? = null
122+
return realDerived {
123+
val nextScope = MainScope()
124+
mutex.withSpinLock {
125+
scope?.cancel()
126+
scope = nextScope
127+
}
128+
val resolver = DIResolverImpl(this, nextScope, this@DIImpl, klass).also {
129+
lastResolver?.scope?.cancel()
130+
lastResolver = it
131+
}
117132
resolver.factory().also { resolver.ready = true }
118133
}
134+
}
119135

120136
// We hide this function as an extension, so nobody can mistakenly get() arbitrary T values not belonging to the DI
121137
public inline fun <reified T : Any> DIResolver.get(noinline default: (() -> T)? = null): LazyProperty<T> =
@@ -171,6 +187,8 @@ public object InternalDI
171187
public interface DIResolver : Resolver {
172188
public val DI: DIImpl
173189

190+
public val scope: CoroutineScope
191+
174192
@InternalReactiveStateApi
175193
public val InternalDI.owner: KClass<*>?
176194

@@ -184,6 +202,7 @@ public fun <T> DIResolver.get(lazyProperty: LazyProperty<T>): T =
184202

185203
private class DIResolverImpl(
186204
delegate: Resolver,
205+
override val scope: CoroutineScope,
187206
override val DI: DIImpl,
188207
private val klass: KClass<*>?,
189208
var ready: Boolean = false,

reactivestate-core/src/commonMain/kotlin/com/ensody/reactivestate/ReactiveStateContext.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import kotlin.coroutines.CoroutineContext
99
*/
1010
@ExperimentalReactiveStateApi
1111
public data class ReactiveStateContext(
12-
public val scope: CoroutineScope,
12+
override val scope: CoroutineScope,
1313
private val resolver: DIResolver,
1414
) : DIResolver by resolver {
1515
public operator fun plus(element: CoroutineContext.Element): ReactiveStateContext =

reactivestate-core/src/commonTest/kotlin/com/ensody/reactivestate/DITest.kt

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
package com.ensody.reactivestate
22

3+
import com.ensody.reactivestate.test.CoroutineTest
4+
import kotlinx.coroutines.CoroutineScope
35
import kotlinx.coroutines.flow.MutableStateFlow
6+
import kotlinx.coroutines.test.runCurrent
47
import kotlin.test.Test
8+
import kotlin.test.assertEquals
59
import kotlin.test.assertFailsWith
610
import kotlin.test.assertFalse
711
import kotlin.test.assertNotSame
812
import kotlin.test.assertSame
913
import kotlin.test.assertTrue
1014

11-
internal class DITest {
15+
internal class DITest : CoroutineTest() {
1216
val someConfigFlag = MutableStateFlow(true)
1317
val testDI = DIImpl()
18+
var destroyedBar = 0
1419

1520
init {
1621
testDI.register { FooDeps(get(someConfigFlag), barDeps, defaultDeps) }
17-
testDI.register { BarDeps(fooDeps) }
22+
testDI.register { BarDeps(fooDeps, scope.apply { invokeOnCompletion { destroyedBar += 1 } }) }
1823
}
1924

2025
private val defaultFlow = testDI.derived { get(defaultDeps) }
@@ -30,6 +35,7 @@ internal class DITest {
3035
assertSame(default, foo.defaultDeps)
3136
assertSame(foo, testDI.derived { get(fooDeps) }.value)
3237
assertSame(bar, testDI.derived { get(barDeps) }.value)
38+
assertEquals(0, destroyedBar)
3339
}
3440

3541
@Test
@@ -47,23 +53,33 @@ internal class DITest {
4753
}
4854

4955
@Test
50-
fun updateDIGraphOnRegister() {
56+
fun updateDIGraphOnRegister() = runTest {
5157
// Replacing FooDeps invalidates the whole subgraph depending on FooDeps. So, BarDeps gets re-created.
5258
testDI.register { FooDeps(get(someConfigFlag), barDeps, defaultDeps) }
5359
assertNotSame(foo, testDI.derived { get(fooDeps) }.value)
5460
assertSame(default, testDI.derived { get(fooDeps) }.value.defaultDeps)
5561
val newBar = testDI.derived { get(barDeps) }.value
5662
assertNotSame(bar, newBar)
5763
assertTrue(foo.circularConfigFlag)
64+
runCurrent()
65+
assertEquals(1, destroyedBar)
5866

5967
// Any flow also auto-updates
6068
assertSame(fooFlow.value, testDI.derived { get(fooDeps) }.value)
6169
assertSame(newBar, testDI.derived { get(barDeps) }.value)
6270
assertSame(barFlow.value, testDI.derived { get(barDeps) }.value)
71+
72+
runCurrent()
73+
assertEquals(1, destroyedBar)
74+
75+
testDI.register { FooDeps(get(someConfigFlag), barDeps, defaultDeps) }
76+
assertNotSame(newBar, testDI.derived { get(barDeps) }.value)
77+
runCurrent()
78+
assertEquals(2, destroyedBar)
6379
}
6480

6581
@Test
66-
fun updateDIGraphOnStateFlowChange() {
82+
fun updateDIGraphOnStateFlowChange() = runTest {
6783
// Changing the StateFlow that FooDeps depends on also invalidates FooDeps and BarDeps
6884
someConfigFlag.value = false
6985
assertNotSame(foo, testDI.derived { get(fooDeps) }.value)
@@ -74,6 +90,15 @@ internal class DITest {
7490
assertFalse(fooFlow.value.circularConfigFlag)
7591
assertSame(fooFlow.value, testDI.derived { get(fooDeps) }.value)
7692
assertSame(barFlow.value, testDI.derived { get(barDeps) }.value)
93+
runCurrent()
94+
assertEquals(1, destroyedBar)
95+
96+
someConfigFlag.value = true
97+
assertNotSame(bar, testDI.derived { get(barDeps) }.value)
98+
assertSame(fooFlow.value, testDI.derived { get(fooDeps) }.value)
99+
assertSame(barFlow.value, testDI.derived { get(barDeps) }.value)
100+
runCurrent()
101+
assertEquals(2, destroyedBar)
77102
}
78103
}
79104

@@ -120,7 +145,7 @@ private class MyUseCase(val circularConfigFlag: Boolean)
120145
private val DIResolver.barDeps: LazyProperty<BarDeps> get() = DI.run { get() }
121146

122147
// Circular dependency to Foo, so we have
123-
private class BarDeps(lazyFooDeps: LazyProperty<FooDeps>) {
148+
private class BarDeps(lazyFooDeps: LazyProperty<FooDeps>, val scope: CoroutineScope) {
124149
val fooDeps: FooDeps by lazyFooDeps
125150

126151
val configFlag: Boolean by lazy { fooDeps.configFlag }

0 commit comments

Comments
 (0)