diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 5a5e532a081e..8cd385046041 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,17 @@ # svelte +## 5.36.14 + +### Patch Changes + +- fix: keep input in sync when binding updated via effect ([#16482](https://github.com/sveltejs/svelte/pull/16482)) + +- fix: rename form accept-charset attribute ([#16478](https://github.com/sveltejs/svelte/pull/16478)) + +- fix: prevent infinite async loop ([#16482](https://github.com/sveltejs/svelte/pull/16482)) + +- fix: exclude derived writes from effect abort and rescheduling ([#16482](https://github.com/sveltejs/svelte/pull/16482)) + ## 5.36.13 ### Patch Changes diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index 1492f777925d..604241592aff 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -996,7 +996,7 @@ export interface HTMLFieldsetAttributes extends HTMLAttributes { - acceptcharset?: string | undefined | null; + 'accept-charset'?: 'utf-8' | (string & {}) | undefined | null; action?: string | undefined | null; autocomplete?: AutoFillBase | undefined | null; enctype?: diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 4bf9a5df22e4..7fe1d161f288 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.36.13", + "version": "5.36.14", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/internal/client/dev/debug.js b/packages/svelte/src/internal/client/dev/debug.js index c47080ed2f1e..2714a3af1f69 100644 --- a/packages/svelte/src/internal/client/dev/debug.js +++ b/packages/svelte/src/internal/client/dev/debug.js @@ -63,6 +63,13 @@ export function log_effect_tree(effect, depth = 0) { // eslint-disable-next-line no-console console.log(callsite); + } else { + // eslint-disable-next-line no-console + console.groupCollapsed(`%cfn`, `font-weight: normal`); + // eslint-disable-next-line no-console + console.log(effect.fn); + // eslint-disable-next-line no-console + console.groupEnd(); } if (effect.deps !== null) { diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index ec082bb595ff..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; } @@ -193,6 +194,8 @@ export class Batch { // if we didn't start any new async work, and no async work // is outstanding from a previous flush, commit if (this.#async_effects.length === 0 && this.#pending === 0) { + this.#commit(); + var render_effects = this.#render_effects; var effects = this.#effects; @@ -200,11 +203,22 @@ export class Batch { this.#effects = []; this.#block_effects = []; - this.#commit(); + // 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; + } + } - return /** @type {T} */ (result); + for (const update of updates.values()) { + // eslint-disable-next-line no-console + console.error(update.error); + } + } + + infinite_loop_guard(); + } + + 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; } } @@ -539,43 +549,47 @@ function flush_queued_effects(effects) { var length = effects.length; if (length === 0) return; - for (var i = 0; i < length; i++) { - var effect = effects[i]; - - if ((effect.f & (DESTROYED | INERT)) === 0) { - if (is_dirty(effect)) { - var wv = write_version; - - update_effect(effect); - - // Effects with no dependencies or teardown do not get added to the effect tree. - // Deferred effects (e.g. `$effect(...)`) _are_ added to the tree because we - // don't know if we need to keep them until they are executed. Doing the check - // here (rather than in `update_effect`) allows us to skip the work for - // immediate effects. - if (effect.deps === null && effect.first === null && effect.nodes_start === null) { - // if there's no teardown or abort controller we completely unlink - // the effect from the graph - if (effect.teardown === null && effect.ac === null) { - // remove this effect from the graph - unlink_effect(effect); - } else { - // keep the effect in the graph, but free up some memory - effect.fn = null; - } - } + var i = 0; - // 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) { - break; + while (i < length) { + var effect = effects[i++]; + + if ((effect.f & (DESTROYED | INERT)) === 0 && is_dirty(effect)) { + var n = current_batch ? current_batch.current.size : 0; + + update_effect(effect); + + // Effects with no dependencies or teardown do not get added to the effect tree. + // Deferred effects (e.g. `$effect(...)`) _are_ added to the tree because we + // don't know if we need to keep them until they are executed. Doing the check + // here (rather than in `update_effect`) allows us to skip the work for + // immediate effects. + if (effect.deps === null && effect.first === null && effect.nodes_start === null) { + // if there's no teardown or abort controller we completely unlink + // the effect from the graph + if (effect.teardown === null && effect.ac === null) { + // remove this effect from the graph + unlink_effect(effect); + } else { + // keep the effect in the graph, but free up some memory + effect.fn = null; } } + + // 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 ( + current_batch !== null && + current_batch.current.size > n && + (effect.f & USER_EFFECT) !== 0 + ) { + break; + } } } - for (; i < length; i += 1) { - schedule_effect(effects[i]); + while (i < length) { + schedule_effect(effects[i++]); } } 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/src/version.js b/packages/svelte/src/version.js index 7d47fbc5f14c..cd9d8b459c32 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.36.13'; +export const VERSION = '5.36.14'; export const PUBLIC_VERSION = '5'; 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}

+ diff --git a/packages/svelte/tests/runtime-runes/samples/effect-order-7/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-order-7/_config.js index 29c33c7b1886..f0a9c2e867bd 100644 --- a/packages/svelte/tests/runtime-runes/samples/effect-order-7/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/effect-order-7/_config.js @@ -2,14 +2,18 @@ import { flushSync } from 'svelte'; import { test } from '../../test'; export default test({ - skip: true, + // For this to work in non-async mode, we would need to abort + // inside `#traverse_effect_tree`, which would be very + // complicated and annoying. Since this hasn't been + // a real issue (AFAICT), we ignore it + skip_no_async: true, - async test({ assert, target, logs }) { + async test({ target }) { const [open, close] = target.querySelectorAll('button'); flushSync(() => open.click()); - flushSync(() => close.click()); - assert.deepEqual(logs, [true]); + // if the effect queue isn't aborted after the state change, this will throw + flushSync(() => close.click()); } }); pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy