breaking: removed deferred event updates (#11855)

pull/11858/head
Dominic Gannaway 1 year ago committed by GitHub
parent 5f218b5f3d
commit 3c87a69999
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
breaking: removed deferred event updates

@ -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();

@ -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) {

@ -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);
});
}

@ -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

@ -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;
});
}

@ -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);
}
}
});
});

@ -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]));
}

@ -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

@ -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<Error>} 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<void>}
*/
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();

@ -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']);
}
});

@ -0,0 +1,12 @@
<script>
let action = $state('old url');
</script>
<form action={action} onsubmit={function(event) {
console.log(this.action);
event.preventDefault();
}}>
<button type="submit" onclick={() => {
action = 'new url';
}}>Submit</button>
</form>

@ -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']);
}
});

@ -0,0 +1,11 @@
<script>
let toggle = $state(false);
const onclick = $derived(toggle ? () => { console.log('works') } : () => { console.log('fails')})
const props = $derived({ onclick });
</script>
<div {...props}>
<button onclick={() => { toggle = true }}>
click me
</button>
</div>

@ -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, []);
}
});

@ -0,0 +1,20 @@
<script>
let toggle = $state(false);
function action(element) {
const handle = () => {
console.log('failed')
}
element.addEventListener('click', handle);
return {
update(toggle) {
if (toggle) {
element.removeEventListener('click', handle);
}
}
}
}
</script>
<button use:action={toggle} onmouseup={() => { toggle = true }}>
click me
</button>

@ -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, []);
}
});

@ -0,0 +1,9 @@
<script>
let disabled = $state(false);
</script>
<button disabled={disabled} onmouseup={() => {
disabled = true;
}} onclick={() => {
console.log('I should not be invoked');
}}>Click me!</button>

@ -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
<script>
+ import { flushSync } from 'svelte';
function onclick() {
- update_something();
+ flushSync(() => update_something());
}
</script>
```
## Other breaking changes
### Stricter `@const` assignment validation

Loading…
Cancel
Save