diff --git a/.changeset/rare-apes-jog.md b/.changeset/rare-apes-jog.md new file mode 100644 index 0000000000..3dc0e72498 --- /dev/null +++ b/.changeset/rare-apes-jog.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +fix: ensure UI consistency between deferred events diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index d087948b5e..40efd5396c 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -4,17 +4,18 @@ export const RENDER_EFFECT = 1 << 3; export const BLOCK_EFFECT = 1 << 4; export const BRANCH_EFFECT = 1 << 5; export const ROOT_EFFECT = 1 << 6; -export const UNOWNED = 1 << 7; -export const CLEAN = 1 << 8; -export const DIRTY = 1 << 9; -export const MAYBE_DIRTY = 1 << 10; -export const INERT = 1 << 11; -export const DESTROYED = 1 << 12; -export const EFFECT_RAN = 1 << 13; +export const ACTION_EFFECT = 1 << 7; +export const UNOWNED = 1 << 8; +export const CLEAN = 1 << 9; +export const DIRTY = 1 << 10; +export const MAYBE_DIRTY = 1 << 11; +export const INERT = 1 << 12; +export const DESTROYED = 1 << 13; +export const EFFECT_RAN = 1 << 14; /** 'Transparent' effects do not create a transition boundary */ -export const EFFECT_TRANSPARENT = 1 << 14; +export const EFFECT_TRANSPARENT = 1 << 15; /** Svelte 4 legacy mode props need to be handled with deriveds and be recognized elsewhere, hence the dedicated flag */ -export const LEGACY_DERIVED_PROP = 1 << 15; +export const LEGACY_DERIVED_PROP = 1 << 16; export const STATE_SYMBOL = Symbol('$state'); export const LOADING_ATTR_SYMBOL = Symbol(''); diff --git a/packages/svelte/src/internal/client/dom/elements/actions.js b/packages/svelte/src/internal/client/dom/elements/actions.js index 50f946b93a..f79db050a4 100644 --- a/packages/svelte/src/internal/client/dom/elements/actions.js +++ b/packages/svelte/src/internal/client/dom/elements/actions.js @@ -1,3 +1,4 @@ +import { ACTION_EFFECT } from '../../constants.js'; import { effect, render_effect } from '../../reactivity/effects.js'; import { deep_read_state, untrack } from '../../runtime.js'; @@ -15,7 +16,7 @@ export function action(dom, action, get_value) { if (get_value && payload?.update) { var inited = false; - render_effect(() => { + var signal = render_effect(() => { var value = get_value(); // Action's update method is coarse-grained, i.e. when anything in the passed value changes, update. @@ -27,6 +28,7 @@ export function action(dom, action, get_value) { /** @type {Function} */ (payload.update)(value); } }); + signal.f |= ACTION_EFFECT; inited = true; } diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index e70cbf3083..791ea87e23 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -16,7 +16,8 @@ import { STATE_SYMBOL, BLOCK_EFFECT, ROOT_EFFECT, - LEGACY_DERIVED_PROP + LEGACY_DERIVED_PROP, + ACTION_EFFECT } from './constants.js'; import { flush_tasks } from './dom/task.js'; import { add_owner } from './dev/ownership.js'; @@ -600,7 +601,11 @@ async function yield_tick() { * @returns {void} */ export function schedule_effect(signal) { - if (current_scheduler_mode === FLUSH_MICROTASK) { + // If we have an action effect then we need to dispatch a standard microtask update, + // that's because actions might need to update the UI or attach/remove event listeners + // and deferring this can lead to hard-to-find bugs. If there are any deferred updates + // already, they'll be handled by the microtask instead. + if (current_scheduler_mode === FLUSH_MICROTASK || (signal.f & ACTION_EFFECT) !== 0) { if (!is_micro_task_queued) { is_micro_task_queued = true; queueMicrotask(process_deferred); @@ -713,6 +718,13 @@ function process_effects(effect, collected_effects) { */ export function yield_updates(fn) { const previous_scheduler_mode = current_scheduler_mode; + // If we're calling yield_updates, and we've already yielded some updates – then it's likely + // that the event might might try and read from the UI. In order for this to be glitch free, + // we can flush any changes, forcing the UI to be up-to-date, so any reads from the UI work as + // expected. + if (previous_scheduler_mode !== FLUSH_YIELD && is_yield_task_queued && !is_micro_task_queued) { + flush_sync(); + } try { current_scheduler_mode = FLUSH_YIELD; return fn(); @@ -740,6 +752,8 @@ 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) { flush_queued_root_effects(previous_queued_root_effects); 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..1d464300be --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency-2/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + 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/_config.js b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency/_config.js new file mode 100644 index 0000000000..7f56aa60fe --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/deferred-events-consistency/_config.js @@ -0,0 +1,13 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target, logs }) { + const [b1] = target.querySelectorAll('button'); + b1.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + 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 @@ + + +