Skip to content

Commit 0a86e45

Browse files
authored
fix: left-click trigger leaked a synthetic contextmenu event to unrelated ancestor listeners (#793)
* fix: stop left-click/hover/touchstart triggers from leaking a synthetic contextmenu event When trigger is 'left' (or 'hover'/'touchstart'), the plugin simulated the native right-click by doing $(this).trigger($.Event('contextmenu', ...)). jQuery's trigger() bubbles that synthetic event through every ancestor's jQuery-bound 'contextmenu' handlers, not just the plugin's own delegated listener. Any unrelated code on the page listening for 'contextmenu' (e.g. a grid widget's own right-click handler) would fire on a plain left-click, indistinguishable from a real right-click. Call the internal dispatcher (handle.contextmenu) directly instead of triggering a bubbling event, so the synthetic event never reaches handlers outside the plugin. Fixes #754 * fix: guard hover-trigger dispatch against a trigger removed during its delay The hover trigger's contextmenu dispatch is delayed by e.data.delay (default 200ms). Calling the dispatcher directly instead of triggering a bubbling event (see the previous commit) means the delayed callback no longer no-ops if the trigger element got removed from the document in the meantime - it would now run against a detached node. Bail out of the timeout callback if the trigger is no longer attached to the document.
1 parent 5d6ecac commit 0a86e45

2 files changed

Lines changed: 113 additions & 4 deletions

File tree

src/jquery.contextMenu.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,19 @@
416416
click: function (e) {
417417
e.preventDefault();
418418
e.stopImmediatePropagation();
419-
$(this).trigger($.Event('contextmenu', {data: e.data, pageX: e.pageX, pageY: e.pageY}));
419+
// Invoke the dispatcher directly instead of triggering a real,
420+
// bubbling 'contextmenu' event: a bubbling synthetic event is
421+
// indistinguishable from a genuine right-click to any other
422+
// 'contextmenu' listener bound on an ancestor element, causing
423+
// unrelated handlers elsewhere on the page to fire on a plain
424+
// left-click. See https://github.com/swisnl/jQuery-contextMenu/issues/754
425+
handle.contextmenu.call(this, $.Event('contextmenu', {
426+
data: e.data,
427+
pageX: e.pageX,
428+
pageY: e.pageY,
429+
target: this,
430+
currentTarget: this
431+
}));
420432
},
421433
// contextMenu right-click trigger
422434
mousedown: function (e) {
@@ -441,7 +453,15 @@
441453
e.preventDefault();
442454
e.stopImmediatePropagation();
443455
$currentTrigger = $this;
444-
$this.trigger($.Event('contextmenu', {data: e.data, pageX: e.pageX, pageY: e.pageY}));
456+
// See handle.click for why we call the dispatcher directly
457+
// instead of triggering a bubbling 'contextmenu' event.
458+
handle.contextmenu.call(this, $.Event('contextmenu', {
459+
data: e.data,
460+
pageX: e.pageX,
461+
pageY: e.pageY,
462+
target: this,
463+
currentTarget: this
464+
}));
445465
}
446466

447467
$this.removeData('contextMenuActive');
@@ -469,11 +489,25 @@
469489
hoveract.timer = setTimeout(function () {
470490
hoveract.timer = null;
471491
$document.off('mousemove.contextMenuShow');
492+
493+
// The trigger may have been removed from the document while
494+
// this delay was pending. Triggering a bubbling event used to
495+
// make that a silent no-op (a detached node can't bubble to
496+
// the delegated listener); calling the dispatcher directly
497+
// doesn't have that safety net, so check explicitly.
498+
if (!$.contains(document.documentElement, $this[0])) {
499+
return;
500+
}
501+
472502
$currentTrigger = $this;
473-
$this.trigger($.Event('contextmenu', {
503+
// See handle.click for why we call the dispatcher directly
504+
// instead of triggering a bubbling 'contextmenu' event.
505+
handle.contextmenu.call($this[0], $.Event('contextmenu', {
474506
data: hoveract.data,
475507
pageX: hoveract.pageX,
476-
pageY: hoveract.pageY
508+
pageY: hoveract.pageY,
509+
target: $this[0],
510+
currentTarget: $this[0]
477511
}));
478512
}, e.data.delay);
479513
},

test/unit/issue-754-repro.test.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
QUnit.module('issue 754 repro', {
2+
afterEach: function() {
3+
$.contextMenu('destroy');
4+
var $fixture = $('#qunit-fixture');
5+
if ($fixture.length) {
6+
$fixture.html('');
7+
}
8+
}
9+
});
10+
11+
QUnit.test('left-click trigger should not bubble a synthetic contextmenu event to unrelated ancestor handlers', function(assert) {
12+
var $fixture = $('#qunit-fixture');
13+
if ($fixture.length === 0) {
14+
$('<div id="qunit-fixture">').appendTo('body');
15+
$fixture = $('#qunit-fixture');
16+
}
17+
18+
$fixture.append('<div class="outer-grid"><span class="left-trigger">left click me</span></div>');
19+
20+
var outerContextMenuCount = 0;
21+
$('.outer-grid').on('contextmenu', function() {
22+
outerContextMenuCount++;
23+
});
24+
25+
var menuOpenCount = 0;
26+
$.contextMenu({
27+
selector: '.left-trigger',
28+
trigger: 'left',
29+
events: {
30+
show: function() { menuOpenCount++; }
31+
},
32+
items: {
33+
copy: {name: 'Copy'}
34+
}
35+
});
36+
37+
$('.left-trigger').trigger($.Event('click', {which: 1, button: 0}));
38+
39+
assert.equal(menuOpenCount, 1, 'sanity check: our own contextMenu opened once');
40+
assert.equal(outerContextMenuCount, 0, 'unrelated ancestor contextmenu handler should NOT fire on left-click trigger');
41+
});
42+
43+
QUnit.test('hover trigger should not dispatch against a trigger removed from the document during its delay', function(assert) {
44+
var done = assert.async();
45+
var $fixture = $('#qunit-fixture');
46+
if ($fixture.length === 0) {
47+
$('<div id="qunit-fixture">').appendTo('body');
48+
$fixture = $('#qunit-fixture');
49+
}
50+
51+
$fixture.append('<span class="hover-trigger">hover me</span>');
52+
var $trigger = $('.hover-trigger');
53+
54+
var menuOpenCount = 0;
55+
$.contextMenu({
56+
selector: '.hover-trigger',
57+
trigger: 'hover',
58+
delay: 10,
59+
events: {
60+
show: function() { menuOpenCount++; }
61+
},
62+
items: {
63+
copy: {name: 'Copy'}
64+
}
65+
});
66+
67+
$trigger.trigger($.Event('mouseenter', {pageX: 0, pageY: 0}));
68+
// remove the trigger from the document before the hover delay elapses
69+
$trigger.remove();
70+
71+
setTimeout(function() {
72+
assert.equal(menuOpenCount, 0, 'menu should not open for a trigger that was removed before the delay elapsed');
73+
done();
74+
}, 30);
75+
});

0 commit comments

Comments
 (0)