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'
|
||||
|
||||
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<number | 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 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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue