From d33bb338de03d5021a3649ad29d36b2fbd2e3a93 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 28 Sep 2026 08:13:25 -0400 Subject: [PATCH] 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 --- .../kotlin/io/flamenet/messenger/app/ChatScreens.kt | 13 +++++++++++-- .../app/TheKeyboardDoesNotHideTheMessagesTest.kt | 13 +++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index d19fe95..220f4a3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1715,8 +1715,17 @@ fun ColumnScope.ChatScreen( val listState = rememberLazyListState() // 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) { - if (chat.lines.isNotEmpty()) listState.scrollToItem(chat.lines.lastIndex) + if (chat.lines.isNotEmpty()) listState.requestScrollToItem(chat.lines.lastIndex) } // Follow the conversation as it grows. LaunchedEffect(chat.lines.size) { @@ -1737,7 +1746,7 @@ fun ColumnScope.ChatScreen( // of that lands short by however much the viewport shrank afterwards. val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current) 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. diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/TheKeyboardDoesNotHideTheMessagesTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/TheKeyboardDoesNotHideTheMessagesTest.kt index 0034f7d..54ac025 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/TheKeyboardDoesNotHideTheMessagesTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/TheKeyboardDoesNotHideTheMessagesTest.kt @@ -58,10 +58,19 @@ class TheKeyboardDoesNotHideTheMessagesTest { // 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 // 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("}") assertTrue( - block.contains("listState.scrollToItem("), - "the keyboard follow should scroll instantly: $block", + block.contains("listState.requestScrollToItem("), + "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( !block.contains("animateScrollToItem"),