From f9b96ec077e0770577ce60dd13a859d70237444b Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Thu, 16 Apr 2026 03:17:20 -0700 Subject: [PATCH] fix: prevent click-outside from closing dropdown during new-view input creation Race condition: showNewViewInput() calls replaceChild() to swap the '+ New View' button for an . The click event then bubbles to the document-level click-outside handler where e.target is the removed button, no longer in the DOM. dropdown.contains(e.target) returns false, so closeViewDropdown() fires immediately and the input disappears. Fix: before closing, check whether the dropdown now contains a .view-dropdown__new-input element. If it does, showNewViewInput() just ran and we must not close the dropdown. Test: added test_click_outside_view_dropdown_guards_against_new_view_input to verify the guard is present in the handler. --- muxplex/frontend/app.js | 2 ++ muxplex/tests/test_frontend_js.py | 33 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 2bd1cd1..6d0cf48 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -3773,6 +3773,8 @@ function bindStaticEventListeners() { if (!dropdown || dropdown.classList.contains('hidden')) return; var trigger = $('view-dropdown-trigger'); if (trigger && trigger.contains(e.target)) return; + // Don't close if a new-view input was just created (replaceChild removes the click target from DOM) + if (dropdown.querySelector('.view-dropdown__new-input')) return; if (!dropdown.contains(e.target)) closeViewDropdown(); }); diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index fffb4bd..2361f9f 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -4186,6 +4186,39 @@ def test_flyout_menu_map_is_const() -> None: # ── Fix B: ARIA role correction in kill confirm sheet (Phase 3 COE re-verification) ── +# ─── Click-outside race condition guard (new-view dropdown) ───────────────── + + +def test_click_outside_view_dropdown_guards_against_new_view_input() -> None: + """Click-outside handler must not close dropdown when new-view input is being shown. + + Race condition: showNewViewInput() replaces the '+ New View' button with an + input via replaceChild. The click event then bubbles up to the document-level + click-outside handler, where e.target is the OLD button that was just removed + from the DOM. Since it is no longer in the DOM, dropdown.contains(e.target) + returns false and the handler calls closeViewDropdown(), making the input + disappear immediately. + + Fix: before closing, check whether the dropdown now contains a + .view-dropdown__new-input element — if so, showNewViewInput() just ran and + we must not close the dropdown. + """ + match = re.search( + r"// Click-outside closes the header view dropdown\s*\n\s*" + r"document\.addEventListener\('click',\s*function\(e\)\s*\{(.*?)\}\s*\);", + _JS, + re.DOTALL, + ) + assert match, "Click-outside handler for header view dropdown not found" + body = match.group(1) + assert ".view-dropdown__new-input" in body, ( + "Click-outside handler must guard: if (dropdown.querySelector" + "('.view-dropdown__new-input')) return; — prevents the race condition " + "where showNewViewInput() replaces the button with an input (removing " + "it from the DOM) and the bubbling click event incorrectly closes the dropdown" + ) + + def test_kill_confirm_buttons_use_role_button() -> None: """Kill and Cancel buttons inside the alertdialog must use role='button', not 'menuitem'.