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
+6 -11
View File
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
const _acct = () => window.__odysseusActiveEmailAccount
@@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
}
function _showEmailMenu(em, anchor, itemEl) {
document.querySelectorAll('.email-dropdown').forEach(d => d.remove());
document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'dropdown email-dropdown show';
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
_showRemindSubmenu(em, dropdown);
return;
}
dropdown.remove();
close();
a.action();
});
dropdown.appendChild(menuItem);
@@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) {
anchor.appendChild(dropdown);
const close = (e) => {
if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
}
// ---- Reminder submenu (creates a Note with a reminder for this email) ----
@@ -987,7 +982,7 @@ function _showRemindSubmenu(em, parentDropdown) {
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
item.addEventListener('click', async (e) => {
e.stopPropagation();
parentDropdown.remove();
dismissOrRemove(parentDropdown);
await _createReplyReminder(em, p.date);
});
parentDropdown.appendChild(item);
@@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) {
customItem.innerHTML = '<span>Pick date and time…</span>';
customItem.addEventListener('click', async (e) => {
e.stopPropagation();
parentDropdown.remove();
dismissOrRemove(parentDropdown);
const tmp = document.createElement('input');
tmp.type = 'datetime-local';
const def = new Date(tomorrow);