From ae71152013ef47cb917c371156a69224248d174c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 15 May 2025 11:18:55 -0400 Subject: [PATCH 01/12] docs: clarify keyed each blocks (#15923) --- documentation/docs/03-template-syntax/03-each.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/documentation/docs/03-template-syntax/03-each.md b/documentation/docs/03-template-syntax/03-each.md index 70666f6a57..006cadd152 100644 --- a/documentation/docs/03-template-syntax/03-each.md +++ b/documentation/docs/03-template-syntax/03-each.md @@ -43,7 +43,9 @@ An each block can also specify an _index_, equivalent to the second argument in {#each expression as name, index (key)}...{/each} ``` -If a _key_ expression is provided — which must uniquely identify each list item — Svelte will use it to diff the list when data changes, rather than adding or removing items at the end. The key can be any object, but strings and numbers are recommended since they allow identity to persist when the objects themselves change. +If a _key_ expression is provided — which must uniquely identify each list item — Svelte will use it to intelligently update the list when data changes by inserting, moving and deleting items, rather than adding or removing items at the end and updating the state in the middle. + +The key can be any object, but strings and numbers are recommended since they allow identity to persist when the objects themselves change. ```svelte {#each items as item (item.id)} From 17a7cf51e44dfb7704e6b2559e14e63f22972cd3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 15 May 2025 11:19:09 -0400 Subject: [PATCH 02/12] docs: clarify ordering of attributes with spread (#15917) --- documentation/docs/03-template-syntax/01-basic-markup.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/documentation/docs/03-template-syntax/01-basic-markup.md b/documentation/docs/03-template-syntax/01-basic-markup.md index fe5f8b02aa..feecfe033e 100644 --- a/documentation/docs/03-template-syntax/01-basic-markup.md +++ b/documentation/docs/03-template-syntax/01-basic-markup.md @@ -82,12 +82,14 @@ As with elements, `name={name}` can be replaced with the `{name}` shorthand. ``` +## Spread attributes + _Spread attributes_ allow many attributes or properties to be passed to an element or component at once. -An element or component can have multiple spread attributes, interspersed with regular ones. +An element or component can have multiple spread attributes, interspersed with regular ones. Order matters — if `things.a` exists it will take precedence over `a="b"`, while `c="d"` would take precedence over `things.c`: ```svelte - + ``` ## Events From a5a0b49003d9cb544ed7919a4320870bda237833 Mon Sep 17 00:00:00 2001 From: Matteo Battista Date: Thu, 15 May 2025 18:14:18 +0200 Subject: [PATCH 03/12] fix: update_branch with (anchor).data possible undefined on ios devices (#15851) * fix: update_branch with (anchor).data possible undefined * error sooner * add tests * changeset --------- Co-authored-by: Rich Harris --- .changeset/silly-apples-remain.md | 5 +++++ .../svelte/src/internal/client/dom/blocks/each.js | 3 ++- .../svelte/src/internal/client/dom/blocks/if.js | 4 +++- .../svelte/src/internal/client/dom/hydration.js | 13 +++++++++++++ .../samples/cloudflare-mirage-borking-2/_config.js | 6 ++++++ .../cloudflare-mirage-borking-2/_expected.html | 1 + .../cloudflare-mirage-borking-2/_override.html | 1 + .../samples/cloudflare-mirage-borking-2/main.svelte | 5 +++++ .../samples/cloudflare-mirage-borking/_config.js | 6 ++++++ .../cloudflare-mirage-borking/_expected.html | 1 + .../cloudflare-mirage-borking/_override.html | 1 + .../samples/cloudflare-mirage-borking/main.svelte | 9 +++++++++ 12 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 .changeset/silly-apples-remain.md create mode 100644 packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_config.js create mode 100644 packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_expected.html create mode 100644 packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_override.html create mode 100644 packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/main.svelte create mode 100644 packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_config.js create mode 100644 packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_expected.html create mode 100644 packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_override.html create mode 100644 packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/main.svelte diff --git a/.changeset/silly-apples-remain.md b/.changeset/silly-apples-remain.md new file mode 100644 index 0000000000..10d43db550 --- /dev/null +++ b/.changeset/silly-apples-remain.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: handle more hydration mismatches diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 92c953b541..2997664fa2 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -12,6 +12,7 @@ import { hydrate_next, hydrate_node, hydrating, + read_hydration_instruction, remove_nodes, set_hydrate_node, set_hydrating @@ -160,7 +161,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f let mismatch = false; if (hydrating) { - var is_else = /** @type {Comment} */ (anchor).data === HYDRATION_START_ELSE; + var is_else = read_hydration_instruction(anchor) === HYDRATION_START_ELSE; if (is_else !== (length === 0)) { // hydration mismatch — remove the server-rendered DOM and start over diff --git a/packages/svelte/src/internal/client/dom/blocks/if.js b/packages/svelte/src/internal/client/dom/blocks/if.js index 925abb9d9d..bf1098c3f4 100644 --- a/packages/svelte/src/internal/client/dom/blocks/if.js +++ b/packages/svelte/src/internal/client/dom/blocks/if.js @@ -4,6 +4,7 @@ import { hydrate_next, hydrate_node, hydrating, + read_hydration_instruction, remove_nodes, set_hydrate_node, set_hydrating @@ -56,7 +57,8 @@ export function if_block(node, fn, [root_index, hydrate_index] = [0, 0]) { if (hydrating && hydrate_index !== -1) { if (root_index === 0) { - const data = /** @type {Comment} */ (anchor).data; + const data = read_hydration_instruction(anchor); + if (data === HYDRATION_START) { hydrate_index = 0; } else if (data === HYDRATION_START_ELSE) { diff --git a/packages/svelte/src/internal/client/dom/hydration.js b/packages/svelte/src/internal/client/dom/hydration.js index 8523ff97d5..ab3256da82 100644 --- a/packages/svelte/src/internal/client/dom/hydration.js +++ b/packages/svelte/src/internal/client/dom/hydration.js @@ -103,3 +103,16 @@ export function remove_nodes() { node = next; } } + +/** + * + * @param {TemplateNode} node + */ +export function read_hydration_instruction(node) { + if (!node || node.nodeType !== 8) { + w.hydration_mismatch(); + throw HYDRATION_ERROR; + } + + return /** @type {Comment} */ (node).data; +} diff --git a/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_config.js b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_config.js new file mode 100644 index 0000000000..56ba73b064 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_config.js @@ -0,0 +1,6 @@ +import { test } from '../../test'; + +// https://github.com/sveltejs/svelte/issues/15819 +export default test({ + expect_hydration_error: true +}); diff --git a/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_expected.html b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_expected.html new file mode 100644 index 0000000000..5179fb04a5 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_expected.html @@ -0,0 +1 @@ +

start

cond

diff --git a/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_override.html b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_override.html new file mode 100644 index 0000000000..2a1c323288 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/_override.html @@ -0,0 +1 @@ +

start

cond

diff --git a/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/main.svelte b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/main.svelte new file mode 100644 index 0000000000..bfb4f2cdb8 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking-2/main.svelte @@ -0,0 +1,5 @@ + + +

start

{#if cond}

cond

{/if} diff --git a/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_config.js b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_config.js new file mode 100644 index 0000000000..56ba73b064 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_config.js @@ -0,0 +1,6 @@ +import { test } from '../../test'; + +// https://github.com/sveltejs/svelte/issues/15819 +export default test({ + expect_hydration_error: true +}); diff --git a/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_expected.html b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_expected.html new file mode 100644 index 0000000000..f6c03b87c1 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_expected.html @@ -0,0 +1 @@ +

start

pre123 mid diff --git a/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_override.html b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_override.html new file mode 100644 index 0000000000..c84efbb00b --- /dev/null +++ b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/_override.html @@ -0,0 +1 @@ +

start

pre123 mid diff --git a/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/main.svelte b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/main.svelte new file mode 100644 index 0000000000..2c9a94686e --- /dev/null +++ b/packages/svelte/tests/hydration/samples/cloudflare-mirage-borking/main.svelte @@ -0,0 +1,9 @@ + + +

start

+pre123 +{#if cond} +mid +{/if} From 60b22ab933c45ce329f4e7919be0b0b73a65ecbb Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Sat, 17 May 2025 10:07:21 +0200 Subject: [PATCH 04/12] fix: falsy attachments types (#15939) --- .changeset/chilled-otters-hear.md | 5 +++++ packages/svelte/elements.d.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/chilled-otters-hear.md diff --git a/.changeset/chilled-otters-hear.md b/.changeset/chilled-otters-hear.md new file mode 100644 index 0000000000..53a9872d96 --- /dev/null +++ b/.changeset/chilled-otters-hear.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: falsy attachments types diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts index c637137365..237e96c699 100644 --- a/packages/svelte/elements.d.ts +++ b/packages/svelte/elements.d.ts @@ -863,8 +863,8 @@ export interface HTMLAttributes extends AriaAttributes, D // allow any data- attribute [key: `data-${string}`]: any; - // allow any attachment - [key: symbol]: Attachment; + // allow any attachment and falsy values (by using false we prevent the usage of booleans values by themselves) + [key: symbol]: Attachment | false | undefined | null; } export type HTMLAttributeAnchorTarget = '_self' | '_blank' | '_parent' | '_top' | (string & {}); From c7e4b8e765ee3e5f184d736ed401e63124065753 Mon Sep 17 00:00:00 2001 From: pengqiseven <134899215+pengqiseven@users.noreply.github.com> Date: Sat, 17 May 2025 16:10:54 +0800 Subject: [PATCH 05/12] chore: remove redundant word in comment (#15942) Signed-off-by: pengqiseven <912170095@qq.com> --- .../svelte/src/compiler/phases/2-analyze/visitors/Attribute.js | 2 +- .../phases/3-transform/client/visitors/RegularElement.js | 2 +- packages/svelte/src/internal/client/dom/elements/class.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js index 3ba81767cc..773aa59744 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js @@ -211,7 +211,7 @@ function get_delegated_event(event_name, handler, context) { if ( binding !== null && - // Bail out if the the binding is a rest param + // Bail out if the binding is a rest param (binding.declaration_kind === 'rest_param' || // Bail out if we reference anything from the EachBlock (for now) that mutates in non-runes mode, (((!context.state.analysis.runes && binding.kind === 'each') || diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index ab981878ad..1d39289f3d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -564,7 +564,7 @@ export function build_style_directives_object(style_directives, context) { /** * Serializes an assignment to an element property by adding relevant statements to either only - * the init or the the init and update arrays, depending on whether or not the value is dynamic. + * the init or the init and update arrays, depending on whether or not the value is dynamic. * Resulting code for static looks something like this: * ```js * element.property = value; diff --git a/packages/svelte/src/internal/client/dom/elements/class.js b/packages/svelte/src/internal/client/dom/elements/class.js index fc081b8956..038ce33f3e 100644 --- a/packages/svelte/src/internal/client/dom/elements/class.js +++ b/packages/svelte/src/internal/client/dom/elements/class.js @@ -24,7 +24,7 @@ export function set_class(dom, is_html, value, hash, prev_classes, next_classes) if (!hydrating || next_class_name !== dom.getAttribute('class')) { // Removing the attribute when the value is only an empty string causes // performance issues vs simply making the className an empty string. So - // we should only remove the class if the the value is nullish + // we should only remove the class if the value is nullish // and there no hash/directives : if (next_class_name == null) { dom.removeAttribute('class'); From c365634ace068698e29c0331399f3a8c03ad5467 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 17 May 2025 05:04:39 -0400 Subject: [PATCH 06/12] Version Packages (#15931) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/chilled-otters-hear.md | 5 ----- .changeset/silly-apples-remain.md | 5 ----- packages/svelte/CHANGELOG.md | 8 ++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) delete mode 100644 .changeset/chilled-otters-hear.md delete mode 100644 .changeset/silly-apples-remain.md diff --git a/.changeset/chilled-otters-hear.md b/.changeset/chilled-otters-hear.md deleted file mode 100644 index 53a9872d96..0000000000 --- a/.changeset/chilled-otters-hear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: falsy attachments types diff --git a/.changeset/silly-apples-remain.md b/.changeset/silly-apples-remain.md deleted file mode 100644 index 10d43db550..0000000000 --- a/.changeset/silly-apples-remain.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: handle more hydration mismatches diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 2b65a1889f..9d5cfe1334 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,13 @@ # svelte +## 5.30.2 + +### Patch Changes + +- fix: falsy attachments types ([#15939](https://github.com/sveltejs/svelte/pull/15939)) + +- fix: handle more hydration mismatches ([#15851](https://github.com/sveltejs/svelte/pull/15851)) + ## 5.30.1 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index b6e8d06c63..4ec70a88b5 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.30.1", + "version": "5.30.2", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index b849318036..a5cc8b7191 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.30.1'; +export const VERSION = '5.30.2'; export const PUBLIC_VERSION = '5'; From b2ce3fa85ed77ba173da9ab27d7bc36f17b7e9c7 Mon Sep 17 00:00:00 2001 From: Mateusz Kadlubowski Date: Sun, 18 May 2025 16:25:11 +0800 Subject: [PATCH 07/12] (fix/ast types) fix: Add missing `AttachTag` in `Tag` union type inside the `AST` namespace from `"svelte/compiler"` (#15946) --- .changeset/funny-carrots-teach.md | 5 +++++ packages/svelte/src/compiler/types/template.d.ts | 8 +++++++- packages/svelte/types/index.d.ts | 8 +++++++- 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 .changeset/funny-carrots-teach.md diff --git a/.changeset/funny-carrots-teach.md b/.changeset/funny-carrots-teach.md new file mode 100644 index 0000000000..53ff135e84 --- /dev/null +++ b/.changeset/funny-carrots-teach.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: Add missing `AttachTag` in `Tag` union type inside the `AST` namespace from `"svelte/compiler"` diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 6dec1f2dbe..b51c9e9a8d 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -547,7 +547,13 @@ export namespace AST { | AST.SvelteWindow | AST.SvelteBoundary; - export type Tag = AST.ExpressionTag | AST.HtmlTag | AST.ConstTag | AST.DebugTag | AST.RenderTag; + export type Tag = + | AST.AttachTag + | AST.ConstTag + | AST.DebugTag + | AST.ExpressionTag + | AST.HtmlTag + | AST.RenderTag; export type TemplateNode = | AST.Root diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index bb958c5108..1fda9a36b8 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1362,7 +1362,13 @@ declare module 'svelte/compiler' { | AST.SvelteWindow | AST.SvelteBoundary; - export type Tag = AST.ExpressionTag | AST.HtmlTag | AST.ConstTag | AST.DebugTag | AST.RenderTag; + export type Tag = + | AST.AttachTag + | AST.ConstTag + | AST.DebugTag + | AST.ExpressionTag + | AST.HtmlTag + | AST.RenderTag; export type TemplateNode = | AST.Root From 42e7e8168d01f2510c73bf23bec1e0a6d9e44a66 Mon Sep 17 00:00:00 2001 From: Christian Decker <50999401+cmdecker95@users.noreply.github.com> Date: Sun, 18 May 2025 04:50:12 -0400 Subject: [PATCH 08/12] chore: fix docs typo in 02-context.md (#15944) --- documentation/docs/06-runtime/02-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/06-runtime/02-context.md b/documentation/docs/06-runtime/02-context.md index 4204bcfe6d..f395de421c 100644 --- a/documentation/docs/06-runtime/02-context.md +++ b/documentation/docs/06-runtime/02-context.md @@ -125,7 +125,7 @@ In many cases this is perfectly fine, but there is a risk: if you mutate the sta ```svelte + + diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/_config.js new file mode 100644 index 0000000000..dd847ce2f2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/_config.js @@ -0,0 +1,13 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ``, + ssrHtml: ``, + + async test({ assert, target }) { + flushSync(); + + assert.htmlEqual(target.innerHTML, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/main.svelte new file mode 100644 index 0000000000..47b8c901eb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-3/main.svelte @@ -0,0 +1,12 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/_config.js new file mode 100644 index 0000000000..f47bee71df --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/main.svelte new file mode 100644 index 0000000000..e2c4f302b3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/main.svelte @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/_config.js new file mode 100644 index 0000000000..4cf1aea213 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/_config.js @@ -0,0 +1,45 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // The component context class instance gets shared between tests, strangely, causing hydration to fail? + mode: ['client', 'server'], + + async test({ assert, target, logs }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1]); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1, 2]); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1, 2, 3]); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(logs, [ + 0, + 'class trigger false', + 'local trigger false', + 1, + 2, + 3, + 4, + 'class trigger true', + 'local trigger true' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/main.svelte new file mode 100644 index 0000000000..03687d01bb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/main.svelte @@ -0,0 +1,37 @@ + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/_config.js new file mode 100644 index 0000000000..02cf36d900 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/_config.js @@ -0,0 +1,20 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ``, + + test({ assert, target }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, ``); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/main.svelte new file mode 100644 index 0000000000..5dbbb10afd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/main.svelte @@ -0,0 +1,12 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/_config.js new file mode 100644 index 0000000000..32cca6c693 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/_config.js @@ -0,0 +1,20 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ``, + + test({ assert, target }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, ``); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/main.svelte new file mode 100644 index 0000000000..d8feb554cd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/main.svelte @@ -0,0 +1,22 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/_config.js new file mode 100644 index 0000000000..f35dc57228 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/_config.js @@ -0,0 +1,20 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ``, + + test({ assert, target }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, ``); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/main.svelte new file mode 100644 index 0000000000..aa8ba1658b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/main.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-1/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-1/errors.json new file mode 100644 index 0000000000..82765c51c1 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-1/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_field_duplicate", + "message": "`count` has already been declared on this class", + "start": { + "line": 5, + "column": 2 + }, + "end": { + "line": 5, + "column": 24 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-1/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-1/input.svelte.js new file mode 100644 index 0000000000..05cd4d9d9d --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-1/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + count = $state(0); + + constructor() { + this.count = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-10/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-10/errors.json new file mode 100644 index 0000000000..c4cb0991d0 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-10/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_field_invalid_assignment", + "message": "Cannot assign to a state field before its declaration", + "start": { + "line": 4, + "column": 3 + }, + "end": { + "line": 4, + "column": 18 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-10/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-10/input.svelte.js new file mode 100644 index 0000000000..e5ad562727 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-10/input.svelte.js @@ -0,0 +1,9 @@ +export class Counter { + constructor() { + if (true) { + this.count = -1; + } + + this.count = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-2/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-2/errors.json new file mode 100644 index 0000000000..82765c51c1 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-2/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_field_duplicate", + "message": "`count` has already been declared on this class", + "start": { + "line": 5, + "column": 2 + }, + "end": { + "line": 5, + "column": 24 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-2/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-2/input.svelte.js new file mode 100644 index 0000000000..e37be4b3e6 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-2/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + constructor() { + this.count = $state(0); + this.count = 1; + this.count = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-3/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-3/errors.json new file mode 100644 index 0000000000..175c41f98c --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-3/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_field_duplicate", + "message": "`count` has already been declared on this class", + "start": { + "line": 5, + "column": 2 + }, + "end": { + "line": 5, + "column": 28 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-3/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-3/input.svelte.js new file mode 100644 index 0000000000..f9196ff3cd --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-3/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + constructor() { + this.count = $state(0); + this.count = 1; + this.count = $state.raw(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-4/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-4/errors.json new file mode 100644 index 0000000000..9f959874c8 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-4/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_invalid_placement", + "message": "`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.", + "start": { + "line": 4, + "column": 16 + }, + "end": { + "line": 4, + "column": 25 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-4/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-4/input.svelte.js new file mode 100644 index 0000000000..bf1aada1b5 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-4/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + constructor() { + if (true) { + this.count = $state(0); + } + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json new file mode 100644 index 0000000000..af2f30dade --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_field_duplicate", + "message": "`count` has already been declared on this class", + "start": { + "line": 5, + "column": 2 + }, + "end": { + "line": 5, + "column": 27 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-5/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-5/input.svelte.js new file mode 100644 index 0000000000..bc3d19a14f --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-5/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + // prettier-ignore + 'count' = $state(0); + constructor() { + this['count'] = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-6/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-6/errors.json new file mode 100644 index 0000000000..ae7a47f31b --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-6/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_field_duplicate", + "message": "`count` has already been declared on this class", + "start": { + "line": 4, + "column": 2 + }, + "end": { + "line": 4, + "column": 27 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-6/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-6/input.svelte.js new file mode 100644 index 0000000000..2ebe52e685 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-6/input.svelte.js @@ -0,0 +1,6 @@ +export class Counter { + count = $state(0); + constructor() { + this['count'] = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-7/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-7/errors.json new file mode 100644 index 0000000000..64e56f8d5c --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-7/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_invalid_placement", + "message": "`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.", + "start": { + "line": 5, + "column": 16 + }, + "end": { + "line": 5, + "column": 25 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-7/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-7/input.svelte.js new file mode 100644 index 0000000000..50c8559837 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-7/input.svelte.js @@ -0,0 +1,7 @@ +const count = 'count'; + +export class Counter { + constructor() { + this[count] = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-8/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-8/errors.json new file mode 100644 index 0000000000..2e0bd10ff8 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-8/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_field_invalid_assignment", + "message": "Cannot assign to a state field before its declaration", + "start": { + "line": 3, + "column": 2 + }, + "end": { + "line": 3, + "column": 17 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-8/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-8/input.svelte.js new file mode 100644 index 0000000000..0a76c6fec9 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-8/input.svelte.js @@ -0,0 +1,6 @@ +export class Counter { + constructor() { + this.count = -1; + this.count = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-9/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-9/errors.json new file mode 100644 index 0000000000..b7dd4c8ed4 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-9/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_field_invalid_assignment", + "message": "Cannot assign to a state field before its declaration", + "start": { + "line": 2, + "column": 1 + }, + "end": { + "line": 2, + "column": 12 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-9/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-9/input.svelte.js new file mode 100644 index 0000000000..a8469e13af --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-9/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + count = -1; + + constructor() { + this.count = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json b/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json index 32594e4268..e1906b181a 100644 --- a/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json +++ b/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json @@ -1,7 +1,7 @@ [ { "code": "state_invalid_placement", - "message": "`$derived(...)` can only be used as a variable declaration initializer or a class field", + "message": "`$derived(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.", "start": { "line": 2, "column": 15 From 21bf947ca8269ada01a2ec7962aeedc841026ac5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 11:56:41 -0400 Subject: [PATCH 10/12] Version Packages (#15947) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/funny-carrots-teach.md | 5 ----- .changeset/mean-squids-scream.md | 5 ----- packages/svelte/CHANGELOG.md | 10 ++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) delete mode 100644 .changeset/funny-carrots-teach.md delete mode 100644 .changeset/mean-squids-scream.md diff --git a/.changeset/funny-carrots-teach.md b/.changeset/funny-carrots-teach.md deleted file mode 100644 index 53ff135e84..0000000000 --- a/.changeset/funny-carrots-teach.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: Add missing `AttachTag` in `Tag` union type inside the `AST` namespace from `"svelte/compiler"` diff --git a/.changeset/mean-squids-scream.md b/.changeset/mean-squids-scream.md deleted file mode 100644 index 2157ea85a6..0000000000 --- a/.changeset/mean-squids-scream.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': minor ---- - -feat: allow state fields to be declared inside class constructors diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 9d5cfe1334..d40ff09317 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,15 @@ # svelte +## 5.31.0 + +### Minor Changes + +- feat: allow state fields to be declared inside class constructors ([#15820](https://github.com/sveltejs/svelte/pull/15820)) + +### Patch Changes + +- fix: Add missing `AttachTag` in `Tag` union type inside the `AST` namespace from `"svelte/compiler"` ([#15946](https://github.com/sveltejs/svelte/pull/15946)) + ## 5.30.2 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 4ec70a88b5..ee35cda2bd 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.30.2", + "version": "5.31.0", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index a5cc8b7191..ed99ef6795 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.30.2'; +export const VERSION = '5.31.0'; export const PUBLIC_VERSION = '5'; From b7b393d50fab48d4f38d0f72c5c177de5c236024 Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Mon, 19 May 2025 18:26:10 +0200 Subject: [PATCH 11/12] chore: watch for messages changes in dev generate script (#15950) * chore: watch for messages changes in dev generate script * no need for this to be async * tweak * guard * only create one timeout --------- Co-authored-by: Rich Harris --- packages/svelte/package.json | 2 +- .../svelte/scripts/process-messages/index.js | 680 +++++++++--------- 2 files changed, 357 insertions(+), 325 deletions(-) diff --git a/packages/svelte/package.json b/packages/svelte/package.json index ee35cda2bd..e0f6afe6fc 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -136,7 +136,7 @@ ], "scripts": { "build": "node scripts/process-messages && rollup -c && pnpm generate:types && node scripts/check-treeshakeability.js", - "dev": "node scripts/process-messages && rollup -cw", + "dev": "node scripts/process-messages -w & rollup -cw", "check": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc", "check:watch": "tsc --watch", "generate:version": "node ./scripts/generate-version.js", diff --git a/packages/svelte/scripts/process-messages/index.js b/packages/svelte/scripts/process-messages/index.js index 80619acfa7..81c59271de 100644 --- a/packages/svelte/scripts/process-messages/index.js +++ b/packages/svelte/scripts/process-messages/index.js @@ -1,409 +1,441 @@ // @ts-check +import process from 'node:process'; import fs from 'node:fs'; import * as acorn from 'acorn'; import { walk } from 'zimmerframe'; import * as esrap from 'esrap'; -/** @type {Record>} */ -const messages = {}; -const seen = new Set(); - const DIR = '../../documentation/docs/98-reference/.generated'; -fs.rmSync(DIR, { force: true, recursive: true }); -fs.mkdirSync(DIR); -for (const category of fs.readdirSync('messages')) { - if (category.startsWith('.')) continue; +const watch = process.argv.includes('-w'); - messages[category] = {}; +function run() { + /** @type {Record>} */ + const messages = {}; + const seen = new Set(); - for (const file of fs.readdirSync(`messages/${category}`)) { - if (!file.endsWith('.md')) continue; + fs.rmSync(DIR, { force: true, recursive: true }); + fs.mkdirSync(DIR); - const markdown = fs - .readFileSync(`messages/${category}/${file}`, 'utf-8') - .replace(/\r\n/g, '\n'); + for (const category of fs.readdirSync('messages')) { + if (category.startsWith('.')) continue; - const sorted = []; + messages[category] = {}; - for (const match of markdown.matchAll(/## ([\w]+)\n\n([^]+?)(?=$|\n\n## )/g)) { - const [_, code, text] = match; + for (const file of fs.readdirSync(`messages/${category}`)) { + if (!file.endsWith('.md')) continue; - if (seen.has(code)) { - throw new Error(`Duplicate message code ${category}/${code}`); - } + const markdown = fs + .readFileSync(`messages/${category}/${file}`, 'utf-8') + .replace(/\r\n/g, '\n'); - sorted.push({ code, _ }); + const sorted = []; - const sections = text.trim().split('\n\n'); - const details = []; + for (const match of markdown.matchAll(/## ([\w]+)\n\n([^]+?)(?=$|\n\n## )/g)) { + const [_, code, text] = match; - while (!sections[sections.length - 1].startsWith('> ')) { - details.unshift(/** @type {string} */ (sections.pop())); - } + if (seen.has(code)) { + throw new Error(`Duplicate message code ${category}/${code}`); + } + + sorted.push({ code, _ }); - if (sections.length === 0) { - throw new Error('No message text'); + const sections = text.trim().split('\n\n'); + const details = []; + + while (!sections[sections.length - 1].startsWith('> ')) { + details.unshift(/** @type {string} */ (sections.pop())); + } + + if (sections.length === 0) { + throw new Error('No message text'); + } + + seen.add(code); + messages[category][code] = { + messages: sections.map((section) => section.replace(/^> /gm, '').replace(/^>\n/gm, '\n')), + details: details.join('\n\n') + }; } - seen.add(code); - messages[category][code] = { - messages: sections.map((section) => section.replace(/^> /gm, '').replace(/^>\n/gm, '\n')), - details: details.join('\n\n') - }; + sorted.sort((a, b) => (a.code < b.code ? -1 : 1)); + + fs.writeFileSync( + `messages/${category}/${file}`, + sorted.map((x) => x._.trim()).join('\n\n') + '\n' + ); } - sorted.sort((a, b) => (a.code < b.code ? -1 : 1)); fs.writeFileSync( - `messages/${category}/${file}`, - sorted.map((x) => x._.trim()).join('\n\n') + '\n' + `${DIR}/${category}.md`, + '\n\n' + + Object.entries(messages[category]) + .map(([code, { messages, details }]) => { + const chunks = [ + `### ${code}`, + ...messages.map((message) => '```\n' + message + '\n```') + ]; + + if (details) { + chunks.push(details); + } + + return chunks.join('\n\n'); + }) + .sort() + .join('\n\n') + + '\n' ); } - fs.writeFileSync( - `${DIR}/${category}.md`, - '\n\n' + - Object.entries(messages[category]) - .map(([code, { messages, details }]) => { - const chunks = [`### ${code}`, ...messages.map((message) => '```\n' + message + '\n```')]; - - if (details) { - chunks.push(details); - } - - return chunks.join('\n\n'); - }) - .sort() - .join('\n\n') + - '\n' - ); -} - -/** - * @param {string} name - * @param {string} dest - */ -function transform(name, dest) { - const source = fs - .readFileSync(new URL(`./templates/${name}.js`, import.meta.url), 'utf-8') - .replace(/\r\n/g, '\n'); - /** - * @type {Array<{ - * type: string; - * value: string; - * start: number; - * end: number - * }>} + * @param {string} name + * @param {string} dest */ - const comments = []; - - let ast = acorn.parse(source, { - ecmaVersion: 'latest', - sourceType: 'module', - onComment: (block, value, start, end) => { - if (block && /\n/.test(value)) { - let a = start; - while (a > 0 && source[a - 1] !== '\n') a -= 1; - - let b = a; - while (/[ \t]/.test(source[b])) b += 1; - - const indentation = source.slice(a, b); - value = value.replace(new RegExp(`^${indentation}`, 'gm'), ''); - } - - comments.push({ type: block ? 'Block' : 'Line', value, start, end }); - } - }); + function transform(name, dest) { + const source = fs + .readFileSync(new URL(`./templates/${name}.js`, import.meta.url), 'utf-8') + .replace(/\r\n/g, '\n'); - ast = walk(ast, null, { - _(node, { next }) { - let comment; + /** + * @type {Array<{ + * type: string; + * value: string; + * start: number; + * end: number + * }>} + */ + const comments = []; + + let ast = acorn.parse(source, { + ecmaVersion: 'latest', + sourceType: 'module', + onComment: (block, value, start, end) => { + if (block && /\n/.test(value)) { + let a = start; + while (a > 0 && source[a - 1] !== '\n') a -= 1; + + let b = a; + while (/[ \t]/.test(source[b])) b += 1; + + const indentation = source.slice(a, b); + value = value.replace(new RegExp(`^${indentation}`, 'gm'), ''); + } - while (comments[0] && comments[0].start < node.start) { - comment = comments.shift(); - // @ts-expect-error - (node.leadingComments ||= []).push(comment); + comments.push({ type: block ? 'Block' : 'Line', value, start, end }); } + }); - next(); - - if (comments[0]) { - const slice = source.slice(node.end, comments[0].start); + ast = walk(ast, null, { + _(node, { next }) { + let comment; - if (/^[,) \t]*$/.test(slice)) { + while (comments[0] && comments[0].start < node.start) { + comment = comments.shift(); // @ts-expect-error - node.trailingComments = [comments.shift()]; + (node.leadingComments ||= []).push(comment); } - } - }, - // @ts-expect-error - Identifier(node, context) { - if (node.name === 'CODES') { - return { - type: 'ArrayExpression', - elements: Object.keys(messages[name]).map((code) => ({ - type: 'Literal', - value: code - })) - }; - } - } - }); - - if (comments.length > 0) { - // @ts-expect-error - (ast.trailingComments ||= []).push(...comments); - } - - const category = messages[name]; - - // find the `export function CODE` node - const index = ast.body.findIndex((node) => { - if ( - node.type === 'ExportNamedDeclaration' && - node.declaration && - node.declaration.type === 'FunctionDeclaration' - ) { - return node.declaration.id.name === 'CODE'; - } - }); - - if (index === -1) throw new Error(`missing export function CODE in ${name}.js`); - const template_node = ast.body[index]; - ast.body.splice(index, 1); + next(); - for (const code in category) { - const { messages } = category[code]; - /** @type {string[]} */ - const vars = []; + if (comments[0]) { + const slice = source.slice(node.end, comments[0].start); - const group = messages.map((text, i) => { - for (const match of text.matchAll(/%(\w+)%/g)) { - const name = match[1]; - if (!vars.includes(name)) { - vars.push(match[1]); + if (/^[,) \t]*$/.test(slice)) { + // @ts-expect-error + node.trailingComments = [comments.shift()]; + } + } + }, + // @ts-expect-error + Identifier(node, context) { + if (node.name === 'CODES') { + return { + type: 'ArrayExpression', + elements: Object.keys(messages[name]).map((code) => ({ + type: 'Literal', + value: code + })) + }; } } - - return { - text, - vars: vars.slice() - }; }); - /** @type {import('estree').Expression} */ - let message = { type: 'Literal', value: '' }; - let prev_vars; + if (comments.length > 0) { + // @ts-expect-error + (ast.trailingComments ||= []).push(...comments); + } - for (let i = 0; i < group.length; i += 1) { - const { text, vars } = group[i]; + const category = messages[name]; - if (vars.length === 0) { - message = { - type: 'Literal', - value: text - }; - prev_vars = vars; - continue; + // find the `export function CODE` node + const index = ast.body.findIndex((node) => { + if ( + node.type === 'ExportNamedDeclaration' && + node.declaration && + node.declaration.type === 'FunctionDeclaration' + ) { + return node.declaration.id.name === 'CODE'; } + }); - const parts = text.split(/(%\w+%)/); + if (index === -1) throw new Error(`missing export function CODE in ${name}.js`); - /** @type {import('estree').Expression[]} */ - const expressions = []; + const template_node = ast.body[index]; + ast.body.splice(index, 1); - /** @type {import('estree').TemplateElement[]} */ - const quasis = []; + for (const code in category) { + const { messages } = category[code]; + /** @type {string[]} */ + const vars = []; - for (let i = 0; i < parts.length; i += 1) { - const part = parts[i]; - if (i % 2 === 0) { - const str = part.replace(/(`|\${)/g, '\\$1'); - quasis.push({ - type: 'TemplateElement', - value: { raw: str, cooked: str }, - tail: i === parts.length - 1 - }); - } else { - expressions.push({ - type: 'Identifier', - name: part.slice(1, -1) - }); + const group = messages.map((text, i) => { + for (const match of text.matchAll(/%(\w+)%/g)) { + const name = match[1]; + if (!vars.includes(name)) { + vars.push(match[1]); + } } - } + + return { + text, + vars: vars.slice() + }; + }); /** @type {import('estree').Expression} */ - const expression = { - type: 'TemplateLiteral', - expressions, - quasis - }; - - if (prev_vars) { - if (vars.length === prev_vars.length) { - throw new Error('Message overloads must have new parameters'); + let message = { type: 'Literal', value: '' }; + let prev_vars; + + for (let i = 0; i < group.length; i += 1) { + const { text, vars } = group[i]; + + if (vars.length === 0) { + message = { + type: 'Literal', + value: text + }; + prev_vars = vars; + continue; } - message = { - type: 'ConditionalExpression', - test: { - type: 'Identifier', - name: vars[prev_vars.length] - }, - consequent: expression, - alternate: message - }; - } else { - message = expression; - } + const parts = text.split(/(%\w+%)/); - prev_vars = vars; - } + /** @type {import('estree').Expression[]} */ + const expressions = []; - const clone = walk(/** @type {import('estree').Node} */ (template_node), null, { - // @ts-expect-error Block is a block comment, which is not recognised - Block(node, context) { - if (!node.value.includes('PARAMETER')) return; - - const value = /** @type {string} */ (node.value) - .split('\n') - .map((line) => { - if (line === ' * MESSAGE') { - return messages[messages.length - 1] - .split('\n') - .map((line) => ` * ${line}`) - .join('\n'); - } + /** @type {import('estree').TemplateElement[]} */ + const quasis = []; - if (line.includes('PARAMETER')) { - return vars - .map((name, i) => { - const optional = i >= group[0].vars.length; + for (let i = 0; i < parts.length; i += 1) { + const part = parts[i]; + if (i % 2 === 0) { + const str = part.replace(/(`|\${)/g, '\\$1'); + quasis.push({ + type: 'TemplateElement', + value: { raw: str, cooked: str }, + tail: i === parts.length - 1 + }); + } else { + expressions.push({ + type: 'Identifier', + name: part.slice(1, -1) + }); + } + } - return optional - ? ` * @param {string | undefined | null} [${name}]` - : ` * @param {string} ${name}`; - }) - .join('\n'); - } + /** @type {import('estree').Expression} */ + const expression = { + type: 'TemplateLiteral', + expressions, + quasis + }; - return line; - }) - .filter((x) => x !== '') - .join('\n'); + if (prev_vars) { + if (vars.length === prev_vars.length) { + throw new Error('Message overloads must have new parameters'); + } - if (value !== node.value) { - return { ...node, value }; + message = { + type: 'ConditionalExpression', + test: { + type: 'Identifier', + name: vars[prev_vars.length] + }, + consequent: expression, + alternate: message + }; + } else { + message = expression; } - }, - FunctionDeclaration(node, context) { - if (node.id.name !== 'CODE') return; - const params = []; + prev_vars = vars; + } - for (const param of node.params) { - if (param.type === 'Identifier' && param.name === 'PARAMETER') { - params.push(...vars.map((name) => ({ type: 'Identifier', name }))); - } else { - params.push(param); + const clone = walk(/** @type {import('estree').Node} */ (template_node), null, { + // @ts-expect-error Block is a block comment, which is not recognised + Block(node, context) { + if (!node.value.includes('PARAMETER')) return; + + const value = /** @type {string} */ (node.value) + .split('\n') + .map((line) => { + if (line === ' * MESSAGE') { + return messages[messages.length - 1] + .split('\n') + .map((line) => ` * ${line}`) + .join('\n'); + } + + if (line.includes('PARAMETER')) { + return vars + .map((name, i) => { + const optional = i >= group[0].vars.length; + + return optional + ? ` * @param {string | undefined | null} [${name}]` + : ` * @param {string} ${name}`; + }) + .join('\n'); + } + + return line; + }) + .filter((x) => x !== '') + .join('\n'); + + if (value !== node.value) { + return { ...node, value }; } - } + }, + FunctionDeclaration(node, context) { + if (node.id.name !== 'CODE') return; + + const params = []; - return /** @type {import('estree').FunctionDeclaration} */ ({ - .../** @type {import('estree').FunctionDeclaration} */ (context.next()), - params, - id: { - ...node.id, - name: code + for (const param of node.params) { + if (param.type === 'Identifier' && param.name === 'PARAMETER') { + params.push(...vars.map((name) => ({ type: 'Identifier', name }))); + } else { + params.push(param); + } } - }); - }, - TemplateLiteral(node, context) { - /** @type {import('estree').TemplateElement} */ - let quasi = { - type: 'TemplateElement', - value: { - ...node.quasis[0].value - }, - tail: node.quasis[0].tail - }; - /** @type {import('estree').TemplateLiteral} */ - let out = { - type: 'TemplateLiteral', - quasis: [quasi], - expressions: [] - }; + return /** @type {import('estree').FunctionDeclaration} */ ({ + .../** @type {import('estree').FunctionDeclaration} */ (context.next()), + params, + id: { + ...node.id, + name: code + } + }); + }, + TemplateLiteral(node, context) { + /** @type {import('estree').TemplateElement} */ + let quasi = { + type: 'TemplateElement', + value: { + ...node.quasis[0].value + }, + tail: node.quasis[0].tail + }; - for (let i = 0; i < node.expressions.length; i += 1) { - const q = structuredClone(node.quasis[i + 1]); - const e = node.expressions[i]; + /** @type {import('estree').TemplateLiteral} */ + let out = { + type: 'TemplateLiteral', + quasis: [quasi], + expressions: [] + }; - if (e.type === 'Literal' && e.value === 'CODE') { - quasi.value.raw += code + q.value.raw; - continue; - } + for (let i = 0; i < node.expressions.length; i += 1) { + const q = structuredClone(node.quasis[i + 1]); + const e = node.expressions[i]; - if (e.type === 'Identifier' && e.name === 'MESSAGE') { - if (message.type === 'Literal') { - const str = /** @type {string} */ (message.value).replace(/(`|\${)/g, '\\$1'); - quasi.value.raw += str + q.value.raw; + if (e.type === 'Literal' && e.value === 'CODE') { + quasi.value.raw += code + q.value.raw; continue; } - if (message.type === 'TemplateLiteral') { - const m = structuredClone(message); - quasi.value.raw += m.quasis[0].value.raw; - out.quasis.push(...m.quasis.slice(1)); - out.expressions.push(...m.expressions); - quasi = m.quasis[m.quasis.length - 1]; - quasi.value.raw += q.value.raw; - continue; + if (e.type === 'Identifier' && e.name === 'MESSAGE') { + if (message.type === 'Literal') { + const str = /** @type {string} */ (message.value).replace(/(`|\${)/g, '\\$1'); + quasi.value.raw += str + q.value.raw; + continue; + } + + if (message.type === 'TemplateLiteral') { + const m = structuredClone(message); + quasi.value.raw += m.quasis[0].value.raw; + out.quasis.push(...m.quasis.slice(1)); + out.expressions.push(...m.expressions); + quasi = m.quasis[m.quasis.length - 1]; + quasi.value.raw += q.value.raw; + continue; + } } + + out.quasis.push((quasi = q)); + out.expressions.push(/** @type {import('estree').Expression} */ (context.visit(e))); } - out.quasis.push((quasi = q)); - out.expressions.push(/** @type {import('estree').Expression} */ (context.visit(e))); + return out; + }, + Literal(node) { + if (node.value === 'CODE') { + return { + type: 'Literal', + value: code + }; + } + }, + Identifier(node) { + if (node.name !== 'MESSAGE') return; + return message; } + }); - return out; - }, - Literal(node) { - if (node.value === 'CODE') { - return { - type: 'Literal', - value: code - }; - } - }, - Identifier(node) { - if (node.name !== 'MESSAGE') return; - return message; - } - }); + // @ts-expect-error + ast.body.push(clone); + } + + const module = esrap.print(ast); - // @ts-expect-error - ast.body.push(clone); + fs.writeFileSync( + dest, + `/* This file is generated by scripts/process-messages/index.js. Do not edit! */\n\n` + + module.code, + 'utf-8' + ); } - const module = esrap.print(ast); + transform('compile-errors', 'src/compiler/errors.js'); + transform('compile-warnings', 'src/compiler/warnings.js'); - fs.writeFileSync( - dest, - `/* This file is generated by scripts/process-messages/index.js. Do not edit! */\n\n` + - module.code, - 'utf-8' - ); + transform('client-warnings', 'src/internal/client/warnings.js'); + transform('client-errors', 'src/internal/client/errors.js'); + transform('server-errors', 'src/internal/server/errors.js'); + transform('shared-errors', 'src/internal/shared/errors.js'); + transform('shared-warnings', 'src/internal/shared/warnings.js'); } -transform('compile-errors', 'src/compiler/errors.js'); -transform('compile-warnings', 'src/compiler/warnings.js'); +if (watch) { + let running = false; + let timeout; + + fs.watch('messages', { recursive: true }, (type, file) => { + if (running) { + timeout ??= setTimeout(() => { + running = false; + timeout = null; + }); + } else { + running = true; + + // eslint-disable-next-line no-console + console.log('Regenerating messages...'); + run(); + } + }); +} -transform('client-warnings', 'src/internal/client/warnings.js'); -transform('client-errors', 'src/internal/client/errors.js'); -transform('server-errors', 'src/internal/server/errors.js'); -transform('shared-errors', 'src/internal/shared/errors.js'); -transform('shared-warnings', 'src/internal/shared/warnings.js'); +run(); From a2ddca1aa10362c7e6962e04ec60e55ec758218d Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Mon, 19 May 2025 20:25:07 +0200 Subject: [PATCH 12/12] fix: avoid auto-parenthesis for special-keywords-only `MediaQuery` (#15937) * fix: avoid auto-parenthesis for special keywords only `MediaQuery` * chore: update changeset * chore: i has english knowledge * fix: fix media queries with commas --- .changeset/sweet-islands-sell.md | 5 +++++ packages/svelte/src/reactivity/media-query.js | 20 ++++++++++++++++++- .../samples/media-query/_config.js | 5 +++++ .../samples/media-query/main.svelte | 5 +++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 .changeset/sweet-islands-sell.md diff --git a/.changeset/sweet-islands-sell.md b/.changeset/sweet-islands-sell.md new file mode 100644 index 0000000000..b4fbbf182c --- /dev/null +++ b/.changeset/sweet-islands-sell.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid auto-parenthesis for special-keywords-only `MediaQuery` diff --git a/packages/svelte/src/reactivity/media-query.js b/packages/svelte/src/reactivity/media-query.js index 22310df18d..d286709719 100644 --- a/packages/svelte/src/reactivity/media-query.js +++ b/packages/svelte/src/reactivity/media-query.js @@ -3,6 +3,19 @@ import { ReactiveValue } from './reactive-value.js'; const parenthesis_regex = /\(.+\)/; +// these keywords are valid media queries but they need to be without parenthesis +// +// eg: new MediaQuery('screen') +// +// however because of the auto-parenthesis logic in the constructor since there's no parentehesis +// in the media query they'll be surrounded by parenthesis +// +// however we can check if the media query is only composed of these keywords +// and skip the auto-parenthesis +// +// https://github.com/sveltejs/svelte/issues/15930 +const non_parenthesized_keywords = new Set(['all', 'print', 'screen', 'and', 'or', 'not', 'only']); + /** * Creates a media query and provides a `current` property that reflects whether or not it matches. * @@ -27,7 +40,12 @@ export class MediaQuery extends ReactiveValue { * @param {boolean} [fallback] Fallback value for the server */ constructor(query, fallback) { - let final_query = parenthesis_regex.test(query) ? query : `(${query})`; + let final_query = + parenthesis_regex.test(query) || + // we need to use `some` here because technically this `window.matchMedia('random,screen')` still returns true + query.split(/[\s,]+/).some((keyword) => non_parenthesized_keywords.has(keyword.trim())) + ? query + : `(${query})`; const q = window.matchMedia(final_query); super( () => q.matches, diff --git a/packages/svelte/tests/runtime-runes/samples/media-query/_config.js b/packages/svelte/tests/runtime-runes/samples/media-query/_config.js index f7a4ca05f5..d8b202955a 100644 --- a/packages/svelte/tests/runtime-runes/samples/media-query/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/media-query/_config.js @@ -5,5 +5,10 @@ export default test({ async test({ window }) { expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 599px), (min-width: 900px)'); expect(window.matchMedia).toHaveBeenCalledWith('(min-width: 900px)'); + expect(window.matchMedia).toHaveBeenCalledWith('screen'); + expect(window.matchMedia).toHaveBeenCalledWith('not print'); + expect(window.matchMedia).toHaveBeenCalledWith('screen,print'); + expect(window.matchMedia).toHaveBeenCalledWith('screen, print'); + expect(window.matchMedia).toHaveBeenCalledWith('screen, random'); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/media-query/main.svelte b/packages/svelte/tests/runtime-runes/samples/media-query/main.svelte index 446a9213dd..fe07ed8ab0 100644 --- a/packages/svelte/tests/runtime-runes/samples/media-query/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/media-query/main.svelte @@ -3,4 +3,9 @@ const mq = new MediaQuery("(max-width: 599px), (min-width: 900px)"); const mq2 = new MediaQuery("min-width: 900px"); + const mq3 = new MediaQuery("screen"); + const mq4 = new MediaQuery("not print"); + const mq5 = new MediaQuery("screen,print"); + const mq6 = new MediaQuery("screen, print"); + const mq7 = new MediaQuery("screen, random");