Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- "*"
pull_request:

concurrency:
group: "build-${{ github.ref }}"
Expand All @@ -26,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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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<State>` to `EffectExecution.Context`
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Check out the [store skill](skills/floschu-store.md) to implement and test `store` with your **AI agents**.

```kotlin
class LoginEnvironment(
val authenticationService: AuthenticationService,
Expand Down
11 changes: 6 additions & 5 deletions example/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -95,8 +95,9 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
val javaVersion = JavaVersion.toVersion(jenvContent)
sourceCompatibility = javaVersion
targetCompatibility = javaVersion
}
}

Expand Down
1 change: 1 addition & 0 deletions example/composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -70,7 +77,7 @@ fun App() {

is Detail -> DetailView(
paddingValues = paddingValues,
state = DetailState(route.id),
state = DetailState(targetRoute.id),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -31,12 +33,31 @@ internal data class SearchState(
internal val SearchReducer = Reducer<SearchEnvironment, SearchAction, SearchState> { 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,
Expand All @@ -57,6 +78,7 @@ internal val SearchReducer = Reducer<SearchEnvironment, SearchAction, SearchStat
previousState.copy(
query = "",
items = emptyList(),
loading = false,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -111,7 +110,6 @@ internal fun SearchView(
modifier = Modifier.fillMaxSize(),
text = if (state.query.isEmpty()) "Please enter a Query" else "No items found.",
textAlign = TextAlign.Center,
color = if (state.query.isEmpty()) Color.Unspecified else AppTheme.colorScheme.error,
)
}
}
Expand Down
8 changes: 4 additions & 4 deletions example/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
4 changes: 2 additions & 2 deletions lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions lib/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion lib/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 8 additions & 8 deletions lib/kotlin-js-store/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,24 +155,33 @@ internal class EffectHandler<Environment, Action, State>(

is EffectExecution<Environment, Action, State> -> {
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)
}
}
}
Expand All @@ -192,17 +201,32 @@ internal class EffectHandler<Environment, Action, State>(
private val mutex = Mutex()
internal val items = mutableListOf<JobItem>()

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<Any>) {
Expand Down
Loading