perf(chat): throttle scroll geometry sampling

This commit is contained in:
imccyu 2026-09-01 23:06:52 +08:00
parent 4e4733c50b
commit e32437d18b
2 changed files with 29 additions and 4 deletions

View file

@ -17,6 +17,7 @@ import { formatRunDuration } from './message-chrome.ts'
import css from './ChatView.module.css' import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24 const FOLLOW_THRESHOLD = 24
const SCROLL_SAMPLE_INTERVAL_MS = 500
/** Active column host when present; otherwise the view-local scroller. */ /** Active column host when present; otherwise the view-local scroller. */
function scrollerOf(from: HTMLElement): HTMLElement { function scrollerOf(from: HTMLElement): HTMLElement {
@ -307,6 +308,8 @@ export function ChatView({
// restores it and normalizes a floor-clamped position back to following. // restores it and normalizes a floor-clamped position back to following.
const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null) const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null)
const atBottomRef = useRef(atBottom) const atBottomRef = useRef(atBottom)
const scrollSamplePendingRef = useRef(false)
const [, setScrollSampleTick] = useState(0)
const [activeTurn, setActiveTurn] = useState<number | null>( const [activeTurn, setActiveTurn] = useState<number | null>(
() => turnNavigationItems.at(-1)?.turn ?? null, () => 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 followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}`
const syncActiveTurn = useCallback((): void => { const syncActiveTurn = useCallback((): void => {
if (scrollSamplePendingRef.current) return
const local = listRef.current const local = listRef.current
const first = turnNavigationItems[0] const first = turnNavigationItems[0]
if (local === null || first === undefined) { if (local === null || first === undefined) {
@ -461,6 +465,7 @@ export function ChatView({
} }
useLayoutEffect(() => { useLayoutEffect(() => {
if (scrollSamplePendingRef.current) return
const local = listRef.current const local = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */ /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (local === null) return if (local === null) return
@ -574,18 +579,33 @@ export function ChatView({
scheduleActiveTurn() scheduleActiveTurn()
} }
// Bind the scroll listener on the resolved scrollport once per mount; // Raw scroll events only schedule work. Geometry is sampled at most once
// reader-input attribution rides the observed-top ledger, not per-device // per interval, with scrollend providing the final sample for a short burst.
// input listeners.
useEffect(() => { useEffect(() => {
const local = listRef.current const local = listRef.current
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */ /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
if (local === null) return if (local === null) return
const el = scrollerOf(local) 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('scroll', onScroll, { passive: true })
el.addEventListener('scrollend', sample, { passive: true })
return () => { return () => {
el.removeEventListener('scroll', onScroll) 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. // initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null) const followRef = useRef<(() => void) | null>(null)
followRef.current = () => { followRef.current = () => {
if (scrollSamplePendingRef.current) return
const local = listRef.current const local = listRef.current
if (local !== null && atBottomRef.current) { if (local !== null && atBottomRef.current) {
const el = scrollerOf(local) const el = scrollerOf(local)

View file

@ -439,6 +439,7 @@ function makeHarness(
function readerScroll(element: HTMLElement, top: number): void { function readerScroll(element: HTMLElement, top: number): void {
element.scrollTop = top element.scrollTop = top
fireEvent.scroll(element) fireEvent.scroll(element)
fireEvent(element, new Event('scrollend'))
} }
function turnProcessControl(container: HTMLElement): HTMLButtonElement | null { 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. // lands exactly on the ledger's floor min, so it is not reader input.
metrics.setLayout(800, 700) metrics.setLayout(800, 700)
fireEvent.scroll(scroller) fireEvent.scroll(scroller)
fireEvent(scroller, new Event('scrollend'))
expect(scroller.scrollTop).toBe(500) expect(scroller.scrollTop).toBe(500)
expect(view.queryByLabelText('回到底部')).toBeNull() expect(view.queryByLabelText('回到底部')).toBeNull()
expect(h.chatScroll.read()).toBeNull() expect(h.chatScroll.read()).toBeNull()
@ -2337,6 +2339,7 @@ describe('ChatView', () => {
// baseline sampled from already-moved raw geometry. // baseline sampled from already-moved raw geometry.
scroller.scrollTop = 500 scroller.scrollTop = 500
fireEvent.scroll(scroller) fireEvent.scroll(scroller)
fireEvent(scroller, new Event('scrollend'))
expect(view.getByLabelText('回到底部')).toBeTruthy() expect(view.getByLabelText('回到底部')).toBeTruthy()
}) })
@ -2359,6 +2362,7 @@ describe('ChatView', () => {
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
scroller.scrollTop = 700 scroller.scrollTop = 700
fireEvent.scroll(scroller) fireEvent.scroll(scroller)
fireEvent(scroller, new Event('scrollend'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
act(() => { notify?.() }) act(() => { notify?.() })
expect(scroller.scrollTop).toBe(1_200) expect(scroller.scrollTop).toBe(1_200)