Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions lib/selection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setInterval> | null = null;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions lib/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventListenerOrEventListenerObject>();
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', () => {
Expand Down
6 changes: 4 additions & 2 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down