fix(web): preserve mobile scroll intent after pointer cancellation (#1312)

Keep touch and pen input eligible to cancel initial bottom settling when native panning dispatches pointercancel before scroll. Add interaction coverage for the mobile event sequence and unrelated global cancellations.
This commit is contained in:
Ananovo
2026-08-02 19:53:31 +08:00
committed by GitHub
parent f018c6027b
commit a6f302ebd1
2 changed files with 164 additions and 8 deletions
@@ -0,0 +1,137 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type { PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/lib/i18n-context'
vi.mock('@assistant-ui/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('@assistant-ui/react')>()
return {
...actual,
useAuiState: (selector: (state: unknown) => unknown) => selector({
thread: { extras: undefined }
}),
ThreadPrimitive: {
...actual.ThreadPrimitive,
Root: ({ children, className }: PropsWithChildren<{ className?: string }>) => (
<div className={className}>{children}</div>
),
Viewport: ({ children }: PropsWithChildren) => children,
Messages: () => null
}
}
})
import { HappyThread } from '@/components/AssistantChat/HappyThread'
import type { ApiClient } from '@/api/client'
import type { Session } from '@/types/api'
const originalScrollTo = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTo')
function renderThread(onViewModeChange = vi.fn()) {
const result = render(
<I18nProvider>
<HappyThread
api={{} as ApiClient}
session={{ metadata: {} } as Session}
sessionId="mobile-scroll-session"
metadata={null}
disabled={false}
onRefresh={vi.fn()}
onViewModeChange={onViewModeChange}
isSyncingTail={false}
messagesWarning={null}
hasMoreMessages={false}
isLoadingMoreMessages={false}
onLoadMore={vi.fn().mockResolvedValue({ status: 'exhausted' })}
onCancelLoadMore={vi.fn()}
unseenCount={0}
rawMessagesCount={1}
normalizedMessagesCount={1}
messagesVersion={1}
historyVersion={0}
forceScrollToken={0}
outlineOpen={false}
outlineItems={[]}
onOutlineOpenChange={vi.fn()}
/>
</I18nProvider>
)
const viewport = result.container.querySelector<HTMLElement>('.chat-scroll-y')
if (!viewport) {
throw new Error('Chat viewport was not rendered')
}
Object.defineProperties(viewport, {
scrollHeight: { configurable: true, value: 1_232 },
clientHeight: { configurable: true, value: 530 }
})
act(() => {
vi.advanceTimersByTime(0)
})
return { ...result, viewport, onViewModeChange }
}
beforeEach(() => {
vi.useFakeTimers()
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
writable: true,
value(this: HTMLElement, options: ScrollToOptions | number, y?: number) {
const requestedTop = typeof options === 'number' ? y ?? 0 : options.top ?? 0
const maxScrollTop = Math.max(0, this.scrollHeight - this.clientHeight)
this.scrollTop = Math.min(Math.max(0, requestedTop), maxScrollTop)
}
})
})
afterEach(() => {
cleanup()
vi.clearAllTimers()
vi.useRealTimers()
if (originalScrollTo) {
Object.defineProperty(HTMLElement.prototype, 'scrollTo', originalScrollTo)
} else {
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
}
})
describe('mobile initial scroll settling', () => {
it('does not snap back after pointer cancellation ends a touch swipe', () => {
const { viewport, onViewModeChange } = renderThread()
expect(viewport.scrollTop).toBe(702)
const pointerDown = new Event('pointerdown', { bubbles: true })
Object.defineProperties(pointerDown, {
button: { value: 0 },
pointerType: { value: 'touch' }
})
fireEvent(viewport, pointerDown)
const pointerCancel = new Event('pointercancel', { bubbles: true })
Object.defineProperty(pointerCancel, 'pointerType', { value: 'touch' })
fireEvent(viewport, pointerCancel)
viewport.scrollTop = 520
fireEvent.scroll(viewport)
act(() => {
vi.advanceTimersByTime(1_800)
})
expect(viewport.scrollTop).toBe(520)
expect(onViewModeChange).toHaveBeenLastCalledWith('history')
})
it('ignores pointer cancellation that did not start in the chat viewport', () => {
const { viewport, onViewModeChange } = renderThread()
const pointerCancel = new Event('pointercancel', { bubbles: true })
Object.defineProperty(pointerCancel, 'pointerType', { value: 'touch' })
fireEvent(window, pointerCancel)
viewport.scrollTop = 520
fireEvent.scroll(viewport)
act(() => {
vi.advanceTimersByTime(1_800)
})
expect(viewport.scrollTop).toBe(702)
expect(onViewModeChange).not.toHaveBeenCalledWith('history')
})
})
@@ -117,6 +117,7 @@ const TOP_PULL_TRIGGER_PX = 64
// run after its bounded retry budget is exhausted.
const WHEEL_GESTURE_GAP_MS = 250
const KEYBOARD_SCROLL_INTENT_WINDOW_MS = 750
const POINTER_CANCEL_INTENT_WINDOW_MS = 750
const UPWARD_SCROLL_KEYS = new Set(['ArrowUp', 'PageUp', 'Home'])
export function getPullToLoadState(distancePx: number): PullToLoadState {
@@ -607,6 +608,7 @@ export function HappyThread(props: {
}
let pointerResumeActive = false
let pointerResumeUntil = 0
let pointerResumeLatched = false
let keyboardResumeUntil = 0
let lastWheelAt = 0
@@ -616,6 +618,7 @@ export function HappyThread(props: {
const hasExplicitUpwardIntent = (intent: ScrollIntent): boolean => {
return intent.isScrollingUp && (
pointerResumeActive
|| pointerResumeUntil >= Date.now()
|| keyboardResumeUntil >= Date.now()
|| wheelIntentUntil >= Date.now()
)
@@ -625,7 +628,7 @@ export function HappyThread(props: {
if (!hasExplicitUpwardIntent(intent)) {
return false
}
if (pointerResumeActive && !pointerResumeLatched) {
if ((pointerResumeActive || pointerResumeUntil >= Date.now()) && !pointerResumeLatched) {
pointerResumeLatched = true
return true
}
@@ -750,11 +753,27 @@ export function HappyThread(props: {
return
}
pointerResumeActive = true
pointerResumeUntil = 0
pointerResumeLatched = false
}
const handlePointerEnd = () => {
const clearPointerIntent = () => {
pointerResumeActive = false
pointerResumeUntil = 0
pointerResumeLatched = false
}
const handlePointerCancel = (event: PointerEvent) => {
const hadActivePointer = pointerResumeActive
pointerResumeActive = false
if (hadActivePointer && (event.pointerType === 'touch' || event.pointerType === 'pen')) {
// Native panning cancels the pointer before some mobile browsers
// dispatch the resulting scroll event. Retain that explicit input
// briefly so initial bottom-settling cannot reclaim the viewport.
pointerResumeUntil = Date.now() + POINTER_CANCEL_INTENT_WINDOW_MS
return
}
pointerResumeUntil = 0
pointerResumeLatched = false
}
@@ -832,9 +851,9 @@ export function HappyThread(props: {
viewport.addEventListener('touchmove', handleTouchMove, { passive: true })
viewport.addEventListener('touchend', handleTouchEnd, { passive: true })
viewport.addEventListener('touchcancel', handleTouchCancel, { passive: true })
window.addEventListener('pointerup', handlePointerEnd, { passive: true })
window.addEventListener('pointercancel', handlePointerEnd, { passive: true })
window.addEventListener('blur', handlePointerEnd)
window.addEventListener('pointerup', clearPointerIntent, { passive: true })
window.addEventListener('pointercancel', handlePointerCancel, { passive: true })
window.addEventListener('blur', clearPointerIntent)
return () => {
viewport.removeEventListener('scroll', handleScroll)
viewport.removeEventListener('keydown', handleKeyDown)
@@ -844,9 +863,9 @@ export function HappyThread(props: {
viewport.removeEventListener('touchmove', handleTouchMove)
viewport.removeEventListener('touchend', handleTouchEnd)
viewport.removeEventListener('touchcancel', handleTouchCancel)
window.removeEventListener('pointerup', handlePointerEnd)
window.removeEventListener('pointercancel', handlePointerEnd)
window.removeEventListener('blur', handlePointerEnd)
window.removeEventListener('pointerup', clearPointerIntent)
window.removeEventListener('pointercancel', handlePointerCancel)
window.removeEventListener('blur', clearPointerIntent)
}
}, []) // Stable: no dependencies, reads from refs