fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684)

The app's ad-hoc dropdown/context menus each wire their own document-level
outside-click listener, but that listener only removes itself on an *outside*
click. Every other dismissal path -- clicking a menu item (which calls
el.remove() directly), a Cancel button, Escape, or the "close the
previously-open menu" reopen sweep -- tears the node down without
unregistering the listener, orphaning it on `document`. The stranded listener
then lingers and can break the next menu interaction: the recurring "the
button stops working until I refresh the page" class of bug (e.g. delete an
email, then the kebab/more button is dead on the other rows).

Route all 16 of these menus through the existing escMenuStack helper
(bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js
_showLibDropdown, cookbookRunning.js, and research/panel.js already do: a
single idempotent close() owns the teardown and is released on every dismissal
path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and
Escape flows through the central LIFO esc-stack arbiter. Net -49 lines.

Menus migrated: cookbook _showDepMenu; document export menu and
_openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary
_showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery
_showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings
(3 unified-integrations dropdowns); skills _openSkillMenu; tasks
_showTaskDropdown; compare _toggleExportMenu.

Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms
ghost-click guard, emailLibrary's reader-more-active anchor class and the
bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles).

Six menus with custom lifecycles (notes _openReminderMenu, sessions
long-press, document markdown-toolbar, emojiPicker, compare model selector)
are intentionally left for a follow-up -- each needs individual review.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mocchibird
2026-06-22 20:40:56 +02:00
committed by GitHub
parent b899095f18
commit 4c82e4a172
10 changed files with 106 additions and 155 deletions
+21 -37
View File
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
import signatureModule from './signature.js';
import * as Modals from './modalManager.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
let API_BASE = '';
let isOpen = false;
@@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js';
let _docAiReplyChoiceMenu = null;
function _closeDocAiReplyChoice() {
if (_docAiReplyChoiceMenu) {
try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
// Tear down through the menu's registered dismiss (drops its outside-click
// listener + Escape-stack entry) rather than orphaning them with a raw
// remove(); the onClose below nulls the ref.
try { dismissOrRemove(_docAiReplyChoiceMenu); } catch (_) {}
_docAiReplyChoiceMenu = null;
}
}
@@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js';
const noteInput = menu.querySelector('[data-note-input]');
setTimeout(() => noteInput?.focus(), 0);
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
document.body.appendChild(menu);
_docAiReplyChoiceMenu = menu;
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual teardown (node removal + state).
const close = bindMenuDismiss(menu, () => {
try { menu.remove(); } catch (_) {}
if (_docAiReplyChoiceMenu === menu) _docAiReplyChoiceMenu = null;
});
menu.addEventListener('click', async (ev) => {
const choice = ev.target.closest('[data-mode]');
if (!choice) return;
@@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js';
ev.stopPropagation();
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
const noteHint = (noteInput?.value || '').trim();
_closeDocAiReplyChoice();
close();
await _aiReply({ mode, noteHint });
});
document.body.appendChild(menu);
_docAiReplyChoiceMenu = menu;
const outsideClose = (ev) => {
if (menu.contains(ev.target)) return;
document.removeEventListener('click', outsideClose, true);
_closeDocAiReplyChoice();
};
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
// Esc to close.
const escClose = (ev) => {
if (ev.key === 'Escape') {
ev.stopPropagation();
document.removeEventListener('keydown', escClose, true);
_closeDocAiReplyChoice();
}
};
document.addEventListener('keydown', escClose, true);
}
async function _aiReply(opts = {}) {
@@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js';
function showExportMenu(e, anchorRect) {
if (e) e.stopPropagation();
// Remove existing menu if any
// Remove existing menu if any (toggle off) — tear it down through its
// registered dismiss so the outside-click listener + Escape-stack entry go.
const existing = document.getElementById('doc-export-menu');
if (existing) { existing.remove(); return; }
if (existing) { dismissOrRemove(existing); return; }
// Position from provided rect, clicked element, or fallback to language select
const rect = anchorRect
@@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js';
const item = document.createElement('button');
item.className = 'doc-overflow-item';
item.textContent = opt.label;
item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); });
item.addEventListener('click', (ev) => { ev.stopPropagation(); close(); opt.fn(); });
menu.appendChild(item);
if (opt._divider) {
const sep = document.createElement('div');
@@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js';
menu.style.top = 'auto';
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
}
const close = (ev) => {
if (ev && ev.type === 'keydown') {
if (ev.key !== 'Escape') return;
ev.preventDefault();
ev.stopPropagation();
ev.stopImmediatePropagation?.();
} else if (ev && menu.contains(ev.target)) {
return;
}
menu.remove();
document.removeEventListener('click', close);
document.removeEventListener('keydown', close, true);
};
setTimeout(() => document.addEventListener('click', close), 100);
document.addEventListener('keydown', close, true);
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual node removal.
const close = bindMenuDismiss(menu, () => { menu.remove(); });
}
function exportAsHtml() {