Skip to content

Commit febf62d

Browse files
committed
fix: handle event delegation correctly when having sibling event listeners
If you had `on:` directives listening to the same name (through multiple on:click on the same element or indirectly through multiple `<svelte:window>` elements with event listeners of the same name) there was a bug of delegation firing too often. This PR fixes that by tweaking the "should I continue with the given path index" logic. fixes #10271
1 parent ecba825 commit febf62d

File tree

9 files changed

+126
-12
lines changed

9 files changed

+126
-12
lines changed

.changeset/big-eyes-carry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
fix: handle event delegation correctly when having sibling event listeners

packages/svelte/src/internal/client/render.js

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,11 +1279,11 @@ export function delegate(events) {
12791279
}
12801280

12811281
/**
1282-
* @param {Node} root_element
1282+
* @param {Node} handler_element
12831283
* @param {Event} event
12841284
* @returns {void}
12851285
*/
1286-
function handle_event_propagation(root_element, event) {
1286+
function handle_event_propagation(handler_element, event) {
12871287
const event_name = event.type;
12881288
const path = event.composedPath?.() || [];
12891289
let current_target = /** @type {null | Element} */ (path[0] || event.target);
@@ -1298,22 +1298,37 @@ function handle_event_propagation(root_element, event) {
12981298
// We check __root to skip all nodes below it in case this is a
12991299
// parent of the __root node, which indicates that there's nested
13001300
// mounted apps. In this case we don't want to trigger events multiple times.
1301-
// We're deliberately not skipping if the index is the same or higher, because
1302-
// someone could create an event programmatically and emit it multiple times,
1303-
// in which case we want to handle the whole propagation chain properly each time.
13041301
let path_idx = 0;
13051302
// @ts-expect-error is added below
13061303
const handled_at = event.__root;
13071304
if (handled_at) {
13081305
const at_idx = path.indexOf(handled_at);
1309-
if (at_idx !== -1 && root_element === document) {
1310-
// This is the fallback document listener but the event was already handled -> ignore
1306+
if (at_idx !== -1 && handler_element === document) {
1307+
// This is the fallback document listener but the event was already handled
1308+
// -> ignore, but set handle_at to document so that we're resetting the event
1309+
// chain in case someone manually dispatches the same event object again.
1310+
// @ts-expect-error
1311+
event.__root = document;
13111312
return;
13121313
}
1313-
if (at_idx < path.indexOf(root_element)) {
1314-
path_idx = at_idx;
1314+
// We're deliberately not skipping if the index is higher, because
1315+
// someone could create an event programmatically and emit it multiple times,
1316+
// in which case we want to handle the whole propagation chain properly each time.
1317+
// (this will only be a false negative if the event is dispatched multiple times and
1318+
// the fallback document listener isn't reached in between, but that's super rare)
1319+
const handler_idx = path.indexOf(handler_element);
1320+
if (handler_idx === -1) {
1321+
// handle_idx can theoretically be -1 (happened in some JSDOM testing scenarios with an event listener on the window object)
1322+
// so guard against that, too, and assume that everything was handled at this point.
1323+
return;
1324+
}
1325+
if (at_idx <= handler_idx) {
1326+
// +1 because at_idx is the element which was already handled, and there can only be one delegated event per element.
1327+
// Avoids on:click and onclick on the same event resulting in onclick being fired twice.
1328+
path_idx = at_idx + 1;
13151329
}
13161330
}
1331+
13171332
current_target = /** @type {Element} */ (path[path_idx] || event.target);
13181333
// Proxy currentTarget to correct target
13191334
define_property(event, 'currentTarget', {
@@ -1339,16 +1354,20 @@ function handle_event_propagation(root_element, event) {
13391354
delegated.call(current_target, event);
13401355
}
13411356
}
1342-
if (event.cancelBubble || parent_element === root_element) {
1357+
if (
1358+
event.cancelBubble ||
1359+
parent_element === handler_element ||
1360+
current_target === handler_element
1361+
) {
13431362
break;
13441363
}
13451364
current_target = parent_element;
13461365
}
13471366

13481367
// @ts-expect-error is used above
1349-
event.__root = root_element;
1368+
event.__root = handler_element;
13501369
// @ts-expect-error is used above
1351-
current_target = root_element;
1370+
current_target = handler_element;
13521371
}
13531372

13541373
/**
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { test } from '../../test';
2+
import { log } from './log.js';
3+
4+
export default test({
5+
before_test() {
6+
log.length = 0;
7+
},
8+
9+
async test({ assert, target }) {
10+
const [btn1, btn2] = target.querySelectorAll('button');
11+
12+
btn1?.click();
13+
await Promise.resolve();
14+
assert.deepEqual(log, [
15+
'button main',
16+
'div main 1',
17+
'div main 2',
18+
'document main',
19+
'document sub',
20+
'window main',
21+
'window sub'
22+
]);
23+
24+
log.length = 0;
25+
btn2?.click();
26+
await Promise.resolve();
27+
assert.deepEqual(log, [
28+
'button sub',
29+
'document main',
30+
'document sub',
31+
'window main',
32+
'window sub'
33+
]);
34+
}
35+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @type {any[]} */
2+
export const log = [];
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<script>
2+
import { log } from "./log";
3+
import Sub from "./sub.svelte";
4+
</script>
5+
6+
<svelte:window onclick="{() => log.push('window main')}" />
7+
<svelte:document onclick="{() => log.push('document main')}" />
8+
9+
<div on:click={() => log.push('div main 1')} on:click={() => log.push('div main 2')}>
10+
<button onclick={() => log.push('button main')}>main</button>
11+
</div>
12+
13+
<Sub />
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
import { log } from "./log";
3+
</script>
4+
5+
<svelte:window onclick={() => log.push('window sub')} />
6+
<svelte:document onclick={() => log.push('document sub')} />
7+
8+
<button onclick={() => log.push('button sub')}>sub</button>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { test } from '../../test';
2+
import { log } from './log.js';
3+
4+
export default test({
5+
before_test() {
6+
log.length = 0;
7+
},
8+
9+
async test({ assert, target }) {
10+
const btn = target.querySelector('button');
11+
12+
btn?.click();
13+
await Promise.resolve();
14+
assert.deepEqual(log, [
15+
'button onclick',
16+
'button on:click',
17+
'inner div on:click',
18+
'outer div onclick'
19+
]);
20+
}
21+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @type {any[]} */
2+
export const log = [];
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
import { log } from "./log";
3+
</script>
4+
5+
<div onclick={() => log.push('outer div onclick')}>
6+
<div on:click={() => log.push('inner div on:click')}>
7+
<button onclick={() => log.push('button onclick')} on:click={() => log.push('button on:click')}>main</button>
8+
</div>
9+
</div>

0 commit comments

Comments
 (0)