Skip to content

Commit c5741d5

Browse files
icyJosephclaude
andauthored
fix(devtools): handle pointercancel when dragging the indicator (#98506)
Fixes #98468 ## Claude explanation of the fix `useDrag` listened for `pointermove` and `pointerup`, but never `pointercancel`. When a user agent cancels a gesture — a browser or system gesture takes over, a second finger arrives — it fires `pointercancel` and no `pointerup`, implicitly releasing pointer capture as it does. So `cancel()` never ran: the state machine stayed `{ state: 'drag' }` and `cleanup.current` was never invoked, leaving the `pointermove`/`pointerup` listeners on `window`. The next `pointerup` anywhere on the page then hit those orphaned listeners, reached `cancel()` with the state still `'drag'`, and called `releasePointerCapture()` on a pointer that no longer existed — the reported `NotFoundError`. Each cancelled drag also leaked another listener pair. This registers `pointercancel` alongside `pointerup` and removes it in the same cleanup, so the machine unwinds when a gesture is cancelled; and it releases pointer capture only when `hasPointerCapture()` says it is still held, which is preferable to `try/catch` swallowing genuine faults too. Note `touch-action: none` (#97723) removed the common touch trigger, but not the defect: on canary a forced `touchCancel` still throws. https://github.com/user-attachments/assets/45a9524d-9ffa-40b6-b59a-5af9d947f8c7 ## Fixed version https://github.com/user-attachments/assets/b5ada68c-7442-42ef-a256-bacc512fbeca ## Browser checks - [x] Chrome, as describe by the bug report - [x] FF works fine pre and post fix (with and without the pointer simulation) - [x] iOS safari simulator, before the fix, I can't see an error, but, the drag gets frozen, with this fix it works correctly https://github.com/user-attachments/assets/bcfdc786-bada-4462-b530-299523fdca6d 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2d417da commit c5741d5

2 files changed

Lines changed: 81 additions & 4 deletions

File tree

‎packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/draggable.tsx‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,14 @@ function useDrag(options: UseDragOptions) {
187187

188188
const cancel = useCallback(() => {
189189
if (machine.current.state === 'drag') {
190-
ref.current?.releasePointerCapture(machine.current.pointerId)
190+
// The user agent implicitly releases pointer capture when a gesture ends
191+
// or is cancelled, so the pointer may already be inactive by the time we
192+
// get here. Releasing an inactive pointer throws NotFoundError, so only
193+
// release capture we still hold.
194+
const { pointerId } = machine.current
195+
if (ref.current?.hasPointerCapture(pointerId)) {
196+
ref.current.releasePointerCapture(pointerId)
197+
}
191198
}
192199

193200
machine.current =
@@ -282,15 +289,20 @@ function useDrag(options: UseDragOptions) {
282289
origin.current = { x: e.clientX, y: e.clientY }
283290
machine.current = { state: 'press' }
284291
window.addEventListener('pointermove', onPointerMove)
285-
window.addEventListener('pointerup', onPointerUp)
292+
// A gesture ends in either pointerup or pointercancel, and a cancelled one
293+
// never fires pointerup. Listening for only the former strands the machine
294+
// in 'drag' with these listeners still attached.
295+
window.addEventListener('pointerup', onPointerEnd)
296+
window.addEventListener('pointercancel', onPointerEnd)
286297

287298
if (cleanup.current !== null) {
288299
cleanup.current()
289300
cleanup.current = null
290301
}
291302
cleanup.current = () => {
292303
window.removeEventListener('pointermove', onPointerMove)
293-
window.removeEventListener('pointerup', onPointerUp)
304+
window.removeEventListener('pointerup', onPointerEnd)
305+
window.removeEventListener('pointercancel', onPointerEnd)
294306
}
295307

296308
ref.current?.addEventListener('click', onClick)
@@ -343,7 +355,8 @@ function useDrag(options: UseDragOptions) {
343355
options.onDrag?.(translation.current)
344356
}
345357

346-
function onPointerUp() {
358+
/** Ends the gesture, whether it was released (pointerup) or cancelled. */
359+
function onPointerEnd() {
347360
const velocity = calculateVelocity(velocities.current)
348361

349362
cancel()

‎test/development/app-dir/devtools-position/default-position.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,68 @@ describe('devtools-position-default', () => {
2929

3030
expect(touchAction).toBe('none')
3131
})
32+
33+
it('should end the drag when the pointer is cancelled', async () => {
34+
const browser = await next.browser('/')
35+
await getDevIndicatorPosition(browser)
36+
37+
// A user agent cancels a gesture (a browser/system gesture takes over, a
38+
// second finger arrives) by firing pointercancel and no pointerup, and it
39+
// implicitly releases pointer capture as it does so. Pointer capture is
40+
// owned by the UA and cannot be driven from script, so the three capture
41+
// methods are stubbed to exactly that post-cancel behaviour: capture is no
42+
// longer held, and releasing it throws NotFoundError.
43+
const result = await browser.eval(() => {
44+
const portal = Array.from(
45+
document.querySelectorAll('nextjs-portal')
46+
).find((p) => p.shadowRoot?.querySelector('[data-nextjs-toast]'))
47+
const indicator = portal?.shadowRoot?.querySelector('[data-nextjs-toast]')
48+
const draggable = indicator?.firstElementChild as HTMLElement
49+
50+
Element.prototype.setPointerCapture = function () {}
51+
Element.prototype.hasPointerCapture = function () {
52+
return false
53+
}
54+
Element.prototype.releasePointerCapture = function () {
55+
throw new DOMException(
56+
"Failed to execute 'releasePointerCapture' on 'Element': No active pointer with the given id is found.",
57+
'NotFoundError'
58+
)
59+
}
60+
61+
const errors: string[] = []
62+
window.addEventListener('error', (event) => errors.push(event.message))
63+
64+
const pointerEvent = (type: string, x: number, y: number) =>
65+
new PointerEvent(type, {
66+
pointerId: 1,
67+
button: 0,
68+
buttons: 1,
69+
clientX: x,
70+
clientY: y,
71+
bubbles: true,
72+
})
73+
74+
const box = draggable.getBoundingClientRect()
75+
const x = box.x + box.width / 2
76+
const y = box.y + box.height / 2
77+
78+
draggable.dispatchEvent(pointerEvent('pointerdown', x, y))
79+
// past the 5px threshold, so the drag actually starts
80+
window.dispatchEvent(pointerEvent('pointermove', x + 40, y - 40))
81+
window.dispatchEvent(pointerEvent('pointercancel', x + 40, y - 40))
82+
const afterCancel = draggable.style.translate
83+
84+
// The gesture is over. Neither of these may still be treated as a drag:
85+
// the move must be ignored, and the up must not release a pointer that
86+
// no longer exists.
87+
window.dispatchEvent(pointerEvent('pointermove', x + 200, y - 200))
88+
window.dispatchEvent(pointerEvent('pointerup', x + 200, y - 200))
89+
90+
return { errors, afterCancel, afterMove: draggable.style.translate }
91+
})
92+
93+
expect(result.errors).toEqual([])
94+
expect(result.afterMove).toBe(result.afterCancel)
95+
})
3296
})

0 commit comments

Comments
 (0)