diff --git a/.changeset/kind-wombats-jam.md b/.changeset/kind-wombats-jam.md new file mode 100644 index 0000000000..ff872aa645 --- /dev/null +++ b/.changeset/kind-wombats-jam.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't delegate events on custom elements diff --git a/.changeset/six-steaks-provide.md b/.changeset/six-steaks-provide.md new file mode 100644 index 0000000000..3459c832d0 --- /dev/null +++ b/.changeset/six-steaks-provide.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: still invoke listener for cancelled event on the element where it was cancelled diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js index b13f3f89b6..eaf3905555 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js @@ -7,6 +7,7 @@ import { get_attribute_expression, is_event_attribute } from '../../../utils/ast.js'; +import { is_custom_element_node } from '../../nodes.js'; import { mark_subtree_dynamic } from './shared/fragment.js'; /** @@ -59,7 +60,6 @@ export function Attribute(node, context) { } if (is_event_attribute(node)) { - const parent = context.path.at(-1); if (parent?.type === 'RegularElement' || parent?.type === 'SvelteElement') { context.state.analysis.uses_event_attributes = true; } @@ -67,7 +67,14 @@ export function Attribute(node, context) { const expression = get_attribute_expression(node); const delegated_event = get_delegated_event(node.name.slice(2), expression, context); - if (delegated_event !== null) { + if ( + delegated_event !== null && + // We can't assume that the events from within the shadow root bubble beyond it. + // If someone dispatches them without the composed option, they won't. Also + // people could repurpose the event names to do something else, or call stopPropagation + // on the shadow root so it doesn't bubble beyond it. + !(parent?.type === 'RegularElement' && is_custom_element_node(parent)) + ) { if (delegated_event.hoisted) { delegated_event.function.metadata.hoisted = true; } diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index a5b7140f25..68574f19d7 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -361,7 +361,8 @@ export function set_attributes(element, prev, next, css_hash, skip_warning = fal const opts = {}; const event_handle_key = '$$' + key; let event_name = key.slice(2); - var delegated = is_delegated(event_name); + // Events on custom elements can be anything, we can't assume they bubble + var delegated = !is_custom_element && is_delegated(event_name); if (is_capture_event(event_name)) { event_name = event_name.slice(0, -7); diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index 15544d7426..0924f7c0b0 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -56,7 +56,10 @@ export function create_event(event_name, dom, handler, options = {}) { // Only call in the bubble phase, else delegated events would be called before the capturing events handle_event_propagation.call(dom, event); } - if (!event.cancelBubble) { + + // @ts-expect-error Use this instead of cancelBubble, because cancelBubble is also true if + // we're the last element on which the event will be handled. + if (!event.__cancelled || event.__cancelled === dom) { return without_reactive_context(() => { return handler?.call(this, event); }); @@ -185,6 +188,8 @@ export function handle_event_propagation(event) { // chain in case someone manually dispatches the same event object again. // @ts-expect-error event.__root = handler_element; + // @ts-expect-error + event.__cancelled = null; return; } @@ -230,6 +235,7 @@ export function handle_event_propagation(event) { set_active_effect(null); try { + var cancelled = event.cancelBubble; /** * @type {unknown} */ @@ -273,6 +279,10 @@ export function handle_event_propagation(event) { } } if (event.cancelBubble || parent_element === handler_element || parent_element === null) { + if (!cancelled && event.cancelBubble) { + // @ts-expect-error + event.__cancelled = current_target; + } break; } current_target = parent_element; diff --git a/packages/svelte/tests/runtime-runes/samples/event-attribute-delegation-custom-elements/_config.js b/packages/svelte/tests/runtime-runes/samples/event-attribute-delegation-custom-elements/_config.js new file mode 100644 index 0000000000..15d3395b7a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/event-attribute-delegation-custom-elements/_config.js @@ -0,0 +1,18 @@ +import { test } from '../../test'; + +export default test({ + mode: ['client'], + async test({ assert, target, logs }) { + const [btn1, btn2] = [...target.querySelectorAll('custom-element')].map((c) => + c.shadowRoot?.querySelector('button') + ); + + btn1?.click(); + await Promise.resolve(); + assert.deepEqual(logs, ['reached shadow root1']); + + btn2?.click(); + await Promise.resolve(); + assert.deepEqual(logs, ['reached shadow root1', 'reached shadow root2']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/event-attribute-delegation-custom-elements/main.svelte b/packages/svelte/tests/runtime-runes/samples/event-attribute-delegation-custom-elements/main.svelte new file mode 100644 index 0000000000..2f5e6afe6c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/event-attribute-delegation-custom-elements/main.svelte @@ -0,0 +1,19 @@ + + +