mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 01:19:33 +00:00
Fix SnackbarDispatcher dropping messages for hosts that subscribe after a post
Publish the current queue head via a StateFlow so any host that subscribes after a message is posted (e.g. a screen recomposing as a flow pops back to it) still observes it. The previous mutex-gated flow delivered each message to a single parked collector, which could starve the host actually on screen and drop the snackbar. Adds a regression test for the late-subscriber case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-19
@@ -18,39 +18,35 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import io.element.android.libraries.designsystem.theme.components.Snackbar
|
import io.element.android.libraries.designsystem.theme.components.Snackbar
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
|
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
|
||||||
|
*
|
||||||
|
* The current head of the queue is exposed as a [MutableStateFlow] so that every collector — including
|
||||||
|
* hosts that subscribe *after* a message was posted (e.g. a screen recomposing as a flow pops back to
|
||||||
|
* it) — observes the current message. An earlier mutex-gated implementation delivered each message to
|
||||||
|
* whichever single collector happened to be parked on the lock, which could starve the host that was
|
||||||
|
* actually on screen, dropping the snackbar.
|
||||||
*/
|
*/
|
||||||
class SnackbarDispatcher {
|
class SnackbarDispatcher {
|
||||||
private val queueMutex = Mutex()
|
|
||||||
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
|
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
|
||||||
val snackbarMessage: Flow<SnackbarMessage?> = flow {
|
private val currentMessage = MutableStateFlow<SnackbarMessage?>(null)
|
||||||
while (currentCoroutineContext().isActive) {
|
|
||||||
queueMutex.lock()
|
|
||||||
emit(snackBarMessageQueue.firstOrNull())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
val snackbarMessage: Flow<SnackbarMessage?> = currentMessage.asStateFlow()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun post(message: SnackbarMessage) {
|
fun post(message: SnackbarMessage) {
|
||||||
if (snackBarMessageQueue.isEmpty()) {
|
|
||||||
snackBarMessageQueue.add(message)
|
snackBarMessageQueue.add(message)
|
||||||
if (queueMutex.isLocked) queueMutex.unlock()
|
currentMessage.value = snackBarMessageQueue.firstOrNull()
|
||||||
} else {
|
|
||||||
snackBarMessageQueue.add(message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
fun clear() {
|
fun clear() {
|
||||||
if (snackBarMessageQueue.isNotEmpty()) {
|
|
||||||
snackBarMessageQueue.removeFirstOrNull()
|
snackBarMessageQueue.removeFirstOrNull()
|
||||||
if (queueMutex.isLocked) queueMutex.unlock()
|
currentMessage.value = snackBarMessageQueue.firstOrNull()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
@@ -54,6 +54,27 @@ class SnackbarDispatcherTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a host that subscribes after a message is posted still receives it`() = runTest {
|
||||||
|
val snackbarDispatcher = SnackbarDispatcher()
|
||||||
|
val message = SnackbarMessage(0)
|
||||||
|
// A first host is already collecting (mirrors a background screen still subscribed).
|
||||||
|
snackbarDispatcher.snackbarMessage.test {
|
||||||
|
assertThat(awaitItem()).isNull()
|
||||||
|
snackbarDispatcher.post(message)
|
||||||
|
assertThat(awaitItem()).isEqualTo(message)
|
||||||
|
|
||||||
|
// A second host subscribes only afterwards — e.g. the screen a flow pops back to once the
|
||||||
|
// message has already been posted. It must observe the current message rather than being
|
||||||
|
// starved waiting for the next post (regression: an earlier mutex-gated implementation
|
||||||
|
// delivered each post to a single parked collector, dropping the snackbar for any host
|
||||||
|
// that subscribed later).
|
||||||
|
snackbarDispatcher.snackbarMessage.test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
|
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
|
||||||
val snackbarDispatcher = SnackbarDispatcher()
|
val snackbarDispatcher = SnackbarDispatcher()
|
||||||
|
|||||||
Reference in New Issue
Block a user