diff --git a/CHANGELOG.md b/CHANGELOG.md index 96635f4c..43124189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Changed: - The deprecated `iosX64`, `macosX64`, `tvosX64`, and `watchosX64` targets have been removed. +Fixed: +- Cancelling a coroutine context during Molecule startup no longer causes `launchMolecule` to call `setContent` on a disposed composition. + ## [2.2.0] - 2025-09-24 [2.2.0]: https://github.com/cashapp/molecule/releases/tag/2.2.0 diff --git a/molecule-runtime/src/commonMain/kotlin/app/cash/molecule/molecule.kt b/molecule-runtime/src/commonMain/kotlin/app/cash/molecule/molecule.kt index b1c19784..ca933269 100644 --- a/molecule-runtime/src/commonMain/kotlin/app/cash/molecule/molecule.kt +++ b/molecule-runtime/src/commonMain/kotlin/app/cash/molecule/molecule.kt @@ -231,34 +231,39 @@ public fun CoroutineScope.launchMolecule( val composition = Composition(UnitApplier, recomposer) var snapshotHandle: ObserverHandle? = null - launch(finalContext, start = UNDISPATCHED) { - try { - recomposer.runRecomposeAndApplyChanges() - } finally { - composition.dispose() - snapshotHandle?.dispose() - } + val recomposerJob = launch(finalContext, start = UNDISPATCHED) { + recomposer.runRecomposeAndApplyChanges() } - when (snapshotNotifier) { - SnapshotNotifier.External -> {} + try { + when (snapshotNotifier) { + SnapshotNotifier.External -> {} - SnapshotNotifier.WhileActive -> { - var applyScheduled = false - snapshotHandle = Snapshot.registerGlobalWriteObserver { - if (!applyScheduled) { - applyScheduled = true - launch(finalContext) { - applyScheduled = false - Snapshot.sendApplyNotifications() + SnapshotNotifier.WhileActive -> { + var applyScheduled = false + snapshotHandle = Snapshot.registerGlobalWriteObserver { + if (!applyScheduled) { + applyScheduled = true + launch(finalContext) { + applyScheduled = false + Snapshot.sendApplyNotifications() + } } } } } - } - composition.setContent { - emitter(body()) + composition.setContent { + emitter(body()) + } + } catch (throwable: Throwable) { + recomposer.cancel() + throw throwable + } finally { + recomposerJob.invokeOnCompletion { + composition.dispose() + snapshotHandle?.dispose() + } } } diff --git a/molecule-runtime/src/commonTest/kotlin/app/cash/molecule/MoleculeStateFlowTest.kt b/molecule-runtime/src/commonTest/kotlin/app/cash/molecule/MoleculeStateFlowTest.kt index 8cd1dd8f..d82471e0 100644 --- a/molecule-runtime/src/commonTest/kotlin/app/cash/molecule/MoleculeStateFlowTest.kt +++ b/molecule-runtime/src/commonTest/kotlin/app/cash/molecule/MoleculeStateFlowTest.kt @@ -25,6 +25,7 @@ import app.cash.molecule.RecompositionMode.ContextClock import app.cash.molecule.RecompositionMode.Immediate import assertk.assertFailure import assertk.assertThat +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isSameInstanceAs import assertk.assertions.isTrue @@ -99,10 +100,33 @@ class MoleculeStateFlowTest { } }.isSameInstanceAs(runtimeException) - // This exception is processed in `composeInitial` and not `runRecomposeAndApplyChanges`, so the job is still active. + runCurrent() + assertThat(job.children.toList()).isEmpty() job.cancelAndJoin() } + @Test fun cancelledContextComposesInitialValueBeforeStopping() = runTest { + for (mode in listOf(ContextClock, Immediate)) { + val job = Job() + val scope = CoroutineScope(coroutineContext + BroadcastFrameClock()) + var effectRan = false + + job.cancel() + + val flow = scope.launchMolecule(mode, context = job) { + LaunchedEffect(Unit) { + effectRan = true + } + 1 + } + assertThat(flow.value).isEqualTo(1) + + runCurrent() + assertThat(effectRan).isEqualTo(false) + assertThat(job.children.toList()).isEmpty() + } + } + @Test fun errorDelayed() = runTest { val job = Job() val clock = BroadcastFrameClock() diff --git a/molecule-runtime/src/commonTest/kotlin/app/cash/molecule/MoleculeTest.kt b/molecule-runtime/src/commonTest/kotlin/app/cash/molecule/MoleculeTest.kt index 9d1d2e43..0b30ac09 100644 --- a/molecule-runtime/src/commonTest/kotlin/app/cash/molecule/MoleculeTest.kt +++ b/molecule-runtime/src/commonTest/kotlin/app/cash/molecule/MoleculeTest.kt @@ -34,6 +34,7 @@ import app.cash.molecule.SnapshotNotifier.External import app.cash.molecule.SnapshotNotifier.WhileActive import assertk.assertFailure import assertk.assertThat +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isNotSameInstanceAs import assertk.assertions.isSameInstanceAs @@ -110,10 +111,29 @@ class MoleculeTest { } }.isSameInstanceAs(runtimeException) - // This exception is processed in `composeInitial` and not `runRecomposeAndApplyChanges`, so the job is still active. + runCurrent() + assertThat(job.children.toList()).isEmpty() job.cancelAndJoin() } + @Test fun cancelledContextComposesInitialValueBeforeStopping() = runTest { + for (mode in listOf(ContextClock, Immediate)) { + val job = Job() + val scope = CoroutineScope(coroutineContext + BroadcastFrameClock()) + var value = 0 + + job.cancel() + + scope.launchMolecule(mode, emitter = { value = it }, context = job) { + 1 + } + runCurrent() + + assertThat(value).isEqualTo(1) + assertThat(job.children.toList()).isEmpty() + } + } + @Test fun errorDelayed() = runTest { val job = Job() val clock = BroadcastFrameClock() @@ -184,7 +204,8 @@ class MoleculeTest { } }.isSameInstanceAs(runtimeException) - // This exception is processed in `composeInitial` and not `runRecomposeAndApplyChanges`, so the job is still active. + runCurrent() + assertThat(job.children.toList()).isEmpty() job.cancelAndJoin() }