fix(browser): stop a cancelled drag from leaving every browser tab unclickable (#26986)

The webview registry made every webview click-through on dragstart and only
restored it on dragend or drop. A dragstart that something cancels (for
example pressing and moving an agent logo in the tab strip, which the tab drag
sensor cancels) never starts a drag, so neither event fires and every existing
browser tab ignores clicks until a later full drag or a reload.

A live native drag delivers no pointer events to the page, so any pointerdown
or pointermove now ends the native-drag passthrough. Renderer pointer drags
hold their own passthrough tokens and are unaffected.
This commit is contained in:
Jinwoo Hong
2026-10-09 19:39:11 -04:00
committed by GitHub
parent 807e93baac
commit 9d94a20f84
2 changed files with 49 additions and 5 deletions
@@ -76,7 +76,13 @@ describe('webview registry drag listeners', () => {
registerPersistentWebview('page-1', createWebview())
expect(addedListeners.map((entry) => entry.type)).toEqual(['dragstart', 'dragend', 'drop'])
expect(addedListeners.map((entry) => entry.type)).toEqual([
'dragstart',
'dragend',
'drop',
'pointerdown',
'pointermove'
])
})
it('removes drag listeners after the last webview is destroyed', async () => {
@@ -86,7 +92,7 @@ describe('webview registry drag listeners', () => {
registerPersistentWebview('page-1', createWebview())
registerPersistentWebview('page-2', createWebview())
expect(addedListeners).toHaveLength(3)
expect(addedListeners).toHaveLength(5)
destroyPersistentWebview('page-1')
@@ -94,7 +100,13 @@ describe('webview registry drag listeners', () => {
destroyPersistentWebview('page-2')
expect(removedListeners.map((entry) => entry.type)).toEqual(['dragstart', 'dragend', 'drop'])
expect(removedListeners.map((entry) => entry.type)).toEqual([
'dragstart',
'dragend',
'drop',
'pointerdown',
'pointermove'
])
expect(unregisterGuestMock).toHaveBeenCalledWith({ browserPageId: 'page-1' })
expect(unregisterGuestMock).toHaveBeenCalledWith({ browserPageId: 'page-2' })
})
@@ -124,13 +136,35 @@ describe('webview registry drag listeners', () => {
expect(secondWebview.style.pointerEvents).toBe('auto')
})
it('releases native drag passthrough when a cancelled dragstart never starts a drag', async () => {
const { registerPersistentWebview } = await import('./webview-registry')
const webview = createWebview()
webview.style.pointerEvents = 'auto'
registerPersistentWebview('page-1', webview)
const listener = (type: string): ((event: Event) => void) => {
const found = addedListeners.find((entry) => entry.type === type)?.listener
if (typeof found !== 'function') {
throw new Error(`${type} listener missing`)
}
return found
}
for (const pointerType of ['pointermove', 'pointerdown']) {
listener('dragstart')(new Event('dragstart'))
expect(webview.style.pointerEvents).toBe('none')
// No dragend or drop follows a cancelled dragstart; the next pointer event proves no drag is live.
listener(pointerType)(new Event(pointerType))
expect(webview.style.pointerEvents).toBe('auto')
}
})
it('keeps one listener set across repeated registrations', async () => {
const { registerPersistentWebview } = await import('./webview-registry')
registerPersistentWebview('page-1', createWebview())
registerPersistentWebview('page-2', createWebview())
expect(addedListeners).toHaveLength(3)
expect(addedListeners).toHaveLength(5)
})
it('profiles live webviews and registered browser guests for memory breadcrumbs', async () => {
@@ -44,6 +44,8 @@ type DragListenerRegistry = {
dragstart: () => void
dragend: () => void
drop: () => void
pointerdown: () => void
pointermove: () => void
}
function getListenerHost(): (Window & { [DRAG_LISTENER_KEY]?: DragListenerRegistry }) | null {
@@ -62,6 +64,8 @@ function removeDragListeners(): void {
window.removeEventListener('dragstart', existingListeners.dragstart, true)
window.removeEventListener('dragend', existingListeners.dragend, true)
window.removeEventListener('drop', existingListeners.drop, true)
window.removeEventListener('pointerdown', existingListeners.pointerdown, true)
window.removeEventListener('pointermove', existingListeners.pointermove, true)
delete listenerHost[DRAG_LISTENER_KEY]
dragListenersAttached = false
nativeDragPassthroughRelease?.()
@@ -81,13 +85,19 @@ function ensureDragListeners(): void {
const dragstart = (): void => setWebviewsDragPassthrough(true)
const dragend = (): void => setWebviewsDragPassthrough(false)
const drop = (): void => setWebviewsDragPassthrough(false)
// Why: a cancelled dragstart never starts a drag, so no dragend or drop follows and every webview
// would stay click-through. A live native drag delivers no pointer events, so one proves it is over.
const pointerdown = (): void => setWebviewsDragPassthrough(false)
const pointermove = (): void => setWebviewsDragPassthrough(false)
window.addEventListener('dragstart', dragstart, true)
window.addEventListener('dragend', dragend, true)
window.addEventListener('drop', drop, true)
window.addEventListener('pointerdown', pointerdown, true)
window.addEventListener('pointermove', pointermove, true)
// Why: only live webviews need drag passthrough listeners; removing them
// when the registry empties keeps browserless sessions free of global hooks.
listenerHost[DRAG_LISTENER_KEY] = { dragstart, dragend, drop }
listenerHost[DRAG_LISTENER_KEY] = { dragstart, dragend, drop, pointerdown, pointermove }
dragListenersAttached = true
}