Skip to content

Commit 829d4af

Browse files
Simplify useAutoScroll and add tests
1 parent d77e545 commit 829d4af

2 files changed

Lines changed: 307 additions & 19 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import { useAutoScroll } from './useAutoScroll'
4+
import type { Message } from '../api/types'
5+
6+
function createScrollContainer() {
7+
const div = document.createElement('div')
8+
document.body.appendChild(div)
9+
10+
let scrollTopValue = 0
11+
const clientHeight = 400
12+
let scrollHeightValue = 800
13+
14+
Object.defineProperty(div, 'clientHeight', {
15+
configurable: true,
16+
get: () => clientHeight,
17+
})
18+
19+
Object.defineProperty(div, 'scrollHeight', {
20+
configurable: true,
21+
get: () => scrollHeightValue,
22+
})
23+
24+
Object.defineProperty(div, 'scrollTop', {
25+
configurable: true,
26+
get: () => scrollTopValue,
27+
set: (value: number) => {
28+
scrollTopValue = Math.max(0, Math.min(value, scrollHeightValue - clientHeight))
29+
div.dispatchEvent(new Event('scroll'))
30+
},
31+
})
32+
33+
return {
34+
div,
35+
setScrollHeight: (value: number) => {
36+
scrollHeightValue = value
37+
},
38+
getScrollTop: () => scrollTopValue,
39+
setScrollTop: (value: number) => {
40+
scrollTopValue = value
41+
},
42+
cleanup: () => {
43+
document.body.removeChild(div)
44+
},
45+
}
46+
}
47+
48+
describe('useAutoScroll', () => {
49+
let containerHarness: ReturnType<typeof createScrollContainer> | null = null
50+
51+
beforeEach(() => {
52+
vi.useFakeTimers()
53+
})
54+
55+
afterEach(() => {
56+
if (containerHarness) {
57+
containerHarness.cleanup()
58+
containerHarness = null
59+
}
60+
vi.restoreAllMocks()
61+
vi.clearAllTimers()
62+
})
63+
64+
const createMessage = (id: string, role: 'user' | 'assistant'): Message => ({
65+
id,
66+
sessionID: 'session-1',
67+
role,
68+
time: { created: Date.now() },
69+
} as Message)
70+
71+
const setupHook = (messages: Message[], sessionId = 'session-1') => {
72+
containerHarness = createScrollContainer()
73+
const containerRef = { current: containerHarness.div }
74+
const onScrollStateChange = vi.fn()
75+
76+
const renderResult = renderHook(
77+
(props) =>
78+
useAutoScroll({
79+
containerRef: props.containerRef,
80+
messages: props.messages,
81+
sessionId: props.sessionId,
82+
contentVersion: props.contentVersion,
83+
onScrollStateChange: props.onScrollStateChange,
84+
}),
85+
{
86+
initialProps: {
87+
containerRef,
88+
messages,
89+
sessionId,
90+
contentVersion: messages.length,
91+
onScrollStateChange,
92+
},
93+
}
94+
)
95+
96+
return { renderResult, containerHarness, onScrollStateChange }
97+
}
98+
99+
it('scrolls to bottom on initial messages', () => {
100+
const messages = [createMessage('1', 'user'), createMessage('2', 'assistant')]
101+
const { containerHarness } = setupHook(messages)
102+
103+
act(() => {
104+
vi.advanceTimersByTime(100)
105+
})
106+
107+
expect(containerHarness.getScrollTop()).toBe(containerHarness.div.scrollHeight - containerHarness.div.clientHeight)
108+
})
109+
110+
it('auto-follows when new assistant message arrives', () => {
111+
const messages = [createMessage('1', 'user')]
112+
const { renderResult, containerHarness, onScrollStateChange } = setupHook(messages)
113+
114+
act(() => {
115+
vi.advanceTimersByTime(100)
116+
})
117+
118+
const initialScrollTop = containerHarness.getScrollTop()
119+
expect(initialScrollTop).toBe(containerHarness.div.scrollHeight - containerHarness.div.clientHeight)
120+
121+
containerHarness.setScrollHeight(containerHarness.div.scrollHeight + 200)
122+
const newMessages = [...messages, createMessage('2', 'assistant')]
123+
124+
act(() => {
125+
renderResult.rerender({
126+
containerRef: { current: containerHarness.div },
127+
messages: newMessages,
128+
sessionId: 'session-1',
129+
contentVersion: newMessages.length,
130+
onScrollStateChange,
131+
})
132+
vi.advanceTimersByTime(100)
133+
})
134+
135+
expect(containerHarness.getScrollTop()).toBe(containerHarness.div.scrollHeight - containerHarness.div.clientHeight)
136+
})
137+
138+
it('stays engaged after scrollToBottom() through streaming reflow drift', () => {
139+
const messages = [createMessage('1', 'user'), createMessage('2', 'assistant')]
140+
const { renderResult, containerHarness, onScrollStateChange } = setupHook(messages)
141+
142+
act(() => {
143+
renderResult.result.current.scrollToBottom()
144+
})
145+
146+
const bottomPosition = containerHarness.div.scrollHeight - containerHarness.div.clientHeight
147+
containerHarness.setScrollTop(bottomPosition - 2)
148+
149+
act(() => {
150+
containerHarness.div.dispatchEvent(new Event('scroll'))
151+
})
152+
153+
containerHarness.setScrollHeight(containerHarness.div.scrollHeight + 100)
154+
155+
act(() => {
156+
renderResult.rerender({
157+
containerRef: { current: containerHarness.div },
158+
messages,
159+
sessionId: 'session-1',
160+
contentVersion: messages.length + 1,
161+
onScrollStateChange,
162+
})
163+
})
164+
165+
expect(containerHarness.getScrollTop()).toBe(containerHarness.div.scrollHeight - containerHarness.div.clientHeight)
166+
})
167+
168+
it('disengages when user fires a wheel-up event', () => {
169+
const messages = [createMessage('1', 'user')]
170+
const { containerHarness, onScrollStateChange } = setupHook(messages)
171+
172+
act(() => {
173+
containerHarness.div.dispatchEvent(
174+
new WheelEvent('wheel', {
175+
deltaY: -50,
176+
bubbles: true,
177+
})
178+
)
179+
})
180+
181+
expect(onScrollStateChange).toHaveBeenCalledWith(true)
182+
183+
const bottomPosition = containerHarness.div.scrollHeight - containerHarness.div.clientHeight
184+
containerHarness.setScrollHeight(bottomPosition + 200)
185+
186+
const currentScrollTop = containerHarness.getScrollTop()
187+
expect(currentScrollTop).not.toBe(containerHarness.div.scrollHeight - containerHarness.div.clientHeight)
188+
})
189+
190+
it('disengages when user drags upward (pointer down -> move up -> up)', () => {
191+
const messages = [createMessage('1', 'user')]
192+
const { containerHarness, onScrollStateChange } = setupHook(messages)
193+
194+
act(() => {
195+
containerHarness.div.dispatchEvent(
196+
new PointerEvent('pointerdown', {
197+
clientY: 200,
198+
bubbles: true,
199+
})
200+
)
201+
containerHarness.div.dispatchEvent(
202+
new PointerEvent('pointermove', {
203+
clientY: 260,
204+
bubbles: true,
205+
})
206+
)
207+
})
208+
209+
expect(onScrollStateChange).toHaveBeenCalledWith(true)
210+
211+
const bottomPosition = containerHarness.div.scrollHeight - containerHarness.div.clientHeight
212+
containerHarness.setScrollHeight(bottomPosition + 200)
213+
214+
const currentScrollTop = containerHarness.getScrollTop()
215+
expect(currentScrollTop).not.toBe(containerHarness.div.scrollHeight - containerHarness.div.clientHeight)
216+
})
217+
218+
it('re-engages when user scrolls back to within threshold', () => {
219+
const messages = [createMessage('1', 'user')]
220+
const { containerHarness, onScrollStateChange } = setupHook(messages)
221+
222+
act(() => {
223+
containerHarness.div.dispatchEvent(
224+
new WheelEvent('wheel', {
225+
deltaY: -50,
226+
bubbles: true,
227+
})
228+
)
229+
})
230+
231+
expect(onScrollStateChange).toHaveBeenCalledWith(true)
232+
233+
const scrollHeight = containerHarness.div.scrollHeight
234+
const clientHeight = containerHarness.div.clientHeight
235+
const withinThresholdPosition = scrollHeight - clientHeight - 30
236+
237+
act(() => {
238+
containerHarness.setScrollTop(withinThresholdPosition)
239+
})
240+
241+
expect(onScrollStateChange).toHaveBeenCalledWith(false)
242+
})
243+
244+
it('always scrolls to bottom when user sends a message', () => {
245+
const messages = [createMessage('1', 'assistant')]
246+
const { renderResult, containerHarness, onScrollStateChange } = setupHook(messages)
247+
248+
act(() => {
249+
containerHarness.div.dispatchEvent(
250+
new WheelEvent('wheel', {
251+
deltaY: -50,
252+
bubbles: true,
253+
})
254+
)
255+
})
256+
257+
expect(onScrollStateChange).toHaveBeenCalledWith(true)
258+
259+
const newMessages = [...messages, createMessage('2', 'user')]
260+
261+
act(() => {
262+
renderResult.rerender({
263+
containerRef: { current: containerHarness.div },
264+
messages: newMessages,
265+
sessionId: 'session-1',
266+
contentVersion: newMessages.length,
267+
onScrollStateChange,
268+
})
269+
})
270+
271+
expect(containerHarness.getScrollTop()).toBe(containerHarness.div.scrollHeight - containerHarness.div.clientHeight)
272+
})
273+
274+
it('resets state when sessionId changes', () => {
275+
const messages = [createMessage('1', 'user')]
276+
const { renderResult, containerHarness } = setupHook(messages, 'session-1')
277+
278+
act(() => {
279+
containerHarness.div.dispatchEvent(
280+
new WheelEvent('wheel', {
281+
deltaY: -50,
282+
bubbles: true,
283+
})
284+
)
285+
})
286+
287+
const newMessages = [createMessage('2', 'user')]
288+
289+
act(() => {
290+
renderResult.rerender({
291+
containerRef: { current: containerHarness.div },
292+
messages: newMessages,
293+
sessionId: 'session-2',
294+
contentVersion: newMessages.length,
295+
onScrollStateChange: vi.fn(),
296+
})
297+
})
298+
299+
expect(containerHarness.getScrollTop()).toBe(containerHarness.div.scrollHeight - containerHarness.div.clientHeight)
300+
})
301+
})

‎frontend/src/hooks/useAutoScroll.ts‎

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { useRef, useEffect, useCallback } from 'react'
22
import type { Message } from '@/api/types'
33

44
const SCROLL_LOCK_MS = 300
5-
const BOTTOM_THRESHOLD_PX = 8
6-
const USER_SCROLL_DELTA_PX = 0.5
5+
const BOTTOM_THRESHOLD_PX = 48
76

87
interface UseAutoScrollOptions {
98
containerRef?: React.RefObject<HTMLDivElement | null>
@@ -29,7 +28,6 @@ export function useAutoScroll({
2928
const userScrolledAtRef = useRef(0)
3029
const userDisengagedRef = useRef(false)
3130
const pointerStartYRef = useRef<number | null>(null)
32-
const lastScrollTopRef = useRef(0)
3331
const onScrollStateChangeRef = useRef(onScrollStateChange)
3432

3533
onScrollStateChangeRef.current = onScrollStateChange
@@ -39,7 +37,6 @@ export function useAutoScroll({
3937
userScrolledAtRef.current = 0
4038
userDisengagedRef.current = false
4139
containerRef.current.scrollTop = containerRef.current.scrollHeight
42-
lastScrollTopRef.current = containerRef.current.scrollTop
4340
onScrollStateChangeRef.current?.(false)
4441
}, [containerRef])
4542

@@ -48,7 +45,6 @@ export function useAutoScroll({
4845
hasInitialScrolledRef.current = false
4946
userScrolledAtRef.current = 0
5047
userDisengagedRef.current = false
51-
lastScrollTopRef.current = 0
5248
}, [sessionId])
5349

5450
useEffect(() => {
@@ -92,28 +88,19 @@ export function useAutoScroll({
9288

9389
const handleScroll = () => {
9490
const { scrollTop, scrollHeight, clientHeight } = container
95-
const isUserScrollingUp = scrollTop < lastScrollTopRef.current - USER_SCROLL_DELTA_PX
9691
const isAtBottom = scrollHeight - scrollTop - clientHeight < BOTTOM_THRESHOLD_PX
9792

98-
if (isUserScrollingUp) {
99-
markDisengaged()
100-
}
101-
102-
if (isAtBottom && !isUserScrollingUp) {
93+
if (isAtBottom) {
10394
if (userDisengagedRef.current) {
10495
userScrolledAtRef.current = 0
10596
userDisengagedRef.current = false
10697
onScrollStateChangeRef.current?.(false)
10798
}
108-
} else {
109-
if (!userDisengagedRef.current) {
110-
userScrolledAtRef.current = Date.now()
111-
userDisengagedRef.current = true
112-
onScrollStateChangeRef.current?.(true)
113-
}
99+
} else if (!userDisengagedRef.current) {
100+
userScrolledAtRef.current = Date.now()
101+
userDisengagedRef.current = true
102+
onScrollStateChangeRef.current?.(true)
114103
}
115-
116-
lastScrollTopRef.current = scrollTop
117104
}
118105

119106
container.addEventListener('pointerdown', handlePointerDown, { passive: true })

0 commit comments

Comments
 (0)