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 @@ -