Skip to content

Commit 121d1a4

Browse files
committed
fix(content): cancel follow authorization on delegated scrollbar drag
1 parent 30f9756 commit 121d1a4

2 files changed

Lines changed: 23 additions & 13 deletions

File tree

src/app/content/components/message-list.browser.test.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -510,19 +510,20 @@ describe('MessageList authorized follow and sole scroll ownership (acceptance re
510510
{
511511
name: 'scrollbar-drag',
512512
dispatch: async () => {
513-
// The repository ScrollBar follows the Radix hover model: it mounts only after a
514-
// real scroll/hover of the scroll area. Drive a real nonzero scroll plus the native
515-
// scroll/pointerenter inputs, then explicitly await and assert its presence before
516-
// the drag. A programmatic offset change carries no user intent, so the authorized
517-
// follow under test is not cancelled by this setup.
513+
// The repository Radix ScrollBar follows the hover model: it is absent at rest and
514+
// mounts only on real scroll/hover. Prove the full path — no mounted scrollbar, an
515+
// outstanding authorized follow, real overflow, hover-mount, then a drag on its
516+
// actual thumb descendant. The delegated root listener (production) is the only
517+
// thing that can cancel here; without it this control fails.
518+
expect(document.querySelector('[data-slot="scroll-area-scrollbar"]')).toBeNull()
519+
scrollParent.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
518520
scrollParent.scrollTop = 100
519521
scrollParent.dispatchEvent(new Event('scroll', { bubbles: true }))
520-
scrollParent.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
521-
await vi.waitFor(() => expect(document.querySelector('[data-slot="scroll-area-scrollbar"]')).not.toBeNull(), {
522+
await vi.waitFor(() => expect(document.querySelector('[data-slot="scroll-area-thumb"]')).not.toBeNull(), {
522523
timeout: 5000
523524
})
524525
document
525-
.querySelector('[data-slot="scroll-area-scrollbar"]')!
526+
.querySelector('[data-slot="scroll-area-thumb"]')!
526527
.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }))
527528
}
528529
}

src/app/content/components/message-list.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,26 @@ const MessageListFollow: FC<{ itemKeys: readonly string[] }> = ({ itemKeys }) =>
6969
// geometry (scroll events), and real manual-intent cancellation. No timers, frame loops,
7070
// debounce, polling, or Resize/Mutation observers are involved.
7171
useEffect(() => {
72-
const root = actionRef.current?.closest('[data-slot="message-scroller"]')
72+
const root = actionRef.current?.closest<HTMLElement>('[data-slot="message-scroller"]')
7373
const viewport = root?.querySelector<HTMLElement>('[data-slot="message-scroller-viewport"]')
74-
if (!viewport) return
74+
if (!viewport || !root) return
7575

7676
const cancelAuthorization = () => {
7777
authorizationRef.current = null
7878
}
7979
const onKeyDown = (event: KeyboardEvent) => {
8080
if (NAV_SCROLL_KEYS.has(event.key)) cancelAuthorization()
8181
}
82+
// Scrollbar drags cancel through one delegated listener on the constantly mounted
83+
// scroller root: the repository Radix ScrollBar only mounts on hover/scroll, so binding
84+
// the scrollbar element itself at mount would miss every later drag. Only pointerdowns
85+
// originating inside the repository scrollbar subtree cancel; every other pointer target
86+
// (messages, content, viewport, selection, clicks) leaves the authorization untouched.
87+
const onPointerDown = (event: PointerEvent) => {
88+
if ((event.target as HTMLElement | null)?.closest('[data-slot="scroll-area-scrollbar"]')) {
89+
cancelAuthorization()
90+
}
91+
}
8292
const retargetIfAdvanced = () => {
8393
const authorization = authorizationRef.current
8494
if (!authorization) return
@@ -105,15 +115,14 @@ const MessageListFollow: FC<{ itemKeys: readonly string[] }> = ({ itemKeys }) =>
105115
viewport.addEventListener('keydown', onKeyDown)
106116
viewport.addEventListener('scroll', retargetIfAdvanced, { passive: true })
107117
viewport.addEventListener('scrollend', onScrollEnd)
108-
const scrollbar = root?.querySelector('[data-slot="scroll-area-scrollbar"]')
109-
scrollbar?.addEventListener('pointerdown', cancelAuthorization)
118+
root.addEventListener('pointerdown', onPointerDown)
110119
return () => {
111120
viewport.removeEventListener('wheel', cancelAuthorization)
112121
viewport.removeEventListener('touchmove', cancelAuthorization)
113122
viewport.removeEventListener('keydown', onKeyDown)
114123
viewport.removeEventListener('scroll', retargetIfAdvanced)
115124
viewport.removeEventListener('scrollend', onScrollEnd)
116-
scrollbar?.removeEventListener('pointerdown', cancelAuthorization)
125+
root.removeEventListener('pointerdown', onPointerDown)
117126
}
118127
}, [scrollToEnd])
119128

0 commit comments

Comments
 (0)