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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ The full documentation is available [here](https://docs.uandcode.com/container).
Add the following line to your `build.gradle` file:

```
implementation "com.elveum:container:3.2.0"
implementation "com.elveum:container:3.2.1"
```

## Core Concepts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ internal class ListMerger<T>(
nonFinalOldItems: List<T>,
nonFinalNewItems: List<T>,
) {
val state = State(nonFinalOldItems, nonFinalNewItems)
// Items are identified by itemId(). Duplicate ids (within a page or from
// the same logical item appearing on more than one page) break the 1:1
// position/anchor correspondence in reorderNonFinalItems() and previously
// caused an IndexOutOfBoundsException. Deduplicate incoming new items by
// id (first occurrence wins) so the merge always operates on unique ids.
val state = State(nonFinalOldItems, nonFinalNewItems.distinctById())

state.apply {
// step 1 - remove items from targetList that exist in old but not in new
Expand All @@ -26,9 +31,10 @@ internal class ListMerger<T>(
// when an anchor is encountered, flush all nonFinalNewItems up to that anchor
val result = buildFinalList()

// step 4 - replace data in targetList by result list
// step 4 - replace data in targetList by result list, keeping ids
// unique so the target never accumulates duplicates across merges
targetList.clear()
targetList += result
targetList += result.distinctById()
}
}

Expand All @@ -47,7 +53,12 @@ internal class ListMerger<T>(
}
}
val anchors = nonFinalNewItems.filter { itemId(it) in existingIds }
for (i in nonFinalPositions.indices) {
// With unique ids the two counts match; guard against any residual
// asymmetry (e.g. a target list that already contained duplicates) so a
// stale duplicate can never cause an out-of-bounds access. Leftover
// duplicate positions are cleaned up by distinctById() in mergeFrom().
val count = minOf(nonFinalPositions.size, anchors.size)
for (i in 0 until count) {
targetList[nonFinalPositions[i]] = anchors[i]
}
}
Expand All @@ -73,6 +84,8 @@ internal class ListMerger<T>(
return result
}

private fun List<T>.distinctById(): List<T> = distinctBy { itemId(it) }

private inner class State(
val nonFinalOldItems: List<T>,
val nonFinalNewItems: List<T>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,15 @@ internal class PageRecordsState<Key, T>(

fun intercept(container: Container<List<T>>): Container<List<T>> {
return if (container.isSuccess()) {
// Enforce id-uniqueness on externally supplied values too: duplicate
// ids crash Compose LazyColumn/LazyRow (which use item id as key) and
// break the merger's invariants. See ListMerger for details.
val uniqueValue = container.value.distinctBy { config.itemId(it) }
coroutineScope.launch {
mutex.withLock {
outputList.clear()
outputList.addAll(container.value)
outputListSnapshotFlow.update { container.value }
outputList.addAll(uniqueValue)
outputListSnapshotFlow.update { uniqueValue }
}
}
container.map { it } // report other instance the loader handled the update manually
Expand Down Expand Up @@ -189,7 +193,10 @@ internal class PageRecordsState<Key, T>(
?.getOrNull()
?: emptyList()
}

// The same logical item can appear on more than one page (common
// with live backend pagination). Deduplicate by id so the merger
// only ever sees unique ids. See ListMerger for details.
.distinctBy { config.itemId(it) }
}

private fun Iterable<PageRecord<Key, T>>.withHighestPriority(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,51 @@ class ListMergerTest {
)
}

@Test
fun duplicateIdsInNewItems_deduplicatedKeepingFirst() {
// nonFinalNewItems contains the same id twice; the output must not
// accumulate duplicates (first occurrence wins).
val base = mutableListOf<Item>()
val merger = createListMerger(base)

merger.mergeFrom(
nonFinalOldItems = emptyList(),
nonFinalNewItems = listOf(updated(1), item(2), updated(1)),
)

assertEquals(
listOf(updated(1), item(2)),
base,
)
}

@Test
fun duplicateIdsInTarget_doesNotCrashAndIsDeduplicated() {
// Reproduces the production IndexOutOfBoundsException: the target list
// ends up holding a duplicate id, then the same page reloads reporting
// that id only once. targetList has more positions matching newIds than
// there are anchors.
val base = mutableListOf<Item>()
val merger = createListMerger(base)

// Merge 1: duplicate id 1 -> without dedup the base would be [1, 2, 1]
merger.mergeFrom(
nonFinalOldItems = emptyList(),
nonFinalNewItems = listOf(item(1), item(2), item(1)),
)

// Merge 2: page reloads, id 1 now appears only once.
merger.mergeFrom(
nonFinalOldItems = listOf(item(1), item(2), item(1)),
nonFinalNewItems = listOf(updated(1), updated(2)),
)

assertEquals(
listOf(updated(1), updated(2)),
base,
)
}

private fun createListMerger(target: MutableList<Item>) =
ListMerger(target) { it.id }

Expand Down
2 changes: 1 addition & 1 deletion gradle-public.properties
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ android.nonTransitiveRClass=true

# Maven Central publishing coordinates
GROUP=com.elveum
VERSION_NAME=3.2.0
VERSION_NAME=3.2.1

# POM metadata
POM_DESCRIPTION=A library for simplifying state management and data loading in Android applications
Expand Down
2 changes: 1 addition & 1 deletion skills/container-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ discovers skills (e.g. `~/.agents/skills/` for Codex), or paste
| `references/api.md` | Every public import, `StoreResult` / `LoadRequest` semantics, typical operations |
| `references/patterns.md` | Layer-by-layer patterns: data sources, repositories, ViewModels, Compose, DI, scoping |

The skill matches library version `3.2.0`.
The skill matches library version `3.2.1`.
4 changes: 2 additions & 2 deletions skills/container-store/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ directly - no decompilation or dependency tree inspection needed.

## Dependency Setup

Maven coordinates: `com.elveum:store:3.2.0` (transitively brings
Maven coordinates: `com.elveum:store:3.2.1` (transitively brings
`com.elveum:container`, whose types are part of the public API).

```toml
# gradle/libs.versions.toml
[versions]
store = "3.2.0"
store = "3.2.1"
[libraries]
store = { module = "com.elveum:store", version.ref = "store" }
```
Expand Down
2 changes: 1 addition & 1 deletion store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ the hood.
Add the following line to your `build.gradle` file:

```
implementation "com.elveum:store:3.2.0"
implementation "com.elveum:store:3.2.1"
```

The `store` artifact depends on `com.elveum:container`, so the Container
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ internal interface CoreLoaderDelegate<R : Any> {
* Default [CoreLoaderDelegate] implementing the cache-then-remote loading algorithm
* shared by all store types:
*
* - [LoadRequestSource.Default]: emit the cached value first (from [loader], falling back
* to the first value of [observer]), then fetch and emit the remote value, saving it if
* - [LoadRequestSource.Default]: emit the cached value first (from `loader`, falling back
* to the first value of `observer`), then fetch and emit the remote value, saving it if
* it differs from the cached one.
* - [LoadRequestSource.Fresh]: skip caches entirely and emit only the remote value.
* - [LoadRequestSource.Offline]: emit only the cached value; never fetch. If there is no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public interface PagedQueryStore<Q : Any, T : Any> :
*
* See [BaseStore.optimisticUpdate] for more details.
*
* See also [update] extension.
* See also: [updateIfSuccess][com.elveum.store.stores.base.updateIfSuccess] extension.
*/
override suspend fun optimisticUpdate(updater: suspend OptimisticUpdateScope<List<T>>.(List<T>) -> Unit)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public interface PagedStore<T : Any> :
*
* See [BaseStore.optimisticUpdate] for more details.
*
* See also: [update] extension.
* See also: [updateIfSuccess][com.elveum.store.stores.base.updateIfSuccess] extension.
*/
override suspend fun optimisticUpdate(updater: suspend OptimisticUpdateScope<List<T>>.(List<T>) -> Unit)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public interface SimpleQueryStore<Q : Any, T : Any> :
*
* See [BaseStore.optimisticUpdate] for more details.
*
* See also: [update] extension.
* See also: [updateIfSuccess][com.elveum.store.stores.base.updateIfSuccess] extension.
*/
override suspend fun optimisticUpdate(updater: suspend OptimisticUpdateScope<T>.(T) -> Unit)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public interface SimpleStore<T : Any> :
*
* See [BaseStore.optimisticUpdate] for more details.
*
* See also: [update] extension.
* See also: [updateIfSuccess][com.elveum.store.stores.base.updateIfSuccess] extension.
*/
override suspend fun optimisticUpdate(updater: suspend OptimisticUpdateScope<T>.(T) -> Unit)

Expand Down
Loading