Skip to content

fix: don't crash when the menu is gone by the time the layer click arrives - #818

Open
bbrala wants to merge 1 commit into
masterfrom
fix/issue-805-layerclick-after-destroy
Open

fix: don't crash when the menu is gone by the time the layer click arrives#818
bbrala wants to merge 1 commit into
masterfrom
fix/issue-805-layerclick-after-destroy

Conversation

@bbrala

@bbrala bbrala commented Jul 29, 2026

Copy link
Copy Markdown
Member

The problem

With useModal: false the menu isn't dismissed by the transparent layer but by a document-level mousedown listener that op.layer() registers, which calls handle.layerClick() and unregisters itself again through the onhide callback it hands in.

That listener outlives the menu it was registered for, so by the time the next click arrives the menu may already be gone. handle.layerClick() guarded that with

if (root.$menu === null || typeof root.$menu === 'undefined' || !root.$menu[0].contains(target)) {
    root.$menu.trigger('contextmenu:hide');
    ...
}

and then dereferenced root.$menu inside the very block it opened for it, so the case the first two clauses detect was the case that threw Cannot read properties of undefined (reading 'trigger'). Because the throw happened before onhide ran, the dismiss listener was never removed either and one more accumulated for every menu that had been opened.

Full credit to @Bogatinov for the diagnosis in #805: the report pinned the exact line, worked out that the listener leak is a consequence of the throw skipping the deregistration, and noted that the leaked listeners resolve themselves once the bail-out path invokes onhide. This PR fixes it in the plugin so the monkey-patch in the issue is no longer needed.

Two ways the menu goes missing

  1. A hide event handler calling $(selector).contextMenu('destroy'), which is the configuration in the report.
  2. A build menu, which empties its own options object (including $menu) once it has finished hiding, in op.hide(). This one needs no destroy at all: open a built menu with useModal: false, pick an item, then click anywhere.

$menu can be left dropped altogether or as an empty jQuery object; the old guard covered neither properly, so both shapes are handled.

The fix

Hoist a menuIsGone check and, in the branch that would have hidden the menu, skip straight to the cleanup instead of dereferencing a menu that isn't there. onhide still runs, so the dismiss listener unregisters itself.

Fixing the throw is enough to fix the leak as well: no extra teardown was added anywhere. The tests assert the listener count returns to zero over repeated open/destroy cycles (it grew 1, 2, 3 before the fix).

Backwards compatibility

Nothing changes for existing callers on any path that doesn't currently throw:

  • When $menu is present and non-empty, the code takes exactly the same branch as before, fires the same contextmenu:hide event, and calls onhide at the same point.
  • The isNearRecentSelectChange() grace period (Bug option-select in submenu in firefox #744) still takes precedence over the new bail-out, so a menu that is being kept open by it is unaffected.
  • document.elementFromPoint() is still called at the same point, for the same reason.
  • handle.layerClick's signature and everything else reachable from $.contextMenu.handle are unchanged, so third-party patches against it keep loading.
  • No changes to any destroy path or to the document-handler ownership introduced in fix: autoHide never applied to a nested trigger with a different trigger mode #808.

Verified against jQuery 1.12.4, 2.2.4, 3.7.1 and 4.0.0 (unit and acceptance).

Tests

  • test/unit/issue-805-layerclick-after-destroy.test.js: the built-menu case, the destroy-from-hide case, the empty-jQuery $menu shape, and a repeated-cycle test for the listener leak. All four fail on master with the reported TypeError and the growing listener count.
  • test/specs/issue-805-no-modal-destroy.js: the same sequence as real browser clicks, asserting no pageerror and a listener count that returns to zero.

Closes #805

…rives

With `useModal: false` the menu is dismissed by a document-level mousedown
listener rather than by the transparent layer. That listener outlives the
menu it was registered for, so the menu can already be gone when the next
click arrives: a `build` menu empties its own options object once it has
finished hiding, and a `hide` handler calling contextMenu('destroy') tears
it down outright.

handle.layerClick() guarded that case with

    root.$menu === null || typeof root.$menu === 'undefined' || ...

and then called root.$menu.trigger('contextmenu:hide') inside the block it
opened for it, so the very case the guard detected was the one that threw
"Cannot read properties of undefined (reading 'trigger')". The throw also
happened before `onhide` could run, so the dismiss listener was never
unregistered and one more piled up for every menu that had been opened.

Skip straight to the cleanup when there is nothing left to hide, covering
both an absent `$menu` and an empty jQuery object. Everything else on the
path is untouched: the same events fire in the same order, `onhide` keeps
its timing, and the `isNearRecentSelectChange()` grace period still takes
precedence exactly as before.

Fixes #805
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uncaught TypeError: Cannot read properties of undefined (reading 'trigger')

1 participant