Files
vercel__next.js/test/development/app-dir/devtools-position/default-position.test.ts
Joseph c5741d5230 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>
2026-09-11 16:01:14 +02:00

97 lines
3.7 KiB
TypeScript

import { nextTestSetup } from 'e2e-utils'
import { getDevIndicatorPosition } from './utils'
describe('devtools-position-default', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should devtools indicator position initially be bottom-left by default', async () => {
const browser = await next.browser('/')
const style = await getDevIndicatorPosition(browser)
expect(style).toContain('bottom: 20px')
expect(style).toContain('left: 20px')
})
it('should disable browser touch gestures on the draggable indicator', async () => {
const browser = await next.browser('/')
await getDevIndicatorPosition(browser)
const touchAction = await browser.eval(() => {
const portal = Array.from(
document.querySelectorAll('nextjs-portal')
).find((p) => p.shadowRoot?.querySelector('[data-nextjs-toast]'))
const indicator = portal?.shadowRoot?.querySelector('[data-nextjs-toast]')
const draggable = indicator?.firstElementChild
return draggable ? getComputedStyle(draggable).touchAction : null
})
expect(touchAction).toBe('none')
})
it('should end the drag when the pointer is cancelled', async () => {
const browser = await next.browser('/')
await getDevIndicatorPosition(browser)
// A user agent cancels a gesture (a browser/system gesture takes over, a
// second finger arrives) by firing pointercancel and no pointerup, and it
// implicitly releases pointer capture as it does so. Pointer capture is
// owned by the UA and cannot be driven from script, so the three capture
// methods are stubbed to exactly that post-cancel behaviour: capture is no
// longer held, and releasing it throws NotFoundError.
const result = await browser.eval(() => {
const portal = Array.from(
document.querySelectorAll('nextjs-portal')
).find((p) => p.shadowRoot?.querySelector('[data-nextjs-toast]'))
const indicator = portal?.shadowRoot?.querySelector('[data-nextjs-toast]')
const draggable = indicator?.firstElementChild as HTMLElement
Element.prototype.setPointerCapture = function () {}
Element.prototype.hasPointerCapture = function () {
return false
}
Element.prototype.releasePointerCapture = function () {
throw new DOMException(
"Failed to execute 'releasePointerCapture' on 'Element': No active pointer with the given id is found.",
'NotFoundError'
)
}
const errors: string[] = []
window.addEventListener('error', (event) => errors.push(event.message))
const pointerEvent = (type: string, x: number, y: number) =>
new PointerEvent(type, {
pointerId: 1,
button: 0,
buttons: 1,
clientX: x,
clientY: y,
bubbles: true,
})
const box = draggable.getBoundingClientRect()
const x = box.x + box.width / 2
const y = box.y + box.height / 2
draggable.dispatchEvent(pointerEvent('pointerdown', x, y))
// past the 5px threshold, so the drag actually starts
window.dispatchEvent(pointerEvent('pointermove', x + 40, y - 40))
window.dispatchEvent(pointerEvent('pointercancel', x + 40, y - 40))
const afterCancel = draggable.style.translate
// The gesture is over. Neither of these may still be treated as a drag:
// the move must be ignored, and the up must not release a pointer that
// no longer exists.
window.dispatchEvent(pointerEvent('pointermove', x + 200, y - 200))
window.dispatchEvent(pointerEvent('pointerup', x + 200, y - 200))
return { errors, afterCancel, afterMove: draggable.style.translate }
})
expect(result.errors).toEqual([])
expect(result.afterMove).toBe(result.afterCancel)
})
})