mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Added a comprehensive collection of Compose development guidelines, best practices, and skill definitions in the .codex directory. Co-authored-by: Junie <junie@jetbrains.com>
5.6 KiB
5.6 KiB
Paging 3 — MVI Integration & Testing
MVI dual-flow pattern for paging, testing strategies, and anti-patterns. This file builds on the core Paging setup in paging.md.
References:
MVI Integration
PagingData must be a separate Flow from the MVI ViewModel state. The ViewModel handles non-paging concerns (filters, selection mode, errors). PagingData flows independently.
class ItemListViewModel(
private val repository: ItemRepository,
) : ViewModel() {
// MVI state — non-paging concerns
private val _state = MutableStateFlow(ItemListState())
val state: StateFlow<ItemListState> = _state.asStateFlow()
// PagingData — separate Flow, reacts to filter changes
private val _statusFilter = MutableStateFlow(StatusFilter.ALL)
val items: Flow<PagingData<ItemUi>> = _statusFilter
.distinctUntilChanged()
.flatMapLatest { status ->
Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { repository.itemPagingSource(status) },
).flow.map { pagingData -> pagingData.map { it.toUi() } }
}
.cachedIn(viewModelScope)
fun onEvent(event: ItemListEvent) {
when (event) {
is ItemListEvent.FilterChanged -> {
_statusFilter.value = event.filter
_state.update { it.copy(selectedFilter = event.filter) }
}
is ItemListEvent.ItemClicked -> {
// emit navigation effect
}
is ItemListEvent.SelectionToggled -> {
_state.update { it.copy(selectedIds = it.selectedIds.toggle(event.id)) }
}
}
}
}
Route collects both flows
The route composable collects both the MVI state and PagingData flow, then passes them to the stateless screen composable. Use a DI-agnostic ViewModel parameter.
@Composable
fun ItemListRoute(viewModel: ItemListViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
val pagingItems = viewModel.items.collectAsLazyPagingItems()
ItemListScreen(
state = state,
pagingItems = pagingItems,
onEvent = viewModel::onEvent,
)
}
The screen composable is dumb — it receives LazyPagingItems and state as props, emits events as callbacks.
Testing
PagingSource unit test
@Test
fun `load returns page of items`() = runTest {
val mockApi = MockItemApi(items = listOf(item1, item2))
val pagingSource = ItemPagingSource(api = mockApi, query = "")
val result = pagingSource.load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
)
assertTrue(result is PagingSource.LoadResult.Page)
val page = result as PagingSource.LoadResult.Page
assertEquals(2, page.data.size)
assertEquals(null, page.prevKey)
assertEquals(2, page.nextKey)
}
@Test
fun `load returns error on network failure`() = runTest {
val mockApi = MockItemApi(error = IOException("Network error"))
val pagingSource = ItemPagingSource(api = mockApi, query = "")
val result = pagingSource.load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
)
assertTrue(result is PagingSource.LoadResult.Error)
}
Testing with asSnapshot
@Test
fun `items flow loads first two pages`() = runTest {
val viewModel = ItemListViewModel(FakeRepository())
val items = viewModel.items.asSnapshot {
scrollTo(index = 30)
}
assertTrue(items.size >= 30)
assertEquals("item_1", items.first().id)
}
Testing transformations
@Test
fun `paging data maps dto to ui model`() = runTest {
val dtos = listOf(ItemDto(id = "1", title = "Test", amount = 100.0))
val pagingSource = dtos.asPagingSourceFactory().invoke()
val pager = TestPager(PagingConfig(pageSize = 10), pagingSource)
val result = pager.refresh() as PagingSource.LoadResult.Page
assertEquals(1, result.data.size)
assertEquals("1", result.data.first().id)
}
Anti-Patterns
| Anti-pattern | Why it hurts | Fix |
|---|---|---|
PagingData inside UiState StateFlow |
Any non-paging state change re-emits the wrapping StateFlow, creating a new flow for collectAsLazyPagingItems() and resetting scroll position (official codelab uses separate flows) |
Expose PagingData as separate Flow |
New Pager per recomposition |
Duplicate network requests, lost pagination state | Store Flow as val in ViewModel |
Reusing PagingSource instance |
Crash: "PagingSource was re-used" | Always create new instance in pagingSourceFactory |
Missing cachedIn(viewModelScope) |
Data lost on config change, duplicate loads | Always call cachedIn |
| Missing list keys | Scroll jumps, state corruption on updates | itemKey { it.id } with stable domain IDs |
combine on PagingData flows |
"Collecting from multiple PagingData concurrently" error | Use flatMapLatest for parameter changes |
Calling refresh() in composable body |
Infinite refresh loop on every recomposition | Call from event handler or LaunchedEffect |
No LoadState handling |
Broken UX: no loading indicator, no error recovery | Handle refresh, append, prepend states |
Transformations after cachedIn |
Transformations lost on cache hit | Apply .map { } / .filter { } before cachedIn |
Catching generic Exception in PagingSource |
Hides bugs, swallows unexpected errors | Catch IOException, HttpException specifically |