diff --git a/.changeset/grumpy-boats-beg.md b/.changeset/grumpy-boats-beg.md new file mode 100644 index 000000000000..f677743defa5 --- /dev/null +++ b/.changeset/grumpy-boats-beg.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: keep input in sync when binding updated via effect diff --git a/.changeset/shiny-walls-fix.md b/.changeset/shiny-walls-fix.md new file mode 100644 index 000000000000..91ed548728a3 --- /dev/null +++ b/.changeset/shiny-walls-fix.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: prevent infinite async loop diff --git a/.changeset/thick-mice-kick.md b/.changeset/thick-mice-kick.md new file mode 100644 index 000000000000..eec55b77eee4 --- /dev/null +++ b/.changeset/thick-mice-kick.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: exclude derived writes from effect abort and rescheduling diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index c45221189404..ce413fa1e186 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -21,14 +21,13 @@ import { is_updating_effect, set_is_updating_effect, set_signal_status, - update_effect, - write_version + update_effect } from '../runtime.js'; import * as e from '../errors.js'; import { flush_tasks } from '../dom/task.js'; import { DEV } from 'esm-env'; import { invoke_error_boundary } from '../error-handling.js'; -import { old_values } from './sources.js'; +import { mark_reactions, old_values } from './sources.js'; import { unlink_effect } from './effects.js'; import { unset_context } from './async.js'; @@ -70,13 +69,15 @@ let last_scheduled_effect = null; let is_flushing = false; +let is_flushing_sync = false; + export class Batch { /** * The current values of any sources that are updated in this batch * They keys of this map are identical to `this.#previous` * @type {Map} */ - #current = new Map(); + current = new Map(); /** * The values of any sources that are updated in this batch _before_ those updates took place. @@ -156,7 +157,7 @@ export class Batch { * * @param {Effect[]} root_effects */ - #process(root_effects) { + process(root_effects) { queued_root_effects = []; /** @type {Map | null} */ @@ -169,7 +170,7 @@ export class Batch { current_values = new Map(); batch_deriveds = new Map(); - for (const [source, current] of this.#current) { + for (const [source, current] of this.current) { current_values.set(source, { v: source.v, wv: source.wv }); source.v = current; } @@ -202,9 +203,22 @@ export class Batch { this.#effects = []; this.#block_effects = []; + // If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with + // newly updated sources, which could lead to infinite loops when effects run over and over again. + current_batch = null; + flush_queued_effects(render_effects); flush_queued_effects(effects); + // Reinstate the current batch if there was no new one created, as `process()` runs in a loop in `flush_effects()`. + // That method expects `current_batch` to be set, and could run the loop again if effects result in new effects + // being scheduled but without writes happening in which case no new batch is created. + if (current_batch === null) { + current_batch = this; + } else { + batches.delete(this); + } + this.#deferred?.resolve(); } else { // otherwise mark effects clean so they get scheduled on the next run @@ -300,7 +314,7 @@ export class Batch { this.#previous.set(source, value); } - this.#current.set(source, source.v); + this.current.set(source, source.v); } activate() { @@ -327,13 +341,13 @@ export class Batch { flush() { if (queued_root_effects.length > 0) { - this.flush_effects(); + flush_effects(); } else { this.#commit(); } if (current_batch !== this) { - // this can happen if a `flushSync` occurred during `this.flush_effects()`, + // this can happen if a `flushSync` occurred during `flush_effects()`, // which is permitted in legacy mode despite being a terrible idea return; } @@ -345,52 +359,6 @@ export class Batch { this.deactivate(); } - flush_effects() { - var was_updating_effect = is_updating_effect; - is_flushing = true; - - try { - var flush_count = 0; - set_is_updating_effect(true); - - while (queued_root_effects.length > 0) { - if (flush_count++ > 1000) { - if (DEV) { - var updates = new Map(); - - for (const source of this.#current.keys()) { - for (const [stack, update] of source.updated ?? []) { - var entry = updates.get(stack); - - if (!entry) { - entry = { error: update.error, count: 0 }; - updates.set(stack, entry); - } - - entry.count += update.count; - } - } - - for (const update of updates.values()) { - // eslint-disable-next-line no-console - console.error(update.error); - } - } - - infinite_loop_guard(); - } - - this.#process(queued_root_effects); - old_values.clear(); - } - } finally { - is_flushing = false; - set_is_updating_effect(was_updating_effect); - - last_scheduled_effect = null; - } - } - /** * Append and remove branches to/from the DOM */ @@ -412,19 +380,8 @@ export class Batch { this.#pending -= 1; if (this.#pending === 0) { - for (const e of this.#render_effects) { - set_signal_status(e, DIRTY); - schedule_effect(e); - } - - for (const e of this.#effects) { - set_signal_status(e, DIRTY); - schedule_effect(e); - } - - for (const e of this.#block_effects) { - set_signal_status(e, DIRTY); - schedule_effect(e); + for (const source of this.current.keys()) { + mark_reactions(source, DIRTY, false); } this.#render_effects = []; @@ -445,12 +402,12 @@ export class Batch { return (this.#deferred ??= deferred()).promise; } - static ensure(autoflush = true) { + static ensure() { if (current_batch === null) { const batch = (current_batch = new Batch()); batches.add(current_batch); - if (autoflush) { + if (!is_flushing_sync) { Batch.enqueue(() => { if (current_batch !== batch) { // a flushSync happened in the meantime @@ -487,32 +444,85 @@ export function flushSync(fn) { e.flush_sync_in_effect(); } - var result; + var was_flushing_sync = is_flushing_sync; + is_flushing_sync = true; - const batch = Batch.ensure(false); + try { + var result; - if (fn) { - batch.flush_effects(); + if (fn) { + flush_effects(); + result = fn(); + } - result = fn(); - } + while (true) { + flush_tasks(); - while (true) { - flush_tasks(); + if (queued_root_effects.length === 0) { + current_batch?.flush(); - if (queued_root_effects.length === 0) { - if (batch === current_batch) { - batch.flush(); + // we need to check again, in case we just updated an `$effect.pending()` + if (queued_root_effects.length === 0) { + // this would be reset in `flush_effects()` but since we are early returning here, + // we need to reset it here as well in case the first time there's 0 queued root effects + last_scheduled_effect = null; + + return /** @type {T} */ (result); + } } - // this would be reset in `batch.flush_effects()` but since we are early returning here, - // we need to reset it here as well in case the first time there's 0 queued root effects - last_scheduled_effect = null; + flush_effects(); + } + } finally { + is_flushing_sync = was_flushing_sync; + } +} + +function flush_effects() { + var was_updating_effect = is_updating_effect; + is_flushing = true; + + try { + var flush_count = 0; + set_is_updating_effect(true); + + while (queued_root_effects.length > 0) { + var batch = Batch.ensure(); + + if (flush_count++ > 1000) { + if (DEV) { + var updates = new Map(); + + for (const source of batch.current.keys()) { + for (const [stack, update] of source.updated ?? []) { + var entry = updates.get(stack); + + if (!entry) { + entry = { error: update.error, count: 0 }; + updates.set(stack, entry); + } + + entry.count += update.count; + } + } + + for (const update of updates.values()) { + // eslint-disable-next-line no-console + console.error(update.error); + } + } + + infinite_loop_guard(); + } - return /** @type {T} */ (result); + batch.process(queued_root_effects); + old_values.clear(); } + } finally { + is_flushing = false; + set_is_updating_effect(was_updating_effect); - batch.flush_effects(); + last_scheduled_effect = null; } } @@ -545,7 +555,7 @@ function flush_queued_effects(effects) { var effect = effects[i++]; if ((effect.f & (DESTROYED | INERT)) === 0 && is_dirty(effect)) { - var wv = write_version; + var n = current_batch ? current_batch.current.size : 0; update_effect(effect); @@ -568,7 +578,11 @@ function flush_queued_effects(effects) { // if state is written in a user effect, abort and re-schedule, lest we run // effects that should be removed as a result of the state change - if (write_version > wv && (effect.f & USER_EFFECT) !== 0) { + if ( + current_batch !== null && + current_batch.current.size > n && + (effect.f & USER_EFFECT) !== 0 + ) { break; } } diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index f6b14f3360de..3b28c8fdceeb 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -179,7 +179,7 @@ export function internal_set(source, value) { source.v = value; - const batch = Batch.ensure(); + var batch = Batch.ensure(); batch.capture(source, old_value); if (DEV) { @@ -301,9 +301,10 @@ export function increment(source) { /** * @param {Value} signal * @param {number} status should be DIRTY or MAYBE_DIRTY + * @param {boolean} schedule_async * @returns {void} */ -function mark_reactions(signal, status) { +export function mark_reactions(signal, status, schedule_async = true) { var reactions = signal.reactions; if (reactions === null) return; @@ -323,14 +324,16 @@ function mark_reactions(signal, status) { continue; } + var should_schedule = (flags & DIRTY) === 0 && (schedule_async || (flags & ASYNC) === 0); + // don't set a DIRTY reaction to MAYBE_DIRTY - if ((flags & DIRTY) === 0) { + if (should_schedule) { set_signal_status(reaction, status); } if ((flags & DERIVED) !== 0) { mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY); - } else if ((flags & DIRTY) === 0) { + } else if (should_schedule) { schedule_effect(/** @type {Effect} */ (reaction)); } } diff --git a/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/Component.svelte b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/Component.svelte new file mode 100644 index 000000000000..7a54323cb97f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/Component.svelte @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/_config.js b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/_config.js new file mode 100644 index 000000000000..2e4a27cf0912 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + async test() {} +}); diff --git a/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte new file mode 100644 index 000000000000..bd326edfb92a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/1000-reading-derived-effects/main.svelte @@ -0,0 +1,8 @@ + + +{#each arr} + +{/each} diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/_config.js b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/_config.js new file mode 100644 index 000000000000..782ae945f9c3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/_config.js @@ -0,0 +1,26 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [input] = target.querySelectorAll('input'); + + input.focus(); + input.value = '3'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + await tick(); + + assert.equal(input.value, '3'); + assert.htmlEqual(target.innerHTML, `

3

`); + + input.focus(); + input.value = '1'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + await tick(); + + assert.equal(input.value, '2'); + assert.htmlEqual(target.innerHTML, `

2

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/main.svelte new file mode 100644 index 000000000000..763ce6ebf073 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused/main.svelte @@ -0,0 +1,27 @@ + + + +

{await value}

+ + + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/_config.js new file mode 100644 index 000000000000..c551cc6b8c39 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/_config.js @@ -0,0 +1,32 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + +

1

+

1

+ ` + ); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + +

2

+

2

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/main.svelte new file mode 100644 index 000000000000..153fe03f0d89 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-triggers-await/main.svelte @@ -0,0 +1,24 @@ + + + + +

{JSON.stringify((await data), null, 2)}

+ {#if true} + +

{unrelated}

+ {/if} + + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/_config.js b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/_config.js new file mode 100644 index 000000000000..7a56c79d7176 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/_config.js @@ -0,0 +1,24 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + const [input] = target.querySelectorAll('input'); + + input.focus(); + input.value = '3'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + flushSync(); + + assert.equal(input.value, '3'); + assert.htmlEqual(target.innerHTML, `

3

`); + + input.focus(); + input.value = '1'; + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + flushSync(); + + assert.equal(input.value, '2'); + assert.htmlEqual(target.innerHTML, `

2

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/main.svelte new file mode 100644 index 000000000000..b0597c223b99 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/binding-update-while-focused-2/main.svelte @@ -0,0 +1,21 @@ + + +

{value}

+