fix(chat): prevent timeline prefetch ANRs (#985)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-08-27 10:47:04 +03:00
1 parent ce8fdc45bd
commit c7d7abf4a1
3 files changed
+60 -1

No files matched your search

@@ -92,6 +92,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay
import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay
import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab
import com.m57.hermescontrol.ui.chat.components.ChatTimelineNoPrefetchStrategy
import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
@@ -158,7 +159,7 @@ fun ChatScreen(
// read its fields recompose on search changes (bar, matched bubbles).
val searchState = viewModel.searchState
val lifecycleOwner = LocalLifecycleOwner.current
val listState = rememberLazyListState()
val listState = rememberLazyListState(prefetchStrategy = ChatTimelineNoPrefetchStrategy)
val scrollScope = rememberCoroutineScope()
val scrollController = rememberChatScrollController(listState, scrollScope)
var isOlderPagingArmed by remember(state.currentSessionId) { mutableStateOf(false) }
@@ -0,0 +1,25 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListPrefetchScope
import androidx.compose.foundation.lazy.LazyListPrefetchStrategy
import androidx.compose.foundation.lazy.layout.NestedPrefetchScope
/**
* Chat messages can contain very large selectable Markdown blocks. Measuring a
* neighboring item during LazyColumn prefetch can monopolize the main thread
* long enough to trigger an input-dispatch ANR, even though that item is still
* off screen. Measure chat items only when they enter the viewport instead.
*/
@OptIn(ExperimentalFoundationApi::class)
internal object ChatTimelineNoPrefetchStrategy : LazyListPrefetchStrategy {
override fun LazyListPrefetchScope.onScroll(
delta: Float,
layoutInfo: LazyListLayoutInfo,
) = Unit
override fun LazyListPrefetchScope.onVisibleItemsUpdated(layoutInfo: LazyListLayoutInfo) = Unit
override fun NestedPrefetchScope.onNestedPrefetch(firstVisibleItemIndex: Int) = Unit
}
@@ -0,0 +1,33 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListPrefetchScope
import androidx.compose.foundation.lazy.layout.NestedPrefetchScope
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
@OptIn(ExperimentalFoundationApi::class)
class ChatTimelinePrefetchStrategyTest {
@Test
fun strategy_neverSchedulesOffscreenChatItems() {
val scope = mockk<LazyListPrefetchScope>(relaxed = true)
val layoutInfo = mockk<LazyListLayoutInfo>(relaxed = true)
val nestedScope = mockk<NestedPrefetchScope>(relaxed = true)
with(ChatTimelineNoPrefetchStrategy) {
with(scope) {
onScroll(delta = 120f, layoutInfo)
onVisibleItemsUpdated(layoutInfo)
}
with(nestedScope) {
onNestedPrefetch(firstVisibleItemIndex = 10)
}
}
verify(exactly = 0) { scope.schedulePrefetch(any(), any()) }
verify(exactly = 0) { nestedScope.schedulePrecomposition(any()) }
verify(exactly = 0) { nestedScope.schedulePrecompositionAndPremeasure(any(), any()) }
}
}