From bd5061e7bd99b863dd5275de5210c41472788790 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 18 Oct 2026 13:05:33 +0700 Subject: [PATCH] fix(app): stop blank rows while scrolling tall Android chats (#7278) Two causes, both reproduced on a clone of a real long thinking-partner chat (replies and thinking rows several screens tall, GFM tables). 1. The window lowered in e42771970 (initial 2 / batch 2 / window 5) holds only two or three tall rows, so a fling outruns the mounted range and the viewport crosses unmounted rows: 65 blank frames over a 4,152-frame traversal. Keep the small initial render that bounds cold-open measurement, but refill a window of 10 in batches of 10: 0 blank frames over 4,347, p95 25 ms. 2. Scroll-to-bottom from deep history animated across every row in between, which no finite window can mount in time: 9 blank frames (~150 ms) per jump. Animate only when the current offset is within the bottom-side overscan, otherwise jump instantly. Near-bottom jumps still animate. Web already does not animate this jump. Cold open on the same chat is unchanged (first content 569 -> 568 ms); the deeper window costs first-scroll acknowledgement (2.58 -> 2.94 s in a debug build). --- .../native-scroll-animation.test.ts | 34 +++++++++++++++++++ .../agent-stream/native-scroll-animation.ts | 10 ++++++ .../app/src/agent-stream/strategy-native.tsx | 18 +++++++--- 3 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/agent-stream/native-scroll-animation.test.ts create mode 100644 packages/app/src/agent-stream/native-scroll-animation.ts diff --git a/packages/app/src/agent-stream/native-scroll-animation.test.ts b/packages/app/src/agent-stream/native-scroll-animation.test.ts new file mode 100644 index 0000000000..947b8c3d1d --- /dev/null +++ b/packages/app/src/agent-stream/native-scroll-animation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation"; + +describe("native scroll-to-bottom animation", () => { + it("jumps across unmounted history instead of animating through blank rows", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }), + ).toBe(false); + }); + + it("keeps near-bottom movement eligible for animation", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }), + ).toBe(true); + }); + + it("bounds travel by the bottom-side overscan, not the whole window", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }), + ).toBe(true); + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }), + ).toBe(false); + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }), + ).toBe(false); + }); + + it("does not animate before the viewport has been measured", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/agent-stream/native-scroll-animation.ts b/packages/app/src/agent-stream/native-scroll-animation.ts new file mode 100644 index 0000000000..72bc39108c --- /dev/null +++ b/packages/app/src/agent-stream/native-scroll-animation.ts @@ -0,0 +1,10 @@ +export function shouldAnimateNativeScrollToBottom(input: { + offsetY: number; + viewportHeight: number; + windowSize: number; +}): boolean { + // VirtualizedList splits the extra window around the viewport. An animated + // jump farther than the bottom-side overscan crosses unmounted history. + const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight; + return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight; +} diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 66409f23e1..e7568edfe8 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -23,6 +23,7 @@ import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; import { useKeyboardStreamInset } from "@/keyboard/shift"; +import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation"; import { useRevisedHistoryRows } from "./history-row-revision"; import { useBottomAnchorController } from "./bottom-anchor-controller"; import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; @@ -64,6 +65,7 @@ const historyStartSlotStyle: ViewStyle = { flexShrink: 0, }; const HISTORY_START_SETTLE_FRAMES = 2; +const NATIVE_STREAM_WINDOW_SIZE = 10; function keyExtractor(item: { id: string }): string { return item.id; @@ -238,7 +240,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat programmaticScrollEventBudgetRef.current = 3; flatListRef.current?.scrollToOffset({ offset: 0, - animated, + animated: + animated && + shouldAnimateNativeScrollToBottom({ + offsetY: scrollOffsetYRef.current, + viewportHeight: streamViewportMetricsRef.current.viewportHeight, + windowSize: NATIVE_STREAM_WINDOW_SIZE, + }), }); scrollOffsetYRef.current = 0; streamViewportMetricsRef.current = { @@ -591,11 +599,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} maintainVisibleContentPosition={maintainVisibleContentPosition} - // A history row can contain an entire long reply. Keep synchronous native - // Markdown measurement bounded while FlatList fills the surrounding window. + // Tall Markdown rows exhaust a shallow window during flings. Keep the initial + // render small, then refill a deeper window in batches large enough to keep up. initialNumToRender={2} - maxToRenderPerBatch={2} - windowSize={5} + maxToRenderPerBatch={10} + windowSize={NATIVE_STREAM_WINDOW_SIZE} removeClippedSubviews={false} scrollEnabled={scrollEnabled} showsVerticalScrollIndicator