From 55dbe3b65e674aa499ee7c2500ae3a6b2125cf68 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 16 Jun 2026 19:19:02 -0700 Subject: [PATCH 1/4] use current document for mouse listeners --- src/browser/services/MouseService.test.ts | 93 +++++++++++++++++++++++ src/browser/services/MouseService.ts | 28 ++++--- 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/src/browser/services/MouseService.test.ts b/src/browser/services/MouseService.test.ts index b4655a1a1..bb48bc0b4 100644 --- a/src/browser/services/MouseService.test.ts +++ b/src/browser/services/MouseService.test.ts @@ -351,3 +351,96 @@ describe('MouseService mouseEventsRequireAlt', () => { assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS)); }); }); + +describe('MouseService multi-window', () => { + interface IRecordingDocument { + doc: Document; + added: string[]; + removed: string[]; + listeners: { [type: string]: EventListener[] }; + } + + function createRecordingDocument(): IRecordingDocument { + const added: string[] = []; + const removed: string[] = []; + const listeners: { [type: string]: EventListener[] } = {}; + const doc = { + addEventListener: (type: string, listener: EventListener) => { + added.push(type); + (listeners[type] ??= []).push(listener); + }, + removeEventListener: (type: string) => removed.push(type) + } as any; + return { doc, added, removed, listeners }; + } + + it('should manage protocol mouseup/mousemove listeners on the element\'s current document, not the document the terminal was opened with', () => { + const mouseStateService = new MouseStateService(); + const optionsService = new OptionsService({}); + const mouseService = new MouseService( + new MockRenderService(), + { + getMouseReportCoords: () => ({ col: 0, row: 0, x: 0, y: 0 }) + } as any, + mouseStateService, + { + triggerDataEvent: () => {}, + triggerBinaryEvent: () => {}, + decPrivateModes: { applicationCursorKeys: false } + } as any, + bufferService, + optionsService, + new TestSelectionService(), + logService, + new MockCoreBrowserService() + ); + + // Simulate a terminal that was opened with one document but whose element now lives in + // another document (eg. it was moved to a child window) + const openDocument = createRecordingDocument(); + const childDocument = createRecordingDocument(); + const elementListeners: { [type: string]: EventListener[] } = {}; + const element = { + classList: { + add: () => {}, + remove: () => {}, + contains: () => false + }, + addEventListener: (type: string, listener: EventListener) => { + (elementListeners[type] ??= []).push(listener); + }, + removeEventListener: () => {}, + ownerDocument: childDocument.doc + } as any; + + mouseService.bindMouse({ + element, + screenElement: createTestMouseTargetElement(), + document: openDocument.doc + }, disposable => disposable, () => {}); + mouseStateService.activeProtocol = 'ANY'; + + elementListeners['mousedown'][0]({ + type: 'mousedown', + button: 0, + buttons: 1, + altKey: false, + ctrlKey: false, + shiftKey: false, + preventDefault: () => {} + } as any); + assert.deepEqual(childDocument.added, ['mouseup', 'mousemove']); + assert.deepEqual(openDocument.added, []); + + childDocument.listeners['mouseup'][0]({ + type: 'mouseup', + button: 0, + buttons: 0, + altKey: false, + ctrlKey: false, + shiftKey: false + } as any); + assert.deepEqual(childDocument.removed, ['mouseup', 'mousemove']); + assert.deepEqual(openDocument.removed, []); + }); +}); diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 61cbfa86c..4239f8d1b 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -21,6 +21,7 @@ interface IMouseBindContext { readonly target: IMouseServiceTarget; readonly focus: () => void; readonly requestedEvents: RequestedMouseEvents; + listenerDocument: Document | null; } export class MouseService implements IMouseService { @@ -61,7 +62,7 @@ export class MouseService implements IMouseService { mousedrag: null, mousemove: null }; - const ctx: IMouseBindContext = { target, focus, requestedEvents }; + const ctx: IMouseBindContext = { target, focus, requestedEvents, listenerDocument: null }; const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = { mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent), wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent), @@ -87,11 +88,12 @@ export class MouseService implements IMouseService { // Ensure document-level listeners are removed on dispose register(toDisposable(() => { + const listenerDocument = ctx.listenerDocument ?? document; if (requestedEvents.mouseup) { - document.removeEventListener('mouseup', requestedEvents.mouseup); + listenerDocument.removeEventListener('mouseup', requestedEvents.mouseup); } if (requestedEvents.mousedrag) { - document.removeEventListener('mousemove', requestedEvents.mousedrag); + listenerDocument.removeEventListener('mousemove', requestedEvents.mousedrag); } })); @@ -199,12 +201,14 @@ export class MouseService implements IMouseService { this._sendEvent(ctx, ev); if (!ev.buttons) { // if no other button is held remove global handlers + const listenerDocument = ctx.listenerDocument ?? ctx.target.document; if (ctx.requestedEvents.mouseup) { - ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup); + listenerDocument.removeEventListener('mouseup', ctx.requestedEvents.mouseup); } if (ctx.requestedEvents.mousedrag) { - ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag); + listenerDocument.removeEventListener('mousemove', ctx.requestedEvents.mousedrag); } + ctx.listenerDocument = null; } } @@ -246,12 +250,15 @@ export class MouseService implements IMouseService { // of the terminal element. // Note: Other emulators also do this for 'mousedown' while a button // is held, we currently limit 'mousedown' to the terminal only. + // Use the element's current document in case it moved to another window after open. + const listenerDocument = ctx.target.element.ownerDocument ?? ctx.target.document; if (ctx.requestedEvents.mouseup) { - ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup); + listenerDocument.addEventListener('mouseup', ctx.requestedEvents.mouseup); } if (ctx.requestedEvents.mousedrag) { - ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag); + listenerDocument.addEventListener('mousemove', ctx.requestedEvents.mousedrag); } + ctx.listenerDocument = listenerDocument; } private _handlePassiveWheel(ctx: IMouseBindContext, ev: WheelEvent): false | void { @@ -398,8 +405,9 @@ export class MouseService implements IMouseService { } private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void { - const { element, document } = ctx.target; + const { element } = ctx.target; const { requestedEvents } = ctx; + const listenerDocument = ctx.listenerDocument ?? ctx.target.document; // apply global changes on events if (events) { if (this._optionsService.rawOptions.logLevel === 'debug') { @@ -434,7 +442,7 @@ export class MouseService implements IMouseService { if (!(events & CoreMouseEventType.UP)) { if (requestedEvents.mouseup) { - document.removeEventListener('mouseup', requestedEvents.mouseup); + listenerDocument.removeEventListener('mouseup', requestedEvents.mouseup); } requestedEvents.mouseup = null; } else { @@ -443,7 +451,7 @@ export class MouseService implements IMouseService { if (!(events & CoreMouseEventType.DRAG)) { if (requestedEvents.mousedrag) { - document.removeEventListener('mousemove', requestedEvents.mousedrag); + listenerDocument.removeEventListener('mousemove', requestedEvents.mousedrag); } requestedEvents.mousedrag = null; } else { From 1701dcef900effdd07433e4a672ca4007a8be5da Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 16 Jun 2026 19:35:29 -0700 Subject: [PATCH 2/4] use disposables for transient mouse listeners --- src/browser/services/MouseService.ts | 48 +++++++++------------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 4239f8d1b..d399adf57 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -7,7 +7,7 @@ import { addDisposableListener } from '../Dom'; import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from '../../common/services/Services'; import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from '../../common/Types'; import { C0 } from '../../common/data/EscapeSequences'; -import { DisposableStore, MutableDisposable, toDisposable } from '../../common/Lifecycle'; +import { DisposableStore, MutableDisposable } from '../../common/Lifecycle'; import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services'; import { Gesture, EventType as GestureEventType, IGestureEvent } from '../scrollable/touch'; @@ -21,7 +21,8 @@ interface IMouseBindContext { readonly target: IMouseServiceTarget; readonly focus: () => void; readonly requestedEvents: RequestedMouseEvents; - listenerDocument: Document | null; + readonly mouseupListener: MutableDisposable; + readonly mousedragListener: MutableDisposable; } export class MouseService implements IMouseService { @@ -46,7 +47,7 @@ export class MouseService implements IMouseService { } public bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void { - const { element, document } = target; + const { element } = target; /** * Event listener state handling. @@ -62,7 +63,11 @@ export class MouseService implements IMouseService { mousedrag: null, mousemove: null }; - const ctx: IMouseBindContext = { target, focus, requestedEvents, listenerDocument: null }; + const mouseupListener = new MutableDisposable(); + const mousedragListener = new MutableDisposable(); + register(mouseupListener); + register(mousedragListener); + const ctx: IMouseBindContext = { target, focus, requestedEvents, mouseupListener, mousedragListener }; const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = { mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent), wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent), @@ -86,17 +91,6 @@ export class MouseService implements IMouseService { // force initial onProtocolChange so we dont miss early mouse requests this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol; - // Ensure document-level listeners are removed on dispose - register(toDisposable(() => { - const listenerDocument = ctx.listenerDocument ?? document; - if (requestedEvents.mouseup) { - listenerDocument.removeEventListener('mouseup', requestedEvents.mouseup); - } - if (requestedEvents.mousedrag) { - listenerDocument.removeEventListener('mousemove', requestedEvents.mousedrag); - } - })); - /** * "Always on" event listeners. */ @@ -201,14 +195,8 @@ export class MouseService implements IMouseService { this._sendEvent(ctx, ev); if (!ev.buttons) { // if no other button is held remove global handlers - const listenerDocument = ctx.listenerDocument ?? ctx.target.document; - if (ctx.requestedEvents.mouseup) { - listenerDocument.removeEventListener('mouseup', ctx.requestedEvents.mouseup); - } - if (ctx.requestedEvents.mousedrag) { - listenerDocument.removeEventListener('mousemove', ctx.requestedEvents.mousedrag); - } - ctx.listenerDocument = null; + ctx.mouseupListener.clear(); + ctx.mousedragListener.clear(); } } @@ -253,12 +241,11 @@ export class MouseService implements IMouseService { // Use the element's current document in case it moved to another window after open. const listenerDocument = ctx.target.element.ownerDocument ?? ctx.target.document; if (ctx.requestedEvents.mouseup) { - listenerDocument.addEventListener('mouseup', ctx.requestedEvents.mouseup); + ctx.mouseupListener.value = addDisposableListener(listenerDocument, 'mouseup', ctx.requestedEvents.mouseup); } if (ctx.requestedEvents.mousedrag) { - listenerDocument.addEventListener('mousemove', ctx.requestedEvents.mousedrag); + ctx.mousedragListener.value = addDisposableListener(listenerDocument, 'mousemove', ctx.requestedEvents.mousedrag); } - ctx.listenerDocument = listenerDocument; } private _handlePassiveWheel(ctx: IMouseBindContext, ev: WheelEvent): false | void { @@ -407,7 +394,6 @@ export class MouseService implements IMouseService { private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void { const { element } = ctx.target; const { requestedEvents } = ctx; - const listenerDocument = ctx.listenerDocument ?? ctx.target.document; // apply global changes on events if (events) { if (this._optionsService.rawOptions.logLevel === 'debug') { @@ -441,18 +427,14 @@ export class MouseService implements IMouseService { } if (!(events & CoreMouseEventType.UP)) { - if (requestedEvents.mouseup) { - listenerDocument.removeEventListener('mouseup', requestedEvents.mouseup); - } + ctx.mouseupListener.clear(); requestedEvents.mouseup = null; } else { requestedEvents.mouseup ??= eventListeners.mouseup; } if (!(events & CoreMouseEventType.DRAG)) { - if (requestedEvents.mousedrag) { - listenerDocument.removeEventListener('mousemove', requestedEvents.mousedrag); - } + ctx.mousedragListener.clear(); requestedEvents.mousedrag = null; } else { requestedEvents.mousedrag ??= eventListeners.mousedrag; From 1efd80cd174ce2dd67d3a1636f8d15c1a1b7367e Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 16 Jun 2026 19:44:27 -0700 Subject: [PATCH 3/4] Dont forget target.document --- src/browser/services/MouseService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index d399adf57..4b3e14dc4 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -76,7 +76,7 @@ export class MouseService implements IMouseService { }; this._altMouseCursor = new AltMouseCursorController( element, - document, + target.document, () => this._mouseStateService.areMouseEventsActive && !!this._optionsService.rawOptions.mouseEventsRequireAlt ); From 9f232d981b81368fdcea34c44aa6baec651da0cb Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 16 Jun 2026 19:53:03 -0700 Subject: [PATCH 4/4] destructure doc from target like the old days --- src/browser/services/MouseService.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 4b3e14dc4..353e0581d 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -47,7 +47,7 @@ export class MouseService implements IMouseService { } public bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void { - const { element } = target; + const { element, document } = target; /** * Event listener state handling. @@ -76,7 +76,7 @@ export class MouseService implements IMouseService { }; this._altMouseCursor = new AltMouseCursorController( element, - target.document, + document, () => this._mouseStateService.areMouseEventsActive && !!this._optionsService.rawOptions.mouseEventsRequireAlt ); @@ -239,7 +239,8 @@ export class MouseService implements IMouseService { // Note: Other emulators also do this for 'mousedown' while a button // is held, we currently limit 'mousedown' to the terminal only. // Use the element's current document in case it moved to another window after open. - const listenerDocument = ctx.target.element.ownerDocument ?? ctx.target.document; + const { element, document: targetDocument } = ctx.target; + const listenerDocument = element.ownerDocument ?? targetDocument; if (ctx.requestedEvents.mouseup) { ctx.mouseupListener.value = addDisposableListener(listenerDocument, 'mouseup', ctx.requestedEvents.mouseup); }