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:
co-authored by
Claude Opus 5.5
parent
a44624cfad
commit
d33bb338de
@@ -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.
|
||||||
|
|||||||
+11
-2
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user