mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
c5741d5230
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>
97 lines
3.7 KiB
TypeScript
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)
|
|
})
|
|
})
|