Scroll the chat by request, not by forcing a layout

The chat keeps its newest message in view when it opens and while the
keyboard slides, and both did it with `listState.scrollToItem`, which forces
a layout on the spot. This screen is composed inside the Scaffold's measure
pass, so when the effect ran straight away it asked for a layout in the
middle of one:

  IllegalArgumentException: performMeasureAndLayout called during measure layout
    at LayoutNode.forceRemeasure
    at LazyListState.snapToItemIndexInternal
    at ChatScreens.kt:1740 (the keyboard follow)
    ... LaunchedEffectImpl.onRemembered ... SubcomposeLayout ... Scaffold

It failed ChatFlowTest's send in every full on-device run since 2026-09-18.
The frames that ran the effect inline are the Compose test dispatcher's; the
shipped app posts effects to the next frame, so this has not been seen to
crash a real device. Forcing a synchronous layout from an effect is wrong
either way.

Both now use `requestScrollToItem` (foundation 1.7.6), which the next layout
applies, and the keyboard is what brings that layout. Still instant, still
not animated. The animated follow for new messages is unchanged: it waits
for animation frames before it moves.

TheKeyboardDoesNotHideTheMessagesTest read the source for
`listState.scrollToItem(`; it now requires `requestScrollToItem(` and refuses
`scrollToItem(` in the keyboard effect. Put the old line back and it fails.

Result on relay1: the full on-device suite passes, 10 of 10, and the crash
buffer has no performMeasureAndLayout. App unit tests: 358 debug, 323
release, all green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-28 08:13:25 -04:00
co-authored by Claude Opus 5.5
parent a44624cfad
commit d33bb338de
2 changed files with 22 additions and 4 deletions
@@ -1715,8 +1715,17 @@ fun ColumnScope.ChatScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Open AT the newest message, a jump, not a tour of the history. // Open AT the newest message, a jump, not a tour of the history.
//
// `requestScrollToItem`, not `scrollToItem`, here and for the keyboard
// below. `scrollToItem` forces a layout on the spot, and this screen is
// composed inside the Scaffold's MEASURE pass, so an effect that runs
// straight away asks for a layout in the middle of one:
// "performMeasureAndLayout called during measure layout", thrown from the
// keyboard effect in the on-device suite (ChatFlowTest, 2026-09-18 to
// 09-28). The request is applied by the next layout instead, which comes
// at once because the keyboard is what is changing the layout.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (chat.lines.isNotEmpty()) listState.scrollToItem(chat.lines.lastIndex) if (chat.lines.isNotEmpty()) listState.requestScrollToItem(chat.lines.lastIndex)
} }
// Follow the conversation as it grows. // Follow the conversation as it grows.
LaunchedEffect(chat.lines.size) { LaunchedEffect(chat.lines.size) {
@@ -1737,7 +1746,7 @@ fun ColumnScope.ChatScreen(
// of that lands short by however much the viewport shrank afterwards. // of that lands short by however much the viewport shrank afterwards.
val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current) val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current)
LaunchedEffect(imeBottom) { LaunchedEffect(imeBottom) {
if (chat.lines.isNotEmpty()) listState.scrollToItem(chat.lines.lastIndex) if (chat.lines.isNotEmpty()) listState.requestScrollToItem(chat.lines.lastIndex)
} }
// One bar, not three stacked rows, and it wears the channel's own colour. // One bar, not three stacked rows, and it wears the channel's own colour.
@@ -58,10 +58,19 @@ class TheKeyboardDoesNotHideTheMessagesTest {
// The inset arrives as a series of values while the keyboard slides. // The inset arrives as a series of values while the keyboard slides.
// One animated jump fired at the start of that lands short by however // One animated jump fired at the start of that lands short by however
// much the viewport shrank afterwards. // much the viewport shrank afterwards.
//
// Instant means `requestScrollToItem`, applied by the next layout, and
// NOT `scrollToItem`, which forces a layout on the spot: this screen is
// composed inside the Scaffold's measure pass, and on-device that threw
// "performMeasureAndLayout called during measure layout".
val block = screens.substringAfter("LaunchedEffect(imeBottom) {").substringBefore("}") val block = screens.substringAfter("LaunchedEffect(imeBottom) {").substringBefore("}")
assertTrue( assertTrue(
block.contains("listState.scrollToItem("), block.contains("listState.requestScrollToItem("),
"the keyboard follow should scroll instantly: $block", "the keyboard follow should scroll instantly, by request: $block",
)
assertTrue(
!block.contains("listState.scrollToItem("),
"scrollToItem forces a layout and throws when it runs inside one: $block",
) )
assertTrue( assertTrue(
!block.contains("animateScrollToItem"), !block.contains("animateScrollToItem"),