Skip to content

fix(web): use global pointer listeners for sidebar resize handle#497

Open
GeT-LeFt wants to merge 3 commits intotiann:mainfrom
GeT-LeFt:fix/sidebar-resize-pointer-capture
Open

fix(web): use global pointer listeners for sidebar resize handle#497
GeT-LeFt wants to merge 3 commits intotiann:mainfrom
GeT-LeFt:fix/sidebar-resize-pointer-capture

Conversation

@GeT-LeFt
Copy link
Copy Markdown

@GeT-LeFt GeT-LeFt commented Apr 19, 2026

Problem

The sidebar resize handle uses setPointerCapture on the 4px-wide handle element. When dragging fast, the cursor easily leaves the handle area, and despite pointer capture, some browsers (especially on Linux/Wayland and touch devices) stop delivering events to the element. This causes:

  1. Cursor stuck as col-resize after releasing the mouse button
  2. Sidebar width stops tracking the pointer — requires a page reload to recover

This is a known limitation of element-level pointer capture on narrow drag targets (related chromium issue).

Solution

Replace element-level setPointerCapture + React event props with document-level pointermove/pointerup/pointercancel listeners, managed via useEffect:

  • Listeners are added when isDragging becomes true
  • Cleaned up when drag ends (via the effect's return)
  • pointercancel is also handled (covers touch interruptions, browser tab switches, etc.)

The hook's public API simplifies from { onPointerDown, onPointerMove, onPointerUp } to just { onPointerDown } — the rest is handled internally.

Changes

File Change
web/src/hooks/useSidebarResize.ts Replace element pointer capture with global document listeners
web/src/router.tsx Remove unused onPointerMove/onPointerUp props from resize handle

Testing

  • Verified on macOS Chrome/Safari and Ubuntu Firefox
  • Fast drag across the screen no longer causes cursor to get stuck
  • Pointer release outside the sidebar area correctly ends the drag
  • Touch drag on mobile devices works as expected

Fixes #500

The current implementation attaches pointermove/pointerup to the
resize handle element via setPointerCapture. When the cursor moves
fast enough to leave the narrow 4px handle, the browser may not
deliver subsequent pointer events to the element, causing:

- Cursor stuck as col-resize even after releasing the mouse
- Sidebar stops tracking the pointer, requiring a page reload

Switch to document-level pointermove/pointerup/pointercancel listeners
that are added on drag start and cleaned up on drag end. This
guarantees events are captured regardless of cursor position.

Also removes onPointerMove and onPointerUp from the hook's return
value (and the JSX props in router.tsx) since they are no longer
needed — the hook manages everything internally via useEffect.
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Drag state is no longer scoped to the initiating pointer. With the new document-level listeners, any unrelated pointermove can resize the sidebar and any unrelated pointerup/pointercancel can stop the drag while isDragging is true. Evidence: web/src/hooks/useSidebarResize.ts:38, web/src/hooks/useSidebarResize.ts:43, web/src/hooks/useSidebarResize.ts:45.

Summary
Review mode: initial
One correctness issue found in the new resize listener flow: the drag now follows every pointer event on the page instead of only the pointer that started the resize. Residual risk: this hook has no automated coverage in the repo, so multi-pointer/touch regressions depend on manual verification.

Testing

  • Not run (automation)

HAPI Bot


const onUp = () => setIsDragging(false)

document.addEventListener('pointermove', onMove)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] These document listeners are not filtered by pointerId, so once a resize starts, any other pointer on the page can move or end it. That is a regression from setPointerCapture, which kept the drag bound to the initiating pointer only.

Suggested fix:

const activePointerIdRef = useRef<number | null>(null)

const onPointerDown = useCallback((e: React.PointerEvent) => {
    e.preventDefault()
    activePointerIdRef.current = e.pointerId
    startXRef.current = e.clientX
    startWidthRef.current = width
    setIsDragging(true)
}, [width])

const onMove = (e: PointerEvent) => {
    if (e.pointerId !== activePointerIdRef.current) return
    const delta = e.clientX - startXRef.current
    setWidth(clamp(startWidthRef.current + delta))
}

const onUp = (e: PointerEvent) => {
    if (e.pointerId !== activePointerIdRef.current) return
    activePointerIdRef.current = null
    setIsDragging(false)
}

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Document-level drag handling is still not scoped to the pointer that started the resize. Any second finger/stylus/mouse pointermove can resize the sidebar, and any unrelated pointerup/pointercancel can end the drag, which regresses the old setPointerCapture behavior. Evidence: web/src/hooks/useSidebarResize.ts:38.
    Suggested fix:
    const activePointerIdRef = useRef<number | null>(null)
    
    const onPointerDown = useCallback((e: React.PointerEvent) => {
        e.preventDefault()
        activePointerIdRef.current = e.pointerId
        startXRef.current = e.clientX
        startWidthRef.current = width
        setIsDragging(true)
    }, [width])
    
    const onMove = (e: PointerEvent) => {
        if (e.pointerId !== activePointerIdRef.current) return
        const delta = e.clientX - startXRef.current
        setWidth(clamp(startWidthRef.current + delta))
    }
    
    const onUp = (e: PointerEvent) => {
        if (e.pointerId !== activePointerIdRef.current) return
        activePointerIdRef.current = null
        setIsDragging(false)
    }

Summary

  • Review mode: follow-up after new commits
  • The follow-up commit only retriggers CI, so the previously reported current-diff correctness issue remains unresolved. Residual risk: this hook has no automated coverage for multi-pointer/touch interruption behavior.

Testing

  • Not run (automation)

HAPI Bot

const onPointerUp = useCallback(() => {
if (!isDragging) return
setIsDragging(false)
const onMove = (e: PointerEvent) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] The document listeners still accept every PointerEvent while isDragging is true. That means a second finger/stylus/mouse can move the sidebar, and an unrelated pointerup/pointercancel can stop the drag, instead of keeping ownership with the pointer that began resizing.

Suggested fix:

const activePointerIdRef = useRef<number | null>(null)

const onPointerDown = useCallback((e: React.PointerEvent) => {
    e.preventDefault()
    activePointerIdRef.current = e.pointerId
    startXRef.current = e.clientX
    startWidthRef.current = width
    setIsDragging(true)
}, [width])

const onMove = (e: PointerEvent) => {
    if (e.pointerId !== activePointerIdRef.current) return
    const delta = e.clientX - startXRef.current
    setWidth(clamp(startWidthRef.current + delta))
}

const onUp = (e: PointerEvent) => {
    if (e.pointerId !== activePointerIdRef.current) return
    activePointerIdRef.current = null
    setIsDragging(false)
}

Address review feedback: filter pointermove/pointerup/pointercancel
by the pointer that started the drag, so a second finger or stylus
cannot interfere with the resize.
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • No new findings on the current diff.

Summary

  • Review mode: follow-up after new commits
  • The follow-up commit addresses the previously reported pointer-ownership issue by tracking the active pointerId in the document-level handlers at web/src/hooks/useSidebarResize.ts:26 and web/src/hooks/useSidebarResize.ts:40. Residual risk: there is still no automated coverage for useSidebarResize, so regressions around pointercancel and multi-pointer interactions would rely on manual testing.

Testing

  • Not run (automation); no dedicated test for web/src/hooks/useSidebarResize.ts found in repo.

HAPI Bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Sidebar resize: cursor gets stuck as col-resize when dragging fast

1 participant