diff --git a/.changeset/dirty-bats-punch.md b/.changeset/dirty-bats-punch.md new file mode 100644 index 0000000000..8274a2bbc3 --- /dev/null +++ b/.changeset/dirty-bats-punch.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: handle delegated events of elements moved outside the container diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index a963c832c3..06bf9d406c 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -1280,6 +1280,10 @@ function handle_event_propagation(root_element, event) { const handled_at = event.__root; if (handled_at) { const at_idx = path.indexOf(handled_at); + if (at_idx !== -1 && root_element === document) { + // This is the fallback document listener but the event was already handled -> ignore + return; + } if (at_idx < path.indexOf(root_element)) { path_idx = at_idx; } @@ -2778,6 +2782,7 @@ export function mount(component, options) { set_current_hydration_fragment(previous_hydration_fragment); } const bound_event_listener = handle_event_propagation.bind(null, container); + const bound_document_event_listener = handle_event_propagation.bind(null, document); /** @param {Array} events */ const event_handle = (events) => { @@ -2785,6 +2790,9 @@ export function mount(component, options) { const event_name = events[i]; if (!registered_events.has(event_name)) { registered_events.add(event_name); + // Add the event listener to both the container and the document. + // The container listener ensures we catch events from within in case + // the outer content stops propagation of the event. container.addEventListener( event_name, bound_event_listener, @@ -2794,6 +2802,17 @@ export function mount(component, options) { } : undefined ); + // The document listener ensures we catch events that originate from elements that were + // manually moved outside of the container (e.g. via manual portals). + document.addEventListener( + event_name, + bound_document_event_listener, + PassiveDelegatedEvents.includes(event_name) + ? { + passive: true + } + : undefined + ); } } }; diff --git a/packages/svelte/tests/runtime-runes/samples/event-listener-moved-outside-container/_config.js b/packages/svelte/tests/runtime-runes/samples/event-listener-moved-outside-container/_config.js new file mode 100644 index 0000000000..a4b60bd912 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/event-listener-moved-outside-container/_config.js @@ -0,0 +1,23 @@ +import { test } from '../../test'; + +// Tests that event delegation still works when the element with the event listener is moved outside the container +export default test({ + async test({ assert, target }) { + const btn1 = target.parentElement?.querySelector('button'); + const btn2 = target.querySelector('button'); + + btn1?.click(); + await Promise.resolve(); + assert.htmlEqual( + target.parentElement?.innerHTML ?? '', + '
' + ); + + btn2?.click(); + await Promise.resolve(); + assert.htmlEqual( + target.parentElement?.innerHTML ?? '', + '
' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/event-listener-moved-outside-container/main.svelte b/packages/svelte/tests/runtime-runes/samples/event-listener-moved-outside-container/main.svelte new file mode 100644 index 0000000000..47edcf7253 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/event-listener-moved-outside-container/main.svelte @@ -0,0 +1,12 @@ + + +
+ + +