diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 96e5e983..00000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-name: Bug report
-about: Report a bug or an issue with existing features
-title: "[\U0001F41E] "
-labels: bug, triage
-assignees: Nek-12
-
----
-
-### Description (required):
-describe the difference between expected and actual behavior, if not evident
-
-
-
-### Steps to reproduce (required)
-Steps to reproduce the behavior:
-
-1. ...
-
----
-
-
- Stacktrace (if applicable):
-
-```plaintext
-
-```
-
-
-
----
-
-
- Relevant code:
-
-```kotlin
-
-```
-
-
-
----
-
-- [ ] This issue hasn't been reported already
-- [ ] I have read the [documentation and FAQ](https://opensource.respawn.pro/FlowMVI/#/faq)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..52f02e1f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,41 @@
+name: Bug report
+description: Report a bug or an issue with existing features
+title: "š: "
+labels: [ triage ]
+assignees: [ Nek-12 ]
+type: 'bug'
+body:
+ - type: input
+ attributes:
+ label: FlowMVI Version
+ validations:
+ required: true
+ - type: input
+ attributes:
+ label: Kotlin Version
+ validations:
+ required: true
+ - type: checkboxes
+ attributes:
+ label: Platforms
+ description: If not sure, select the platform where you reproduced this issue on
+ options:
+ - label: 'Android'
+ - label: 'iOS'
+ - label: 'macOS'
+ - label: 'Linux'
+ - label: 'Windows'
+ - label: 'JS'
+ - label: 'Wasm'
+ - type: checkboxes
+ attributes:
+ label: Before you submit
+ validations:
+ required: true
+ options:
+ - label: 'I have read the FAQ and documentation'
+ - label: 'I have used search to find similar issues already reported'
+ - type: textarea
+ description: Provide more details about what happened. Reproducers or examples will greatly speed up the resolution.
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 5672751a..00000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-name: Feature request
-about: Suggest an idea for this project
-title: "[\U0001F680] "
-labels: feature, triage
-assignees: Nek-12
-
----
-
-### Description (required)
-describe what you are trying to solve and what is missing
-
----
-
-
- Relevant code (if applicable):
-
-```kotlin
-
-```
-
-
-
----
-
-- [ ] This issue hasn't been reported already
-- [ ] I have read the [documentation and FAQ](https://opensource.respawn.pro/FlowMVI/#/faq)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000..a3d7bb6d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,11 @@
+name: Feature request
+description: Request a new feature
+title: "š: "
+labels: [ triage ]
+assignees: [ Nek-12 ]
+type: 'feature'
+body:
+ - type: textarea
+ description: What do you want to see being implemented? Code examples will be greatly appreciated.
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
new file mode 100644
index 00000000..397dded9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -0,0 +1,19 @@
+name: Ask a question
+title: "ā: "
+description: Ask a question, request documentation, or something else
+labels: [ triage ]
+assignees: [ Nek-12 ]
+type: 'task'
+body:
+ - type: textarea
+ description: What do you want to know?
+ validations:
+ required: true
+ - type: checkboxes
+ attributes:
+ label: Before you submit
+ validations:
+ required: true
+ options:
+ - label: 'I have read the FAQ and documentation'
+ - label: 'I have used search to see if this question has already been answered'
diff --git a/.github/ISSUE_TEMPLATE/something-else.md b/.github/ISSUE_TEMPLATE/something-else.md
deleted file mode 100644
index e43c9a52..00000000
--- a/.github/ISSUE_TEMPLATE/something-else.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-name: Something else
-about: Ask a question, request documentation, or something else
-title: "[ā] "
-labels: question, triage
-assignees: Nek-12
-
----
-
----
-
-- [ ] This question or issue hasn't been asked or reported already via Discussions or Issues
-- [ ] I have read the [documentation and FAQ](https://opensource.respawn.pro/FlowMVI/#/faq)
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
new file mode 100644
index 00000000..8d6b2a52
--- /dev/null
+++ b/.github/workflows/benchmarks.yml
@@ -0,0 +1,42 @@
+name: CI
+
+on:
+ push:
+ branches: [ master ]
+
+jobs:
+ benchmark:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Copy CI gradle.properties
+ run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
+
+ - name: set up JDK
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ check-latest: true
+ java-version: 22
+ cache: 'gradle'
+
+ - name: Validate gradle wrapper
+ uses: gradle/actions/wrapper-validation@v4
+
+ - name: Create local properties
+ env:
+ LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }}
+ run: echo "$LOCAL_PROPERTIES" | base64 --decode > local.properties
+
+ - name: Cache konan directory
+ uses: actions/cache@v4
+ with:
+ path: ~/.konan
+ key: ${{ runner.os }}-konan-${{ hashFiles('*.gradle.kts', 'buildSrc/*') }}
+ restore-keys: |
+ ${{ runner.os }}-konan-
+
+ - name: Run benchmarks
+ run: ./gradlew benchmarks:benchmark
diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml
index 7cef95be..17d3850f 100644
--- a/.github/workflows/desktop-linux.yml
+++ b/.github/workflows/desktop-linux.yml
@@ -1,17 +1,24 @@
name: debugger-linux
on:
- push:
- branches: [ master ]
+ workflow_call:
+ outputs:
+ debugger:
+ value: ${{ jobs.publish.outputs.debugger-url }}
+ sample:
+ value: ${{ jobs.publish.outputs.sample-url }}
concurrency:
group: "publish-linux"
cancel-in-progress: true
jobs:
- publish-windows:
+ publish:
runs-on: ubuntu-latest
environment: publishing
+ outputs:
+ debugger-url: ${{ steps.upload-debugger.outputs.artifact-url }}
+ sample-url: ${{ steps.upload-sample.outputs.artifact-url }}
steps:
- uses: actions/checkout@v4
@@ -49,17 +56,21 @@ jobs:
run: ./gradlew sample:packageDistributionForCurrentOS
- name: Upload debugger
+ id: upload-debugger
uses: actions/upload-artifact@v4.4.3
with:
name: Debugger_Linux
path: ./debugger/app/build/compose/binaries/main/deb/*
if-no-files-found: error
+ compression-level: 0
overwrite: false
- name: Upload sample
+ id: upload-sample
uses: actions/upload-artifact@v4.4.3
with:
name: Sample_Linux
path: ./sample/build/compose/binaries/main/deb/*
if-no-files-found: error
+ compression-level: 0
overwrite: false
diff --git a/.github/workflows/desktop-macos.yml b/.github/workflows/desktop-macos.yml
index 93b54179..bc4eae64 100644
--- a/.github/workflows/desktop-macos.yml
+++ b/.github/workflows/desktop-macos.yml
@@ -1,17 +1,24 @@
name: debugger-macos
on:
- push:
- branches: [ master ]
+ workflow_call:
+ outputs:
+ debugger:
+ value: ${{ jobs.publish.outputs.debugger-url }}
+ sample:
+ value: ${{ jobs.publish.outputs.sample-url }}
concurrency:
group: "publish-macos"
cancel-in-progress: true
jobs:
- publish-windows:
+ publish:
runs-on: macos-latest
environment: publishing
+ outputs:
+ debugger-url: ${{ steps.upload-debugger.outputs.artifact-url }}
+ sample-url: ${{ steps.upload-sample.outputs.artifact-url }}
steps:
- uses: actions/checkout@v4
@@ -49,17 +56,21 @@ jobs:
run: ./gradlew sample:packageDistributionForCurrentOS
- name: Upload debugger
+ id: upload-debugger
uses: actions/upload-artifact@v4.4.3
with:
name: Debugger_MacOS
path: ./debugger/app/build/compose/binaries/main/dmg/*
if-no-files-found: error
+ compression-level: 0
overwrite: false
- name: Upload sample
+ id: upload-sample
uses: actions/upload-artifact@v4.4.3
with:
name: Sample_MacOS
path: ./sample/build/compose/binaries/main/dmg/*
if-no-files-found: error
+ compression-level: 0
overwrite: false
diff --git a/.github/workflows/desktop-win.yml b/.github/workflows/desktop-win.yml
index a17bc22f..c52078d1 100644
--- a/.github/workflows/desktop-win.yml
+++ b/.github/workflows/desktop-win.yml
@@ -1,17 +1,24 @@
name: debugger-windows
on:
- push:
- branches: [ master ]
+ workflow_call:
+ outputs:
+ debugger:
+ value: ${{ jobs.publish.outputs.debugger-url }}
+ sample:
+ value: ${{ jobs.publish.outputs.sample-url }}
concurrency:
group: "publish-win"
cancel-in-progress: true
jobs:
- publish-windows:
+ publish:
runs-on: windows-latest
environment: publishing
+ outputs:
+ debugger-url: ${{ steps.upload-debugger.outputs.artifact-url }}
+ sample-url: ${{ steps.upload-sample.outputs.artifact-url }}
steps:
- uses: actions/checkout@v4
@@ -50,17 +57,21 @@ jobs:
run: ./gradlew sample:packageDistributionForCurrentOS
- name: Upload debugger
+ id: upload-debugger
uses: actions/upload-artifact@v4.4.3
with:
name: Debugger_Windows
path: ./debugger/app/build/compose/binaries/main/exe/*
if-no-files-found: error
+ compression-level: 0
overwrite: false
- name: Upload sample
+ id: upload-sample
uses: actions/upload-artifact@v4.4.3
with:
name: Sample_Windows
path: ./sample/build/compose/binaries/main/exe/*
if-no-files-found: error
+ compression-level: 0
overwrite: false
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 94dc0f6e..200ce077 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -16,6 +16,13 @@ concurrency:
cancel-in-progress: true
jobs:
+ debugger-macos:
+ uses: ./.github/workflows/desktop-macos.yml
+ debugger-linux:
+ uses: ./.github/workflows/desktop-linux.yml
+ debugger-win:
+ uses: ./.github/workflows/desktop-win.yml
+
publish:
runs-on: macos-latest
@@ -72,6 +79,13 @@ jobs:
commitMode: true
configuration: ".github/changelog_config.json"
+ - name: Publish new plugin version
+ run: ./gradlew debugger:ideplugin:publishPlugin --no-configuration-cache
+ continue-on-error: true # TODO: Remove once verified works
+ env:
+ CHANGELOG: ${{steps.build_changelog.outputs.changelog}}
+
+ # TODO: Use matrix strat and attach artifacts to the release
- name: Create GH release
uses: ncipollo/release-action@v1.14.0
id: create_release
diff --git a/.idea/runConfigurations/All_benchmarks.xml b/.idea/runConfigurations/All_benchmarks.xml
new file mode 100644
index 00000000..a816025e
--- /dev/null
+++ b/.idea/runConfigurations/All_benchmarks.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+ false
+ true
+ false
+ true
+
+
+
\ No newline at end of file
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 80c5c53c..02d224bb 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.kotlinMultiplatform.id)
+ id(libs.plugins.kotlin.multiplatform.id)
id(libs.plugins.androidLibrary.id)
alias(libs.plugins.maven.publish)
dokkaDocumentation
diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts
new file mode 100644
index 00000000..58856dc3
--- /dev/null
+++ b/benchmarks/build.gradle.kts
@@ -0,0 +1,66 @@
+
+import kotlinx.benchmark.gradle.JvmBenchmarkTarget
+import kotlinx.benchmark.gradle.benchmark
+
+@Suppress("DSL_SCOPE_VIOLATION")
+plugins {
+ id(libs.plugins.kotlin.multiplatform.id)
+ alias(libs.plugins.kotlin.benchmark)
+ alias(libs.plugins.kotlin.allopen)
+}
+
+allOpen { // jmh benchmark classes must be open
+ annotation("org.openjdk.jmh.annotations.State")
+}
+kotlin {
+ configureMultiplatform(
+ ext = this,
+ explicitApi = false,
+ wasmWasi = false,
+ android = false,
+ linux = false,
+ iOs = false,
+ macOs = false,
+ watchOs = false,
+ tvOs = false,
+ windows = false,
+ wasmJs = false,
+ jvm = true,
+ )
+}
+tasks.withType().configureEach {
+ jvmArgs("-Dkotlinx.coroutines.debug=off")
+}
+dependencies {
+ commonMainImplementation(projects.core)
+
+ val fluxo = "0.1-2306082-SNAPSHOT"
+ //noinspection UseTomlInstead
+ commonMainImplementation("io.github.fluxo-kt:fluxo-core:$fluxo")
+
+ commonMainImplementation(libs.kotlin.coroutines.test)
+ commonMainImplementation(libs.kotlin.test)
+ commonMainImplementation(libs.kotlin.benchmark)
+}
+
+benchmark {
+ configurations {
+ named("main") {
+ iterations = 100
+ warmups = 20
+ iterationTime = 500
+ iterationTimeUnit = "ms"
+ outputTimeUnit = "us"
+ mode = "avgt" // "thrpt" - throughput, "avgt" - average
+ reportFormat = "text"
+ // advanced("nativeGCAfterIteration", true)
+ // advanced("jvmForks", "definedByJmh")
+ }
+ }
+ targets {
+ register("jvm") {
+ this as JvmBenchmarkTarget
+ jmhVersion = libs.versions.jmh.get()
+ }
+ }
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt
new file mode 100644
index 00000000..02618d75
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/BenchmarkDefaults.kt
@@ -0,0 +1,6 @@
+package pro.respawn.flowmvi.benchmarks
+
+internal object BenchmarkDefaults {
+
+ const val intentsPerIteration = 10000
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt
new file mode 100644
index 00000000..bf543844
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/Main.kt
@@ -0,0 +1,25 @@
+package pro.respawn.flowmvi.benchmarks
+
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.yield
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment
+import pro.respawn.flowmvi.benchmarks.setup.atomic.atomicStore
+
+/**
+ * run an infinite process for profiling
+ */
+fun main() = runBlocking {
+ println(ProcessHandle.current().pid())
+ val store = atomicStore(this)
+ launch {
+ while (isActive) {
+ store.intent(Increment)
+ yield()
+ }
+ }
+ awaitCancellation()
+ Unit
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkIntent.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkIntent.kt
new file mode 100644
index 00000000..7fe05393
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkIntent.kt
@@ -0,0 +1,7 @@
+package pro.respawn.flowmvi.benchmarks.setup
+
+import pro.respawn.flowmvi.api.MVIIntent
+
+internal sealed interface BenchmarkIntent : MVIIntent {
+ data object Increment : BenchmarkIntent
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkState.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkState.kt
new file mode 100644
index 00000000..39ebb288
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/BenchmarkState.kt
@@ -0,0 +1,7 @@
+package pro.respawn.flowmvi.benchmarks.setup
+
+import pro.respawn.flowmvi.api.MVIState
+
+internal data class BenchmarkState(
+ val counter: Int = 0
+) : MVIState
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicFMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicFMVIBenchmark.kt
new file mode 100644
index 00000000..3b8dd2f4
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicFMVIBenchmark.kt
@@ -0,0 +1,29 @@
+package pro.respawn.flowmvi.benchmarks.setup.atomic
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.openjdk.jmh.annotations.Benchmark
+import org.openjdk.jmh.annotations.Scope
+import org.openjdk.jmh.annotations.State
+import org.openjdk.jmh.annotations.Threads
+import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent
+import pro.respawn.flowmvi.dsl.collect
+
+@Threads(Threads.MAX)
+@Suppress("unused")
+@State(Scope.Benchmark)
+internal class AtomicFMVIBenchmark {
+
+ @Benchmark
+ fun benchmark() = runBlocking {
+ val store = atomicStore(this)
+ repeat(BenchmarkDefaults.intentsPerIteration) {
+ store.emit(BenchmarkIntent.Increment)
+ }
+ store.collect {
+ states.first { state -> state.counter == BenchmarkDefaults.intentsPerIteration }
+ }
+ store.closeAndWait()
+ }
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicStore.kt
new file mode 100644
index 00000000..259d6edb
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/atomic/AtomicStore.kt
@@ -0,0 +1,35 @@
+package pro.respawn.flowmvi.benchmarks.setup.atomic
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import pro.respawn.flowmvi.api.ActionShareBehavior
+import pro.respawn.flowmvi.api.StateStrategy
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState
+import pro.respawn.flowmvi.dsl.StoreBuilder
+import pro.respawn.flowmvi.dsl.store
+import pro.respawn.flowmvi.plugins.reduce
+
+private fun StoreBuilder<*, *, *>.config() = configure {
+ logger = null
+ debuggable = false
+ actionShareBehavior = ActionShareBehavior.Disabled
+ stateStrategy = StateStrategy.Atomic(reentrant = false)
+ parallelIntents = false
+ verifyPlugins = false
+ onOverflow = BufferOverflow.SUSPEND
+ intentCapacity = Channel.UNLIMITED
+}
+
+internal fun atomicStore(
+ scope: CoroutineScope
+) = store(BenchmarkState(), scope) {
+ config()
+ reduce {
+ when (it) {
+ is Increment -> updateState { copy(counter = counter + 1) }
+ }
+ }
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelBasedTraditionalStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelBasedTraditionalStore.kt
new file mode 100644
index 00000000..8b573082
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelBasedTraditionalStore.kt
@@ -0,0 +1,39 @@
+package pro.respawn.flowmvi.benchmarks.setup.channelbased
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState
+
+internal class ChannelBasedTraditionalStore(scope: CoroutineScope) {
+
+ private val _state = MutableStateFlow(BenchmarkState())
+ val state = _state.asStateFlow()
+ val intents = Channel(capacity = Channel.UNLIMITED, onBufferOverflow = BufferOverflow.SUSPEND)
+ private var job: Job? = null
+
+ init {
+ job = scope.launch {
+ for (intent in intents) reduce(intent)
+ }
+ }
+
+ fun onIntent(intent: BenchmarkIntent) = intents.trySend(intent)
+
+ private fun reduce(intent: BenchmarkIntent) = when (intent) {
+ is BenchmarkIntent.Increment -> _state.update { state ->
+ state.copy(counter = state.counter + 1)
+ }
+ }
+
+ suspend fun close() {
+ job!!.cancelAndJoin()
+ }
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelTraditionalMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelTraditionalMVIBenchmark.kt
new file mode 100644
index 00000000..0ac9da44
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/channelbased/ChannelTraditionalMVIBenchmark.kt
@@ -0,0 +1,26 @@
+package pro.respawn.flowmvi.benchmarks.setup.channelbased
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.openjdk.jmh.annotations.Benchmark
+import org.openjdk.jmh.annotations.Scope
+import org.openjdk.jmh.annotations.State
+import org.openjdk.jmh.annotations.Threads
+import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent
+
+@Threads(Threads.MAX)
+@Suppress("unused")
+@State(Scope.Benchmark)
+internal class ChannelTraditionalMVIBenchmark {
+
+ @Benchmark
+ fun benchmark() = runBlocking {
+ val store = ChannelBasedTraditionalStore(this)
+ repeat(BenchmarkDefaults.intentsPerIteration) {
+ store.onIntent(BenchmarkIntent.Increment)
+ }
+ store.state.first { state -> state.counter == BenchmarkDefaults.intentsPerIteration }
+ store.close()
+ }
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt
new file mode 100644
index 00000000..61ad16cb
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoIntentBenchmark.kt
@@ -0,0 +1,29 @@
+package pro.respawn.flowmvi.benchmarks.setup.fluxo
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kt.fluxo.core.annotation.ExperimentalFluxoApi
+import kt.fluxo.core.closeAndWait
+import org.openjdk.jmh.annotations.Benchmark
+import org.openjdk.jmh.annotations.Scope
+import org.openjdk.jmh.annotations.State
+import org.openjdk.jmh.annotations.Threads
+import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent
+
+@Threads(Threads.MAX)
+@Suppress("unused")
+@State(Scope.Benchmark)
+internal class FluxoIntentBenchmark {
+
+ @OptIn(ExperimentalFluxoApi::class)
+ @Benchmark
+ fun benchmark() = runBlocking {
+ val store = fluxoStore()
+ repeat(BenchmarkDefaults.intentsPerIteration) {
+ store.send(BenchmarkIntent.Increment)
+ }
+ store.first { state -> state.counter == BenchmarkDefaults.intentsPerIteration }
+ store.closeAndWait()
+ }
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt
new file mode 100644
index 00000000..1411d8bf
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStartStopBenchmark.kt
@@ -0,0 +1,23 @@
+package pro.respawn.flowmvi.benchmarks.setup.fluxo
+
+import kotlinx.benchmark.Benchmark
+import kotlinx.coroutines.runBlocking
+import kt.fluxo.core.annotation.ExperimentalFluxoApi
+import kt.fluxo.core.closeAndWait
+import org.openjdk.jmh.annotations.Scope
+import org.openjdk.jmh.annotations.State
+import org.openjdk.jmh.annotations.Threads
+
+@Threads(Threads.MAX)
+@Suppress("unused")
+@State(Scope.Benchmark)
+internal class FluxoStartStopBenchmark {
+
+ @OptIn(ExperimentalFluxoApi::class)
+ @Benchmark
+ fun benchmark() = runBlocking {
+ val store = fluxoStore()
+ store.start().join()
+ store.closeAndWait()
+ }
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt
new file mode 100644
index 00000000..7e98509d
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/fluxo/FluxoStore.kt
@@ -0,0 +1,19 @@
+package pro.respawn.flowmvi.benchmarks.setup.fluxo
+
+import kotlinx.coroutines.Dispatchers
+import kt.fluxo.core.annotation.ExperimentalFluxoApi
+import kt.fluxo.core.store
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState
+
+@OptIn(ExperimentalFluxoApi::class)
+internal inline fun fluxoStore() = store(BenchmarkState(), reducer = { it: BenchmarkIntent ->
+ when (it) {
+ BenchmarkIntent.Increment -> copy(counter = counter + 1)
+ }
+}) {
+ coroutineContext = Dispatchers.Unconfined
+ intentStrategy = Direct
+ debugChecks = false
+ lazy = false
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt
new file mode 100644
index 00000000..1e2b4649
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIBenchmark.kt
@@ -0,0 +1,27 @@
+package pro.respawn.flowmvi.benchmarks.setup.optimized
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.openjdk.jmh.annotations.Benchmark
+import org.openjdk.jmh.annotations.Scope
+import org.openjdk.jmh.annotations.State
+import org.openjdk.jmh.annotations.Threads
+import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent
+import pro.respawn.flowmvi.dsl.collect
+
+@Threads(Threads.MAX)
+@Suppress("unused")
+@State(Scope.Benchmark)
+internal class OptimizedFMVIBenchmark {
+
+ @Benchmark
+ fun benchmark() = runBlocking {
+ val store = optimizedStore(this)
+ repeat(BenchmarkDefaults.intentsPerIteration) {
+ store.intent(BenchmarkIntent.Increment)
+ }
+ store.collect { states.first { it.counter == BenchmarkDefaults.intentsPerIteration } }
+ store.closeAndWait()
+ }
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIStartStopBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIStartStopBenchmark.kt
new file mode 100644
index 00000000..e6d0e2fa
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedFMVIStartStopBenchmark.kt
@@ -0,0 +1,19 @@
+package pro.respawn.flowmvi.benchmarks.setup.optimized
+
+import kotlinx.benchmark.Benchmark
+import kotlinx.coroutines.runBlocking
+import org.openjdk.jmh.annotations.Scope
+import org.openjdk.jmh.annotations.State
+import org.openjdk.jmh.annotations.Threads
+
+@Threads(Threads.MAX)
+@State(Scope.Benchmark)
+internal class OptimizedFMVIStartStopBenchmark {
+
+ @Benchmark
+ fun benchmark() = runBlocking {
+ val store = optimizedStore()
+ store.start(this).awaitStartup()
+ store.closeAndWait()
+ }
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt
new file mode 100644
index 00000000..73415de1
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/optimized/OptimizedStore.kt
@@ -0,0 +1,47 @@
+package pro.respawn.flowmvi.benchmarks.setup.optimized
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import pro.respawn.flowmvi.api.ActionShareBehavior.Disabled
+import pro.respawn.flowmvi.api.StateStrategy.Immediate
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent.Increment
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState
+import pro.respawn.flowmvi.dsl.StoreBuilder
+import pro.respawn.flowmvi.dsl.store
+import pro.respawn.flowmvi.dsl.updateStateImmediate
+import pro.respawn.flowmvi.plugins.reducePlugin
+
+internal fun StoreBuilder<*, *, *>.config() {
+ configure {
+ logger = null
+ debuggable = false
+ actionShareBehavior = Disabled
+ stateStrategy = Immediate
+ parallelIntents = false
+ verifyPlugins = false
+ onOverflow = BufferOverflow.SUSPEND
+ intentCapacity = Channel.UNLIMITED
+ }
+}
+
+private val reduce = reducePlugin {
+ when (it) {
+ is Increment -> updateStateImmediate {
+ copy(counter = counter + 1)
+ }
+ }
+}
+
+internal inline fun optimizedStore(
+ scope: CoroutineScope,
+) = store(BenchmarkState(), scope) {
+ config()
+ install(reduce)
+}
+
+internal inline fun optimizedStore() = store(BenchmarkState()) {
+ config()
+ install(reduce)
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt
new file mode 100644
index 00000000..9de9347e
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIBenchmark.kt
@@ -0,0 +1,25 @@
+package pro.respawn.flowmvi.benchmarks.setup.traditional
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.openjdk.jmh.annotations.Benchmark
+import org.openjdk.jmh.annotations.Scope
+import org.openjdk.jmh.annotations.State
+import org.openjdk.jmh.annotations.Threads
+import pro.respawn.flowmvi.benchmarks.BenchmarkDefaults
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent
+
+@Threads(Threads.MAX)
+@Suppress("unused")
+@State(Scope.Benchmark)
+internal class TraditionalMVIBenchmark {
+
+ @Benchmark
+ fun benchmark() = runBlocking {
+ val store = TraditionalMVIStore()
+ repeat(BenchmarkDefaults.intentsPerIteration) {
+ store.onIntent(BenchmarkIntent.Increment)
+ }
+ store.state.first { state -> state.counter >= BenchmarkDefaults.intentsPerIteration }
+ }
+}
diff --git a/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIStore.kt b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIStore.kt
new file mode 100644
index 00000000..d4a7d9de
--- /dev/null
+++ b/benchmarks/src/jvmMain/kotlin/pro/respawn/flowmvi/benchmarks/setup/traditional/TraditionalMVIStore.kt
@@ -0,0 +1,19 @@
+package pro.respawn.flowmvi.benchmarks.setup.traditional
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkIntent
+import pro.respawn.flowmvi.benchmarks.setup.BenchmarkState
+
+internal class TraditionalMVIStore {
+
+ private val _state = MutableStateFlow(BenchmarkState())
+ val state = _state.asStateFlow()
+
+ fun onIntent(intent: BenchmarkIntent) = when (intent) {
+ is BenchmarkIntent.Increment -> _state.update { state ->
+ state.copy(counter = state.counter + 1)
+ }
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 900a4e3a..51cb96ef 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -16,16 +16,18 @@ plugins {
alias(libs.plugins.detekt)
// alias(libs.plugins.gradleDoctor)
alias(libs.plugins.version.catalog.update)
- alias(libs.plugins.atomicfu)
// alias(libs.plugins.dependencyAnalysis)
- alias(libs.plugins.serialization) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.compose) apply false
alias(libs.plugins.maven.publish) apply false
+ alias(libs.plugins.atomicfu) apply false
+ alias(libs.plugins.compose.compiler) apply false
+ alias(libs.plugins.kotlin.benchmark) apply false
// plugins already on a classpath (conventions)
// alias(libs.plugins.androidApplication) apply false
// alias(libs.plugins.androidLibrary) apply false
+ // alias(libs.plugins.kotlin.multiplatform) apply false
// alias(libs.plugins.kotlinMultiplatform) apply false
- alias(libs.plugins.compose.compiler) apply false
id(libs.plugins.dokka.id)
}
@@ -140,12 +142,6 @@ versionCatalogUpdate {
}
}
-atomicfu {
- dependenciesVersion = libs.versions.kotlinx.atomicfu.get()
- transformJvm = true
- jvmVariant = "VH"
-}
-
tasks {
withType().configureEach {
buildUponDefaultConfig = true
diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt
index d1bb1f8d..3cbfa305 100644
--- a/buildSrc/src/main/kotlin/Config.kt
+++ b/buildSrc/src/main/kotlin/Config.kt
@@ -20,7 +20,7 @@ object Config {
const val majorRelease = 3
const val minorRelease = 1
const val patch = 0
- const val postfix = "-beta04" // include dash (-)
+ const val postfix = "-beta05" // include dash (-)
const val versionCode = 8
const val majorVersionName = "$majorRelease.$minorRelease.$patch"
@@ -45,6 +45,7 @@ object Config {
"kotlinx.coroutines.FlowPreview",
"kotlin.RequiresOptIn",
"kotlin.experimental.ExperimentalTypeInference",
+ "kotlin.uuid.ExperimentalUuidApi",
"kotlin.contracts.ExperimentalContracts",
"org.jetbrains.compose.resources.ExperimentalResourceApi"
)
diff --git a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt
index 7e98d1cd..e776a581 100644
--- a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt
+++ b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt
@@ -22,10 +22,11 @@ fun Project.configureMultiplatform(
windows: Boolean = true,
wasmJs: Boolean = true,
wasmWasi: Boolean = true,
+ explicitApi: Boolean = true,
configure: KotlinHierarchyBuilder.Root.() -> Unit = {},
) = ext.apply {
val libs by versionCatalog
- explicitApi()
+ if (explicitApi) explicitApi()
applyDefaultHierarchyTemplate(configure)
withSourcesJar(true)
compilerOptions {
diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts
index ecf5beb9..291a2c7f 100644
--- a/compose/build.gradle.kts
+++ b/compose/build.gradle.kts
@@ -1,7 +1,7 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
plugins {
- id(libs.plugins.kotlinMultiplatform.id)
+ id(libs.plugins.kotlin.multiplatform.id)
id(libs.plugins.androidLibrary.id)
alias(libs.plugins.compose)
alias(libs.plugins.compose.compiler)
diff --git a/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ComposeDsl.kt b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ComposeDsl.kt
index 45c3e6b1..aaa1a93f 100644
--- a/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ComposeDsl.kt
+++ b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ComposeDsl.kt
@@ -18,6 +18,7 @@ import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.api.SubscriberLifecycle
import pro.respawn.flowmvi.api.SubscriptionMode
+import pro.respawn.flowmvi.dsl.state
import pro.respawn.flowmvi.dsl.subscribe
import pro.respawn.flowmvi.util.immediateOrDefault
import kotlin.jvm.JvmName
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index c1756f96..30f24b95 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -9,6 +9,12 @@ plugins {
dokkaDocumentation
}
+atomicfu {
+ dependenciesVersion = libs.versions.kotlinx.atomicfu.get()
+ transformJvm = true
+ jvmVariant = "VH"
+}
+
android {
namespace = Config.namespace
}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt
index 0953be55..45b512e6 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/StoreImpl.kt
@@ -1,3 +1,5 @@
+@file:OptIn(InternalFlowMVIAPI::class)
+
package pro.respawn.flowmvi
import kotlinx.coroutines.CoroutineScope
@@ -5,43 +7,49 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
+import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI
import pro.respawn.flowmvi.annotation.NotIntendedForInheritance
import pro.respawn.flowmvi.api.ActionProvider
import pro.respawn.flowmvi.api.ActionReceiver
import pro.respawn.flowmvi.api.DelicateStoreApi
+import pro.respawn.flowmvi.api.ImmediateStateReceiver
import pro.respawn.flowmvi.api.IntentReceiver
import pro.respawn.flowmvi.api.MVIAction
import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.api.Provider
+import pro.respawn.flowmvi.api.StateProvider
import pro.respawn.flowmvi.api.Store
import pro.respawn.flowmvi.api.StoreConfiguration
import pro.respawn.flowmvi.api.StorePlugin
import pro.respawn.flowmvi.api.context.ShutdownContext
import pro.respawn.flowmvi.exceptions.NonSuspendingSubscriberException
import pro.respawn.flowmvi.exceptions.SubscribeBeforeStartException
-import pro.respawn.flowmvi.exceptions.UnhandledIntentException
import pro.respawn.flowmvi.impl.plugin.PluginInstance
import pro.respawn.flowmvi.modules.RecoverModule
import pro.respawn.flowmvi.modules.RestartableLifecycle
import pro.respawn.flowmvi.modules.StateModule
import pro.respawn.flowmvi.modules.SubscriptionModule
import pro.respawn.flowmvi.modules.actionModule
+import pro.respawn.flowmvi.modules.catch
import pro.respawn.flowmvi.modules.intentModule
import pro.respawn.flowmvi.modules.launchPipeline
import pro.respawn.flowmvi.modules.observeSubscribers
-import pro.respawn.flowmvi.modules.recoverModule
+import pro.respawn.flowmvi.modules.observesSubscribers
import pro.respawn.flowmvi.modules.restartableLifecycle
-import pro.respawn.flowmvi.modules.stateModule
import pro.respawn.flowmvi.modules.subscriptionModule
-@OptIn(NotIntendedForInheritance::class)
+@OptIn(NotIntendedForInheritance::class, DelicateStoreApi::class)
internal class StoreImpl(
override val config: StoreConfiguration,
private val plugin: PluginInstance,
- recover: RecoverModule = recoverModule(plugin),
- subs: SubscriptionModule = subscriptionModule(),
- states: StateModule = stateModule(config.initial, config.atomicStateUpdates),
+ private val recover: RecoverModule = RecoverModule(plugin.onException),
+ private val stateModule: StateModule = StateModule(
+ config.initial,
+ config.stateStrategy,
+ config.debuggable,
+ plugin.onState,
+ ),
) : Store,
Provider,
ShutdownContext,
@@ -49,15 +57,14 @@ internal class StoreImpl(
ActionProvider,
ActionReceiver,
RestartableLifecycle by restartableLifecycle(),
+ SubscriptionModule by subscriptionModule(),
StorePlugin by plugin,
- RecoverModule by recover,
- SubscriptionModule by subs,
- StateModule by states {
+ StateProvider by stateModule,
+ ImmediateStateReceiver by stateModule {
- private val intents = intentModule(
- parallel = config.parallelIntents,
- capacity = config.intentCapacity,
- overflow = config.onOverflow,
+ private val intents = intentModule(
+ config = config,
+ onIntent = plugin.onIntent?.let { onIntent -> { intent -> catch(recover) { onIntent(this, intent) } } },
onUndeliveredIntent = plugin.onUndeliveredIntent?.let { { intent -> it(this, intent) } },
)
@@ -66,38 +73,26 @@ internal class StoreImpl(
onUndeliveredAction = plugin.onUndeliveredAction?.let { { action -> it(this, action) } }
)
- override suspend fun emit(intent: I) = intents.emit(intent)
- override fun intent(intent: I) = intents.intent(intent)
-
// region pipeline
override fun start(scope: CoroutineScope) = launchPipeline(
parent = scope,
storeConfig = config,
+ states = stateModule,
+ recover = recover,
onAction = { action -> onAction(action)?.let { _actions.action(it) } },
- onTransformState = { transform -> this@StoreImpl.updateState { onState(this, transform()) ?: this } },
onStop = { e -> close().also { plugin.onStop?.invoke(this, e) } },
onStart = pipeline@{ lifecycle ->
beginStartup(lifecycle)
- val startup = launch {
- if (plugin.onStart != null) catch { onStart() }
- lifecycle.completeStartup()
- }
- if (plugin.onSubscribe != null || plugin.onUnsubscribe != null) launch {
- startup.join()
- // catch exceptions to not let this job fail
- observeSubscribers(
- onSubscribe = { catch { onSubscribe(it) } },
- onUnsubscribe = { catch { onUnsubscribe(it) } }
- )
- }
- if (plugin.onIntent != null) launch {
- startup.join()
- intents.awaitIntents {
- catch {
- val result = onIntent(it)
- if (result != null && config.debuggable) throw UnhandledIntentException(result)
- }
+ launch {
+ catch(recover) { onStart() }
+ if (plugin.observesSubscribers) launch {
+ observeSubscribers(
+ onSubscribe = { catch(recover) { onSubscribe(it) } },
+ onUnsubscribe = { catch(recover) { onUnsubscribe(it) } }
+ )
}
+ lifecycle.completeStartup()
+ intents.run { reduceForever() }
}
}
)
@@ -109,16 +104,17 @@ internal class StoreImpl(
if (!isActive && !config.allowIdleSubscriptions) throw SubscribeBeforeStartException()
launch { awaitUnsubscription() }
block(this@StoreImpl)
- if (config.debuggable) throw NonSuspendingSubscriberException()
+ if (!config.allowTransientSubscriptions) throw NonSuspendingSubscriberException()
cancel()
}
// region contract
override val name by config::name
+ override val states by stateModule::states
override val actions: Flow by _actions::actions
-
- @DelicateStoreApi
override fun send(action: A) = _actions.send(action)
+ override suspend fun emit(intent: I) = intents.emit(intent)
+ override fun intent(intent: I) = intents.intent(intent)
override suspend fun action(action: A) = _actions.action(action)
override fun hashCode() = name?.hashCode() ?: super.hashCode()
override fun toString(): String = name ?: super.toString()
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/InternalFlowMVIAPI.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/InternalFlowMVIAPI.kt
index 3703d7a1..3ff5412d 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/InternalFlowMVIAPI.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/annotation/InternalFlowMVIAPI.kt
@@ -1,9 +1,10 @@
package pro.respawn.flowmvi.annotation
private const val Message = """
-This API is internal to the library.
-Do not use this API directly as public API already exists for the same thing.
-If you have the use-case for this api you can't avoid, please submit an issue.
+This API is internal to the library and is exposed for performance reasons.
+Do NOT use this API directly as public API already exists for the same thing.
+If you have the use-case for this api you can't avoid, please submit an issue BEFORE you use it.
+This API may be removed, changed or change behavior without prior notice at any moment.
"""
/**
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt
index fab14fc9..955f955d 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmediateStateReceiver.kt
@@ -1,33 +1,23 @@
package pro.respawn.flowmvi.api
+import kotlinx.coroutines.flow.StateFlow
+import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI
+import pro.respawn.flowmvi.dsl.updateStateImmediate
+
/**
* [StateReceiver] version that can only accept immediate state updates. It is recommended to use [StateReceiver] and
* its methods if possible. See the method docs for details
*/
-public interface ImmediateStateReceiver {
+public interface ImmediateStateReceiver : StateProvider {
/**
- * A function that obtains current state and updates it atomically (in the thread context), and non-atomically in
- * the coroutine context, which means it can cause races when you want to update states in parallel.
- *
- * This function is performant, but **ignores ALL plugins** and
- * **does not perform a serializable state transaction**
- *
- * It should only be used for the state updates that demand the highest performance and happen very often.
- * If [StoreConfiguration.atomicStateUpdates] is `false`, then this function is the same
- * as [StateReceiver.updateState]
+ * Directly compare and set the current state.
*
- * @see StateReceiver.updateState
- * @see StateReceiver.withState
+ * Please read [updateStateImmediate] to learn about repercussions of using this.
*/
- @FlowMVIDSL
- public fun updateStateImmediate(block: S.() -> S)
+ @InternalFlowMVIAPI
+ public fun compareAndSet(old: S, new: S): Boolean
- /**
- * Obtain the current value of state in an unsafe manner.
- * It is recommended to always use [StateReceiver.withState] or [StateReceiver.updateState] as obtaining this value can lead
- * to data races when the state transaction changes the value of the state previously obtained.
- */
- @DelicateStoreApi
- public val state: S
+ @InternalFlowMVIAPI
+ override val states: StateFlow
}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmutableStore.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmutableStore.kt
index c3f7a321..c3607f3a 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmutableStore.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ImmutableStore.kt
@@ -2,7 +2,8 @@ package pro.respawn.flowmvi.api
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI
import pro.respawn.flowmvi.api.lifecycle.ImmutableStoreLifecycle
import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle
@@ -10,7 +11,9 @@ import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle
* A [Store] that does not allow sending intents.
* @see Store
*/
-public interface ImmutableStore : ImmutableStoreLifecycle {
+public interface ImmutableStore :
+ ImmutableStoreLifecycle,
+ StateProvider {
/**
* The name of the store. Used for debugging purposes and when storing multiple stores in a collection.
@@ -48,17 +51,8 @@ public interface ImmutableStore.() -> Unit): Job
- /**
- * Obtain the current state in an unsafe manner.
- * This property is not thread-safe and parallel state updates will introduce a race condition when not
- * handled properly.
- * Such race conditions arise when using multiple data streams such as [Flow]s.
- *
- * Accessing the state this way will **circumvent ALL plugins**.
- */
- @DelicateStoreApi
- public val state: S
-
+ @InternalFlowMVIAPI
+ override val states: StateFlow
override fun hashCode(): Int
override fun equals(other: Any?): Boolean
}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateProvider.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateProvider.kt
index c9055c11..ec1bab3b 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateProvider.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateProvider.kt
@@ -1,6 +1,5 @@
package pro.respawn.flowmvi.api
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
@@ -10,20 +9,7 @@ import kotlinx.coroutines.flow.StateFlow
public interface StateProvider {
/**
- * A flow of states to be handled by the subscriber.
+ * A flow of states to be rendered by the subscriber.
*/
public val states: StateFlow
-
- /**
- * Obtain the current state in an unsafe manner.
- * This property is not thread-safe and parallel state updates will introduce a race condition when not
- * handled properly.
- * Such race conditions arise when using multiple data streams such as [Flow]s.
- *
- * Accessing and modifying the state this way will **circumvent ALL plugins** and will not make state updates atomic.
- *
- * Consider accessing state via [StateReceiver.withState] or [StateReceiver.updateState] instead.
- */
- @DelicateStoreApi
- public val state: S
}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateReceiver.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateReceiver.kt
index 516c9f5d..5d06fa2d 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateReceiver.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateReceiver.kt
@@ -44,13 +44,4 @@ public interface StateReceiver : ImmediateStateReceiver {
*/
@FlowMVIDSL
public suspend fun withState(block: suspend S.() -> Unit)
-
- // region deprecated
-
- @FlowMVIDSL
- @Suppress("UndocumentedPublicFunction")
- @Deprecated("renamed to updateStateImmediate()", ReplaceWith("updateStateImmediate(block)"))
- public fun useState(block: S.() -> S): Unit = updateStateImmediate(block)
-
- // endregion
}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateStrategy.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateStrategy.kt
new file mode 100644
index 00000000..175daf21
--- /dev/null
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StateStrategy.kt
@@ -0,0 +1,48 @@
+package pro.respawn.flowmvi.api
+
+import pro.respawn.flowmvi.dsl.updateStateImmediate
+
+/**
+ * Defines available strategies [Store] can use when a state
+ * operation ([StateReceiver.updateState] or [StateReceiver.withState])
+ * is requested.
+ *
+ * Set during store configuration.
+ */
+public sealed interface StateStrategy {
+
+ /**
+ * State transactions are not [Atomic] (not serializable). This means `
+ * [StateReceiver.updateState] and [StateReceiver.withState] functions are
+ * no-op and forward to [updateStateImmediate].
+ *
+ * This leads to the following consequences:
+ * 1. The order of state operations is **undefined** in parallel contexts.
+ * 2. There is **no thread-safety** for state reads and writes.
+ * 3. State operation **performance is increased** significantly (about 10x faster)
+ *
+ * * Be very careful with this strategy and use it when you will ensure the safety of updates manually **and** you
+ * absolutely must squeeze the maximum performance out of a Store. Do not optimize prematurely.
+ * * For a semi-safe but faster alternative, consider using [Atomic] with [Atomic.reentrant] set to `false`.
+ * * This strategy configures state transactions for the whole Store.
+ * For one-time usage of non-atomic updates, see [updateStateImmediate].
+ */
+ public object Immediate : StateStrategy
+
+ /**
+ * Enables transaction serialization for state updates, making state updates atomic and suspendable.
+ *
+ * * Synchronizes state updates, allowing only **one** client to read and/or update the state at a time.
+ * All other clients attempting to get the state will wait on a FIFO queue and suspend the parent coroutine.
+ * * This strategy configures state transactions for the whole store.
+ * For one-time usage of non-atomic updates, see [updateStateImmediate].
+ * * Has a small performance impact because of coroutine context switching and mutex usage.
+ *
+ * * Performance impact can be minimized at the cost of lock reentrancy. Set [reentrant] to `false` to use it, but
+ * **HERE BE DRAGONS** if you do that, as using the state within another state transaction will
+ * cause a **permanent deadlock**.
+ */
+ public data class Atomic(
+ val reentrant: Boolean = true,
+ ) : StateStrategy
+}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt
index c8a6a557..4580c99a 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/StoreConfiguration.kt
@@ -10,18 +10,27 @@ import kotlin.coroutines.CoroutineContext
*
* Please see [StoreConfigurationBuilder] for details on the meaning behind the properties listed here
*/
+@ConsistentCopyVisibility
@Suppress("UndocumentedPublicProperty")
-public data class StoreConfiguration(
+public data class StoreConfiguration internal constructor(
val initial: S,
val allowIdleSubscriptions: Boolean,
+ val allowTransientSubscriptions: Boolean,
val parallelIntents: Boolean,
val actionShareBehavior: ActionShareBehavior,
+ val stateStrategy: StateStrategy,
val intentCapacity: Int,
val onOverflow: BufferOverflow,
val debuggable: Boolean,
val coroutineContext: CoroutineContext,
val logger: StoreLogger,
- val atomicStateUpdates: Boolean,
val verifyPlugins: Boolean,
val name: String?,
-)
+) {
+
+ @Deprecated(
+ "Please use the StateStrategy directly",
+ ReplaceWith("this.stateStrategy is StateStrategy.Atomic")
+ )
+ val atomicStateUpdates: Boolean get() = stateStrategy is StateStrategy.Atomic
+}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt
index eea2aa20..d7145327 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StateDsl.kt
@@ -1,12 +1,58 @@
package pro.respawn.flowmvi.dsl
+import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI
+import pro.respawn.flowmvi.api.DelicateStoreApi
import pro.respawn.flowmvi.api.FlowMVIDSL
+import pro.respawn.flowmvi.api.ImmediateStateReceiver
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.api.PipelineContext
+import pro.respawn.flowmvi.api.StateProvider
import pro.respawn.flowmvi.api.StateReceiver
+import pro.respawn.flowmvi.api.StoreConfiguration
import pro.respawn.flowmvi.exceptions.InvalidStateException
import pro.respawn.flowmvi.util.typed
import pro.respawn.flowmvi.util.withType
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+import kotlin.jvm.JvmName
+
+/**
+ * Obtain the current state in an unsafe manner.
+ *
+ * This property is not thread-safe and parallel state updates will introduce a race condition when not
+ * handled properly.
+ * Such race conditions arise when using multiple data streams such as [Flow]s.
+ *
+ * Accessing and modifying the state this way will **circumvent ALL plugins** and will not make state updates atomic.
+ *
+ * Consider accessing state via [StateReceiver.withState] or [StateReceiver.updateState] instead.
+ */
+public inline val StateProvider.state get() = states.value
+
+/**
+ * A function that obtains current state and updates it atomically (in the thread context), and non-atomically in
+ * the coroutine context, which means it can cause races when you want to update states in parallel.
+ *
+ * This function is performant, but **ignores ALL plugins** and
+ * **does not perform a serializable state transaction**
+ *
+ * It should only be used for the state updates that demand the highest performance and happen very often.
+ * If [StoreConfiguration.atomicStateUpdates] is `false`, then this function is the same
+ * as [StateReceiver.updateState]
+ *
+ * @see StateReceiver.updateState
+ * @see StateReceiver.withState
+ */
+@OptIn(DelicateStoreApi::class, InternalFlowMVIAPI::class)
+@FlowMVIDSL
+public inline fun ImmediateStateReceiver.updateStateImmediate(
+ @BuilderInference transform: S.() -> S
+) {
+ contract {
+ callsInPlace(transform, InvocationKind.AT_LEAST_ONCE)
+ }
+ while (true) if (compareAndSet(state, transform(state))) return
+}
/**
* A typed overload of [StateReceiver.withState].
@@ -25,15 +71,15 @@ public suspend inline fun StateReceiver.updateS
) = updateState { withType { transform() } }
/**
- * A typed overload of [StateReceiver.updateStateImmediate].
+ * A typed overload of [updateStateImmediate].
*
* @see StateReceiver.withState
- * @see StateReceiver.updateStateImmediate
* @see StateReceiver.updateState
*/
@FlowMVIDSL
-public inline fun StateReceiver.updateStateImmediate(
- @BuilderInference crossinline transform: T.() -> S
+@JvmName("updateStateImmediateTyped")
+public inline fun ImmediateStateReceiver.updateStateImmediate(
+ @BuilderInference transform: T.() -> S
) = updateStateImmediate { withType { transform() } }
/**
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt
index b6df2489..8d4ac724 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreConfigurationBuilder.kt
@@ -7,6 +7,7 @@ import pro.respawn.flowmvi.api.FlowMVIDSL
import pro.respawn.flowmvi.api.IntentReceiver
import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
+import pro.respawn.flowmvi.api.StateStrategy
import pro.respawn.flowmvi.api.Store
import pro.respawn.flowmvi.api.StoreConfiguration
import pro.respawn.flowmvi.logging.NoOpStoreLogger
@@ -42,6 +43,20 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() {
@FlowMVIDSL
public var actionShareBehavior: ActionShareBehavior = ActionShareBehavior.Distribute()
+ /**
+ * Configure the [StateStrategy] of this [Store].
+ *
+ * Available strategies:
+ * * [StateStrategy.Atomic]
+ * * [StateStrategy.Immediate]
+ *
+ * Make sure to read the documentation of the strategy before modifying this property.
+ *
+ * [StateStrategy.Atomic] with [StateStrategy.Atomic.reentrant] = `true` by default
+ */
+ @FlowMVIDSL
+ public var stateStrategy: StateStrategy = StateStrategy.Atomic(true)
+
/**
* Designate the maximum capacity of [MVIIntent]s waiting for processing
* in the [pro.respawn.flowmvi.api.IntentReceiver]'s queue.
@@ -78,6 +93,18 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() {
@FlowMVIDSL
public var allowIdleSubscriptions: Boolean? = null
+ /**
+ * Whether to allow subscribers that can unsubscribe on their own.
+ *
+ * Normally, if a subscriber appears, but their subscription coroutine ends (i.e. they did not suspend forever),
+ * an exception will be thrown. This can indicate an error in the client code (e.g. forgot to collect a flow), but
+ * can be intended behavior sometimes.
+ *
+ * By default, this is enabled if [debuggable] is `true`.
+ */
+ @FlowMVIDSL
+ public var allowTransientSubscriptions: Boolean? = null
+
/**
* A coroutine context overrides for the [Store].
* This context will be merged with the one the store was launched with (e.g. `viewModelScope`).
@@ -97,21 +124,6 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() {
@FlowMVIDSL
public var logger: StoreLogger? = null
- /**
- * Enables transaction serialization for state updates, making state updates atomic and suspendable.
- *
- * * Serializes both state reads and writes using a mutex.
- * * Synchronizes state updates, allowing only **one** client to read and/or update the state at a time.
- * All other clients attempt to get the state will wait on a FIFO queue and suspend the parent coroutine.
- * * This property disables state transactions for the whole store.
- * For one-time usage of non-atomic updates, see [updateStateImmediate].
- * * Has a small performance impact because of coroutine context switching and mutex usage.
- *
- * `true` by default
- */
- @FlowMVIDSL
- public var atomicStateUpdates: Boolean = true
-
/**
* Signals to plugins that they should enable their own verification logic.
*
@@ -129,6 +141,20 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() {
@FlowMVIDSL
public var name: String? = null
+ // region deprecated
+ @Deprecated(
+ "Please use the StateStrategy property",
+ replaceWith = ReplaceWith("stateStrategy = StateStrategy.Immediate"),
+ )
+ @FlowMVIDSL
+ @Suppress("UndocumentedPublicProperty")
+ public var atomicStateUpdates: Boolean
+ get() = stateStrategy is StateStrategy.Atomic
+ set(value) {
+ stateStrategy = if (value) StateStrategy.Atomic(true) else StateStrategy.Immediate
+ }
+ // endregion
+
/**
* Create the [StoreConfiguration]
*/
@@ -141,11 +167,12 @@ public class StoreConfigurationBuilder @PublishedApi internal constructor() {
onOverflow = onOverflow,
debuggable = debuggable,
coroutineContext = coroutineContext,
- logger = logger ?: if (debuggable) PlatformStoreLogger else NoOpStoreLogger,
- atomicStateUpdates = atomicStateUpdates,
name = name,
+ logger = logger ?: if (debuggable) PlatformStoreLogger else NoOpStoreLogger,
allowIdleSubscriptions = allowIdleSubscriptions ?: !debuggable,
+ allowTransientSubscriptions = allowTransientSubscriptions ?: !debuggable,
verifyPlugins = verifyPlugins ?: debuggable,
+ stateStrategy = stateStrategy
)
}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt
index c4cf9d0e..7fd297d9 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/exceptions/InternalStoreExceptions.kt
@@ -57,3 +57,14 @@ internal class SubscribeBeforeStartException(cause: Exception? = null) : Unrecov
If not, please always call Store.start() before you try using it.
""".trimIndent()
)
+
+@PublishedApi
+internal class RecursiveStateTransactionException(cause: Exception?) : UnrecoverableException(
+ cause = cause,
+ message = """
+ You have tried to start a state transaction while already in one.
+ This happened because state transactions are Atomic and reentrant = false.
+ Please avoid using recursion in transactions, otherwise you will get a permanent deadlock, or use
+ StateStrategy.Atomic.reentrant = true at the cost of some performance.
+ """.trimIndent()
+)
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt
index 2420982b..b4235f36 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/IntentModule.kt
@@ -2,72 +2,103 @@ package pro.respawn.flowmvi.modules
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
-import kotlinx.coroutines.yield
import pro.respawn.flowmvi.api.IntentReceiver
+import pro.respawn.flowmvi.api.MVIAction
import pro.respawn.flowmvi.api.MVIIntent
+import pro.respawn.flowmvi.api.MVIState
+import pro.respawn.flowmvi.api.PipelineContext
+import pro.respawn.flowmvi.api.StoreConfiguration
+import pro.respawn.flowmvi.exceptions.UnhandledIntentException
-internal interface IntentModule : IntentReceiver {
+internal interface IntentModule : IntentReceiver {
- suspend fun awaitIntents(onIntent: suspend (intent: I) -> Unit)
+ suspend fun PipelineContext.reduceForever()
}
-internal fun intentModule(
- parallel: Boolean,
- capacity: Int,
- overflow: BufferOverflow,
+@Suppress("UNCHECKED_CAST")
+internal fun intentModule(
+ config: StoreConfiguration,
onUndeliveredIntent: ((intent: I) -> Unit)?,
-): IntentModule = when {
- !parallel -> SequentialChannelIntentModule(capacity, overflow, onUndeliveredIntent)
- else -> ParallelChannelIntentModule(capacity, overflow, onUndeliveredIntent)
+ onIntent: (suspend PipelineContext.(intent: I) -> I?)?,
+): IntentModule = when {
+ onIntent == null -> NoOpIntentModule(config.debuggable)
+ !config.parallelIntents -> SequentialChannelIntentModule(
+ capacity = config.intentCapacity,
+ overflow = config.onOverflow,
+ onUndeliveredIntent = onUndeliveredIntent,
+ onIntent = onIntent,
+ debuggable = config.debuggable
+ )
+ else -> ParallelChannelIntentModule(
+ capacity = config.intentCapacity,
+ overflow = config.onOverflow,
+ onUndeliveredIntent = onUndeliveredIntent,
+ onIntent = onIntent,
+ debuggable = config.debuggable
+ )
+}
+
+private class NoOpIntentModule(
+ private val debuggable: Boolean
+) : IntentModule {
+
+ override suspend fun PipelineContext.reduceForever() = Unit
+ override suspend fun emit(intent: I) = intent(intent)
+ override fun intent(intent: I) = unhandled(intent, debuggable)
}
-private abstract class ChannelIntentModule(
+private abstract class ChannelIntentModule(
capacity: Int,
overflow: BufferOverflow,
- onUndeliveredIntent: ((intent: I) -> Unit)?,
-) : IntentModule {
+ private val onUndeliveredIntent: ((intent: I) -> Unit)?,
+ private val onIntent: suspend PipelineContext.(intent: I) -> I?,
+ private val debuggable: Boolean,
+) : IntentModule {
val intents = Channel(capacity, overflow, onUndeliveredIntent)
-
override suspend fun emit(intent: I) = intents.send(intent)
+
override fun intent(intent: I) {
intents.trySend(intent)
}
+
+ abstract suspend fun PipelineContext.dispatch(intent: I)
+
+ suspend inline fun PipelineContext.reduce(intent: I) {
+ val unhandled = onIntent(intent) ?: return
+ onUndeliveredIntent?.invoke(unhandled) ?: unhandled(unhandled, debuggable)
+ }
+
+ override suspend fun PipelineContext.reduceForever() {
+ while (isActive) dispatch(intents.receive())
+ }
}
-private class SequentialChannelIntentModule(
+private class SequentialChannelIntentModule(
capacity: Int,
overflow: BufferOverflow,
onUndeliveredIntent: ((intent: I) -> Unit)?,
-) : ChannelIntentModule(capacity, overflow, onUndeliveredIntent) {
-
- override suspend fun awaitIntents(onIntent: suspend (intent: I) -> Unit) = coroutineScope {
- // must always suspend the current scope to wait for intents
- for (intent in intents) {
- onIntent(intent)
- yield()
- }
- }
+ onIntent: suspend PipelineContext.(intent: I) -> I?,
+ debuggable: Boolean,
+) : ChannelIntentModule(capacity, overflow, onUndeliveredIntent, onIntent, debuggable) {
+
+ override suspend fun PipelineContext.dispatch(intent: I) = reduce(intent)
}
-private class ParallelChannelIntentModule(
+private class ParallelChannelIntentModule(
capacity: Int,
overflow: BufferOverflow,
onUndeliveredIntent: ((intent: I) -> Unit)?,
-) : IntentModule {
-
- private val intents = Channel(capacity, overflow, onUndeliveredIntent)
+ onIntent: suspend PipelineContext.(intent: I) -> I?,
+ debuggable: Boolean,
+) : ChannelIntentModule(capacity, overflow, onUndeliveredIntent, onIntent, debuggable) {
- override suspend fun emit(intent: I) = intents.send(intent)
- override fun intent(intent: I) {
- intents.trySend(intent)
+ override suspend fun PipelineContext.dispatch(intent: I) {
+ launch { reduce(intent) }
}
+}
- // TODO: We should let the user limit parallelism here to avoid starvation
- override suspend fun awaitIntents(onIntent: suspend (intent: I) -> Unit) = coroutineScope {
- // must always suspend the current scope to wait for intents
- for (intent in intents) launch { onIntent(intent) }
- }
+private inline fun unhandled(intent: I, debuggable: Boolean) {
+ if (debuggable) throw UnhandledIntentException(intent)
}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt
index 0a33879f..ebda27a4 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt
@@ -11,12 +11,12 @@ import kotlinx.coroutines.launch
import pro.respawn.flowmvi.annotation.NotIntendedForInheritance
import pro.respawn.flowmvi.api.ActionReceiver
import pro.respawn.flowmvi.api.DelicateStoreApi
+import pro.respawn.flowmvi.api.ImmediateStateReceiver
import pro.respawn.flowmvi.api.IntentReceiver
import pro.respawn.flowmvi.api.MVIAction
import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.api.PipelineContext
-import pro.respawn.flowmvi.api.StateReceiver
import pro.respawn.flowmvi.api.StoreConfiguration
import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle
@@ -34,11 +34,12 @@ import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle
internal inline fun T.launchPipeline(
parent: CoroutineScope,
storeConfig: StoreConfiguration,
+ states: StateModule,
+ recover: RecoverModule,
crossinline onStop: (e: Exception?) -> Unit,
crossinline onAction: suspend PipelineContext.(action: A) -> Unit,
- crossinline onTransformState: suspend PipelineContext.(transform: suspend S.() -> S) -> Unit,
onStart: PipelineContext.(lifecycle: StoreLifecycleModule) -> Unit,
-): StoreLifecycle where T : IntentReceiver, T : StateReceiver, T : RecoverModule {
+): StoreLifecycle where T : IntentReceiver {
val job = SupervisorJob(parent.coroutineContext[Job]).apply {
invokeOnCompletion {
when (it) {
@@ -50,14 +51,14 @@ internal inline fun T.launchPipe
}
return object :
IntentReceiver by this,
- StateReceiver by this,
+ ImmediateStateReceiver by states,
PipelineContext,
StoreLifecycleModule by storeLifecycle(job),
ActionReceiver {
override val config = storeConfig
override val key = PipelineContext // recoverable should be separate from this key
- private val handler = PipelineExceptionHandler()
+ private val handler = PipelineExceptionHandler(recover)
private val pipelineName = CoroutineName(toString())
override val coroutineContext = parent.coroutineContext +
@@ -69,8 +70,17 @@ internal inline fun T.launchPipe
override fun toString(): String = "${storeConfig.name.orEmpty()}PipelineContext"
- override suspend fun updateState(transform: suspend S.() -> S) = catch { onTransformState(transform) }
- override suspend fun action(action: A) = catch { onAction(action) }
+ override suspend fun updateState(transform: suspend S.() -> S) {
+ catch(recover) { states.run { useState(transform) } }
+ }
+
+ override suspend fun withState(block: suspend S.() -> Unit) {
+ catch(recover) { states.withState(block) }
+ }
+
+ override suspend fun action(action: A) {
+ catch(recover) { onAction(action) }
+ }
override fun send(action: A) {
launch { action(action) }
}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt
index 582013e4..8d20fb4f 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/RecoverModule.kt
@@ -8,7 +8,6 @@ import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.api.PipelineContext
import pro.respawn.flowmvi.api.Store
-import pro.respawn.flowmvi.api.StorePlugin
import pro.respawn.flowmvi.api.UnrecoverableException
import pro.respawn.flowmvi.exceptions.RecursiveRecoverException
import pro.respawn.flowmvi.exceptions.UnhandledStoreException
@@ -16,51 +15,20 @@ import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
-internal fun recoverModule(
- delegate: StorePlugin,
-) = RecoverModule { e ->
- with(delegate) {
- if (e is UnrecoverableException) throw e
- onException(e)?.let { throw UnhandledStoreException(it) }
- }
-}
-
/**
* An entity that can [recover] from exceptions happening during its lifecycle. Most often, a [Store]
*/
-internal fun interface RecoverModule : CoroutineContext.Element {
+internal class RecoverModule(
+ private val handler: (suspend PipelineContext.(e: Exception) -> Exception?)?
+) : CoroutineContext.Element {
override val key: CoroutineContext.Key<*> get() = RecoverModule
- suspend fun PipelineContext.handle(e: Exception)
-
- /**
- * Run [block] catching any exceptions and invoking [recover]. This will add this [RecoverModule] key to the coroutine
- * context of the [recover] block.
- */
- suspend fun PipelineContext.catch(block: suspend () -> Unit): Unit = try {
- block()
- } catch (expected: Exception) {
- when {
- expected is CancellationException || expected is UnrecoverableException -> throw expected
- alreadyRecovered() -> throw RecursiveRecoverException(expected)
- else -> withContext(this@RecoverModule) { handle(expected) }
- }
- }
+ val hasHandler = handler != null
- @Suppress("FunctionName")
- fun PipelineContext.PipelineExceptionHandler() = CoroutineExceptionHandler { ctx, e ->
- when {
- e !is Exception || e is CancellationException -> throw e
- e is UnrecoverableException -> throw e.unwrapRecursion()
- ctx.alreadyRecovered -> throw e
- // add Recoverable to the coroutine context
- // and handle the exception asynchronously to allow suspending inside recover
- // Do NOT use the "ctx" parameter here, as that coroutine context is already invalid and will not launch
- else -> launch(this@RecoverModule) { handle(e) }.invokeOnCompletion { cause ->
- if (cause != null && cause !is CancellationException) throw cause
- }
- }
+ suspend fun PipelineContext.handle(e: Exception) {
+ if (handler == null) throw UnhandledStoreException(e)
+ handler.invoke(this@handle, e)?.let { throw UnhandledStoreException(it) }
}
companion object : CoroutineContext.Key>
@@ -69,11 +37,49 @@ internal fun interface RecoverModule
private tailrec fun UnrecoverableException.unwrapRecursion(): Exception = when (val cause = cause) {
null -> this
this -> this // cause is the same exception
- is UnrecoverableException -> cause.unwrapRecursion()
is CancellationException -> throw cause
+ is UnrecoverableException -> cause.unwrapRecursion()
else -> cause
}
-private suspend fun alreadyRecovered() = coroutineContext.alreadyRecovered
+internal suspend inline fun alreadyRecovered() = coroutineContext.alreadyRecovered
+
+internal inline val CoroutineContext.alreadyRecovered get() = this[RecoverModule] != null
-private val CoroutineContext.alreadyRecovered get() = this[RecoverModule] != null
+/**
+ * Run [block] catching any exceptions and invoking [recover]. This will add this [RecoverModule] key to the coroutine
+ * context of the [recover] block.
+ */
+internal suspend inline fun PipelineContext.catch(
+ recover: RecoverModule,
+ block: suspend () -> R
+): R? = try {
+ block()
+} catch (expected: Exception) {
+ when {
+ expected is CancellationException || expected is UnrecoverableException -> throw expected
+ !recover.hasHandler -> throw UnhandledStoreException(expected)
+ alreadyRecovered() -> throw RecursiveRecoverException(expected)
+ else -> withContext(recover) {
+ recover.run { handle(expected) }
+ null
+ }
+ }
+}
+
+internal fun PipelineContext.PipelineExceptionHandler(
+ recover: RecoverModule
+) = CoroutineExceptionHandler { ctx, e ->
+ when {
+ e !is Exception || e is CancellationException -> throw e
+ !recover.hasHandler -> throw UnhandledStoreException(e)
+ e is UnrecoverableException -> throw e.unwrapRecursion()
+ ctx.alreadyRecovered -> throw e
+ // add Recoverable to the coroutine context
+ // and handle the exception asynchronously to allow suspending inside recover
+ // Do NOT use the "ctx" parameter here, as that coroutine context is already invalid and will not launch
+ else -> launch(recover) { recover.run { handle(e) } }.invokeOnCompletion { cause ->
+ if (cause != null && cause !is CancellationException) throw cause
+ }
+ }
+}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt
index 494d9fa4..d0d8d3c0 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt
@@ -1,44 +1,60 @@
-@file:Suppress("OVERRIDE_BY_INLINE")
+@file:OptIn(InternalFlowMVIAPI::class)
package pro.respawn.flowmvi.modules
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
-import pro.respawn.flowmvi.api.DelicateStoreApi
+import kotlinx.coroutines.sync.withLock
+import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI
+import pro.respawn.flowmvi.api.ImmediateStateReceiver
+import pro.respawn.flowmvi.api.MVIAction
+import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
-import pro.respawn.flowmvi.api.StateProvider
-import pro.respawn.flowmvi.api.StateReceiver
+import pro.respawn.flowmvi.api.PipelineContext
+import pro.respawn.flowmvi.api.StateStrategy
+import pro.respawn.flowmvi.api.StateStrategy.Atomic
+import pro.respawn.flowmvi.api.StateStrategy.Immediate
+import pro.respawn.flowmvi.dsl.updateStateImmediate
+import pro.respawn.flowmvi.util.ReentrantMutexContextElement
+import pro.respawn.flowmvi.util.ReentrantMutexContextKey
import pro.respawn.flowmvi.util.withReentrantLock
+import pro.respawn.flowmvi.util.withValidatedLock
-internal fun stateModule(
+internal class StateModule(
initial: S,
- atomic: Boolean,
-): StateModule = StateModuleImpl(initial, if (atomic) Mutex() else null)
-
-internal interface StateModule : StateReceiver, StateProvider
-
-private class StateModuleImpl(
- initial: S,
- private val mutex: Mutex?,
-) : StateModule {
+ strategy: StateStrategy,
+ private val debuggable: Boolean,
+ private val chain: (suspend PipelineContext.(old: S, new: S) -> S?)?
+) : ImmediateStateReceiver {
@Suppress("VariableNaming")
private val _states = MutableStateFlow(initial)
override val states: StateFlow = _states.asStateFlow()
-
- @DelicateStoreApi
- override val state: S by states::value
-
- override inline fun updateStateImmediate(block: S.() -> S) = _states.update(block)
-
- override suspend inline fun withState(
+ private val reentrant = strategy is Atomic && strategy.reentrant
+ private val mutexElement = when (strategy) {
+ is Immediate -> null
+ is Atomic -> Mutex().let(::ReentrantMutexContextKey).let(::ReentrantMutexContextElement)
+ }
+
+ private suspend inline fun withLock(crossinline block: suspend () -> Unit) = when {
+ mutexElement == null -> block()
+ reentrant -> mutexElement.withReentrantLock(block)
+ debuggable -> mutexElement.withValidatedLock(block)
+ else -> mutexElement.key.mutex.withLock { block() }
+ }
+
+ override fun compareAndSet(expect: S, new: S) = _states.compareAndSet(expect, new)
+
+ suspend inline fun withState(
crossinline block: suspend S.() -> Unit
- ) = mutex.withReentrantLock { block(states.value) }
+ ) = withLock { block(states.value) }
- override suspend inline fun updateState(
+ suspend inline fun PipelineContext.useState(
crossinline transform: suspend S.() -> S
- ) = mutex.withReentrantLock { _states.update { transform(it) } }
+ ) = withLock {
+ val chain = chain ?: return@withLock updateStateImmediate { transform() }
+ updateStateImmediate { chain(this, transform()) ?: this }
+ }
}
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/SubscriptionModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/SubscriptionModule.kt
index df54f5df..ba18dc70 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/SubscriptionModule.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/SubscriptionModule.kt
@@ -2,6 +2,7 @@ package pro.respawn.flowmvi.modules
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
+import pro.respawn.flowmvi.impl.plugin.PluginInstance
import pro.respawn.flowmvi.util.withPrevious
internal fun subscriptionModule(): SubscriptionModule = SubscriptionModuleImpl()
@@ -37,3 +38,5 @@ internal suspend inline fun SubscriptionModule.observeSubscribers(
new < previous -> onUnsubscribe(new)
}
}
+
+internal inline val PluginInstance<*, *, *>.observesSubscribers get() = onSubscribe != null || onUnsubscribe != null
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ResetStatePlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ResetStatePlugin.kt
new file mode 100644
index 00000000..0fcb685c
--- /dev/null
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/ResetStatePlugin.kt
@@ -0,0 +1,35 @@
+package pro.respawn.flowmvi.plugins
+
+import pro.respawn.flowmvi.api.MVIAction
+import pro.respawn.flowmvi.api.MVIIntent
+import pro.respawn.flowmvi.api.MVIState
+import pro.respawn.flowmvi.api.Store
+import pro.respawn.flowmvi.api.StorePlugin
+import pro.respawn.flowmvi.dsl.StoreBuilder
+import pro.respawn.flowmvi.dsl.plugin
+import pro.respawn.flowmvi.dsl.updateStateImmediate
+
+private const val Name = "ResetStatePlugin"
+
+/**
+ * Normally, [Store]s do not reset their state back to the initial value when they are stopped.
+ *
+ * This plugin sets the state back to the initial value (defined in the builder) each time the store is stopped.
+ *
+ * Install this plugin first preferably.
+ *
+ * **This plugin can only be installed ONCE**.
+ **/
+public fun resetStatePlugin(): StorePlugin = plugin {
+ this.name = Name
+ onStop { e ->
+ updateStateImmediate { config.initial }
+ }
+}
+
+/**
+ * Install a new [resetStatePlugin].
+ **/
+public fun StoreBuilder.resetStateOnStop() = install(
+ resetStatePlugin()
+)
diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt
index d6585e73..6386dde3 100644
--- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt
+++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ReentrantLock.kt
@@ -3,20 +3,27 @@ package pro.respawn.flowmvi.util
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
+import pro.respawn.flowmvi.exceptions.RecursiveStateTransactionException
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.coroutineContext
import kotlin.jvm.JvmInline
@PublishedApi
-internal suspend inline fun Mutex?.withReentrantLock(crossinline block: suspend () -> T): T {
- if (this == null) return block()
- val key = ReentrantMutexContextKey(this)
+internal suspend inline fun ReentrantMutexContextElement.withReentrantLock(
+ crossinline block: suspend () -> T
+) = when {
// call block directly when this mutex is already locked in the context
- if (coroutineContext[key] != null) return block()
+ coroutineContext[key] != null -> block()
// otherwise add it to the context and lock the mutex
- return withContext(ReentrantMutexContextElement(key)) {
- withLock { block() }
- }
+ else -> withContext(this) { key.mutex.withLock { block() } }
+}
+
+@PublishedApi
+internal suspend inline fun ReentrantMutexContextElement.withValidatedLock(
+ crossinline block: suspend () -> T
+) = when {
+ coroutineContext[key] != null -> throw RecursiveStateTransactionException(null)
+ else -> withContext(this) { key.mutex.withLock { block() } }
}
@JvmInline
diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt
index f55682ce..b3ec0bb3 100644
--- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt
+++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt
@@ -25,7 +25,11 @@ class StoreLaunchTest : FreeSpec({
val timeTravel = testTimeTravel()
afterEach { timeTravel.reset() }
"Given store" - {
- val store = testStore(timeTravel)
+ val store = testStore(timeTravel) {
+ configure {
+ allowIdleSubscriptions = true
+ }
+ }
"then can be launched and stopped" {
coroutineScope {
val job = shouldNotThrowAny {
diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt
index beddd65a..06849104 100644
--- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt
+++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt
@@ -1,13 +1,17 @@
package pro.respawn.flowmvi.test.store
import app.cash.turbine.test
+import io.kotest.assertions.throwables.shouldThrowExactly
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
+import pro.respawn.flowmvi.api.StateStrategy.Atomic
import pro.respawn.flowmvi.dsl.LambdaIntent
import pro.respawn.flowmvi.dsl.intent
-import pro.respawn.flowmvi.dsl.send
+import pro.respawn.flowmvi.dsl.state
+import pro.respawn.flowmvi.dsl.updateStateImmediate
+import pro.respawn.flowmvi.exceptions.RecursiveStateTransactionException
import pro.respawn.flowmvi.test.subscribeAndTest
import pro.respawn.flowmvi.util.TestAction
import pro.respawn.flowmvi.util.TestState
@@ -17,12 +21,18 @@ import pro.respawn.flowmvi.util.testStore
import pro.respawn.flowmvi.util.testTimeTravel
class StoreStatesTest : FreeSpec({
+
asUnconfined()
+
val timeTravel = testTimeTravel()
beforeEach { timeTravel.reset() }
"given lambdaIntent store" - {
- val store = testStore(timeTravel)
+ val store = testStore(timeTravel) {
+ configure {
+ parallelIntents = false
+ }
+ }
"and intent that blocks state" - {
val blockingIntent = LambdaIntent {
launch {
@@ -34,6 +44,7 @@ class StoreStatesTest : FreeSpec({
"then state is never updated by another intent" {
store.subscribeAndTest {
emit(blockingIntent)
+ idle()
intent {
updateState {
TestState.SomeData(1)
@@ -48,7 +59,8 @@ class StoreStatesTest : FreeSpec({
}
"then withState is never executed" {
store.subscribeAndTest {
- send(blockingIntent)
+ emit(blockingIntent)
+ idle()
intent {
withState {
throw AssertionError("WithState was executed")
@@ -66,7 +78,8 @@ class StoreStatesTest : FreeSpec({
store.subscribeAndTest {
states.test {
awaitItem() shouldBe TestState.Some
- intent(blockingIntent)
+ emit(blockingIntent)
+ idle()
intent { updateStateImmediate { newState } }
awaitItem() shouldBe newState
state shouldBe newState
@@ -75,4 +88,28 @@ class StoreStatesTest : FreeSpec({
}
}
}
+ "given non-reentrant atomic store" - {
+ val store = testStore {
+ configure {
+ stateStrategy = Atomic(reentrant = false)
+ parallelIntents = false
+ }
+ }
+ "and recursive intent that blocks state" - {
+ val blockingIntent = LambdaIntent {
+ updateState {
+ updateState { awaitCancellation() }
+ this
+ }
+ }
+ // TODO: Throws correctly, but crashes the test suite
+ "then store throws".config(enabled = false) {
+ shouldThrowExactly {
+ store.subscribeAndTest {
+ emit(blockingIntent)
+ }
+ }
+ }
+ }
+ }
})
diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt
index e708593e..5cd147f1 100644
--- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt
+++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt
@@ -4,6 +4,7 @@ package pro.respawn.flowmvi.util
import pro.respawn.flowmvi.annotation.ExperimentalFlowMVIAPI
import pro.respawn.flowmvi.api.ActionShareBehavior
+import pro.respawn.flowmvi.api.StateStrategy
import pro.respawn.flowmvi.decorator.DecoratorBuilder
import pro.respawn.flowmvi.decorator.decorator
import pro.respawn.flowmvi.dsl.BuildStore
@@ -13,6 +14,7 @@ import pro.respawn.flowmvi.dsl.store
import pro.respawn.flowmvi.logging.PlatformStoreLogger
import pro.respawn.flowmvi.plugins.TimeTravel
import pro.respawn.flowmvi.plugins.enableLogging
+import pro.respawn.flowmvi.plugins.resetStateOnStop
import pro.respawn.flowmvi.plugins.timeTravel
internal typealias TestTimeTravel = TimeTravel, TestAction>
@@ -27,16 +29,18 @@ internal fun testTimeTravel() = TestTimeTravel()
internal fun testStore(
timeTravel: TestTimeTravel = testTimeTravel(),
initial: TestState = TestState.Some,
- behavior: ActionShareBehavior = ActionShareBehavior.Distribute(),
configure: BuildStore, TestAction> = {},
) = store(initial) {
configure {
- debuggable = false
+ debuggable = true
+ allowTransientSubscriptions = true
name = "TestStore"
- actionShareBehavior = behavior
- atomicStateUpdates = true
+ actionShareBehavior = ActionShareBehavior.Distribute()
+ stateStrategy = StateStrategy.Atomic(reentrant = false)
logger = PlatformStoreLogger
+ parallelIntents = false
}
+ resetStateOnStop()
enableLogging()
timeTravel(timeTravel)
configure()
diff --git a/debugger/app/build.gradle.kts b/debugger/app/build.gradle.kts
index 2862a76d..e0548598 100644
--- a/debugger/app/build.gradle.kts
+++ b/debugger/app/build.gradle.kts
@@ -1,7 +1,7 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
- id(libs.plugins.kotlinMultiplatform.id)
+ id(libs.plugins.kotlin.multiplatform.id)
alias(libs.plugins.compose)
alias(libs.plugins.compose.compiler)
}
diff --git a/debugger/debugger-client/build.gradle.kts b/debugger/debugger-client/build.gradle.kts
index 19d7b6fd..f5495998 100644
--- a/debugger/debugger-client/build.gradle.kts
+++ b/debugger/debugger-client/build.gradle.kts
@@ -1,7 +1,7 @@
plugins {
kotlin("multiplatform")
id("com.android.library")
- alias(libs.plugins.serialization)
+ alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.maven.publish)
dokkaDocumentation
}
@@ -26,5 +26,4 @@ dependencies {
commonMainImplementation(libs.kotlin.atomicfu)
commonMainImplementation(libs.bundles.ktor.client)
commonMainImplementation(libs.bundles.serialization)
- commonMainImplementation(libs.uuid)
}
diff --git a/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebugClientStore.kt b/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebugClientStore.kt
index 72fb411c..dd4ddfcb 100644
--- a/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebugClientStore.kt
+++ b/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebugClientStore.kt
@@ -1,6 +1,5 @@
package pro.respawn.flowmvi.debugger.client
-import com.benasher44.uuid.uuid4
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.receiveDeserialized
@@ -36,6 +35,7 @@ import pro.respawn.flowmvi.plugins.init
import pro.respawn.flowmvi.plugins.recover
import pro.respawn.flowmvi.plugins.reduce
import kotlin.time.Duration
+import kotlin.uuid.Uuid
internal typealias DebugClientStore = Store
@@ -47,7 +47,7 @@ internal fun debugClientStore(
reconnectionDelay: Duration,
logEvents: Boolean = false,
) = store(EmptyState) {
- val id = uuid4()
+ val id = Uuid.random()
val session = MutableStateFlow(null)
configure {
name = "${clientName}Debugger"
diff --git a/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebuggerPlugin.kt b/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebuggerPlugin.kt
index dd8308c7..64ecd7dc 100644
--- a/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebuggerPlugin.kt
+++ b/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebuggerPlugin.kt
@@ -26,6 +26,7 @@ import pro.respawn.flowmvi.debugger.model.ServerEvent.RollbackState
import pro.respawn.flowmvi.debugger.model.ServerEvent.RollbackToInitialState
import pro.respawn.flowmvi.dsl.StoreBuilder
import pro.respawn.flowmvi.dsl.plugin
+import pro.respawn.flowmvi.dsl.updateStateImmediate
import pro.respawn.flowmvi.logging.warn
import pro.respawn.flowmvi.plugins.NoOpPlugin
import pro.respawn.flowmvi.plugins.TimeTravel
@@ -60,7 +61,7 @@ private fun DebugClientStore.asPlug
timeTravel.states.lastIndex - 1
// ignore plugins, including self, to not loop the event
)?.let { previous -> updateStateImmediate { previous } }
- is RollbackToInitialState -> updateStateImmediate { config.initial }
+ is RollbackToInitialState -> updateStateImmediate { this@ctx.config.initial }
is ServerEvent.Stop -> this@ctx.close()
}
}
diff --git a/debugger/debugger-common/build.gradle.kts b/debugger/debugger-common/build.gradle.kts
index 8dc2e24a..e61d96b9 100644
--- a/debugger/debugger-common/build.gradle.kts
+++ b/debugger/debugger-common/build.gradle.kts
@@ -1,7 +1,7 @@
plugins {
kotlin("multiplatform")
id("com.android.library")
- alias(libs.plugins.serialization)
+ alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.maven.publish)
dokkaDocumentation
}
@@ -19,5 +19,4 @@ dependencies {
commonMainApi(projects.core)
commonMainImplementation(libs.kotlin.atomicfu)
commonMainImplementation(libs.bundles.serialization)
- commonMainImplementation(libs.uuid)
}
diff --git a/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/model/ClientEvent.kt b/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/model/ClientEvent.kt
index 0e6fca28..abbb2a89 100644
--- a/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/model/ClientEvent.kt
+++ b/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/model/ClientEvent.kt
@@ -1,17 +1,14 @@
-@file:UseSerializers(UUIDSerializer::class)
@file:Suppress("UndocumentedPublicClass", "UndocumentedPublicProperty") // response models for internal usage
package pro.respawn.flowmvi.debugger.model
-import com.benasher44.uuid.Uuid
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-import kotlinx.serialization.UseSerializers
import pro.respawn.flowmvi.api.MVIAction
import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.debugger.name
-import pro.respawn.flowmvi.debugger.serializers.UUIDSerializer
+import kotlin.uuid.Uuid
@Serializable
@SerialName("client")
diff --git a/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/model/ServerEvent.kt b/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/model/ServerEvent.kt
index a580a391..9e2f5ef9 100644
--- a/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/model/ServerEvent.kt
+++ b/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/model/ServerEvent.kt
@@ -1,14 +1,11 @@
-@file:UseSerializers(UUIDSerializer::class)
@file:Suppress("UndocumentedPublicClass", "UndocumentedPublicProperty") // response models for internal usage
package pro.respawn.flowmvi.debugger.model
-import com.benasher44.uuid.Uuid
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-import kotlinx.serialization.UseSerializers
import pro.respawn.flowmvi.api.MVIAction
-import pro.respawn.flowmvi.debugger.serializers.UUIDSerializer
+import kotlin.uuid.Uuid
@Serializable
@SerialName("ServerEvent")
diff --git a/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/serializers/UUIDSerializer.kt b/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/serializers/UUIDSerializer.kt
deleted file mode 100644
index d31f1777..00000000
--- a/debugger/debugger-common/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/serializers/UUIDSerializer.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package pro.respawn.flowmvi.debugger.serializers
-
-import com.benasher44.uuid.Uuid
-import com.benasher44.uuid.uuidFrom
-import kotlinx.serialization.KSerializer
-import kotlinx.serialization.descriptors.PrimitiveKind
-import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
-import kotlinx.serialization.descriptors.SerialDescriptor
-import kotlinx.serialization.encoding.Decoder
-import kotlinx.serialization.encoding.Encoder
-
-/**
- * Serializer for the internal flowmvi UUID type
- */
-public object UUIDSerializer : KSerializer {
-
- override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
- serialName = "com.benasher44.uuid.UUID",
- kind = PrimitiveKind.STRING
- )
-
- override fun deserialize(decoder: Decoder): Uuid = uuidFrom(decoder.decodeString())
- override fun serialize(encoder: Encoder, value: Uuid): Unit = encoder.encodeString(value.toString())
-}
diff --git a/debugger/debugger-plugin/build.gradle.kts b/debugger/debugger-plugin/build.gradle.kts
index 04ad0f78..0240e19d 100644
--- a/debugger/debugger-plugin/build.gradle.kts
+++ b/debugger/debugger-plugin/build.gradle.kts
@@ -1,7 +1,7 @@
plugins {
kotlin("multiplatform")
id("com.android.library")
- alias(libs.plugins.serialization)
+ alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.maven.publish)
dokkaDocumentation
}
@@ -31,5 +31,4 @@ dependencies {
commonMainImplementation(libs.bundles.ktor.client)
commonMainImplementation(libs.ktor.client.engine)
commonMainImplementation(libs.bundles.serialization)
- commonMainImplementation(libs.uuid)
}
diff --git a/debugger/ideplugin/build.gradle.kts b/debugger/ideplugin/build.gradle.kts
index 29aa032f..fec2162c 100644
--- a/debugger/ideplugin/build.gradle.kts
+++ b/debugger/ideplugin/build.gradle.kts
@@ -34,6 +34,7 @@ intellijPlatform {
}
publishing {
token = props["plugin.publishing.token"]?.toString()
+ hidden = true
}
pluginConfiguration {
ideaVersion {
@@ -49,6 +50,7 @@ intellijPlatform {
description = Config.Plugin.description
name = Config.Plugin.name
version = Config.versionName
+ changeNotes = System.getenv("CHANGELOG")
}
}
diff --git a/debugger/ideplugin/src/main/resources/LiveTemplates.xml b/debugger/ideplugin/src/main/resources/LiveTemplates.xml
index 75d721de..34e469c5 100644
--- a/debugger/ideplugin/src/main/resources/LiveTemplates.xml
+++ b/debugger/ideplugin/src/main/resources/LiveTemplates.xml
@@ -1,5 +1,5 @@
-
+
@@ -8,12 +8,25 @@
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/debugger/server/build.gradle.kts b/debugger/server/build.gradle.kts
index cb28e9ac..b34759ca 100644
--- a/debugger/server/build.gradle.kts
+++ b/debugger/server/build.gradle.kts
@@ -1,10 +1,8 @@
-import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
-
plugins {
- id(libs.plugins.kotlinMultiplatform.id)
+ id(libs.plugins.kotlin.multiplatform.id)
alias(libs.plugins.compose)
alias(libs.plugins.compose.compiler)
- alias(libs.plugins.serialization)
+ alias(libs.plugins.kotlin.serialization)
}
val parentNamespace = namespaceByPath()
@@ -45,13 +43,17 @@ tasks {
}
kotlin {
jvm {
- @OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget = Config.jvmTarget
}
}
-
sourceSets {
+ all {
+ languageSettings {
+ progressiveMode = true
+ Config.optIns.forEach { optIn(it) }
+ }
+ }
commonMain {
kotlin.srcDir(generateBuildConfig.map { it.destinationDir })
}
@@ -79,7 +81,6 @@ kotlin {
implementation(libs.kotlin.datetime)
implementation(libs.kotlin.collections)
implementation(applibs.apiresult)
- implementation(libs.uuid)
implementation(applibs.bundles.koin)
implementation(libs.kotlin.io)
implementation(libs.kotlin.atomicfu)
diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServer.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServer.kt
index 143bd697..a65f1401 100644
--- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServer.kt
+++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServer.kt
@@ -27,6 +27,7 @@ import pro.respawn.flowmvi.logging.PlatformStoreLogger
import pro.respawn.flowmvi.logging.StoreLogLevel
import pro.respawn.flowmvi.logging.invoke
import pro.respawn.kmmutils.common.asUUID
+import kotlin.uuid.toKotlinUuid
internal object DebugServer : Container {
@@ -44,7 +45,7 @@ internal object DebugServer : Container
routing {
get("/") { call.respondText("FlowMVI Debugger Online", null) }
webSocket("/{id}") {
- val storeId = call.parameters.getOrFail("id").asUUID
+ val storeId = call.parameters.getOrFail("id").asUUID.toKotlinUuid()
with(store) {
try {
subscribe {
diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerContract.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerContract.kt
index aaef56a7..12348d8f 100644
--- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerContract.kt
+++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerContract.kt
@@ -1,8 +1,6 @@
package pro.respawn.flowmvi.debugger.server
import androidx.compose.runtime.Immutable
-import com.benasher44.uuid.Uuid
-import com.benasher44.uuid.uuid4
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentListOf
@@ -14,6 +12,7 @@ import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.debugger.model.ClientEvent
import pro.respawn.flowmvi.debugger.model.ServerEvent
+import kotlin.uuid.Uuid
internal enum class StoreCommand {
Stop, ResendIntent, RollbackState, ResendAction, RethrowException, SetInitialState
@@ -33,7 +32,7 @@ internal data class ServerEventEntry(
val name: String,
val event: ClientEvent,
val timestamp: Instant = Clock.System.now(),
- val id: Uuid = uuid4(),
+ val id: Uuid = Uuid.random(),
)
@Immutable
diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerStore.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerStore.kt
index 5c5d0cca..a5dd8380 100644
--- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerStore.kt
+++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerStore.kt
@@ -1,6 +1,5 @@
package pro.respawn.flowmvi.debugger.server
-import com.benasher44.uuid.Uuid
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
@@ -22,9 +21,11 @@ import pro.respawn.flowmvi.debugger.server.ServerState.Running
import pro.respawn.flowmvi.debugger.server.arch.configuration.debuggable
import pro.respawn.flowmvi.dsl.lazyStore
import pro.respawn.flowmvi.dsl.updateState
+import pro.respawn.flowmvi.dsl.updateStateImmediate
import pro.respawn.flowmvi.plugins.enableLogging
import pro.respawn.flowmvi.plugins.recover
import pro.respawn.flowmvi.plugins.reduce
+import kotlin.uuid.Uuid
import pro.respawn.flowmvi.debugger.server.ServerAction as Action
import pro.respawn.flowmvi.debugger.server.ServerIntent as Intent
import pro.respawn.flowmvi.debugger.server.ServerState as State
diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/AppNavigator.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/AppNavigator.kt
index 638b2b57..7490a458 100644
--- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/AppNavigator.kt
+++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/AppNavigator.kt
@@ -1,7 +1,7 @@
package pro.respawn.flowmvi.debugger.server.navigation
-import com.benasher44.uuid.Uuid
import pro.respawn.flowmvi.debugger.server.navigation.util.Navigator
+import kotlin.uuid.Uuid
interface AppNavigator : Navigator {
fun connect()
diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/AppNavigatorImpl.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/AppNavigatorImpl.kt
index 13b97ea0..b21ab9be 100644
--- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/AppNavigatorImpl.kt
+++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/AppNavigatorImpl.kt
@@ -5,11 +5,11 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
-import com.benasher44.uuid.Uuid
import pro.respawn.flowmvi.debugger.server.navigation.component.RootComponent
import pro.respawn.flowmvi.debugger.server.navigation.component.StackComponent
import pro.respawn.flowmvi.debugger.server.navigation.destination.Destination
import pro.respawn.flowmvi.debugger.server.navigation.details.DetailsComponent
+import kotlin.uuid.Uuid
@Composable
fun rememberAppNavigator(
diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/destination/Destination.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/destination/Destination.kt
index 5c9519a3..27a3b5bf 100644
--- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/destination/Destination.kt
+++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/navigation/destination/Destination.kt
@@ -1,12 +1,9 @@
-@file:UseSerializers(UUIDSerializer::class)
package pro.respawn.flowmvi.debugger.server.navigation.destination
import androidx.compose.runtime.Immutable
-import com.benasher44.uuid.Uuid
import kotlinx.serialization.Serializable
-import kotlinx.serialization.UseSerializers
-import pro.respawn.flowmvi.debugger.serializers.UUIDSerializer
+import kotlin.uuid.Uuid
@Serializable
@Immutable
diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsContainer.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsContainer.kt
index 815d3abc..13b0dc62 100644
--- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsContainer.kt
+++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsContainer.kt
@@ -1,6 +1,5 @@
package pro.respawn.flowmvi.debugger.server.ui.screens.storedetails
-import com.benasher44.uuid.Uuid
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onEach
@@ -27,6 +26,7 @@ import pro.respawn.flowmvi.plugins.recover
import pro.respawn.flowmvi.plugins.reduce
import pro.respawn.flowmvi.plugins.whileSubscribed
import pro.respawn.flowmvi.util.typed
+import kotlin.uuid.Uuid
private typealias Ctx = PipelineContext
diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsContract.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsContract.kt
index eac25891..0464d348 100644
--- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsContract.kt
+++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsContract.kt
@@ -1,7 +1,6 @@
package pro.respawn.flowmvi.debugger.server.ui.screens.storedetails
import androidx.compose.runtime.Immutable
-import com.benasher44.uuid.Uuid
import kotlinx.collections.immutable.ImmutableList
import pro.respawn.flowmvi.api.MVIAction
import pro.respawn.flowmvi.api.MVIIntent
@@ -9,6 +8,7 @@ import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.debugger.server.ServerEventEntry
import pro.respawn.flowmvi.debugger.server.StoreCommand
import pro.respawn.flowmvi.debugger.server.ui.screens.timeline.FocusedEvent
+import kotlin.uuid.Uuid
@Immutable
internal sealed interface StoreDetailsState : MVIState {
diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsScreen.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsScreen.kt
index 71b6234c..bf1f306a 100644
--- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsScreen.kt
+++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/storedetails/StoreDetailsScreen.kt
@@ -23,8 +23,6 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
-import com.benasher44.uuid.Uuid
-import com.benasher44.uuid.uuid4
import kotlinx.collections.immutable.toImmutableList
import org.koin.core.parameter.parametersOf
import pro.respawn.flowmvi.api.IntentReceiver
@@ -50,6 +48,7 @@ import pro.respawn.flowmvi.debugger.server.ui.widgets.TypeCrossfade
import pro.respawn.flowmvi.util.typed
import pro.respawn.kmmutils.common.copies
import pro.respawn.kmmutils.compose.annotate
+import kotlin.uuid.Uuid
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -130,13 +129,13 @@ private fun StoreDetailsScreenPreview() = RespawnTheme {
EmptyReceiver {
StoreDetailsScreenContent(
state = DisplayingStore(
- id = uuid4(),
+ id = Uuid.random(),
name = "Store ".repeat(10),
connected = false,
eventLog = ServerEventEntry(
- storeId = uuid4(),
+ storeId = Uuid.random(),
name = "Store",
- event = ClientEvent.StoreConnected("Store", id = uuid4())
+ event = ClientEvent.StoreConnected("Store", id = Uuid.random())
).copies(10).toImmutableList(),
),
)
diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineContract.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineContract.kt
index fa793a2d..dc4d9e9c 100644
--- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineContract.kt
+++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineContract.kt
@@ -1,7 +1,6 @@
package pro.respawn.flowmvi.debugger.server.ui.screens.timeline
import androidx.compose.runtime.Immutable
-import com.benasher44.uuid.Uuid
import kotlinx.collections.immutable.ImmutableList
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
@@ -12,6 +11,7 @@ import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.debugger.model.ClientEvent
import pro.respawn.flowmvi.debugger.server.ServerEventEntry
import pro.respawn.flowmvi.debugger.server.util.type
+import kotlin.uuid.Uuid
internal enum class EventType {
Intent, Action, StateChange, Subscription, Connection, Exception, Initialization
diff --git a/essenty/essenty-compose/build.gradle.kts b/essenty/essenty-compose/build.gradle.kts
index a1960514..aeac63d6 100644
--- a/essenty/essenty-compose/build.gradle.kts
+++ b/essenty/essenty-compose/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id(libs.plugins.kotlinMultiplatform.id)
+ id(libs.plugins.kotlin.multiplatform.id)
id(libs.plugins.androidLibrary.id)
alias(libs.plugins.compose)
alias(libs.plugins.compose.compiler)
diff --git a/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/plugins/KeepStatePlugin.kt b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/plugins/KeepStatePlugin.kt
index 5ef85f63..5881ef6b 100644
--- a/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/plugins/KeepStatePlugin.kt
+++ b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/plugins/KeepStatePlugin.kt
@@ -11,6 +11,7 @@ import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.api.StorePlugin
import pro.respawn.flowmvi.dsl.StoreBuilder
import pro.respawn.flowmvi.dsl.plugin
+import pro.respawn.flowmvi.dsl.state
import pro.respawn.flowmvi.essenty.dsl.retainedStore
import pro.respawn.flowmvi.essenty.savedstate.ensureNotRegistered
import pro.respawn.flowmvi.util.typed
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2c2f6c73..c613cd3d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -13,25 +13,26 @@ detekt = "1.23.7"
dokka = "2.0.0-Beta"
essenty = "2.3.0"
fragment = "1.8.5"
-gradleAndroid = "8.7.2"
+gradleAndroid = "8.8.0-rc01"
gradleDoctorPlugin = "0.10.0"
intellij-ide-plugin = "2.1.0"
intellij-idea = "2024.1"
junit = "4.13.2"
kotest = "6.0.0.M1"
+jmh = "1.37"
# @pin
kotlin = "2.1.0"
kotlin-collections = "0.3.8"
kotlin-browser = "0.3"
kotlin-io = "0.6.0"
-kotlinx-atomicfu = "0.26.0"
-ktor = "3.0.1"
+kotlinx-atomicfu = "0.26.1"
+ktor = "3.0.2"
lifecycle = "2.8.4"
maven-publish-plugin = "0.30.0"
serialization = "1.7.3"
turbine = "1.2.0"
-uuid = "0.8.4"
versionCatalogUpdatePlugin = "0.8.5"
+kotlin-benchmark = "0.4.13"
[libraries]
android-gradle = { module = "com.android.tools.build:gradle", version.ref = "gradleAndroid" }
@@ -68,6 +69,7 @@ kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-c
kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" }
+kotlin-benchmark = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlin-benchmark" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
@@ -93,7 +95,6 @@ lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runti
lifecycle-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" }
lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
-uuid = { module = "com.benasher44:uuid", version.ref = "uuid" }
[bundles]
ktor-client = [
@@ -144,7 +145,9 @@ dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
gradleDoctor = { id = "com.osacky.doctor", version.ref = "gradleDoctorPlugin" }
intellij-ide = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-ide-plugin" }
kotest = { id = "io.kotest.multiplatform", version.ref = "kotest" }
-kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+kotlin-allopen = { id = "org.jetbrains.kotlin.plugin.allopen", version.ref = "kotlin" }
maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish-plugin" }
-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdatePlugin" }
+kotlin-benchmark = { id = "org.jetbrains.kotlinx.benchmark", version.ref = "kotlin-benchmark" }
diff --git a/metrics/build.gradle.kts b/metrics/build.gradle.kts
new file mode 100644
index 00000000..0e70684e
--- /dev/null
+++ b/metrics/build.gradle.kts
@@ -0,0 +1,28 @@
+import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
+
+@Suppress("DSL_SCOPE_VIOLATION")
+plugins {
+ id(libs.plugins.kotlin.multiplatform.id)
+ id(libs.plugins.androidLibrary.id)
+ alias(libs.plugins.atomicfu)
+ alias(libs.plugins.maven.publish)
+ dokkaDocumentation
+}
+
+kotlin {
+ @OptIn(ExperimentalKotlinGradlePluginApi::class)
+ configureMultiplatform(
+ ext = this,
+ wasmWasi = false, // datetime does not support wasmWasi
+ )
+}
+
+android {
+ configureAndroidLibrary(this)
+ namespace = "${Config.namespace}.metrics"
+}
+
+dependencies {
+ commonMainImplementation(libs.kotlin.datetime)
+ commonMainImplementation(libs.kotlin.atomicfu)
+}
diff --git a/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/P2QuantileEstimator.kt b/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/P2QuantileEstimator.kt
new file mode 100644
index 00000000..e8c4d426
--- /dev/null
+++ b/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/P2QuantileEstimator.kt
@@ -0,0 +1,149 @@
+package pro.respawn.flowmvi.metrics
+
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
+import kotlin.math.abs
+import kotlin.math.round
+import kotlin.math.sign
+
+/**
+ * P2quantile algorithm implementation to estimate median values.
+ * Credit: https://aakinshin.net/posts/ex-p2-quantile-estimator/
+ * Accessed on 2024-11-27
+ */
+internal class P2QuantileEstimator(private vararg val probabilities: Double) : SynchronizedObject() {
+
+ private val m: Int = probabilities.size
+ private val markerCount: Int = 2 * m + 3
+ private val n: IntArray = IntArray(markerCount)
+ private val ns: DoubleArray = DoubleArray(markerCount)
+ private val q: DoubleArray = DoubleArray(markerCount)
+
+ var count: Int = 0
+ private set
+
+ fun add(value: Double) = synchronized(this) {
+ if (count < markerCount) {
+ q[count++] = value
+ if (count == markerCount) {
+ q.sort()
+
+ updateNs(markerCount - 1)
+ for (i in 0 until markerCount) {
+ n[i] = round(ns[i]).toInt()
+ }
+ q.copyInto(ns)
+ for (i in 0 until markerCount) {
+ q[i] = ns[n[i]]
+ }
+ updateNs(markerCount - 1)
+ }
+ return@synchronized
+ }
+
+ var k = -1
+ if (value < q[0]) {
+ q[0] = value
+ k = 0
+ } else {
+ for (i in 1 until markerCount) {
+ if (value < q[i]) {
+ k = i - 1
+ break
+ }
+ }
+ if (k == -1) {
+ q[markerCount - 1] = value
+ k = markerCount - 2
+ }
+ }
+
+ for (i in k + 1 until markerCount) {
+ n[i]++
+ }
+ updateNs(count)
+
+ var leftI = 1
+ var rightI = markerCount - 2
+ while (leftI <= rightI) {
+ val i: Int
+ if (abs(ns[leftI] / count - 0.5) <= abs(ns[rightI] / count - 0.5)) {
+ i = leftI
+ leftI++
+ } else {
+ i = rightI
+ rightI--
+ }
+ adjust(i)
+ }
+
+ count++
+ }
+
+ private fun updateNs(maxIndex: Int) {
+ // Principal markers
+ ns[0] = 0.0
+ for (i in 0 until m) {
+ ns[i * 2 + 2] = maxIndex * probabilities[i]
+ }
+ ns[markerCount - 1] = maxIndex.toDouble()
+
+ // Middle markers
+ ns[1] = maxIndex * probabilities[0] / 2.0
+ for (i in 1 until m) {
+ ns[2 * i + 1] = maxIndex * (probabilities[i - 1] + probabilities[i]) / 2.0
+ }
+ ns[markerCount - 2] = maxIndex * (1 + probabilities[m - 1]) / 2.0
+ }
+
+ private fun adjust(i: Int) {
+ val d = ns[i] - n[i]
+ if (d >= 1 && n[i + 1] - n[i] > 1 || d <= -1 && n[i - 1] - n[i] < -1) {
+ val dInt = d.sign.toInt()
+ val qs = parabolic(i, dInt.toDouble())
+ if (q[i - 1] < qs && qs < q[i + 1]) {
+ q[i] = qs
+ } else {
+ q[i] = linear(i, dInt)
+ }
+ n[i] += dInt
+ }
+ }
+
+ private fun parabolic(i: Int, d: Double): Double {
+ val nPlus1 = n[i + 1].toDouble()
+ val nMinus1 = n[i - 1].toDouble()
+ val nI = n[i].toDouble()
+
+ val qPlus1 = q[i + 1]
+ val qMinus1 = q[i - 1]
+ val qI = q[i]
+
+ val numerator = (nI - nMinus1 + d) * (qPlus1 - qI) / (nPlus1 - nI) +
+ (nPlus1 - nI - d) * (qI - qMinus1) / (nI - nMinus1)
+
+ return qI + d / (nPlus1 - nMinus1) * numerator
+ }
+
+ private fun linear(i: Int, d: Int) = q[i] + d * (q[i + d] - q[i]) / (n[i + d] - n[i]).toDouble()
+
+ fun getQuantile(p: Double): Double = synchronized(this) {
+ if (count == 0) return Double.NaN
+ if (count <= markerCount) {
+ q.sort(0, count)
+ val index = round((count - 1) * p).toInt()
+ return q[index]
+ }
+
+ for (i in probabilities.indices) {
+ if (probabilities[i] == p)
+ return q[2 * i + 2]
+ }
+
+ throw IllegalStateException("Target quantile ($p) wasn't requested in the constructor")
+ }
+
+ fun clear() {
+ count = 0
+ }
+}
diff --git a/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/PerformanceMetrics.kt b/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/PerformanceMetrics.kt
new file mode 100644
index 00000000..20917527
--- /dev/null
+++ b/metrics/src/commonMain/kotlin/pro/respawn/flowmvi/metrics/PerformanceMetrics.kt
@@ -0,0 +1,65 @@
+package pro.respawn.flowmvi.metrics
+
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
+import kotlinx.datetime.Clock
+import kotlin.math.min
+import kotlin.time.Duration.Companion.seconds
+
+internal class PerformanceMetrics : SynchronizedObject() {
+
+ // For Average Time using EMA
+ private var ema: Double = 0.0
+ private val alpha: Double = 0.1 // Smoothing factor
+
+ // For Median Time using PĀ² Algorithm
+ private var p2: P2QuantileEstimator = P2QuantileEstimator(0.5) // Median
+
+ var totalOperations: Long = 0
+ private set
+
+ private val numberOfBuckets: Int = 60 // Last 60 seconds
+ private val frequencyBuckets = IntArray(numberOfBuckets)
+ private val bucketDurationMillis = 1.seconds // 1 second per bucket
+ private var lastBucketTime = Clock.System.now()
+
+ // Call this method when an operation is measured
+ fun recordOperation(durationMillis: Long) = synchronized(this) {
+ totalOperations++
+
+ // Update EMA
+ ema = if (ema == 0.0) durationMillis.toDouble() else alpha * durationMillis + (1 - alpha) * ema
+
+ // Update Median Estimate
+ p2.add(durationMillis.toDouble())
+
+ // Update Frequency Counter
+ updateFrequencyCounter()
+ }
+
+ private fun updateFrequencyCounter() = synchronized(this) {
+ val currentTime = Clock.System.now()
+ val elapsedBuckets = ((currentTime - lastBucketTime) / bucketDurationMillis).toInt()
+
+ if (elapsedBuckets > 0) {
+ // Shift the buckets
+ val shift = min(elapsedBuckets, numberOfBuckets)
+ frequencyBuckets.copyInto(frequencyBuckets, shift, 0, numberOfBuckets - shift)
+ for (i in numberOfBuckets - shift until numberOfBuckets) {
+ frequencyBuckets[i] = 0
+ }
+ lastBucketTime += bucketDurationMillis * elapsedBuckets
+ }
+
+ // Increment the current bucket
+ frequencyBuckets[numberOfBuckets - 1]++
+ }
+
+ val averageTime get() = ema
+
+ fun medianTime(q: Double): Double = p2.getQuantile(q)
+
+ fun opsPerSecond(): Double = synchronized(this) {
+ return frequencyBuckets.sum().toDouble() / numberOfBuckets
+ }
+}
diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts
index 61608746..8cf46086 100644
--- a/sample/build.gradle.kts
+++ b/sample/build.gradle.kts
@@ -3,11 +3,11 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
- id(libs.plugins.kotlinMultiplatform.id)
+ id(libs.plugins.kotlin.multiplatform.id)
id(applibs.plugins.android.application.id)
alias(libs.plugins.compose)
alias(libs.plugins.compose.compiler)
- alias(libs.plugins.serialization)
+ alias(libs.plugins.kotlin.serialization)
}
// region buildconfig
@@ -90,7 +90,6 @@ kotlin {
implementation(libs.bundles.serialization)
implementation(libs.kotlin.datetime)
- implementation(libs.uuid)
implementation(libs.kotlin.io)
implementation(applibs.bundles.kmputils)
diff --git a/sample/libs.versions.toml b/sample/libs.versions.toml
index 90296512..bd3d363d 100644
--- a/sample/libs.versions.toml
+++ b/sample/libs.versions.toml
@@ -23,7 +23,6 @@ koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-android-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
-koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
apiresult = { module = "pro.respawn.apiresult:core", version.ref = "apiresult" }
androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" }
view-material = { module = "com.google.android.material:material", version.ref = "material" }
diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/undoredo/UndoRedoContainer.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/undoredo/UndoRedoContainer.kt
index 37abe3bf..bce3c96f 100644
--- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/undoredo/UndoRedoContainer.kt
+++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/features/undoredo/UndoRedoContainer.kt
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import pro.respawn.flowmvi.api.Container
import pro.respawn.flowmvi.dsl.store
+import pro.respawn.flowmvi.dsl.updateStateImmediate
import pro.respawn.flowmvi.plugins.recover
import pro.respawn.flowmvi.plugins.reduce
import pro.respawn.flowmvi.plugins.undoRedo
diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destination.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destination.kt
index 2ba43edf..57f33933 100644
--- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destination.kt
+++ b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/navigation/destination/Destination.kt
@@ -1,11 +1,8 @@
-@file:UseSerializers(UUIDSerializer::class)
package pro.respawn.flowmvi.sample.navigation.destination
import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
-import kotlinx.serialization.UseSerializers
-import pro.respawn.flowmvi.sample.util.UUIDSerializer
import pro.respawn.kmmutils.common.fastLazy
@Serializable
diff --git a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/util/UUIDSerializer.kt b/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/util/UUIDSerializer.kt
deleted file mode 100644
index 0176a7db..00000000
--- a/sample/src/commonMain/kotlin/pro/respawn/flowmvi/sample/util/UUIDSerializer.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package pro.respawn.flowmvi.sample.util
-
-import com.benasher44.uuid.Uuid
-import com.benasher44.uuid.uuidFrom
-import kotlinx.serialization.KSerializer
-import kotlinx.serialization.descriptors.PrimitiveKind
-import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
-import kotlinx.serialization.encoding.Decoder
-import kotlinx.serialization.encoding.Encoder
-
-object UUIDSerializer : KSerializer {
-
- override val descriptor = PrimitiveSerialDescriptor("Uuid", PrimitiveKind.STRING)
-
- override fun deserialize(decoder: Decoder) = uuidFrom(decoder.decodeString())
-
- override fun serialize(encoder: Encoder, value: Uuid) = encoder.encodeString(value.toString())
-}
diff --git a/savedstate/build.gradle.kts b/savedstate/build.gradle.kts
index 4f7d9aef..1b6e6203 100644
--- a/savedstate/build.gradle.kts
+++ b/savedstate/build.gradle.kts
@@ -1,9 +1,9 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
plugins {
- kotlin("multiplatform")
- alias(libs.plugins.serialization)
- id("com.android.library")
+ alias(libs.plugins.kotlin.serialization)
+ id(libs.plugins.kotlin.multiplatform.id)
+ id(libs.plugins.androidLibrary.id)
alias(libs.plugins.maven.publish)
dokkaDocumentation
}
diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt
index ec1b72ab..2752f708 100644
--- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt
+++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt
@@ -3,6 +3,7 @@ package pro.respawn.flowmvi.savedstate.api
import pro.respawn.flowmvi.savedstate.plugins.saveStatePlugin
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
/**
* An interface that specifies **when** [saveStatePlugin] is going to save the store's state.
@@ -35,6 +36,15 @@ public sealed interface SaveBehavior {
*/
public data class OnUnsubscribe(val remainingSubscribers: Int = 0) : SaveBehavior
+ /**
+ * Save the data periodically once [delay] period of time has passed.
+ *
+ * First save will occur **after** the delay defined.
+ *
+ * Do not use multiple values of this strategy, because only the smallest value will be respected.
+ */
+ public data class Periodic(val delay: Duration = 5.seconds) : SaveBehavior
+
@Suppress("UndocumentedPublicClass") // document a companion?
public companion object {
diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt
index e7e30025..21562eb5 100644
--- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt
+++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt
@@ -39,7 +39,7 @@ public inline fun DefaultFileSaver(
mutex.withLock { write(state, path) }
}
- // allow cancelling reads (no "NonCancellable here")
+ // allow cancelling reads (no "NonCancellable" here)
override suspend fun restore(): T? = mutex.withLock { read(path) }
}
diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt
index 74a820f6..0fe5afa8 100644
--- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt
+++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt
@@ -17,6 +17,7 @@ import pro.respawn.flowmvi.dsl.lazyPlugin
import pro.respawn.flowmvi.savedstate.api.SaveBehavior
import pro.respawn.flowmvi.savedstate.api.SaveBehavior.OnChange
import pro.respawn.flowmvi.savedstate.api.SaveBehavior.OnUnsubscribe
+import pro.respawn.flowmvi.savedstate.api.SaveBehavior.Periodic
import pro.respawn.flowmvi.savedstate.api.Saver
import pro.respawn.flowmvi.savedstate.dsl.CallbackSaver
import pro.respawn.flowmvi.savedstate.dsl.CompressedFileSaver
@@ -73,11 +74,25 @@ public fun saveStatePlugin(
var job: Job? by atomic(null)
val loggingSaver = LoggingSaver(saver, config.logger, tag = config.name)
+ val saveDelay = behaviors
+ .asSequence()
+ .filterIsInstance()
+ .minOfOrNull { it.delay }
+ ?.also { require(it.isPositive()) { "Periodic save delay must be positive" } }
+
onStart {
withContext(this + context) {
updateState { loggingSaver.restoreCatching() ?: this }
}
+
+ if (saveDelay != null) launch(this + context) {
+ while (isActive) {
+ delay(saveDelay)
+ withState { loggingSaver.saveCatching(this) }
+ }
+ }
}
+
if (resetOnException) onException {
withContext(this + context) { loggingSaver.saveCatching(null) }
it
@@ -88,6 +103,7 @@ public fun saveStatePlugin(
.filterIsInstance()
.maxOfOrNull { it.remainingSubscribers }
?.also { require(it >= 0) { "Subscriber count must be >= 0" } }
+
if (maxSubscribers != null) onUnsubscribe { remainingSubs ->
if (remainingSubs > maxSubscribers) return@onUnsubscribe
job?.cancelAndJoin()
@@ -99,6 +115,7 @@ public fun saveStatePlugin(
.filterIsInstance()
.minOfOrNull { it.delay }
?.also { require(!it.isNegative() && it.isFinite()) { "Delay must be >= 0" } }
+
if (saveTimeout != null) onState { _, new ->
job?.cancelAndJoin()
job = launch(context) {
diff --git a/settings.gradle.kts b/settings.gradle.kts
index b6fdc777..a9d7217d 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -16,9 +16,10 @@ dependencyResolutionManagement {
// REQUIRED for IDE module configuration to resolve IDE platform
repositoriesMode = RepositoriesMode.PREFER_PROJECT
repositories {
- mavenLocal()
+ // mavenLocal()
google()
mavenCentral()
+ maven("https://s01.oss.sonatype.org/content/repositories/snapshots/")
}
versionCatalogs {
@@ -38,6 +39,8 @@ include(":core")
include(":android")
include(":compose")
include(":savedstate")
+include(":metrics")
+include(":benchmarks")
include(":essenty")
include(":essenty:essenty-compose")
include(":debugger:app")
diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt
index 44eb79fb..b716f574 100644
--- a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt
+++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/StoreTestScope.kt
@@ -1,8 +1,9 @@
package pro.respawn.flowmvi.test
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withTimeout
-import pro.respawn.flowmvi.api.DelicateStoreApi
+import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI
import pro.respawn.flowmvi.api.MVIAction
import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
@@ -14,14 +15,14 @@ import kotlin.test.assertIs
/**
* A class which implements a dsl for testing [Store].
*/
+@OptIn(InternalFlowMVIAPI::class)
public class StoreTestScope @PublishedApi internal constructor(
public val provider: Provider,
public val store: Store,
public val timeoutMs: Long = 3000L,
) : Store by store, Provider by provider {
- @OptIn(DelicateStoreApi::class)
- override val state: S by store::state
+ override val states: StateFlow by provider::states
override fun hashCode(): Int = store.hashCode()
override fun equals(other: Any?): Boolean = store == other
override suspend fun emit(intent: I): Unit = store.emit(intent)
diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt
index 7a7b76d5..10fb9bc8 100644
--- a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt
+++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt
@@ -9,6 +9,7 @@ import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.api.Store
import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle
+import pro.respawn.flowmvi.dsl.collect
import kotlin.test.assertTrue
/**
@@ -17,9 +18,12 @@ import kotlin.test.assertTrue
public suspend inline fun Store.test(
crossinline block: suspend Store.() -> Unit
): Unit = coroutineScope {
- start(this)
- block()
- closeAndWait()
+ try {
+ start(this)
+ block()
+ } finally {
+ closeAndWait()
+ }
}
/**
@@ -29,10 +33,8 @@ public suspend inline fun Store Store.subscribeAndTest(
crossinline block: suspend StoreTestScope.() -> Unit,
): Unit = test {
- coroutineScope {
- subscribe {
- StoreTestScope(this, this@subscribeAndTest).run { block() }
- }
+ collect {
+ StoreTestScope(this, this@subscribeAndTest).run { block() }
}
}
diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt
index e3f1c74a..10524498 100644
--- a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt
+++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/plugin/TestPipelineContext.kt
@@ -2,10 +2,13 @@
package pro.respawn.flowmvi.test.plugin
-import kotlinx.atomicfu.atomic
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import pro.respawn.flowmvi.annotation.ExperimentalFlowMVIAPI
+import pro.respawn.flowmvi.annotation.InternalFlowMVIAPI
import pro.respawn.flowmvi.annotation.NotIntendedForInheritance
import pro.respawn.flowmvi.api.DelicateStoreApi
import pro.respawn.flowmvi.api.MVIAction
@@ -15,10 +18,12 @@ import pro.respawn.flowmvi.api.PipelineContext
import pro.respawn.flowmvi.api.StoreConfiguration
import pro.respawn.flowmvi.api.StorePlugin
import pro.respawn.flowmvi.api.lifecycle.StoreLifecycle
+import pro.respawn.flowmvi.dsl.state
+import pro.respawn.flowmvi.dsl.updateStateImmediate
import pro.respawn.flowmvi.test.TestStoreLifecycle
import pro.respawn.flowmvi.test.ensureStarted
-@OptIn(ExperimentalFlowMVIAPI::class, NotIntendedForInheritance::class)
+@OptIn(ExperimentalFlowMVIAPI::class, NotIntendedForInheritance::class, InternalFlowMVIAPI::class)
internal class TestPipelineContext @PublishedApi internal constructor(
override val config: StoreConfiguration,
val plugin: StorePlugin,
@@ -26,8 +31,9 @@ internal class TestPipelineContext @
override val coroutineContext by config::coroutineContext
- override var state: S by atomic(config.initial)
- private set
+ private val _state = MutableStateFlow(config.initial)
+ override val states: StateFlow = _state.asStateFlow()
+ override fun compareAndSet(old: S, new: S): Boolean = _state.compareAndSet(old, new)
@DelicateStoreApi
override fun send(action: A) {
@@ -51,17 +57,11 @@ internal class TestPipelineContext @
override suspend fun updateState(transform: suspend S.() -> S) = with(plugin) {
ensureStarted()
- onState(state, state.transform())?.also { state = it }
- Unit
+ updateStateImmediate { onState(this, transform()) ?: this }
}
override suspend fun withState(block: suspend S.() -> Unit) {
ensureStarted()
block(state)
}
-
- override fun updateStateImmediate(block: S.() -> S) {
- ensureStarted()
- state = block(state)
- }
}