perf(chat): throttle scroll geometry sampling
This commit is contained in:
parent
4e4733c50b
commit
e32437d18b
2 changed files with 29 additions and 4 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue