fix(mailbox): show spinner during initial folder fetch instead of empty state #167

Merged
JMR-dev merged 3 commits from fix-149-folder-loading-spinner into main 2026-07-02 22:25:22 +00:00
3 changed files with 127 additions and 1 deletions
@@ -120,6 +120,7 @@ fun MailboxScreen(
val searchActive by viewModel.searchActive.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val isSyncingFolder by viewModel.isSyncingFolder.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val selectedIds by viewModel.selectedIds.collectAsStateWithLifecycle()
val pendingConfirm by viewModel.pendingConfirm.collectAsStateWithLifecycle()
@@ -330,8 +331,16 @@ fun MailboxScreen(
}
} else if (messages.isEmpty()) {
item {
// Hold the empty state back while the folder's initial background
// sync is still in flight, so opening an uncached folder doesn't
// flash "No messages to display" before the fetch has a chance to
// populate anything (issue #149).
if (searchActive && searchQuery.isNotBlank()) {
NoResultsState(Modifier.fillParentMaxSize())
} else if (isSyncingFolder) {
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
NoMessagesState(Modifier.fillParentMaxSize())
}
@@ -186,6 +186,22 @@ class MailboxViewModel @Inject constructor(
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
/**
* True while [selectFolder]'s background sync for the currently selected folder is in flight,
* so the screen can hold the empty state back until the initial fetch either populates the
* cache or confirms the folder really is empty (issue #149).
*/
private val _isSyncingFolder = MutableStateFlow(false)
val isSyncingFolder: StateFlow<Boolean> = _isSyncingFolder.asStateFlow()
/**
* (accountId, folder) of the most recent [selectFolder] call. Used only so a completing sync
* can tell whether it's stale — superseded by a newer folder selection — before clearing
* [_isSyncingFolder], so rapid folder switching can't let a stale sync hide the spinner for
* whichever folder is actually selected now.
*/
private var latestFolderSelection: Pair<String, String>? = null
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
@@ -365,7 +381,18 @@ class MailboxViewModel @Inject constructor(
_selectedAccountId.value = accountId
explicitDrawerAccountId.value = accountId
_selectedFolder.value = folderFullName
viewModelScope.launch { mailSyncer.syncFolder(accountId, folderFullName) }
val key = accountId to folderFullName
latestFolderSelection = key
_isSyncingFolder.value = true
viewModelScope.launch {
try {
mailSyncer.syncFolder(accountId, folderFullName)
} finally {
// Only clear if this is still the most recent selection — a stale sync superseded
// by another selectFolder() must not clobber the newer one's in-flight spinner.
if (latestFolderSelection == key) _isSyncingFolder.value = false
}
}
}
/** Returns to the unified inbox across all accounts. */
@@ -8,6 +8,7 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -108,6 +109,95 @@ class MailboxViewModelTest {
coVerify { syncer.syncFolder("imap:a", "Archive") }
}
// Issue #149: opening a per-account folder with no cached messages yet must show a spinner
// (via isSyncingFolder) rather than "No messages to display" until the background sync settles.
@Test
fun `isSyncingFolder is true while the initial folder sync is in flight, then clears`() = runTest(testDispatcher) {
val syncer = mockk<MailSyncer>()
val gate = CompletableDeferred<Unit>()
coEvery { syncer.syncFolder("imap:a", "Archive") } coAnswers {
gate.await()
Result.success(0)
}
val vm = createViewModel(accounts = listOf(alice), messages = emptyList(), syncer = syncer)
backgroundScope.launch { vm.messages.collect {} }
vm.isSyncingFolder.test {
assertEquals(false, awaitItem())
vm.selectFolder("imap:a", "Archive")
assertEquals(true, awaitItem())
gate.complete(Unit)
assertEquals(false, awaitItem())
}
}
// A failed sync must still clear the flag (try/finally), so a folder that errors out doesn't
// spin forever. Syncer.syncFolder reports failure via Result.failure (see refresh()'s
// onFailure handling below), not a thrown exception, so the stub mirrors that contract.
@Test
fun `isSyncingFolder clears even when the sync fails`() = runTest(testDispatcher) {
val syncer = mockk<MailSyncer>()
val gate = CompletableDeferred<Unit>()
coEvery { syncer.syncFolder("imap:a", "Archive") } coAnswers {
gate.await()
Result.failure(IllegalStateException("boom"))
}
val vm = createViewModel(accounts = listOf(alice), messages = emptyList(), syncer = syncer)
backgroundScope.launch { vm.messages.collect {} }
vm.isSyncingFolder.test {
assertEquals(false, awaitItem())
vm.selectFolder("imap:a", "Archive")
assertEquals(true, awaitItem())
gate.complete(Unit)
assertEquals(false, awaitItem())
}
}
// Issue #149: switching folders before the previous folder's sync resolves must not let that
// stale completion clear the spinner for the folder actually selected now.
@Test
fun `rapidly switching folders keeps the spinner for the folder actually selected`() = runTest(testDispatcher) {
val syncer = mockk<MailSyncer>()
val archiveGate = CompletableDeferred<Unit>()
val sentGate = CompletableDeferred<Unit>()
coEvery { syncer.syncFolder("imap:a", "Archive") } coAnswers {
archiveGate.await()
Result.success(0)
}
coEvery { syncer.syncFolder("imap:a", "Sent") } coAnswers {
sentGate.await()
Result.success(0)
}
val vm = createViewModel(accounts = listOf(alice), messages = emptyList(), syncer = syncer)
backgroundScope.launch { vm.messages.collect {} }
vm.isSyncingFolder.test {
assertEquals(false, awaitItem())
vm.selectFolder("imap:a", "Archive")
assertEquals(true, awaitItem())
// Switch away before Archive's sync resolves; still syncing (now Sent's own fetch).
vm.selectFolder("imap:a", "Sent")
expectNoEvents()
assertEquals(true, vm.isSyncingFolder.value)
// Archive's now-stale sync finishing must not clear the flag out from under Sent.
archiveGate.complete(Unit)
expectNoEvents()
assertEquals(true, vm.isSyncingFolder.value)
// Only Sent's own completion (the folder actually selected now) clears it.
sentGate.complete(Unit)
assertEquals(false, awaitItem())
}
}
@Test
fun `folders expose the drawer account's folders in order`() = runTest(testDispatcher) {
val vm = createViewModel(