mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user