Skip to content

Commit c05abb5

Browse files
committed
fix: don't delegate events on custom elements, solve edge case stopPropagation issue
- don't delegate events on custom elements - still invoke listener for cancelled event on the element where it was cancelled: when you do `stopPropagation`, `event.cancelBubble` becomes `true`. We can't use this as an indicator to not invoke a listener directly, because the listner could be on the element where propagation was cancelled, i.e. it should still run for that listener. Instead, adjust the event propagation algorithm to detect when a delegated event listener caused the event to be cancelled fixes #14704
1 parent c4e9faa commit c05abb5

File tree

7 files changed

+69
-4
lines changed

7 files changed

+69
-4
lines changed

Diff for: .changeset/kind-wombats-jam.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: don't delegate events on custom elements

Diff for: .changeset/six-steaks-provide.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: still invoke listener for cancelled event on the element where it was cancelled

Diff for: packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
get_attribute_expression,
88
is_event_attribute
99
} from '../../../utils/ast.js';
10+
import { is_custom_element_node } from '../../nodes.js';
1011
import { mark_subtree_dynamic } from './shared/fragment.js';
1112

1213
/**
@@ -67,15 +68,21 @@ export function Attribute(node, context) {
6768
}
6869

6970
if (is_event_attribute(node)) {
70-
const parent = context.path.at(-1);
7171
if (parent?.type === 'RegularElement' || parent?.type === 'SvelteElement') {
7272
context.state.analysis.uses_event_attributes = true;
7373
}
7474

7575
const expression = get_attribute_expression(node);
7676
const delegated_event = get_delegated_event(node.name.slice(2), expression, context);
7777

78-
if (delegated_event !== null) {
78+
if (
79+
delegated_event !== null &&
80+
// We can't assume that the events from within the shadow root bubble beyond it.
81+
// If someone dispatches them without the composed option, they won't. Also
82+
// people could repurpose the event names to do something else, or call stopPropagation
83+
// on the shadow root so it doesn't bubble beyond it.
84+
!(parent?.type === 'RegularElement' && is_custom_element_node(parent))
85+
) {
7986
if (delegated_event.hoisted) {
8087
delegated_event.function.metadata.hoisted = true;
8188
}

Diff for: packages/svelte/src/internal/client/dom/elements/attributes.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,8 @@ export function set_attributes(
318318
const opts = {};
319319
const event_handle_key = '$$' + key;
320320
let event_name = key.slice(2);
321-
var delegated = is_delegated(event_name);
321+
// Events on custom elements can be anything, we can't assume they bubble
322+
var delegated = !is_custom_element && is_delegated(event_name);
322323

323324
if (is_capture_event(event_name)) {
324325
event_name = event_name.slice(0, -7);

Diff for: packages/svelte/src/internal/client/dom/elements/events.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ export function create_event(event_name, dom, handler, options) {
6161
// Only call in the bubble phase, else delegated events would be called before the capturing events
6262
handle_event_propagation.call(dom, event);
6363
}
64-
if (!event.cancelBubble) {
64+
65+
// @ts-expect-error Use this instead of cancelBubble, because cancelBubble is also true if
66+
// we're the last element on which the event will be handled.
67+
if (!event.__cancelled || event.__cancelled === dom) {
6568
return without_reactive_context(() => {
6669
return handler.call(this, event);
6770
});
@@ -171,6 +174,8 @@ export function handle_event_propagation(event) {
171174
// chain in case someone manually dispatches the same event object again.
172175
// @ts-expect-error
173176
event.__root = handler_element;
177+
// @ts-expect-error
178+
event.__cancelled = null;
174179
return;
175180
}
176181

@@ -216,6 +221,7 @@ export function handle_event_propagation(event) {
216221
set_active_effect(null);
217222

218223
try {
224+
var cancelled = event.cancelBubble;
219225
/**
220226
* @type {unknown}
221227
*/
@@ -253,6 +259,10 @@ export function handle_event_propagation(event) {
253259
}
254260
}
255261
if (event.cancelBubble || parent_element === handler_element || parent_element === null) {
262+
if (!cancelled && event.cancelBubble) {
263+
// @ts-expect-error
264+
event.__cancelled = current_target;
265+
}
256266
break;
257267
}
258268
current_target = parent_element;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
mode: ['client'],
5+
async test({ assert, target, logs }) {
6+
const [btn1, btn2] = [...target.querySelectorAll('custom-element')].map((c) =>
7+
c.shadowRoot?.querySelector('button')
8+
);
9+
10+
btn1?.click();
11+
await Promise.resolve();
12+
assert.deepEqual(logs, ['reached shadow root1']);
13+
14+
btn2?.click();
15+
await Promise.resolve();
16+
assert.deepEqual(logs, ['reached shadow root1', 'reached shadow root2']);
17+
}
18+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script>
2+
class CustomElement extends HTMLElement {
3+
constructor() {
4+
super();
5+
this.attachShadow({ mode: 'open' });
6+
this.shadowRoot.innerHTML = '<button>click me</button>';
7+
// Looks weird, but some custom element implementations actually do this
8+
// to prevent unwanted side upwards event propagation
9+
this.addEventListener('click', (e) => e.stopPropagation());
10+
}
11+
}
12+
13+
customElements.define('custom-element', CustomElement);
14+
</script>
15+
16+
<div onclick={() => console.log('bubbled beyond shadow root')}>
17+
<custom-element onclick={() => console.log('reached shadow root1')}></custom-element>
18+
<custom-element {...{onclick:() => console.log('reached shadow root2')}}></custom-element>
19+
</div>

0 commit comments

Comments
 (0)