From 225b0a579ccc95be1c7b1377f573b35191736c55 Mon Sep 17 00:00:00 2001 From: Camron Flanders Date: Fri, 16 Oct 2026 16:45:08 -0500 Subject: [PATCH] fix: remove document listeners on dispose SelectionManager added an anonymous mousedown listener to document that dispose() could not remove, and Terminal.dispose() cleared isOpen before the check that removes its mouseup listener. Both listeners kept a disposed terminal reachable; the first also kept its renderer and canvas, with the canvas's GPU backing store. An app that opens and disposes terminals over time keeps every canvas. --- lib/selection-manager.ts | 12 ++++++++++-- lib/terminal.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ lib/terminal.ts | 6 ++++-- 3 files changed, 52 insertions(+), 4 deletions(-) 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); }