From 0642d60204ab993fe1933e106346fccc8421832b Mon Sep 17 00:00:00 2001 From: flosch Date: Thu, 22 Jan 2026 21:59:42 +0100 Subject: [PATCH 1/7] Implement 0.5.0 --- .opencode/skills/floschu-store.md | 262 ++++++++++++++++++ CHANGELOG.md | 6 + README.md | 2 + example/composeApp/build.gradle.kts | 11 +- example/gradle/libs.versions.toml | 8 +- lib/build.gradle.kts | 4 +- lib/gradle/libs.versions.toml | 4 +- lib/gradle/wrapper/gradle-wrapper.properties | 2 +- .../florianschuster/store/Implementation.kt | 78 ++++-- .../store/ExecutionJobsTest.kt | 122 +++++++- .../at/florianschuster/store/StoreTest.kt | 103 +++++++ 11 files changed, 553 insertions(+), 49 deletions(-) create mode 100644 .opencode/skills/floschu-store.md diff --git a/.opencode/skills/floschu-store.md b/.opencode/skills/floschu-store.md new file mode 100644 index 0000000..9ce5d23 --- /dev/null +++ b/.opencode/skills/floschu-store.md @@ -0,0 +1,262 @@ +--- +name: floschu-store +description: Implement, debug, and test floschu/store - a unidirectional data flow state management kmp library with coroutines +license: Apache-2.0 +compatibility: opencode +metadata: + language: kotlin + framework: kotlin-multiplatform +--- + +# floschu/store - Kotlin State Management + +A skill for working with the [store](https://github.com/floschu/store) library - an opinionated Kotlin Coroutines multiplatform library for unidirectional data flow (UDF) state management. + +## Core Concepts + +The library follows a Redux/Flux-like pattern: +- **State**: Immutable data held in a `StateFlow` +- **Actions**: Events dispatched to trigger state changes +- **Reducers**: Pure functions that process actions and return new state +- **Effects**: Async side effects (network, database, etc.) +- **Environment**: Dependency container for effects + +## Implementation Patterns + +### 1. Define the Components + +```kotlin +// Environment: holds dependencies for side effects +class LoginEnvironment( + val authService: AuthenticationService, + val tokenRepo: TokenRepository, +) + +// Actions: sealed interface for all possible events +sealed interface LoginAction { + data class EmailChanged(val value: String) : LoginAction + data class PasswordChanged(val value: String) : LoginAction + data object Login : LoginAction + data class LoginResult(val result: Result) : LoginAction +} + +// State: immutable data class +data class LoginState( + val email: String = "", + val password: String = "", + val loading: Boolean = false, + val error: Boolean = false, +) { + val isInputValid = email.isNotEmpty() && password.isNotEmpty() +} +``` + +### 2. Create the Reducer + +```kotlin +val LoginReducer = Reducer { previousState, action -> + when (action) { + is LoginAction.EmailChanged -> previousState.copy(email = action.value) + is LoginAction.PasswordChanged -> previousState.copy(password = action.value) + is LoginAction.Login -> { + if (!previousState.isInputValid) return@Reducer previousState + // Cancel any in-flight login, then start new one + cancelEffect(id = LoginAction.Login) + effect(id = LoginAction.Login) { + val result = environment.authService.authenticate( + previousState.email, + previousState.password, + ) + dispatch(LoginAction.LoginResult(result)) + } + previousState.copy(loading = true, error = false) + } + is LoginAction.LoginResult -> { + action.result.onSuccess { token -> + effect { environment.tokenRepo.store(token) } + } + previousState.copy(loading = false, error = action.result.isFailure) + } + } +} +``` + +### 3. Create the Store + +Use Kotlin's delegation pattern: + +```kotlin +class LoginStore( + effectScope: CoroutineScope, + environment: LoginEnvironment, +) : Store by Store( + initialState = LoginState(), + environment = environment, + effectScope = effectScope, + reducer = LoginReducer, +) +``` + +### 4. Use in UI (Compose) + +```kotlin +@Composable +fun LoginScreen(store: LoginStore) { + val state by store.state.collectAsState() + + TextField( + value = state.email, + onValueChange = { store.dispatch(LoginAction.EmailChanged(it)) } + ) + + Button( + onClick = { store.dispatch(LoginAction.Login) }, + enabled = state.isInputValid && !state.loading + ) { + if (state.loading) CircularProgressIndicator() else Text("Login") + } +} +``` + +## Effect Patterns + +### Restarting and Debouncing (e.g., search) + +```kotlin +is SearchAction.QueryChanged -> { + cancelEffect(id = SearchAction.QueryChanged::class) + effect(id = SearchAction.QueryChanged::class) { + delay(300.milliseconds) // Debounce + val items = environment.searchRepo.search(action.query) + dispatch(SearchAction.ItemsLoaded(items)) + } + previousState.copy(loading = true) +} +``` + +### Initial Effect (runs on store creation) + +```kotlin +val NavigationReducer = Reducer( + initialEffect = effect { + environment.tokenRepo.token.collect { token -> + if (token != null) dispatch(NavAction.GoTo(Route.Home)) + } + } +) { previousState, action -> /* ... */ } +``` + +### Accessing Current State in Effects + +```kotlin +effect { + val currentState = state.value // StateFlow + val result = environment.api.fetch(currentState.query) + dispatch(Action.Result(result)) +} +``` + +## Store Composition (Delegation) + +Compose multiple stores into a parent: + +```kotlin +val appStore = Store( + initialState = AppState(), + environment = appEnv, + effectScope = scope, + delegates = listOf( + loginStore.delegate( + scopeAction = scopeAction(AppAction.Login::action), + expandState = { state, loginState -> state.copy(login = loginState) } + ), + searchStore.delegate( + scopeAction = scopeAction(AppAction.Search::action), + expandState = { state, searchState -> state.copy(search = searchState) } + ), + ) +) +``` + +## Debugging + +Enable logging with `StoreEvents`: + +```kotlin +val store = Store( + initialState = State(), + environment = env, + effectScope = scope, + reducer = reducer, + events = StoreEvents.Println("MyStore"), // Logs all events +) +// Output: MyStore > init > (initialState="...", ...) +// Output: MyStore > dispatch > "..." +// Output: MyStore > reduce > ("...", "...") -> "..." +``` + +## Testing + +### Test Reducers Directly + +```kotlin +@Test +fun `email changed updates state`() = runTest { + val store = LoginStore() + store.dispatch(LoginAction.EmailChanged("test@example.com")) + assertEquals("test@example.com", store.state.value.email) +} +``` + +### Test with Turbine (StateFlow testing) + +```kotlin +@Test +fun `login flow`() = runTest { + val store = LoginStore() + store.state.test { + assertEquals(LoginState(), awaitItem()) // Initial + + store.dispatch(LoginAction.Login) + assertEquals(true, awaitItem().loading) // Loading + + assertEquals(false, awaitItem().loading) // Complete + } +} +``` + +### Mock Environment for Testing + +```kotlin +val testLoginEnvironment = LoginEnvironment( + authService = object : AuthenticationService { + override suspend fun authenticate(email: String, password: String) = + Result.success(Token("test-token")) + }, + tokenRepo = FakeTokenRepository(), +) +``` + +## Common Mistakes to Avoid + +1. **Mutating state directly** - Always use `copy()` to create new state +2. **Blocking in reducers** - Reducers must be synchronous; use `effect {}` for async +3. **Forgetting effect IDs** - Use IDs when you need cancellation or deduplication +4. **Not handling all actions** - Use exhaustive `when` to handle all sealed cases +5. **Leaking coroutine scope** - Pass a lifecycle-aware scope (e.g., `viewModelScope`) + +## Installation + +```groovy +kotlin { + sourceSets { + commonMain { + dependencies { + implementation("at.florianschuster.store:store:$version") + } + } + } +} +``` + +See the [changelog](https://github.com/floschu/store/blob/main/CHANGELOG.md) for versions. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0f325..d9fe14b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # changelog +## `[0.5.0]` - 2025-01-22 + +- Update kotlin to `2.3.0` +- Fix concurrency bug in `Implementation.kt` +- Add agent skill for AI coding assistants (`.opencode/skills/floschu-store/SKILL.md`) + ## `[0.4.0]` - 2025-11-21 - Add `val state: StateFlow` to `EffectExecution.Context` diff --git a/README.md b/README.md index 94319f1..2f8d6ca 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ see [changelog](https://github.com/floschu/store/blob/main/CHANGELOG.md) for ver Go to [store](https://github.com/floschu/store/blob/main/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt) as entry point for more information. +For AI coding agents, see the [agent skill](.opencode/skills/floschu-store/SKILL.md) for implementation patterns, debugging tips, and testing guidance. + ```kotlin class LoginEnvironment( val authenticationService: AuthenticationService, diff --git a/example/composeApp/build.gradle.kts b/example/composeApp/build.gradle.kts index 17fcdea..358f3f7 100644 --- a/example/composeApp/build.gradle.kts +++ b/example/composeApp/build.gradle.kts @@ -9,12 +9,12 @@ plugins { alias(libs.plugins.composeCompiler) } +val jenvContent = File("../.java-version").readText().trim() + kotlin { androidTarget { compilerOptions { - jvmTarget.set( - JvmTarget.fromTarget(File("../.java-version").readText()) - ) + jvmTarget.set(JvmTarget.fromTarget(jenvContent)) } } @@ -95,8 +95,9 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + val javaVersion = JavaVersion.toVersion(jenvContent) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion } } diff --git a/example/gradle/libs.versions.toml b/example/gradle/libs.versions.toml index 8555039..c439fba 100644 --- a/example/gradle/libs.versions.toml +++ b/example/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] -android-gradle-plugin = "8.13.1" +android-gradle-plugin = "8.13.2" android-compileSdk = "36" android-minSdk = "33" android-targetSdk = "36" -androidx-activity = "1.12.0" +androidx-activity = "1.12.2" androidx-lifecycle = "2.9.6" -compose-multiplatform = "1.9.3" -kotlin = "2.2.21" +compose-multiplatform = "1.10.0" +kotlin = "2.3.0" kotlinx-coroutines = "1.10.2" store = "0.4.0" diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index 62b8ab7..4ed4a2c 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -105,8 +105,8 @@ mavenPublishing { licenses { license { name = "The Apache Software License, Version 2.0" - url = "http://www.apache.org/licenses/LICENSE-2.0.txt" - distribution = "http://www.apache.org/licenses/LICENSE-2.0.txt" + url = "https://www.apache.org/licenses/LICENSE-2.0.txt" + distribution = "https://www.apache.org/licenses/LICENSE-2.0.txt" } } developers { diff --git a/lib/gradle/libs.versions.toml b/lib/gradle/libs.versions.toml index 7fc20e8..6e9c044 100644 --- a/lib/gradle/libs.versions.toml +++ b/lib/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -kotlin = "2.2.21" +kotlin = "2.3.0" kotlinx-coroutines = "1.10.2" -maven-publish-plugin = "0.34.0" +maven-publish-plugin = "0.36.0" [libraries] kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } diff --git a/lib/gradle/wrapper/gradle-wrapper.properties b/lib/gradle/wrapper/gradle-wrapper.properties index bad7c24..19a6bde 100644 --- a/lib/gradle/wrapper/gradle-wrapper.properties +++ b/lib/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/lib/src/commonMain/kotlin/at/florianschuster/store/Implementation.kt b/lib/src/commonMain/kotlin/at/florianschuster/store/Implementation.kt index 3c72574..f925817 100644 --- a/lib/src/commonMain/kotlin/at/florianschuster/store/Implementation.kt +++ b/lib/src/commonMain/kotlin/at/florianschuster/store/Implementation.kt @@ -155,24 +155,33 @@ internal class EffectHandler( is EffectExecution -> { val effectId = effect.id - // only launch effect if it is not already launched - if (effectId != null && executionJobList.isActive(effectId)) { - continue - } - // launch new effect - val newJob = launch { effect.block(executionContext) } - events?.emit(StoreEvent.Effect.Launch(effectId)) - newJob.invokeOnCompletion { cause -> - if (cause is CancellationException && effectId != null) { - events?.emit(StoreEvent.Effect.Cancel(effectId)) - } else { - events?.emit(StoreEvent.Effect.Complete(effectId)) + // If effect has an ID, use atomic check-and-add to prevent race conditions + if (effectId != null) { + val newJob = executionJobList.launchIfNotActive(effectId) { + launch { effect.block(executionContext) } + } + if (newJob != null) { + events?.emit(StoreEvent.Effect.Launch(effectId)) + newJob.invokeOnCompletion { cause -> + if (cause is CancellationException) { + events?.emit(StoreEvent.Effect.Cancel(effectId)) + } else { + events?.emit(StoreEvent.Effect.Complete(effectId)) + } + } + } + // If newJob is null, effect was already active - skip + } else { + // No effect ID - just launch without tracking + val newJob = launch { effect.block(executionContext) } + events?.emit(StoreEvent.Effect.Launch(effectId)) + newJob.invokeOnCompletion { cause -> + if (cause is CancellationException) { + events?.emit(StoreEvent.Effect.Cancel(effectId)) + } else { + events?.emit(StoreEvent.Effect.Complete(effectId)) + } } - } - // only track job if it has id and has not already completed - if (effectId != null && !newJob.isCompleted) { - val item = ExecutionJobList.JobItem(effectId = effectId, job = newJob) - executionJobList.add(item) } } } @@ -192,17 +201,32 @@ internal class EffectHandler( private val mutex = Mutex() internal val items = mutableListOf() - suspend fun isActive(id: Any): Boolean = mutex.withLock { - val item = items.firstOrNull { it.effectId == id } - item != null && item.job.isActive - } - - suspend fun add(jobItem: JobItem) { - suspend fun remove() = mutex.withLock { items.remove(jobItem) } - mutex.withLock { - items.add(jobItem) - jobItem.job.invokeOnCompletion { effectScope.launch { remove() } } + /** + * Atomically checks if an effect with the given ID is already active. + * If not active, launches the job and adds it to tracking. + * Returns the launched Job if successful, or null if the effect was already active. + * + * This method prevents race conditions by combining the check and add + * operations within a single mutex lock. + */ + suspend fun launchIfNotActive(id: Any, createJob: () -> Job): Job? = mutex.withLock { + // Check if already active while holding lock + val existingItem = items.firstOrNull { it.effectId == id } + if (existingItem != null && existingItem.job.isActive) { + return@withLock null // Already active, don't create new job + } + // Create and add the job while still holding lock + val job = createJob() + if (!job.isCompleted) { + val item = JobItem(effectId = id, job = job) + items.add(item) + job.invokeOnCompletion { + effectScope.launch { + mutex.withLock { items.remove(item) } + } + } } + job } suspend fun cancel(ids: List) { diff --git a/lib/src/commonTest/kotlin/at/florianschuster/store/ExecutionJobsTest.kt b/lib/src/commonTest/kotlin/at/florianschuster/store/ExecutionJobsTest.kt index aa601c2..50e5297 100644 --- a/lib/src/commonTest/kotlin/at/florianschuster/store/ExecutionJobsTest.kt +++ b/lib/src/commonTest/kotlin/at/florianschuster/store/ExecutionJobsTest.kt @@ -1,33 +1,139 @@ package at.florianschuster.store +import kotlinx.coroutines.CompletableJob import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class ExecutionJobsTest { @Test - fun `ExecutionJobs can be added and cancelled`() = runTest { + fun `ExecutionJobs can be added via launchIfNotActive and cancelled`() = runTest { val sut = EffectHandler.ExecutionJobList(backgroundScope) assertTrue(sut.items.isEmpty()) + // Cancel on empty list should be safe sut.cancel(listOf(1)) assertTrue(sut.items.isEmpty()) val itemId = 1 - val item = EffectHandler.ExecutionJobList.JobItem(effectId = 1, job = Job()) - sut.add(item) - assertEquals(item, sut.items.single()) - assertTrue(item.job.isActive) - assertTrue(sut.isActive(itemId)) + var createdJob: CompletableJob? = null + + // Launch a new job via launchIfNotActive + val job = sut.launchIfNotActive(itemId) { + Job().also { createdJob = it } + } + + assertNotNull(job) + assertEquals(createdJob, job) + assertEquals(1, sut.items.size) + assertTrue(job.isActive) + // Trying to launch again with same ID should return null (already active) + val duplicateJob = sut.launchIfNotActive(itemId) { + Job() // This should not be called + } + assertNull(duplicateJob) + assertEquals(1, sut.items.size) // Still only 1 item + + // Cancel should remove and cancel the job sut.cancel(listOf(itemId)) assertTrue(sut.items.isEmpty()) - assertFalse(item.job.isActive) - assertFalse(sut.isActive(itemId)) + assertFalse(job.isActive) + + // After cancellation, a new job can be launched with same ID + val newJob = sut.launchIfNotActive(itemId) { + Job() + } + assertNotNull(newJob) + assertEquals(1, sut.items.size) + } + + @Test + fun `ExecutionJobs allows launching with different IDs`() = runTest { + val sut = EffectHandler.ExecutionJobList(backgroundScope) + + val job1 = sut.launchIfNotActive(1) { Job() } + val job2 = sut.launchIfNotActive(2) { Job() } + val job3 = sut.launchIfNotActive(3) { Job() } + + assertNotNull(job1) + assertNotNull(job2) + assertNotNull(job3) + assertEquals(3, sut.items.size) + + // Cancel just one + sut.cancel(listOf(2)) + assertEquals(2, sut.items.size) + assertTrue(job1.isActive) + assertFalse(job2.isActive) + assertTrue(job3.isActive) + + // Cancel remaining + sut.cancel(listOf(1, 3)) + assertTrue(sut.items.isEmpty()) + } + + @Test + fun `concurrent launchIfNotActive with same ID only creates one job`() = runTest { + val sut = EffectHandler.ExecutionJobList(backgroundScope) + val results = mutableListOf() + val resultsMutex = Mutex() + val effectId = "concurrent-test-id" + + // Launch multiple concurrent attempts with the same ID + // This tests that the mutex properly serializes access and prevents duplicates + repeat(50) { + launch { + val job = sut.launchIfNotActive(effectId) { Job() } + resultsMutex.withLock { + results.add(job) + } + } + } + + advanceUntilIdle() + + // Only one should have succeeded (non-null), rest should be null + val successfulLaunches = results.filterNotNull() + assertEquals(1, successfulLaunches.size, "Expected exactly one successful launch, got ${successfulLaunches.size}") + assertEquals(1, sut.items.size, "Expected exactly one item tracked") + + // The successful job should be active + assertTrue(successfulLaunches.first().isActive) + } + + @Test + fun `concurrent launchIfNotActive with different IDs all succeed`() = runTest { + val sut = EffectHandler.ExecutionJobList(backgroundScope) + val results = mutableListOf() + val resultsMutex = Mutex() + + // Launch concurrent attempts with different IDs + repeat(20) { id -> + launch { + val job = sut.launchIfNotActive("id-$id") { Job() } + resultsMutex.withLock { + results.add(job) + } + } + } + + advanceUntilIdle() + + // All should succeed since they have different IDs + val successfulLaunches = results.filterNotNull() + assertEquals(20, successfulLaunches.size, "All launches should succeed with unique IDs") + assertEquals(20, sut.items.size, "All items should be tracked") } } diff --git a/lib/src/commonTest/kotlin/at/florianschuster/store/StoreTest.kt b/lib/src/commonTest/kotlin/at/florianschuster/store/StoreTest.kt index 3267007..ee691c3 100644 --- a/lib/src/commonTest/kotlin/at/florianschuster/store/StoreTest.kt +++ b/lib/src/commonTest/kotlin/at/florianschuster/store/StoreTest.kt @@ -2,7 +2,9 @@ package at.florianschuster.store import kotlinx.coroutines.delay import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -214,4 +216,105 @@ internal class StoreTest { ) assertFailsWith { sut.dispatch(1) } } + + @Test + fun `destructuring operators work as expected`() = runTest { + val sut: Store = Store( + initialState = 42, + effectScope = backgroundScope, + environment = Unit, + reducer = Reducer { previousState, action -> previousState + action }, + ) + + val (state, dispatch) = sut + + assertEquals(42, state.value) + + dispatch(8) + assertEquals(50, state.value) + + dispatch(10) + assertEquals(60, state.value) + } + + @Test + fun `multiple effects can be cancelled at once`() = runTest { + var effect1Running = false + var effect2Running = false + var effect3Running = false + + val sut: Store = Store( + initialState = 0, + effectScope = backgroundScope, + environment = Unit, + reducer = Reducer { previousState, action -> + when (action) { + "start" -> { + effect(id = "effect1") { + effect1Running = true + try { + delay(10.seconds) + } finally { + effect1Running = false + } + } + effect(id = "effect2") { + effect2Running = true + try { + delay(10.seconds) + } finally { + effect2Running = false + } + } + effect(id = "effect3") { + effect3Running = true + try { + delay(10.seconds) + } finally { + effect3Running = false + } + } + previousState + } + "cancelMultiple" -> { + cancelEffects(ids = listOf("effect1", "effect2")) + previousState + } + else -> previousState + } + }, + ) + + sut.dispatch("start") + runCurrent() + assertEquals(true, effect1Running) + assertEquals(true, effect2Running) + assertEquals(true, effect3Running) + + sut.dispatch("cancelMultiple") + runCurrent() + assertEquals(false, effect1Running) + assertEquals(false, effect2Running) + assertEquals(true, effect3Running) // effect3 should still be running + } + + @Test + fun `concurrent dispatches are handled safely`() = runTest { + val sut: Store = Store( + initialState = 0, + effectScope = backgroundScope, + environment = Unit, + reducer = Reducer { previousState, action -> previousState + action }, + ) + + // Dispatch many actions concurrently + repeat(100) { + launch { + sut.dispatch(1) + } + } + advanceUntilIdle() + + assertEquals(100, sut.state.value) + } } From 52a42ff0049e78b6b987a6f1d47710dba132d783 Mon Sep 17 00:00:00 2001 From: flosch Date: Thu, 22 Jan 2026 21:59:55 +0100 Subject: [PATCH 2/7] Update example --- .../src/androidMain/AndroidManifest.xml | 1 + .../at/florianschuster/store/example/App.kt | 13 +++++++-- .../florianschuster/store/example/AppStore.kt | 2 +- .../store/example/login/LoginStore.kt | 7 +++-- .../store/example/search/SearchReducer.kt | 28 +++++++++++++++++-- .../store/example/search/SearchView.kt | 2 -- 6 files changed, 42 insertions(+), 11 deletions(-) diff --git a/example/composeApp/src/androidMain/AndroidManifest.xml b/example/composeApp/src/androidMain/AndroidManifest.xml index 70b8dba..927ee19 100644 --- a/example/composeApp/src/androidMain/AndroidManifest.xml +++ b/example/composeApp/src/androidMain/AndroidManifest.xml @@ -10,6 +10,7 @@ android:theme="@android:style/Theme.Material.Light.NoActionBar"> diff --git a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/App.kt b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/App.kt index 5cce056..f35c835 100644 --- a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/App.kt +++ b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/App.kt @@ -49,8 +49,15 @@ fun App() { AnimatedContent( targetState = state.navigationState.route, transitionSpec = { fadeIn() togetherWith fadeOut() }, - ) { route -> - when (val route = state.navigationState.route) { + contentKey = { route -> + when (route) { + is Login -> "login" + is Search -> "search" + is Detail -> "detail-${route.id}" + } + }, + ) { targetRoute -> + when (targetRoute) { is Login -> { LoginView( paddingValues = paddingValues, @@ -70,7 +77,7 @@ fun App() { is Detail -> DetailView( paddingValues = paddingValues, - state = DetailState(route.id), + state = DetailState(targetRoute.id), ) } } diff --git a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/AppStore.kt b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/AppStore.kt index 5b7e0b0..afc5f23 100644 --- a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/AppStore.kt +++ b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/AppStore.kt @@ -53,7 +53,7 @@ internal class AppStore( environment = Unit, delegates = listOf( NavigationReducer.delegate( - initialState = NavigationState(), + initialState = initialState.navigationState, environment = navigationEnvironment, effectScope = effectScope, scopeAction = scopeAction(AppAction.Navigation::action), diff --git a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/login/LoginStore.kt b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/login/LoginStore.kt index f4c7907..e601722 100644 --- a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/login/LoginStore.kt +++ b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/login/LoginStore.kt @@ -75,10 +75,13 @@ internal class LoginStore( // This effect has an Id, so it will not be executed if this authentication // effect is already in progress. effect(id = LoginAction.Authenticate) { + // Use state.value to get the current state at execution time, + // not the captured previousState which could be stale + val currentState = state.value runCatching { environment.authenticationService.authenticate( - checkNotNull(previousState.email), - checkNotNull(previousState.password), + checkNotNull(currentState.email), + checkNotNull(currentState.password), ) }.fold( onSuccess = { token -> diff --git a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/search/SearchReducer.kt b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/search/SearchReducer.kt index a5cebab..9b6fffd 100644 --- a/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/search/SearchReducer.kt +++ b/example/composeApp/src/commonMain/kotlin/at/florianschuster/store/example/search/SearchReducer.kt @@ -4,8 +4,10 @@ import androidx.compose.runtime.Immutable import at.florianschuster.store.Reducer import at.florianschuster.store.cancelEffect import at.florianschuster.store.effect +import at.florianschuster.store.example.Log import at.florianschuster.store.example.service.SearchRepository import at.florianschuster.store.example.service.TokenRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.milliseconds @@ -31,12 +33,31 @@ internal data class SearchState( internal val SearchReducer = Reducer { previousState, action -> when (action) { is SearchAction.QueryChanged -> { - // if a new query is entered, we cancel the previous effect + // Cancel any previous search effect cancelEffect(id = SearchAction.QueryChanged::class) + + // Don't trigger search for empty queries - just clear results + if (action.query.isEmpty()) { + return@Reducer previousState.copy( + query = "", + items = emptyList(), + loading = false, + ) + } + effect(id = SearchAction.QueryChanged::class) { delay(300.milliseconds) // we debounce the search query - val items = environment.searchRepository.loadQueryItems(action.query) - dispatch(SearchAction.ItemsLoaded(items)) + runCatching { environment.searchRepository.loadQueryItems(action.query) }.fold( + onSuccess = { items -> dispatch(SearchAction.ItemsLoaded(items)) }, + onFailure = { error -> + Log.e(error) + // On error, dispatch empty results to clear loading state + // and avoid leaving the UI in a loading state forever + if (error !is CancellationException) { + dispatch(SearchAction.ItemsLoaded(emptyList())) + } + } + ) } previousState.copy( query = action.query, @@ -57,6 +78,7 @@ internal val SearchReducer = Reducer Date: Thu, 22 Jan 2026 22:19:22 +0100 Subject: [PATCH 3/7] Add architecture diagram --- .opencode/skills/floschu-store.md | 24 +++++++++++++++++++ .../kotlin/at/florianschuster/store/Store.kt | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/.opencode/skills/floschu-store.md b/.opencode/skills/floschu-store.md index 9ce5d23..7f01057 100644 --- a/.opencode/skills/floschu-store.md +++ b/.opencode/skills/floschu-store.md @@ -21,6 +21,30 @@ The library follows a Redux/Flux-like pattern: - **Effects**: Async side effects (network, database, etc.) - **Environment**: Dependency container for effects + ``` + dispatch(Action) +view ▶──────────────────────┬◀─────────────────────────────┐ + │ │ + │ │ + ┏━━━━━━━━━━━━━━━━━━━━━━▼━━━━━━━━━━━━━━━━━━━━━┓ │ + ┃ Store │ actions ┃ │ + ┃ with Environment │ ┃ │ + ┃ ┏━━━━━▼━━━━━┓ ┃ ┏━━━━━━━━━━━━━━┓ + ┃ ┌────────────▶┃ reducer ┃ ─ ─ ─ ─ ─ ─ ─ - ─▶ effect ┃ + ┃ │ ┗━━━━━━━━━━━┛ can produce ┃ ┗━━━━━━━━━━━━━━┛ + ┃ │ │ ┃ uses Environment + ┃ │ previous │ ┃ + ┃ │ state │ new state ┃ + ┃ │ │ ┃ + ┃ │ ┏━━━━━▼━━━━━┓ ┃ + ┃ └─────────────┃ State ┃ ┃ + ┃ ┗━━━━━━━━━━━┛ ┃ + ┃ │ ┃ + ┗━━━━━━━━━━━━━━━━━━━━━━▼━━━━━━━━━━━━━━━━━━━━━┛ + │ +view ◀─────────────────────┘ +``` + ## Implementation Patterns ### 1. Define the Components diff --git a/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt b/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt index 2761765..5b79cc9 100644 --- a/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt +++ b/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt @@ -5,6 +5,30 @@ import kotlinx.coroutines.flow.StateFlow /** * A store that holds [State] and allows dispatching [Action]s. + * + * ``` + * dispatch(Action) + * view ▶──────────────────────┬◀──────────────────────────────┐ + * │ │ + * │ │ + * ┏━━━━━━━━━━━━━━━━━━━━━━▼━━━━━━━━━━━━━━━━━━━━━┓ │ + * ┃ Store │ actions ┃ │ + * ┃ with Environment │ ┃ │ + * ┃ ┏━━━━━▼━━━━━┓ ┃ ┏━━━━━━━━━━━━━━┓ + * ┃ ┌────────────▶┃ reducer ┃ ─ ─ ─ ─ ─ ─ ─ - ─▶ effect ┃ + * ┃ │ ┗━━━━━━━━━━━┛ can produce ┃ ┗━━━━━━━━━━━━━━┛ + * ┃ │ │ ┃ uses Environment + * ┃ │ previous │ ┃ + * ┃ │ state │ new state ┃ + * ┃ │ │ ┃ + * ┃ │ ┏━━━━━▼━━━━━┓ ┃ + * ┃ └─────────────┃ State ┃ ┃ + * ┃ ┗━━━━━━━━━━━┛ ┃ + * ┃ │ ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━▼━━━━━━━━━━━━━━━━━━━━━┛ + * │ + * view ◀───────────────────────┘ + * ``` */ interface Store { val state: StateFlow From adba1ccb9d57eeb25c8cdeaa921cb3bcd024c972 Mon Sep 17 00:00:00 2001 From: flosch Date: Thu, 22 Jan 2026 22:23:16 +0100 Subject: [PATCH 4/7] Add pull request trigger to build workflow --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 91dd970..bf3b1c6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: push: branches: - "*" + pull_request: concurrency: group: "build-${{ github.ref }}" From 9413dbfef4b7ce73dd06150bd255e5172c8b66a6 Mon Sep 17 00:00:00 2001 From: flosch Date: Thu, 22 Jan 2026 22:26:47 +0100 Subject: [PATCH 5/7] Add API compatibility check to build workflow --- .github/workflows/build.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf3b1c6..74b3de7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,23 @@ jobs: run: | cd lib ./gradlew build + + api-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: "adopt" + java-version-file: .java-version + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: wrapper + add-job-summary: on-failure + - name: "check api compatibility" + run: | + cd lib + ./gradlew checkLegacyAbi build-example: runs-on: macos-latest From e7f1705cdac19fce19c3827670854a1cdb6acd15 Mon Sep 17 00:00:00 2001 From: flosch Date: Thu, 22 Jan 2026 22:42:40 +0100 Subject: [PATCH 6/7] Update skill --- README.md | 2 +- {.opencode/skills => skills}/floschu-store.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) rename {.opencode/skills => skills}/floschu-store.md (98%) diff --git a/README.md b/README.md index 2f8d6ca..024895e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ see [changelog](https://github.com/floschu/store/blob/main/CHANGELOG.md) for ver Go to [store](https://github.com/floschu/store/blob/main/lib/src/commonMain/kotlin/at/florianschuster/store/Store.kt) as entry point for more information. -For AI coding agents, see the [agent skill](.opencode/skills/floschu-store/SKILL.md) for implementation patterns, debugging tips, and testing guidance. +Check out the [store skill](skills/floschu-store.md) to implement and test `store` with your **AI agents**. ```kotlin class LoginEnvironment( diff --git a/.opencode/skills/floschu-store.md b/skills/floschu-store.md similarity index 98% rename from .opencode/skills/floschu-store.md rename to skills/floschu-store.md index 7f01057..d3f6f2b 100644 --- a/.opencode/skills/floschu-store.md +++ b/skills/floschu-store.md @@ -1,11 +1,6 @@ --- name: floschu-store description: Implement, debug, and test floschu/store - a unidirectional data flow state management kmp library with coroutines -license: Apache-2.0 -compatibility: opencode -metadata: - language: kotlin - framework: kotlin-multiplatform --- # floschu/store - Kotlin State Management From c741e3eba27f1695363d7624299ced1a09cf047b Mon Sep 17 00:00:00 2001 From: flosch Date: Thu, 22 Jan 2026 22:51:47 +0100 Subject: [PATCH 7/7] Update Kotlin yarn.lock --- lib/kotlin-js-store/yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/kotlin-js-store/yarn.lock b/lib/kotlin-js-store/yarn.lock index bb59bd0..cee572a 100644 --- a/lib/kotlin-js-store/yarn.lock +++ b/lib/kotlin-js-store/yarn.lock @@ -249,10 +249,10 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -kotlin-web-helpers@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/kotlin-web-helpers/-/kotlin-web-helpers-2.1.0.tgz#6cd4b0f0dc3baea163929c8638155b8d19c55a74" - integrity sha512-NAJhiNB84tnvJ5EQx7iER3GWw7rsTZkX9HVHZpe7E3dDBD/dhTzqgSwNU3MfQjniy2rB04bP24WM9Z32ntUWRg== +kotlin-web-helpers@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/kotlin-web-helpers/-/kotlin-web-helpers-3.0.0.tgz#3ed6b48f694f74bb60a737a9d7e2c0e3b29abdb9" + integrity sha512-kdQO4AJQkUPvpLh9aglkXDRyN+CfXO7pKq+GESEnxooBFkQpytLrqZis3ABvmFN1cGw/ZQ/K38u5sRGW+NfBnw== dependencies: format-util "^1.0.5" @@ -288,10 +288,10 @@ minimatch@^9.0.4, minimatch@^9.0.5: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== -mocha@11.7.1: - version "11.7.1" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.1.tgz#91948fecd624fb4bd154ed260b7e1ad3910d7c7a" - integrity sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A== +mocha@11.7.2: + version "11.7.2" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5" + integrity sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ== dependencies: browser-stdout "^1.3.1" chokidar "^4.0.1"