fix(mailbox): keep the inbox pager warm on reader return so it doesn't flash empty #273

Merged
JMR-dev merged 9 commits from fix-219-empty-state-flash into main 2026-07-04 03:36:51 +00:00
JMR-dev commented 2026-07-04 01:26:29 +00:00 (Migrated from github.com)

Closes #219

Root cause

Returning from the reader/message screen to the inbox briefly showed the "No messages" empty state and reloaded (a multi-second stall). The inbox's only Paging presenter — collectAsLazyPagingItems in MailboxScreen — is torn down the instant a message is opened, so the cachedIn(viewModelScope) pager in MailboxViewModel (MailboxViewModel.kt, pagedMessages) loses its downstream collector. Opening an unread message writes setRead, which invalidates the Room PagingSource; with nothing collecting the cached flow, the fresh generation only cold-loaded once the inbox re-entered composition — presenting itemCount == 0 for a frame on return.

The empty-state gate (itemCount == 0 && refresh is NotLoading && append.endOfPaginationReached) already landed with #214/#223; this ticket's remaining scope was the structural cold-reload.

Fix

Add an always-on, invisible PagingDataPresenter<Message> in MailboxViewModel that stays subscribed to the cached paged flow across the reader visit:

viewModelScope.launch {
    pagedMessages.collectLatest { keepAlivePresenter.collectFrom(it) }
}

collectLatest hands each new generation (including the one produced when setRead invalidates the source) to collectFrom, which drives its initial load in the background while the reader is up. The return then replays a full window with zero empty frames. The presenter renders nothing — LazyPagingItems still drives the visible list.

Tests

  • MailboxViewModelTest (unit): a real, invalidatable Pager proves the pager loads its initial window and reloads after invalidate() with no UI collector attached — i.e. the ViewModel's keep-alive alone drives both loads.
  • MailboxScreenTest (instrumented): the empty state is held back while refresh is Loading and shown only once the pager settles genuinely empty, driven via PagingData.from(..., LoadStates) through a new FakeMailRepository paged override.

Validation

:app:testDebugUnitTest (incl. the new keep-alive test) + :app:compileDebugAndroidTestKotlin + :app:lintDebug + :app:ktlintCheck + :app:detekt all green locally (JDK 21). The instrumented suite is deferred to CI (local emulator was offline/peer-owned; per repo policy a second emulator was not booted).

🤖 Generated with Claude Code

Closes #219 ## Root cause Returning from the reader/message screen to the inbox briefly showed the **"No messages"** empty state and reloaded (a multi-second stall). The inbox's only Paging presenter — `collectAsLazyPagingItems` in `MailboxScreen` — is torn down the instant a message is opened, so the `cachedIn(viewModelScope)` pager in `MailboxViewModel` (`MailboxViewModel.kt`, `pagedMessages`) loses its downstream collector. Opening an *unread* message writes `setRead`, which invalidates the Room `PagingSource`; with nothing collecting the cached flow, the fresh generation only cold-loaded once the inbox re-entered composition — presenting `itemCount == 0` for a frame on return. The empty-state *gate* (`itemCount == 0 && refresh is NotLoading && append.endOfPaginationReached`) already landed with #214/#223; this ticket's remaining scope was the structural cold-reload. ## Fix Add an always-on, invisible `PagingDataPresenter<Message>` in `MailboxViewModel` that stays subscribed to the cached paged flow across the reader visit: ```kotlin viewModelScope.launch { pagedMessages.collectLatest { keepAlivePresenter.collectFrom(it) } } ``` `collectLatest` hands each new generation (including the one produced when `setRead` invalidates the source) to `collectFrom`, which drives its initial load in the background while the reader is up. The return then replays a full window with zero empty frames. The presenter renders nothing — `LazyPagingItems` still drives the visible list. ## Tests - **`MailboxViewModelTest`** (unit): a real, invalidatable `Pager` proves the pager loads its initial window **and** reloads after `invalidate()` with **no UI collector attached** — i.e. the ViewModel's keep-alive alone drives both loads. - **`MailboxScreenTest`** (instrumented): the empty state is held back while `refresh` is `Loading` and shown only once the pager settles genuinely empty, driven via `PagingData.from(..., LoadStates)` through a new `FakeMailRepository` paged override. ## Validation `:app:testDebugUnitTest` (incl. the new keep-alive test) + `:app:compileDebugAndroidTestKotlin` + `:app:lintDebug` + `:app:ktlintCheck` + `:app:detekt` all green locally (JDK 21). The instrumented suite is deferred to CI (local emulator was `offline`/peer-owned; per repo policy a second emulator was not booted). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.