From e32437d18b4aa2c9c4feda530b9cdda49dcb75f6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:06:52 +0800 Subject: [PATCH] perf(chat): throttle scroll geometry sampling --- .../ui-chat/src/client/chat/ChatView.tsx | 29 ++++++++++++++++--- .../ui-chat/tests/chat-view.client.spec.tsx | 4 +++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 7374af1293..403eecd96f 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -17,6 +17,7 @@ import { formatRunDuration } from './message-chrome.ts' import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 +const SCROLL_SAMPLE_INTERVAL_MS = 500 /** Active column host when present; otherwise the view-local scroller. */ function scrollerOf(from: HTMLElement): HTMLElement { @@ -307,6 +308,8 @@ export function ChatView({ // restores it and normalizes a floor-clamped position back to following. const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null) const atBottomRef = useRef(atBottom) + const scrollSamplePendingRef = useRef(false) + const [, setScrollSampleTick] = useState(0) const [activeTurn, setActiveTurn] = useState( () => turnNavigationItems.at(-1)?.turn ?? null, ) @@ -343,6 +346,7 @@ export function ChatView({ const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}` const syncActiveTurn = useCallback((): void => { + if (scrollSamplePendingRef.current) return const local = listRef.current const first = turnNavigationItems[0] if (local === null || first === undefined) { @@ -461,6 +465,7 @@ export function ChatView({ } useLayoutEffect(() => { + if (scrollSamplePendingRef.current) return const local = listRef.current /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */ if (local === null) return @@ -574,18 +579,33 @@ export function ChatView({ scheduleActiveTurn() } - // Bind the scroll listener on the resolved scrollport once per mount; - // reader-input attribution rides the observed-top ledger, not per-device - // input listeners. + // Raw scroll events only schedule work. Geometry is sampled at most once + // per interval, with scrollend providing the final sample for a short burst. useEffect(() => { const local = listRef.current /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */ if (local === null) return const el = scrollerOf(local) - const onScroll = (): void => { onScrollRef.current() } + let sampleTimer: number | undefined + const sample = (): void => { + if (!scrollSamplePendingRef.current) return + scrollSamplePendingRef.current = false + if (sampleTimer !== undefined) window.clearTimeout(sampleTimer) + sampleTimer = undefined + onScrollRef.current() + setScrollSampleTick(tick => tick + 1) + } + const onScroll = (): void => { + scrollSamplePendingRef.current = true + sampleTimer ??= window.setTimeout(sample, SCROLL_SAMPLE_INTERVAL_MS) + } el.addEventListener('scroll', onScroll, { passive: true }) + el.addEventListener('scrollend', sample, { passive: true }) return () => { el.removeEventListener('scroll', onScroll) + el.removeEventListener('scrollend', sample) + if (sampleTimer !== undefined) window.clearTimeout(sampleTimer) + scrollSamplePendingRef.current = false } }, []) @@ -593,6 +613,7 @@ export function ChatView({ // initializer a function initial value would need never exists. const followRef = useRef<(() => void) | null>(null) followRef.current = () => { + if (scrollSamplePendingRef.current) return const local = listRef.current if (local !== null && atBottomRef.current) { const el = scrollerOf(local) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 83d0dc6b9a..9e0818f37c 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -439,6 +439,7 @@ function makeHarness( function readerScroll(element: HTMLElement, top: number): void { element.scrollTop = top fireEvent.scroll(element) + fireEvent(element, new Event('scrollend')) } function turnProcessControl(container: HTMLElement): HTMLButtonElement | null { @@ -2315,6 +2316,7 @@ describe('ChatView', () => { // lands exactly on the ledger's floor min, so it is not reader input. metrics.setLayout(800, 700) fireEvent.scroll(scroller) + fireEvent(scroller, new Event('scrollend')) expect(scroller.scrollTop).toBe(500) expect(view.queryByLabelText('回到底部')).toBeNull() expect(h.chatScroll.read()).toBeNull() @@ -2337,6 +2339,7 @@ describe('ChatView', () => { // baseline sampled from already-moved raw geometry. scroller.scrollTop = 500 fireEvent.scroll(scroller) + fireEvent(scroller, new Event('scrollend')) expect(view.getByLabelText('回到底部')).toBeTruthy() }) @@ -2359,6 +2362,7 @@ describe('ChatView', () => { Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) scroller.scrollTop = 700 fireEvent.scroll(scroller) + fireEvent(scroller, new Event('scrollend')) Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) act(() => { notify?.() }) expect(scroller.scrollTop).toBe(1_200)