diff --git a/.changeset/sixty-plants-cover.md b/.changeset/sixty-plants-cover.md new file mode 100644 index 0000000000..608a0e344a --- /dev/null +++ b/.changeset/sixty-plants-cover.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +breaking: removed deferred event updates diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js index 203f911ac5..1196a60339 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -4,7 +4,6 @@ import { stringify } from '../../../render.js'; import { listen_to_event_and_reset_event } from './shared.js'; import * as e from '../../../errors.js'; import { get_proxied_value, is } from '../../../proxy.js'; -import { yield_event_updates } from '../../../runtime.js'; /** * @param {HTMLInputElement} input @@ -19,9 +18,7 @@ export function bind_value(input, get_value, update) { e.bind_invalid_checkbox_value(); } - yield_event_updates(() => - update(is_numberlike_input(input) ? to_number(input.value) : input.value) - ); + update(is_numberlike_input(input) ? to_number(input.value) : input.value); }); render_effect(() => { @@ -87,10 +84,10 @@ export function bind_group(inputs, group_index, input, get_value, update) { value = get_binding_group_value(binding_group, value, input.checked); } - yield_event_updates(() => update(value)); + update(value); }, // TODO better default value handling - () => yield_event_updates(() => update(is_checkbox ? [] : null)) + () => update(is_checkbox ? [] : null) ); render_effect(() => { @@ -131,7 +128,7 @@ export function bind_group(inputs, group_index, input, get_value, update) { export function bind_checked(input, get_value, update) { listen_to_event_and_reset_event(input, 'change', () => { var value = input.checked; - yield_event_updates(() => update(value)); + update(value); }); if (get_value() == undefined) { @@ -190,7 +187,7 @@ function to_number(value) { */ export function bind_files(input, get_value, update) { listen_to_event_and_reset_event(input, 'change', () => { - yield_event_updates(() => update(input.files)); + update(input.files); }); render_effect(() => { input.files = get_value(); diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/media.js b/packages/svelte/src/internal/client/dom/elements/bindings/media.js index 000443763b..56606168c8 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/media.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/media.js @@ -1,7 +1,6 @@ import { hydrating } from '../../hydration.js'; import { render_effect, effect } from '../../../reactivity/effects.js'; import { listen } from './shared.js'; -import { yield_event_updates } from '../../../runtime.js'; /** @param {TimeRanges} ranges */ function time_ranges_to_array(ranges) { @@ -36,7 +35,7 @@ export function bind_current_time(media, get_value, update) { } updating = true; - yield_event_updates(() => update(media.currentTime)); + update(media.currentTime); }; raf_id = requestAnimationFrame(callback); @@ -61,9 +60,7 @@ export function bind_current_time(media, get_value, update) { * @param {(array: Array<{ start: number; end: number }>) => void} update */ export function bind_buffered(media, update) { - listen(media, ['loadedmetadata', 'progress'], () => - yield_event_updates(() => update(time_ranges_to_array(media.buffered))) - ); + listen(media, ['loadedmetadata', 'progress'], () => update(time_ranges_to_array(media.buffered))); } /** @@ -79,9 +76,7 @@ export function bind_seekable(media, update) { * @param {(array: Array<{ start: number; end: number }>) => void} update */ export function bind_played(media, update) { - listen(media, ['timeupdate'], () => - yield_event_updates(() => update(time_ranges_to_array(media.played))) - ); + listen(media, ['timeupdate'], () => update(time_ranges_to_array(media.played))); } /** @@ -89,7 +84,7 @@ export function bind_played(media, update) { * @param {(seeking: boolean) => void} update */ export function bind_seeking(media, update) { - listen(media, ['seeking', 'seeked'], () => yield_event_updates(() => update(media.seeking))); + listen(media, ['seeking', 'seeked'], () => update(media.seeking)); } /** @@ -97,7 +92,7 @@ export function bind_seeking(media, update) { * @param {(seeking: boolean) => void} update */ export function bind_ended(media, update) { - listen(media, ['timeupdate', 'ended'], () => yield_event_updates(() => update(media.ended))); + listen(media, ['timeupdate', 'ended'], () => update(media.ended)); } /** @@ -108,7 +103,7 @@ export function bind_ready_state(media, update) { listen( media, ['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'playing', 'waiting', 'emptied'], - () => yield_event_updates(() => update(media.readyState)) + () => update(media.readyState) ); } @@ -132,7 +127,7 @@ export function bind_playback_rate(media, get_value, update) { } listen(media, ['ratechange'], () => { - if (!updating) yield_event_updates(() => update(media.playbackRate)); + if (!updating) update(media.playbackRate); updating = false; }); }); @@ -150,7 +145,7 @@ export function bind_paused(media, get_value, update) { var callback = () => { if (paused !== media.paused) { paused = media.paused; - yield_event_updates(() => update((paused = media.paused))); + update((paused = media.paused)); } }; @@ -175,7 +170,7 @@ export function bind_paused(media, get_value, update) { media.pause(); } else { media.play().catch(() => { - yield_event_updates(() => update((paused = true))); + update((paused = true)); }); } }; @@ -239,7 +234,7 @@ export function bind_muted(media, get_value, update) { var callback = () => { updating = true; - yield_event_updates(() => update(media.muted)); + update(media.muted); }; if (get_value() == null) { diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/navigator.js b/packages/svelte/src/internal/client/dom/elements/bindings/navigator.js index 39a7e60527..a5d505400c 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/navigator.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/navigator.js @@ -1,4 +1,3 @@ -import { yield_event_updates } from '../../../runtime.js'; import { listen } from './shared.js'; /** @@ -7,6 +6,6 @@ import { listen } from './shared.js'; */ export function bind_online(update) { listen(window, ['online', 'offline'], () => { - yield_event_updates(() => update(navigator.onLine)); + update(navigator.onLine); }); } diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/select.js b/packages/svelte/src/internal/client/dom/elements/bindings/select.js index 5891aa5f81..533e8e9af9 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js @@ -1,6 +1,6 @@ import { effect } from '../../../reactivity/effects.js'; import { listen_to_event_and_reset_event } from './shared.js'; -import { untrack, yield_event_updates } from '../../../runtime.js'; +import { untrack } from '../../../runtime.js'; import { is } from '../../../proxy.js'; /** @@ -90,7 +90,7 @@ export function bind_select_value(select, get_value, update) { value = selected_option && get_option_value(selected_option); } - yield_event_updates(() => update(value)); + update(value); }); // Needs to be an effect, not a render_effect, so that in case of each loops the logic runs after the each block has updated diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/size.js b/packages/svelte/src/internal/client/dom/elements/bindings/size.js index 5ade7d5a7e..5dcd211b71 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/size.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/size.js @@ -1,5 +1,5 @@ import { effect, render_effect } from '../../../reactivity/effects.js'; -import { untrack, yield_event_updates } from '../../../runtime.js'; +import { untrack } from '../../../runtime.js'; /** * Resize observer singleton. @@ -88,10 +88,7 @@ export function bind_resize_observer(element, type, update) { ? resize_observer_border_box : resize_observer_device_pixel_content_box; - var unsub = observer.observe( - element, - /** @param {any} entry */ (entry) => yield_event_updates(() => update(entry[type])) - ); + var unsub = observer.observe(element, /** @param {any} entry */ (entry) => update(entry[type])); render_effect(() => unsub); } @@ -104,7 +101,7 @@ export function bind_element_size(element, type, update) { var unsub = resize_observer_border_box.observe(element, () => update(element[type])); effect(() => { - yield_event_updates(() => untrack(() => update(element[type]))); + update(element[type]); return unsub; }); } diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/this.js b/packages/svelte/src/internal/client/dom/elements/bindings/this.js index df06c46f2f..de78858b60 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/this.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/this.js @@ -1,6 +1,6 @@ import { STATE_SYMBOL } from '../../../constants.js'; import { effect, render_effect } from '../../../reactivity/effects.js'; -import { untrack, yield_event_updates } from '../../../runtime.js'; +import { untrack } from '../../../runtime.js'; import { queue_micro_task } from '../../task.js'; /** @@ -37,14 +37,12 @@ export function bind_this(element_or_component, update, get_value, get_parts) { untrack(() => { if (element_or_component !== get_value(...parts)) { - yield_event_updates(() => { - update(element_or_component, ...parts); - // If this is an effect rerun (cause: each block context changes), then nullfiy the binding at - // the previous position if it isn't already taken over by a different effect. - if (old_parts && is_bound_this(get_value(...old_parts), element_or_component)) { - update(null, ...old_parts); - } - }); + update(element_or_component, ...parts); + // If this is an effect rerun (cause: each block context changes), then nullfiy the binding at + // the previous position if it isn't already taken over by a different effect. + if (old_parts && is_bound_this(get_value(...old_parts), element_or_component)) { + update(null, ...old_parts); + } } }); }); diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/window.js b/packages/svelte/src/internal/client/dom/elements/bindings/window.js index 511f2d5666..4e01211294 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/window.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/window.js @@ -1,5 +1,4 @@ import { effect, render_effect } from '../../../reactivity/effects.js'; -import { yield_event_updates } from '../../../runtime.js'; import { listen } from './shared.js'; /** @@ -16,7 +15,7 @@ export function bind_window_scroll(type, get_value, update) { clearTimeout(timeout); timeout = setTimeout(clear, 100); // TODO use scrollend event if supported (or when supported everywhere?) - yield_event_updates(() => update(window[is_scrolling_x ? 'scrollX' : 'scrollY'])); + update(window[is_scrolling_x ? 'scrollX' : 'scrollY']); }; addEventListener('scroll', target_handler, { @@ -64,5 +63,5 @@ export function bind_window_scroll(type, get_value, update) { * @param {(size: number) => void} update */ export function bind_window_size(type, update) { - listen(window, ['resize'], () => yield_event_updates(() => update(window[type]))); + listen(window, ['resize'], () => update(window[type])); } diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js index ea2fb351d4..09d7ffeebd 100644 --- a/packages/svelte/src/internal/client/dom/elements/events.js +++ b/packages/svelte/src/internal/client/dom/elements/events.js @@ -1,6 +1,5 @@ import { render_effect } from '../../reactivity/effects.js'; import { all_registered_events, root_event_handles } from '../../render.js'; -import { yield_event_updates } from '../../runtime.js'; import { define_property, is_array } from '../../utils.js'; import { hydrating } from '../hydration.js'; import { queue_micro_task } from '../task.js'; @@ -48,7 +47,7 @@ export function create_event(event_name, dom, handler, options) { handle_event_propagation(dom, event); } if (!event.cancelBubble) { - return yield_event_updates(() => handler.call(this, event)); + return handler.call(this, event); } } @@ -182,42 +181,41 @@ export function handle_event_propagation(handler_element, event) { * @type {unknown[]} */ var other_errors = []; - yield_event_updates(() => { - while (current_target !== null) { - /** @type {null | Element} */ - var parent_element = - current_target.parentNode || /** @type {any} */ (current_target).host || null; - - try { - // @ts-expect-error - var delegated = current_target['__' + event_name]; - - if (delegated !== undefined && !(/** @type {any} */ (current_target).disabled)) { - if (is_array(delegated)) { - var [fn, ...data] = delegated; - fn.apply(current_target, [event, ...data]); - } else { - delegated.call(current_target, event); - } - } - } catch (error) { - if (throw_error) { - other_errors.push(error); + while (current_target !== null) { + /** @type {null | Element} */ + var parent_element = + current_target.parentNode || /** @type {any} */ (current_target).host || null; + + try { + // @ts-expect-error + var delegated = current_target['__' + event_name]; + + if (delegated !== undefined && !(/** @type {any} */ (current_target).disabled)) { + if (is_array(delegated)) { + var [fn, ...data] = delegated; + fn.apply(current_target, [event, ...data]); } else { - throw_error = error; + delegated.call(current_target, event); } } - if ( - event.cancelBubble || - parent_element === handler_element || - parent_element === null || - current_target === handler_element - ) { - break; + } catch (error) { + if (throw_error) { + other_errors.push(error); + } else { + throw_error = error; } - current_target = parent_element; } - }); + if ( + event.cancelBubble || + parent_element === handler_element || + parent_element === null || + current_target === handler_element + ) { + break; + } + current_target = parent_element; + } + if (throw_error) { for (let error of other_errors) { // Throw the rest of the errors, one-by-one on a microtask diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 545d3f6561..3e3206ad66 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -36,7 +36,6 @@ import { lifecycle_outside_component } from '../shared/errors.js'; const FLUSH_MICROTASK = 0; const FLUSH_SYNC = 1; -export const FLUSH_YIELD = 2; // Used for DEV time error handling /** @param {WeakSet} value */ @@ -45,7 +44,6 @@ const handled_errors = new WeakSet(); let current_scheduler_mode = FLUSH_MICROTASK; // Used for handling scheduling let is_micro_task_queued = false; -let is_yield_task_queued = false; export let is_flushing_effect = false; export let is_destroying_effect = false; @@ -607,29 +605,17 @@ function flush_queued_effects(effects) { function process_deferred() { is_micro_task_queued = false; - is_yield_task_queued = false; if (flush_count > 1001) { return; } const previous_queued_root_effects = current_queued_root_effects; current_queued_root_effects = []; flush_queued_root_effects(previous_queued_root_effects); - if (!is_micro_task_queued && !is_yield_task_queued) { + if (!is_micro_task_queued) { flush_count = 0; } } -async function yield_tick() { - // TODO: replace this with scheduler.yield when it becomes standard - await new Promise((fulfil) => { - requestAnimationFrame(() => { - setTimeout(fulfil, 0); - }); - // In case of being within background tab, the rAF won't fire - setTimeout(fulfil, 100); - }); -} - /** * @param {import('#client').Effect} signal * @returns {void} @@ -640,11 +626,6 @@ export function schedule_effect(signal) { is_micro_task_queued = true; queueMicrotask(process_deferred); } - } else if (current_scheduler_mode === FLUSH_YIELD) { - if (!is_yield_task_queued) { - is_yield_task_queued = true; - yield_tick().then(process_deferred); - } } var effect = signal; @@ -743,19 +724,6 @@ function process_effects(effect, collected_effects) { } } -/** - * @param {{ (): void; (): any; }} fn - */ -export function yield_event_updates(fn) { - const previous_scheduler_mode = current_scheduler_mode; - try { - current_scheduler_mode = FLUSH_YIELD; - return fn(); - } finally { - current_scheduler_mode = previous_scheduler_mode; - } -} - /** * Internal version of `flushSync` with the option to not flush previous effects. * Returns the result of the passed function, if given. @@ -775,7 +743,6 @@ export function flush_sync(fn, flush_previous = true) { current_scheduler_mode = FLUSH_SYNC; current_queued_root_effects = root_effects; - is_yield_task_queued = false; is_micro_task_queued = false; if (flush_previous) { @@ -803,7 +770,7 @@ export function flush_sync(fn, flush_previous = true) { * @returns {Promise} */ export async function tick() { - await yield_tick(); + await Promise.resolve(); // By calling flush_sync we guarantee that any pending state changes are applied after one tick. // TODO look into whether we can make flushing subsequent updates synchronously in the future. flush_sync(); diff --git a/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-2/_config.js b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-2/_config.js new file mode 100644 index 0000000000..c15f5519e9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-2/_config.js @@ -0,0 +1,14 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + skip: true, // need to make a browser test of this because calling click() sync will not let Svelte yield + + async test({ assert, target, logs }) { + const [b1] = target.querySelectorAll('button'); + b1.click(); + flushSync(); + + assert.deepEqual(logs, ['http://localhost:3000/new%20url']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-2/main.svelte new file mode 100644 index 0000000000..dc430f1a2f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-2/main.svelte @@ -0,0 +1,12 @@ + + +
+ +
diff --git a/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-3/_config.js b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-3/_config.js new file mode 100644 index 0000000000..9f0bd8ce2b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-3/_config.js @@ -0,0 +1,13 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [b1] = target.querySelectorAll('button'); + b1.click(); + flushSync(); + + // TODO: this should likely be ['works'], as if we don't use spread this works as intended + assert.deepEqual(logs, ['fails']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-3/main.svelte new file mode 100644 index 0000000000..52303f43fe --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-3/main.svelte @@ -0,0 +1,11 @@ + + +
+ +
diff --git a/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-4/_config.js b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-4/_config.js new file mode 100644 index 0000000000..1f8ad01dbb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-4/_config.js @@ -0,0 +1,15 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [b1] = target.querySelectorAll('button'); + b1.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + // The browser will yield a microtask between events + await Promise.resolve(); + b1.click(); + flushSync(); + + assert.deepEqual(logs, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-4/main.svelte new file mode 100644 index 0000000000..5ee9f0a4eb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-4/main.svelte @@ -0,0 +1,20 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency/_config.js b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency/_config.js new file mode 100644 index 0000000000..df361b747e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency/_config.js @@ -0,0 +1,14 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [b1] = target.querySelectorAll('button'); + b1.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + await Promise.resolve(); + b1.click(); + flushSync(); + + assert.deepEqual(logs, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency/main.svelte b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency/main.svelte new file mode 100644 index 0000000000..cac6686661 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency/main.svelte @@ -0,0 +1,9 @@ + + + diff --git a/sites/svelte-5-preview/src/routes/docs/content/03-appendix/02-breaking-changes.md b/sites/svelte-5-preview/src/routes/docs/content/03-appendix/02-breaking-changes.md index 2501ae0ff7..81b28ce817 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/03-appendix/02-breaking-changes.md +++ b/sites/svelte-5-preview/src/routes/docs/content/03-appendix/02-breaking-changes.md @@ -183,25 +183,6 @@ In Svelte 4, doing the following triggered reactivity: This is because the Svelte compiler treated the assignment to `foo.value` as an instruction to update anything that referenced `foo`. In Svelte 5, reactivity is determined at runtime rather than compile time, so you should define `value` as a reactive `$state` field on the `Foo` class. Wrapping `new Foo()` with `$state(...)` will have no effect — only vanilla objects and arrays are made deeply reactive. -### Events and bindings use delegated handlers - -Event handlers added to elements with things like `onclick={...}` or `bind:value={...}` use _delegated_ handlers where possible. This means that Svelte attaches a single event handler to a root node rather than using `addEventListener` on each individual node, resulting in better performance and memory usage. - -Additionally, updates that happen inside event handlers will not be reflected in the DOM (or in `$effect(...)`) until the browser has had a chance to repaint. This ensures that your app stays responsive, and your Core Web Vitals (CWV) aren't penalised. - -In very rare cases you may need to force updates to happen immediately. You can do this with `flushSync`: - -```diff - -``` - ## Other breaking changes ### Stricter `@const` assignment validation