diff --git a/lib/selection-manager.ts b/lib/selection-manager.ts index 56d46059..d22e478a 100644 --- a/lib/selection-manager.ts +++ b/lib/selection-manager.ts @@ -61,6 +61,7 @@ export class SelectionManager { private boundContextMenuHandler: ((e: MouseEvent) => void) | null = null; private boundClickHandler: ((e: MouseEvent) => void) | null = null; private boundDocumentMouseMoveHandler: ((e: MouseEvent) => void) | null = null; + private boundDocumentMouseDownHandler: ((e: MouseEvent) => void) | null = null; // Auto-scroll state for drag selection private autoScrollInterval: ReturnType | null = null; @@ -405,6 +406,12 @@ export class SelectionManager { this.boundDocumentMouseMoveHandler = null; } + // Clean up document mousedown listener + if (this.boundDocumentMouseDownHandler) { + document.removeEventListener('mousedown', this.boundDocumentMouseDownHandler); + this.boundDocumentMouseDownHandler = null; + } + // Clean up context menu event listener if (this.boundContextMenuHandler) { const canvas = this.renderer.getCanvas(); @@ -566,9 +573,10 @@ export class SelectionManager { document.addEventListener('mousemove', this.boundDocumentMouseMoveHandler); // Track mousedown on document to know if a click started inside the canvas - document.addEventListener('mousedown', (e: MouseEvent) => { + this.boundDocumentMouseDownHandler = (e: MouseEvent) => { this.mouseDownTarget = e.target; - }); + }; + document.addEventListener('mousedown', this.boundDocumentMouseDownHandler); // CRITICAL FIX: Listen for mouseup on DOCUMENT, not just canvas // This catches mouseup events that happen outside the canvas (common during drag) diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index 8c764612..3654cb27 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -160,6 +160,44 @@ describe('Terminal', () => { // open() is synchronous and throws immediately expect(() => term.open(container!)).toThrow('has been disposed'); }); + + test('removes every document listener it added on dispose', async () => { + // A listener left on document keeps the terminal, its renderer and its + // canvas alive after dispose(). + const added = new Set(); + const originalAdd = document.addEventListener; + const originalRemove = document.removeEventListener; + document.addEventListener = function ( + this: Document, + type: string, + listener: any, + opts?: any + ) { + added.add(listener); + return originalAdd.call(this, type, listener, opts); + } as typeof document.addEventListener; + document.removeEventListener = function ( + this: Document, + type: string, + listener: any, + opts?: any + ) { + added.delete(listener); + return originalRemove.call(this, type, listener, opts); + } as typeof document.removeEventListener; + + try { + const term = await createIsolatedTerminal(); + term.open(container!); + expect(added.size).toBeGreaterThan(0); + + term.dispose(); + expect(added.size).toBe(0); + } finally { + document.addEventListener = originalAdd; + document.removeEventListener = originalRemove; + } + }); }); describe('Properties', () => { diff --git a/lib/terminal.ts b/lib/terminal.ts index 92d5f3d0..72b1cc91 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -1260,8 +1260,10 @@ export class Terminal implements ITerminalCore { this.element.removeAttribute('aria-multiline'); } - // Remove document-level listeners (only if opened) - if (this.isOpen && typeof document !== 'undefined') { + // Remove document-level listeners. dispose() clears isOpen before it + // gets here, so this cannot depend on it; removing a listener that was + // never added is a no-op. + if (typeof document !== 'undefined') { document.removeEventListener('mouseup', this.handleMouseUp); }