From e6110d31c5be64f4964cda40abe1dd0bedd49eb9 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Tue, 26 May 2026 14:45:29 +0200 Subject: [PATCH 1/9] perf: use Set instead of Array for constant lookups in utils.js (#18250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several constant lookup tables in `utils.js` were arrays searched with `Array.prototype.includes`, which is O(n). They're queried often — per attribute during attribute setup and SSR, per event during event delegation, and per identifier during compilation. Switching them to `Set` makes each lookup O(1) without changing any public behaviour. --- .changeset/swift-sets-lookup.md | 5 ++ packages/svelte/src/utils.js | 128 ++++++++++++++++---------------- 2 files changed, 69 insertions(+), 64 deletions(-) create mode 100644 .changeset/swift-sets-lookup.md diff --git a/.changeset/swift-sets-lookup.md b/.changeset/swift-sets-lookup.md new file mode 100644 index 0000000000..af6f774ca4 --- /dev/null +++ b/.changeset/swift-sets-lookup.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: use `Set` for static attribute and event lookups diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index 54757a6f13..fa8ccdfe12 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -13,7 +13,7 @@ export function hash(str) { return (hash >>> 0).toString(36); } -const VOID_ELEMENT_NAMES = [ +const VOID_ELEMENT_NAMES = new Set([ 'area', 'base', 'br', @@ -30,17 +30,17 @@ const VOID_ELEMENT_NAMES = [ 'source', 'track', 'wbr' -]; +]); /** * Returns `true` if `name` is of a void element * @param {string} name */ export function is_void(name) { - return VOID_ELEMENT_NAMES.includes(name) || name.toLowerCase() === '!doctype'; + return VOID_ELEMENT_NAMES.has(name) || name.toLowerCase() === '!doctype'; } -const RESERVED_WORDS = [ +const RESERVED_WORDS = new Set([ 'arguments', 'await', 'break', @@ -89,14 +89,14 @@ const RESERVED_WORDS = [ 'while', 'with', 'yield' -]; +]); /** * Returns `true` if `word` is a reserved JavaScript keyword * @param {string} word */ export function is_reserved(word) { - return RESERVED_WORDS.includes(word); + return RESERVED_WORDS.has(word); } /** @@ -106,8 +106,8 @@ export function is_capture_event(name) { return name.endsWith('capture') && name !== 'gotpointercapture' && name !== 'lostpointercapture'; } -/** List of Element events that will be delegated */ -const DELEGATED_EVENTS = [ +/** Set of Element events that will be delegated */ +const DELEGATED_EVENTS = new Set([ 'beforeinput', 'click', 'change', @@ -131,20 +131,20 @@ const DELEGATED_EVENTS = [ 'touchend', 'touchmove', 'touchstart' -]; +]); /** * Returns `true` if `event_name` is a delegated event * @param {string} event_name */ export function can_delegate_event(event_name) { - return DELEGATED_EVENTS.includes(event_name); + return DELEGATED_EVENTS.has(event_name); } /** * Attributes that are boolean, i.e. they are present or not present. */ -const DOM_BOOLEAN_ATTRIBUTES = [ +const DOM_BOOLEAN_ATTRIBUTES = new Set([ 'allowfullscreen', 'async', 'autofocus', @@ -173,14 +173,14 @@ const DOM_BOOLEAN_ATTRIBUTES = [ 'defer', 'disablepictureinpicture', 'disableremoteplayback' -]; +]); /** * Returns `true` if `name` is a boolean attribute * @param {string} name */ export function is_boolean_attribute(name) { - return DOM_BOOLEAN_ATTRIBUTES.includes(name); + return DOM_BOOLEAN_ATTRIBUTES.has(name); } /** @@ -213,7 +213,7 @@ export function normalize_attribute(name) { return ATTRIBUTE_ALIASES[name] ?? name; } -const DOM_PROPERTIES = [ +const DOM_PROPERTIES = new Set([ ...DOM_BOOLEAN_ATTRIBUTES, 'formNoValidate', 'isMap', @@ -229,16 +229,16 @@ const DOM_PROPERTIES = [ 'allowFullscreen', 'disablePictureInPicture', 'disableRemotePlayback' -]; +]); /** * @param {string} name */ export function is_dom_property(name) { - return DOM_PROPERTIES.includes(name); + return DOM_PROPERTIES.has(name); } -const NON_STATIC_PROPERTIES = ['autofocus', 'muted', 'defaultValue', 'defaultChecked']; +const NON_STATIC_PROPERTIES = new Set(['autofocus', 'muted', 'defaultValue', 'defaultChecked']); /** * Returns `true` if the given attribute cannot be set through the template @@ -246,7 +246,7 @@ const NON_STATIC_PROPERTIES = ['autofocus', 'muted', 'defaultValue', 'defaultChe * @param {string} name */ export function cannot_be_set_statically(name) { - return NON_STATIC_PROPERTIES.includes(name); + return NON_STATIC_PROPERTIES.has(name); } /** @@ -258,24 +258,24 @@ export function cannot_be_set_statically(name) { * - they apply to mobile which is generally less performant * we're marking them as passive by default for other elements, too. */ -const PASSIVE_EVENTS = ['touchstart', 'touchmove']; +const PASSIVE_EVENTS = new Set(['touchstart', 'touchmove']); /** * Returns `true` if `name` is a passive event * @param {string} name */ export function is_passive_event(name) { - return PASSIVE_EVENTS.includes(name); + return PASSIVE_EVENTS.has(name); } -const CONTENT_EDITABLE_BINDINGS = ['textContent', 'innerHTML', 'innerText']; +const CONTENT_EDITABLE_BINDINGS = new Set(['textContent', 'innerHTML', 'innerText']); /** @param {string} name */ export function is_content_editable_binding(name) { - return CONTENT_EDITABLE_BINDINGS.includes(name); + return CONTENT_EDITABLE_BINDINGS.has(name); } -const LOAD_ERROR_ELEMENTS = [ +const LOAD_ERROR_ELEMENTS = new Set([ 'body', 'embed', 'iframe', @@ -285,17 +285,17 @@ const LOAD_ERROR_ELEMENTS = [ 'script', 'style', 'track' -]; +]); /** * Returns `true` if the element emits `load` and `error` events * @param {string} name */ export function is_load_error_element(name) { - return LOAD_ERROR_ELEMENTS.includes(name); + return LOAD_ERROR_ELEMENTS.has(name); } -const SVG_ELEMENTS = [ +const SVG_ELEMENTS = new Set([ 'altGlyph', 'altGlyphDef', 'altGlyphItem', @@ -382,14 +382,14 @@ const SVG_ELEMENTS = [ 'use', 'view', 'vkern' -]; +]); /** @param {string} name */ export function is_svg(name) { - return SVG_ELEMENTS.includes(name); + return SVG_ELEMENTS.has(name); } -const MATHML_ELEMENTS = [ +const MATHML_ELEMENTS = new Set([ 'annotation', 'annotation-xml', 'maction', @@ -420,64 +420,64 @@ const MATHML_ELEMENTS = [ 'munder', 'munderover', 'semantics' -]; +]); /** @param {string} name */ export function is_mathml(name) { - return MATHML_ELEMENTS.includes(name); + return MATHML_ELEMENTS.has(name); } -const STATE_CREATION_RUNES = /** @type {const} */ ([ - '$state', - '$state.raw', - '$derived', - '$derived.by' -]); - -const RUNES = /** @type {const} */ ([ - ...STATE_CREATION_RUNES, - '$state.eager', - '$state.snapshot', - '$props', - '$props.id', - '$bindable', - '$effect', - '$effect.pre', - '$effect.tracking', - '$effect.root', - '$effect.pending', - '$inspect', - '$inspect().with', - '$inspect.trace', - '$host' -]); - -/** @typedef {typeof RUNES[number]} RuneName */ +const STATE_CREATION_RUNES = new Set( + /** @type {const} */ (['$state', '$state.raw', '$derived', '$derived.by']) +); + +const RUNES = new Set( + /** @type {const} */ ([ + ...STATE_CREATION_RUNES, + '$state.eager', + '$state.snapshot', + '$props', + '$props.id', + '$bindable', + '$effect', + '$effect.pre', + '$effect.tracking', + '$effect.root', + '$effect.pending', + '$inspect', + '$inspect().with', + '$inspect.trace', + '$host' + ]) +); + +/** @typedef {typeof RUNES extends Set ? T : never} RuneName */ +/** @typedef {typeof STATE_CREATION_RUNES extends Set ? T : never} StateCreationRuneName */ /** * @param {string} name * @returns {name is RuneName} */ export function is_rune(name) { - return RUNES.includes(/** @type {RuneName} */ (name)); + return RUNES.has(/** @type {RuneName} */ (name)); } -/** @typedef {typeof STATE_CREATION_RUNES[number]} StateCreationRuneName */ - /** * @param {string} name * @returns {name is StateCreationRuneName} */ export function is_state_creation_rune(name) { - return STATE_CREATION_RUNES.includes(/** @type {StateCreationRuneName} */ (name)); + return STATE_CREATION_RUNES.has(/** @type {StateCreationRuneName} */ (name)); } -/** List of elements that require raw contents and should not have SSR comments put in them */ -const RAW_TEXT_ELEMENTS = /** @type {const} */ (['textarea', 'script', 'style', 'title']); +/** Elements that require raw contents and should not have SSR comments put in them */ +const RAW_TEXT_ELEMENTS = new Set(/** @type {const} */ (['textarea', 'script', 'style', 'title'])); + +/** @typedef {typeof RAW_TEXT_ELEMENTS extends Set ? T : never} RawTextElement */ /** @param {string} name */ export function is_raw_text_element(name) { - return RAW_TEXT_ELEMENTS.includes(/** @type {typeof RAW_TEXT_ELEMENTS[number]} */ (name)); + return RAW_TEXT_ELEMENTS.has(/** @type {RawTextElement} */ (name)); } // Matches valid HTML/SVG/MathML element names and custom element names. From 871e590c594c2c4ec42ad9fa160bff7f3e445784 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 26 May 2026 18:14:35 +0200 Subject: [PATCH 2/9] fix: settle discarded batch (#18290) Without this the promise would never resolve --- .changeset/neat-groups-grin.md | 5 +++ .../src/internal/client/reactivity/batch.js | 1 + .../samples/async-settled-discard/_config.js | 37 +++++++++++++++++++ .../samples/async-settled-discard/main.svelte | 22 +++++++++++ 4 files changed, 65 insertions(+) create mode 100644 .changeset/neat-groups-grin.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-settled-discard/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-settled-discard/main.svelte diff --git a/.changeset/neat-groups-grin.md b/.changeset/neat-groups-grin.md new file mode 100644 index 0000000000..1d615216f0 --- /dev/null +++ b/.changeset/neat-groups-grin.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: settle discarded batch diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 82c97cf95c..08e8cf24c4 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -633,6 +633,7 @@ export class Batch { this.#fork_commit_callbacks.clear(); this.#unlink(); + this.#deferred?.resolve(); } /** diff --git a/packages/svelte/tests/runtime-runes/samples/async-settled-discard/_config.js b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/_config.js new file mode 100644 index 0000000000..29ed72d90a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/_config.js @@ -0,0 +1,37 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.deepEqual(logs, ['settled 2', 'settled 2']); + assert.htmlEqual( + target.innerHTML, + ` + 2 + + + ` + ); + + pop.click(); + await tick(); + assert.deepEqual(logs, ['settled 2', 'settled 2']); + assert.htmlEqual( + target.innerHTML, + ` + 2 + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-settled-discard/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/main.svelte new file mode 100644 index 0000000000..c3675f8add --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-settled-discard/main.svelte @@ -0,0 +1,22 @@ + + +{await push(count)} + + From 60eaa92b0bdd576d8ad791a8ed7ad56bcb1575db Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 26 May 2026 18:16:27 +0200 Subject: [PATCH 3/9] fix: resume outro-ed branches if they were kept around (#18291) If a branch is removed from the visible dom, it may be kept around because a subsequent batch will intro it again. If we don't resume the effects it will stay inert and therefore not react to updates anymore --- .changeset/sad-shoes-help.md | 5 ++ .../internal/client/dom/blocks/branches.js | 2 + .../samples/async-branch-reintro/_config.js | 58 +++++++++++++++++++ .../samples/async-branch-reintro/main.svelte | 23 ++++++++ 4 files changed, 88 insertions(+) create mode 100644 .changeset/sad-shoes-help.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-branch-reintro/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-branch-reintro/main.svelte diff --git a/.changeset/sad-shoes-help.md b/.changeset/sad-shoes-help.md new file mode 100644 index 0000000000..76db87ba95 --- /dev/null +++ b/.changeset/sad-shoes-help.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: resume outro-ed branches if they were kept around diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index 33c34f58bb..141b914537 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -90,6 +90,8 @@ export class BranchManager { var offscreen = this.#offscreen.get(key); if (offscreen) { + // effect could have been outro'ed before through a prior batch — resume if necessary + resume_effect(offscreen.effect); this.#onscreen.set(key, offscreen.effect); this.#offscreen.delete(key); diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/_config.js b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/_config.js new file mode 100644 index 0000000000..928db008e6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/_config.js @@ -0,0 +1,58 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [inc_count, inc_both, shift] = target.querySelectorAll('button'); + + inc_both.click(); + await tick(); + inc_count.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 0 + 0 + + ` + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 1 + 2 + + ` + ); + + const button = /** @type {HTMLButtonElement} */ (target.querySelector('button:last-child')); + button.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + 2 + 2 + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/main.svelte new file mode 100644 index 0000000000..92b7669fa9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-branch-reintro/main.svelte @@ -0,0 +1,23 @@ + + + + + + +{await push(other)} +{#if count % 2 === 0} + {await push(count)} + +{/if} From d2c7fa34945272aae500f90536aa9fdffdc35e7e Mon Sep 17 00:00:00 2001 From: RonGamzu <37371774+RonGamzu@users.noreply.github.com> Date: Tue, 26 May 2026 19:21:51 +0300 Subject: [PATCH 4/9] fix: use consistent spelling in script_unknown_attribute warning (#18281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The `script_unknown_attribute` warning message used American English (`Unrecognized`) while every other user-facing error/warning in the compiler uses British English (`Unrecognised`): - `options_unrecognised` error: `Unrecognised compiler option…` - `unknown_code` warning: `is not a recognised code` This change updates `script_unknown_attribute` to match, then regenerates `warnings.js` and the reference documentation from the source message file via `node scripts/process-messages`. ## Test plan - Spelling change is isolated to the `script_unknown_attribute` warning message - `warnings.js` and generated docs were updated by running `node scripts/process-messages` - All three occurrences (source, generated JS, generated docs) are consistent --- .../docs/98-reference/.generated/compile-warnings.md | 2 +- packages/svelte/messages/compile-warnings/template.md | 2 +- packages/svelte/src/compiler/warnings.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/documentation/docs/98-reference/.generated/compile-warnings.md b/documentation/docs/98-reference/.generated/compile-warnings.md index fb53c644cf..f372a010a5 100644 --- a/documentation/docs/98-reference/.generated/compile-warnings.md +++ b/documentation/docs/98-reference/.generated/compile-warnings.md @@ -842,7 +842,7 @@ Reassignments of module-level declarations will not cause reactive statements to ### script_unknown_attribute ``` -Unrecognized attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it +Unrecognised attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it ``` ### slot_element_deprecated diff --git a/packages/svelte/messages/compile-warnings/template.md b/packages/svelte/messages/compile-warnings/template.md index 3650e07b47..f1e32a6d18 100644 --- a/packages/svelte/messages/compile-warnings/template.md +++ b/packages/svelte/messages/compile-warnings/template.md @@ -107,7 +107,7 @@ This code will work when the component is rendered on the client (which is why t ## script_unknown_attribute -> Unrecognized attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it +> Unrecognised attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it ## slot_element_deprecated diff --git a/packages/svelte/src/compiler/warnings.js b/packages/svelte/src/compiler/warnings.js index c7d660a617..98f407671d 100644 --- a/packages/svelte/src/compiler/warnings.js +++ b/packages/svelte/src/compiler/warnings.js @@ -804,11 +804,11 @@ export function script_context_deprecated(node) { } /** - * Unrecognized attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it + * Unrecognised attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it * @param {null | NodeLike} node */ export function script_unknown_attribute(node) { - w(node, 'script_unknown_attribute', `Unrecognized attribute — should be one of \`generics\`, \`lang\` or \`module\`. If this exists for a preprocessor, ensure that the preprocessor removes it\nhttps://svelte.dev/e/script_unknown_attribute`); + w(node, 'script_unknown_attribute', `Unrecognised attribute — should be one of \`generics\`, \`lang\` or \`module\`. If this exists for a preprocessor, ensure that the preprocessor removes it\nhttps://svelte.dev/e/script_unknown_attribute`); } /** From c9f7298270d4e913a2bb6826f5f193d95bf34cd6 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 26 May 2026 12:29:40 -0400 Subject: [PATCH 5/9] chore: fix spelling in test (#18292) ugh i forgot to approve workflows in slop PR #18281 and now tests are failing --- .../samples/script-invalid-spread-attribute/warnings.json | 2 +- .../validator/samples/script-unknown-attribute/warnings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/tests/validator/samples/script-invalid-spread-attribute/warnings.json b/packages/svelte/tests/validator/samples/script-invalid-spread-attribute/warnings.json index c6748f711d..35045313ba 100644 --- a/packages/svelte/tests/validator/samples/script-invalid-spread-attribute/warnings.json +++ b/packages/svelte/tests/validator/samples/script-invalid-spread-attribute/warnings.json @@ -1,7 +1,7 @@ [ { "code": "script_unknown_attribute", - "message": "Unrecognized attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it", + "message": "Unrecognised attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it", "start": { "column": 8, "line": 1 diff --git a/packages/svelte/tests/validator/samples/script-unknown-attribute/warnings.json b/packages/svelte/tests/validator/samples/script-unknown-attribute/warnings.json index fdcad269a6..0d41c2dc12 100644 --- a/packages/svelte/tests/validator/samples/script-unknown-attribute/warnings.json +++ b/packages/svelte/tests/validator/samples/script-unknown-attribute/warnings.json @@ -1,7 +1,7 @@ [ { "code": "script_unknown_attribute", - "message": "Unrecognized attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it", + "message": "Unrecognised attribute — should be one of `generics`, `lang` or `module`. If this exists for a preprocessor, ensure that the preprocessor removes it", "start": { "column": 8, "line": 1 From 070a0c4f7708ae0caa209ff762d98c0f117e6adb Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 26 May 2026 15:44:56 -0400 Subject: [PATCH 6/9] Revert "perf: use Set instead of Array for constant lookups in utils.js" (#18294) Reverts sveltejs/svelte#18250 --- .changeset/swift-sets-lookup.md | 5 -- packages/svelte/src/utils.js | 128 ++++++++++++++++---------------- 2 files changed, 64 insertions(+), 69 deletions(-) delete mode 100644 .changeset/swift-sets-lookup.md diff --git a/.changeset/swift-sets-lookup.md b/.changeset/swift-sets-lookup.md deleted file mode 100644 index af6f774ca4..0000000000 --- a/.changeset/swift-sets-lookup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -perf: use `Set` for static attribute and event lookups diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index fa8ccdfe12..54757a6f13 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -13,7 +13,7 @@ export function hash(str) { return (hash >>> 0).toString(36); } -const VOID_ELEMENT_NAMES = new Set([ +const VOID_ELEMENT_NAMES = [ 'area', 'base', 'br', @@ -30,17 +30,17 @@ const VOID_ELEMENT_NAMES = new Set([ 'source', 'track', 'wbr' -]); +]; /** * Returns `true` if `name` is of a void element * @param {string} name */ export function is_void(name) { - return VOID_ELEMENT_NAMES.has(name) || name.toLowerCase() === '!doctype'; + return VOID_ELEMENT_NAMES.includes(name) || name.toLowerCase() === '!doctype'; } -const RESERVED_WORDS = new Set([ +const RESERVED_WORDS = [ 'arguments', 'await', 'break', @@ -89,14 +89,14 @@ const RESERVED_WORDS = new Set([ 'while', 'with', 'yield' -]); +]; /** * Returns `true` if `word` is a reserved JavaScript keyword * @param {string} word */ export function is_reserved(word) { - return RESERVED_WORDS.has(word); + return RESERVED_WORDS.includes(word); } /** @@ -106,8 +106,8 @@ export function is_capture_event(name) { return name.endsWith('capture') && name !== 'gotpointercapture' && name !== 'lostpointercapture'; } -/** Set of Element events that will be delegated */ -const DELEGATED_EVENTS = new Set([ +/** List of Element events that will be delegated */ +const DELEGATED_EVENTS = [ 'beforeinput', 'click', 'change', @@ -131,20 +131,20 @@ const DELEGATED_EVENTS = new Set([ 'touchend', 'touchmove', 'touchstart' -]); +]; /** * Returns `true` if `event_name` is a delegated event * @param {string} event_name */ export function can_delegate_event(event_name) { - return DELEGATED_EVENTS.has(event_name); + return DELEGATED_EVENTS.includes(event_name); } /** * Attributes that are boolean, i.e. they are present or not present. */ -const DOM_BOOLEAN_ATTRIBUTES = new Set([ +const DOM_BOOLEAN_ATTRIBUTES = [ 'allowfullscreen', 'async', 'autofocus', @@ -173,14 +173,14 @@ const DOM_BOOLEAN_ATTRIBUTES = new Set([ 'defer', 'disablepictureinpicture', 'disableremoteplayback' -]); +]; /** * Returns `true` if `name` is a boolean attribute * @param {string} name */ export function is_boolean_attribute(name) { - return DOM_BOOLEAN_ATTRIBUTES.has(name); + return DOM_BOOLEAN_ATTRIBUTES.includes(name); } /** @@ -213,7 +213,7 @@ export function normalize_attribute(name) { return ATTRIBUTE_ALIASES[name] ?? name; } -const DOM_PROPERTIES = new Set([ +const DOM_PROPERTIES = [ ...DOM_BOOLEAN_ATTRIBUTES, 'formNoValidate', 'isMap', @@ -229,16 +229,16 @@ const DOM_PROPERTIES = new Set([ 'allowFullscreen', 'disablePictureInPicture', 'disableRemotePlayback' -]); +]; /** * @param {string} name */ export function is_dom_property(name) { - return DOM_PROPERTIES.has(name); + return DOM_PROPERTIES.includes(name); } -const NON_STATIC_PROPERTIES = new Set(['autofocus', 'muted', 'defaultValue', 'defaultChecked']); +const NON_STATIC_PROPERTIES = ['autofocus', 'muted', 'defaultValue', 'defaultChecked']; /** * Returns `true` if the given attribute cannot be set through the template @@ -246,7 +246,7 @@ const NON_STATIC_PROPERTIES = new Set(['autofocus', 'muted', 'defaultValue', 'de * @param {string} name */ export function cannot_be_set_statically(name) { - return NON_STATIC_PROPERTIES.has(name); + return NON_STATIC_PROPERTIES.includes(name); } /** @@ -258,24 +258,24 @@ export function cannot_be_set_statically(name) { * - they apply to mobile which is generally less performant * we're marking them as passive by default for other elements, too. */ -const PASSIVE_EVENTS = new Set(['touchstart', 'touchmove']); +const PASSIVE_EVENTS = ['touchstart', 'touchmove']; /** * Returns `true` if `name` is a passive event * @param {string} name */ export function is_passive_event(name) { - return PASSIVE_EVENTS.has(name); + return PASSIVE_EVENTS.includes(name); } -const CONTENT_EDITABLE_BINDINGS = new Set(['textContent', 'innerHTML', 'innerText']); +const CONTENT_EDITABLE_BINDINGS = ['textContent', 'innerHTML', 'innerText']; /** @param {string} name */ export function is_content_editable_binding(name) { - return CONTENT_EDITABLE_BINDINGS.has(name); + return CONTENT_EDITABLE_BINDINGS.includes(name); } -const LOAD_ERROR_ELEMENTS = new Set([ +const LOAD_ERROR_ELEMENTS = [ 'body', 'embed', 'iframe', @@ -285,17 +285,17 @@ const LOAD_ERROR_ELEMENTS = new Set([ 'script', 'style', 'track' -]); +]; /** * Returns `true` if the element emits `load` and `error` events * @param {string} name */ export function is_load_error_element(name) { - return LOAD_ERROR_ELEMENTS.has(name); + return LOAD_ERROR_ELEMENTS.includes(name); } -const SVG_ELEMENTS = new Set([ +const SVG_ELEMENTS = [ 'altGlyph', 'altGlyphDef', 'altGlyphItem', @@ -382,14 +382,14 @@ const SVG_ELEMENTS = new Set([ 'use', 'view', 'vkern' -]); +]; /** @param {string} name */ export function is_svg(name) { - return SVG_ELEMENTS.has(name); + return SVG_ELEMENTS.includes(name); } -const MATHML_ELEMENTS = new Set([ +const MATHML_ELEMENTS = [ 'annotation', 'annotation-xml', 'maction', @@ -420,64 +420,64 @@ const MATHML_ELEMENTS = new Set([ 'munder', 'munderover', 'semantics' -]); +]; /** @param {string} name */ export function is_mathml(name) { - return MATHML_ELEMENTS.has(name); + return MATHML_ELEMENTS.includes(name); } -const STATE_CREATION_RUNES = new Set( - /** @type {const} */ (['$state', '$state.raw', '$derived', '$derived.by']) -); - -const RUNES = new Set( - /** @type {const} */ ([ - ...STATE_CREATION_RUNES, - '$state.eager', - '$state.snapshot', - '$props', - '$props.id', - '$bindable', - '$effect', - '$effect.pre', - '$effect.tracking', - '$effect.root', - '$effect.pending', - '$inspect', - '$inspect().with', - '$inspect.trace', - '$host' - ]) -); - -/** @typedef {typeof RUNES extends Set ? T : never} RuneName */ -/** @typedef {typeof STATE_CREATION_RUNES extends Set ? T : never} StateCreationRuneName */ +const STATE_CREATION_RUNES = /** @type {const} */ ([ + '$state', + '$state.raw', + '$derived', + '$derived.by' +]); + +const RUNES = /** @type {const} */ ([ + ...STATE_CREATION_RUNES, + '$state.eager', + '$state.snapshot', + '$props', + '$props.id', + '$bindable', + '$effect', + '$effect.pre', + '$effect.tracking', + '$effect.root', + '$effect.pending', + '$inspect', + '$inspect().with', + '$inspect.trace', + '$host' +]); + +/** @typedef {typeof RUNES[number]} RuneName */ /** * @param {string} name * @returns {name is RuneName} */ export function is_rune(name) { - return RUNES.has(/** @type {RuneName} */ (name)); + return RUNES.includes(/** @type {RuneName} */ (name)); } +/** @typedef {typeof STATE_CREATION_RUNES[number]} StateCreationRuneName */ + /** * @param {string} name * @returns {name is StateCreationRuneName} */ export function is_state_creation_rune(name) { - return STATE_CREATION_RUNES.has(/** @type {StateCreationRuneName} */ (name)); + return STATE_CREATION_RUNES.includes(/** @type {StateCreationRuneName} */ (name)); } -/** Elements that require raw contents and should not have SSR comments put in them */ -const RAW_TEXT_ELEMENTS = new Set(/** @type {const} */ (['textarea', 'script', 'style', 'title'])); - -/** @typedef {typeof RAW_TEXT_ELEMENTS extends Set ? T : never} RawTextElement */ +/** List of elements that require raw contents and should not have SSR comments put in them */ +const RAW_TEXT_ELEMENTS = /** @type {const} */ (['textarea', 'script', 'style', 'title']); /** @param {string} name */ export function is_raw_text_element(name) { - return RAW_TEXT_ELEMENTS.has(/** @type {RawTextElement} */ (name)); + return RAW_TEXT_ELEMENTS.includes(/** @type {typeof RAW_TEXT_ELEMENTS[number]} */ (name)); } // Matches valid HTML/SVG/MathML element names and custom element names. From c01e598fff2a851323fb61c6325ef51b87f019fd Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 26 May 2026 23:18:51 +0200 Subject: [PATCH 7/9] fix: avoid waterfall-warning when async resolves to same value (#18297) --- .changeset/swift-terms-exist.md | 5 +++++ .../internal/client/reactivity/deriveds.js | 6 +++--- .../async-derived-same-value/_config.js | 19 +++++++++++++++++++ .../async-derived-same-value/main.svelte | 12 ++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 .changeset/swift-terms-exist.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-same-value/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-same-value/main.svelte diff --git a/.changeset/swift-terms-exist.md b/.changeset/swift-terms-exist.md new file mode 100644 index 0000000000..cc69f1b3b1 --- /dev/null +++ b/.changeset/swift-terms-exist.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid waterfall-warning when async resolves to same value diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 7ea6de6306..fd64f3b45d 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -230,9 +230,7 @@ export function async_derived(fn, label, location) { signal.f ^= ERROR_VALUE; } - internal_set(signal, value); - - if (DEV && location !== undefined) { + if (DEV && location !== undefined && !signal.equals(value)) { recent_async_deriveds.add(signal); setTimeout(() => { @@ -242,6 +240,8 @@ export function async_derived(fn, label, location) { } }); } + + internal_set(signal, value); } batch.deactivate(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-same-value/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-same-value/_config.js new file mode 100644 index 0000000000..b4eb464e23 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-same-value/_config.js @@ -0,0 +1,19 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { dev: true }, + async test({ assert, target, warnings }) { + await tick(); + const [button] = target.querySelectorAll('button'); + + button.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ''); + + button.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ''); + assert.deepEqual(warnings, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-same-value/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-same-value/main.svelte new file mode 100644 index 0000000000..78475047cd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-same-value/main.svelte @@ -0,0 +1,12 @@ + + + From b40c359d44196b7b87745259243333763ed61ec5 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 26 May 2026 23:37:17 +0200 Subject: [PATCH 8/9] fix: don't assume boundary exists during increment/decrement (#18289) Follow-up to #18273 (not merged yet hence no changeset here): We can run into a null-pointer when wanting to increment/decrement inside an effect root that is outside a the component tree. Similarly, if not using the component logic to unset context at the right time we gotta do it "manually" inside `save`. --------- Co-authored-by: Rich Harris Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- .../client/visitors/VariableDeclaration.js | 4 ++-- packages/svelte/src/compiler/utils/ast.js | 5 ++-- .../src/internal/client/reactivity/async.js | 23 +++++++++++++++---- .../main.svelte | 11 --------- .../_config.js | 8 ++++++- .../main.svelte | 19 +++++++++++++++ .../_config.js | 14 +++++++++++ .../main.svelte | 15 ++++++++++++ 8 files changed, 78 insertions(+), 21 deletions(-) delete mode 100644 packages/svelte/tests/runtime-runes/samples/async-async-disconnected-effect-root/main.svelte rename packages/svelte/tests/runtime-runes/samples/{async-async-disconnected-effect-root => async-disconnected-effect-root}/_config.js (57%) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-unset-context-after-restore/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-unset-context-after-restore/main.svelte diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js index dffa79cd7a..8b4891da8e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js @@ -213,7 +213,7 @@ export function VariableDeclaration(node, context) { location ? b.literal(location) : undefined ); - call = should_save ? save(call) : b.await(call); + call = should_save ? save(call, true) : b.await(call); declarations.push(b.declarator(declarator.id, call)); } else { @@ -251,7 +251,7 @@ export function VariableDeclaration(node, context) { location ? b.literal(location) : undefined ); - call = should_save ? save(call) : b.await(call); + call = should_save ? save(call, true) : b.await(call); } declarations.push(b.declarator(id, call)); diff --git a/packages/svelte/src/compiler/utils/ast.js b/packages/svelte/src/compiler/utils/ast.js index 75aadd905b..8d6df28f31 100644 --- a/packages/svelte/src/compiler/utils/ast.js +++ b/packages/svelte/src/compiler/utils/ast.js @@ -633,7 +633,8 @@ export function has_await_expression(node) { /** * Turns `await ...` to `(await $.save(...))()` * @param {ESTree.Expression} expression + * @param {boolean} unset */ -export function save(expression) { - return b.call(b.await(b.call('$.save', expression))); +export function save(expression, unset = false) { + return b.call(b.await(b.call('$.save', expression, unset && b.true))); } diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 12c5e9baa5..10cdb40c57 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -25,6 +25,7 @@ import { set_reactivity_loss_tracker } from './deriveds.js'; import { aborted } from './effects.js'; +import { queue_micro_task } from '../dom/task.js'; /** * @param {Blocker[]} blockers @@ -148,13 +149,25 @@ export function capture() { * `await a + b` becomes `(await $.save(a))() + b` * @template T * @param {Promise} promise + * @param {boolean} unset * @returns {Promise<() => T>} */ -export async function save(promise) { +export async function save(promise, unset) { + var batch = current_batch; var restore = capture(); var value = await promise; return () => { + if (unset) { + // If this is happening outside the context of an async derived, + // context will not automatically be unset + queue_micro_task(() => { + if (batch === current_batch) { + unset_context(); + } + }); + } + restore(); return value; }; @@ -352,15 +365,15 @@ export function wait(blockers) { */ export function increment_pending() { var effect = /** @type {Effect} */ (active_effect); - var boundary = /** @type {Boundary} */ (effect.b); + var boundary = effect.b; // undefined if called outside the render tree, e.g. a standalone $effect.root var batch = /** @type {Batch} */ (current_batch); - var blocking = boundary.is_rendered(); + var blocking = !!boundary?.is_rendered(); - boundary.update_pending_count(1, batch); + boundary?.update_pending_count(1, batch); batch.increment(blocking, effect); return () => { - boundary.update_pending_count(-1, batch); + boundary?.update_pending_count(-1, batch); batch.decrement(blocking, effect); }; } diff --git a/packages/svelte/tests/runtime-runes/samples/async-async-disconnected-effect-root/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-async-disconnected-effect-root/main.svelte deleted file mode 100644 index 37c8919023..0000000000 --- a/packages/svelte/tests/runtime-runes/samples/async-async-disconnected-effect-root/main.svelte +++ /dev/null @@ -1,11 +0,0 @@ - diff --git a/packages/svelte/tests/runtime-runes/samples/async-async-disconnected-effect-root/_config.js b/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/_config.js similarity index 57% rename from packages/svelte/tests/runtime-runes/samples/async-async-disconnected-effect-root/_config.js rename to packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/_config.js index ecf048abc3..fdc773751b 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-async-disconnected-effect-root/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/_config.js @@ -1,9 +1,15 @@ +import { tick } from 'svelte'; import { test } from '../../test'; export default test({ // Test that an async derived inside an $effect.root not connected to the component tree still works async test({ assert, logs }) { await new Promise((resolve) => setTimeout(resolve, 10)); - assert.deepEqual(logs, [1]); + assert.deepEqual(logs, [1, 1]); + const [button] = document.querySelectorAll('button'); + + button.click(); + await tick(); + assert.deepEqual(logs, [1, 1, 2]); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/main.svelte new file mode 100644 index 0000000000..3b42acf171 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-disconnected-effect-root/main.svelte @@ -0,0 +1,19 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-unset-context-after-restore/_config.js b/packages/svelte/tests/runtime-runes/samples/async-unset-context-after-restore/_config.js new file mode 100644 index 0000000000..ad543e91ae --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-unset-context-after-restore/_config.js @@ -0,0 +1,14 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, logs }) { + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.deepEqual(logs, [1, 1]); + const [button] = document.querySelectorAll('button'); + + button.click(); + await tick(); + assert.deepEqual(logs, [1, 1, 2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-unset-context-after-restore/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-unset-context-after-restore/main.svelte new file mode 100644 index 0000000000..a39c75fb94 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-unset-context-after-restore/main.svelte @@ -0,0 +1,15 @@ + + + From ec08dbc7ee156d34816586feb49bb8650579e41f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 27 May 2026 04:20:27 -0400 Subject: [PATCH 9/9] fix: only unlink batch if we're done with it (#18298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alternative to #18296 — no need to make batches relinkable if we only unlink them when we're fully done with them --- .changeset/khaki-states-train.md | 5 +++ .../src/internal/client/reactivity/batch.js | 31 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) create mode 100644 .changeset/khaki-states-train.md diff --git a/.changeset/khaki-states-train.md b/.changeset/khaki-states-train.md new file mode 100644 index 0000000000..5d4054fb15 --- /dev/null +++ b/.changeset/khaki-states-train.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: properly unlink batches diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 08e8cf24c4..21e2cc6922 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -393,31 +393,30 @@ export class Batch { var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); - if (this.linked && this.#pending === 0) { + if (this.#pending === 0 && (this.#roots.length === 0 || next_batch !== null)) { this.#unlink(); - } - // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, - // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. - // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode - // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed - if (async_mode_flag && !this.linked) { - this.#commit(); - // Rebases can activate other batches or null it out, therefore restore the new one here - current_batch = next_batch; + // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, + // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. + // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode + // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed + if (async_mode_flag) { + this.#commit(); + // Rebases can activate other batches or null it out, therefore restore the new one here + current_batch = next_batch; + } } // Edge case: During traversal new branches might create effects that run immediately and set state, // causing an effect and therefore a root to be scheduled again. We need to traverse the current batch // once more in that case - most of the time this will just clean up dirty branches. if (this.#roots.length > 0) { - if (next_batch === null) { + if (next_batch !== null) { + const batch = next_batch; + batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); + } else { next_batch = this; - this.#link(); } - - const batch = next_batch; - batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r))); } if (next_batch !== null) { @@ -644,8 +643,6 @@ export class Batch { } #commit() { - this.#unlink(); - // If there are other pending batches, they now need to be 'rebased' — // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more