From d99d87251905abd57404f46f16df4d299ddf9d3b Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Mon, 16 Jun 2025 23:54:12 -0600 Subject: [PATCH 01/15] fix: Make docs example not infinitely recurse (#16183) --- documentation/docs/07-misc/02-testing.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/documentation/docs/07-misc/02-testing.md b/documentation/docs/07-misc/02-testing.md index 64bf49d77a..db99b70770 100644 --- a/documentation/docs/07-misc/02-testing.md +++ b/documentation/docs/07-misc/02-testing.md @@ -129,12 +129,12 @@ test('Effect', () => { // effects normally run after a microtask, // use flushSync to execute all pending effects synchronously flushSync(); - expect(log.value).toEqual([0]); + expect(log).toEqual([0]); count = 1; flushSync(); - expect(log.value).toEqual([0, 1]); + expect(log).toEqual([0, 1]); }); cleanup(); @@ -148,17 +148,13 @@ test('Effect', () => { */ export function logger(getValue) { /** @type {any[]} */ - let log = $state([]); + let log = []; $effect(() => { log.push(getValue()); }); - return { - get value() { - return log; - } - }; + return log; } ``` From b224c3fb4b019880a20f4286e60f078e1dcbacc0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Jun 2025 12:43:52 -0400 Subject: [PATCH 02/15] fix: coarse reactivity, alternative approach (#16100) Make sure we track statically visible dependencies and untrack indirect dependencies Fixes #14351 --------- Co-authored-by: 7nik Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .changeset/popular-dancers-switch.md | 5 ++ .../src/compiler/phases/1-parse/state/tag.js | 29 +++++++-- .../visitors/AssignmentExpression.js | 4 ++ .../phases/2-analyze/visitors/AwaitBlock.js | 5 +- .../phases/2-analyze/visitors/ConstTag.js | 5 +- .../phases/2-analyze/visitors/HtmlTag.js | 2 +- .../phases/2-analyze/visitors/Identifier.js | 1 + .../phases/2-analyze/visitors/IfBlock.js | 8 ++- .../phases/2-analyze/visitors/KeyBlock.js | 3 +- .../2-analyze/visitors/MemberExpression.js | 5 +- .../phases/2-analyze/visitors/RenderTag.js | 2 +- .../2-analyze/visitors/UpdateExpression.js | 4 ++ .../2-analyze/visitors/shared/function.js | 10 ++++ .../3-transform/client/visitors/AttachTag.js | 13 +--- .../3-transform/client/visitors/AwaitBlock.js | 5 +- .../3-transform/client/visitors/ConstTag.js | 24 ++++---- .../3-transform/client/visitors/EachBlock.js | 21 ++++--- .../3-transform/client/visitors/HtmlTag.js | 4 +- .../3-transform/client/visitors/IfBlock.js | 5 +- .../3-transform/client/visitors/KeyBlock.js | 3 +- .../client/visitors/RegularElement.js | 2 +- .../3-transform/client/visitors/RenderTag.js | 12 +++- .../client/visitors/TitleElement.js | 3 +- .../client/visitors/shared/element.js | 6 +- .../client/visitors/shared/fragment.js | 28 ++++----- .../client/visitors/shared/utils.js | 59 ++++++++++++++++--- packages/svelte/src/compiler/phases/nodes.js | 5 +- packages/svelte/src/compiler/types/index.d.ts | 8 ++- .../svelte/src/compiler/types/template.d.ts | 21 +++++++ .../block-expression-assign/_config.js | 12 ++++ .../block-expression-assign/main.svelte | 45 ++++++++++++++ .../block-expression-fn-call/_config.js | 12 ++++ .../block-expression-fn-call/main.svelte | 36 +++++++++++ .../block-expression-member-access/_config.js | 12 ++++ .../main.svelte | 46 +++++++++++++++ .../Item.svelte | 4 +- .../main.svelte | 4 +- .../purity/_expected/client/index.svelte.js | 6 +- 38 files changed, 392 insertions(+), 87 deletions(-) create mode 100644 .changeset/popular-dancers-switch.md create mode 100644 packages/svelte/tests/runtime-legacy/samples/block-expression-assign/_config.js create mode 100644 packages/svelte/tests/runtime-legacy/samples/block-expression-assign/main.svelte create mode 100644 packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/_config.js create mode 100644 packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/main.svelte create mode 100644 packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/_config.js create mode 100644 packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/main.svelte diff --git a/.changeset/popular-dancers-switch.md b/.changeset/popular-dancers-switch.md new file mode 100644 index 0000000000..b8c26c210e --- /dev/null +++ b/.changeset/popular-dancers-switch.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: use compiler-driven reactivity in legacy mode template expressions diff --git a/packages/svelte/src/compiler/phases/1-parse/state/tag.js b/packages/svelte/src/compiler/phases/1-parse/state/tag.js index 4153463c83..5d77d6a8f4 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -63,7 +63,10 @@ function open(parser) { end: -1, test: read_expression(parser), consequent: create_fragment(), - alternate: null + alternate: null, + metadata: { + expression: create_expression_metadata() + } }); parser.allow_whitespace(); @@ -244,7 +247,10 @@ function open(parser) { error: null, pending: null, then: null, - catch: null + catch: null, + metadata: { + expression: create_expression_metadata() + } }); if (parser.eat('then')) { @@ -326,7 +332,10 @@ function open(parser) { start, end: -1, expression, - fragment: create_fragment() + fragment: create_fragment(), + metadata: { + expression: create_expression_metadata() + } }); parser.stack.push(block); @@ -461,7 +470,10 @@ function next(parser) { elseif: true, test: expression, consequent: create_fragment(), - alternate: null + alternate: null, + metadata: { + expression: create_expression_metadata() + } }); parser.stack.push(child); @@ -624,7 +636,10 @@ function special(parser) { type: 'HtmlTag', start, end: parser.index, - expression + expression, + metadata: { + expression: create_expression_metadata() + } }); return; @@ -699,6 +714,9 @@ function special(parser) { declarations: [{ type: 'VariableDeclarator', id, init, start: id.start, end: init.end }], start: start + 2, // start at const, not at @const end: parser.index - 1 + }, + metadata: { + expression: create_expression_metadata() } }); } @@ -725,6 +743,7 @@ function special(parser) { end: parser.index, expression: /** @type {AST.RenderTag['expression']} */ (expression), metadata: { + expression: create_expression_metadata(), dynamic: false, arguments: [], path: [], diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AssignmentExpression.js index 673c79f2df..39358f72fc 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AssignmentExpression.js @@ -23,5 +23,9 @@ export function AssignmentExpression(node, context) { } } + if (context.state.expression) { + context.state.expression.has_assignment = true; + } + context.next(); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitBlock.js index a71f325154..5aa04ba3b9 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitBlock.js @@ -41,5 +41,8 @@ export function AwaitBlock(node, context) { mark_subtree_dynamic(context.path); - context.next(); + context.visit(node.expression, { ...context.state, expression: node.metadata.expression }); + if (node.pending) context.visit(node.pending); + if (node.then) context.visit(node.then); + if (node.catch) context.visit(node.catch); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js index f723f8447c..d5f5f7b2e0 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js @@ -32,5 +32,8 @@ export function ConstTag(node, context) { e.const_tag_invalid_placement(node); } - context.next(); + const declaration = node.declaration.declarations[0]; + + context.visit(declaration.id); + context.visit(declaration.init, { ...context.state, expression: node.metadata.expression }); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js index c89b11ad36..7b0e501760 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js @@ -15,5 +15,5 @@ export function HtmlTag(node, context) { // unfortunately this is necessary in order to fix invalid HTML mark_subtree_dynamic(context.path); - context.next(); + context.next({ ...context.state, expression: node.metadata.expression }); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js index abf70769c0..cced326f9b 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js @@ -90,6 +90,7 @@ export function Identifier(node, context) { if (binding) { if (context.state.expression) { context.state.expression.dependencies.add(binding); + context.state.expression.references.add(binding); context.state.expression.has_state ||= binding.kind !== 'static' && !binding.is_function() && diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/IfBlock.js index a65771bcfc..dcdae3587f 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/IfBlock.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/IfBlock.js @@ -17,5 +17,11 @@ export function IfBlock(node, context) { mark_subtree_dynamic(context.path); - context.next(); + context.visit(node.test, { + ...context.state, + expression: node.metadata.expression + }); + + context.visit(node.consequent); + if (node.alternate) context.visit(node.alternate); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/KeyBlock.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/KeyBlock.js index 88bb6a98e7..09e604ea66 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/KeyBlock.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/KeyBlock.js @@ -16,5 +16,6 @@ export function KeyBlock(node, context) { mark_subtree_dynamic(context.path); - context.next(); + context.visit(node.expression, { ...context.state, expression: node.metadata.expression }); + context.visit(node.fragment); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/MemberExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/MemberExpression.js index 245a164c71..0a3b386198 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/MemberExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/MemberExpression.js @@ -15,8 +15,9 @@ export function MemberExpression(node, context) { } } - if (context.state.expression && !is_pure(node, context)) { - context.state.expression.has_state = true; + if (context.state.expression) { + context.state.expression.has_member_expression = true; + context.state.expression.has_state ||= !is_pure(node, context); } if (!is_safe_identifier(node, context.state.scope)) { diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/RenderTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/RenderTag.js index a8c9d408bd..1230ef6b04 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/RenderTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/RenderTag.js @@ -54,7 +54,7 @@ export function RenderTag(node, context) { mark_subtree_dynamic(context.path); - context.visit(callee); + context.visit(callee, { ...context.state, expression: node.metadata.expression }); for (const arg of expression.arguments) { const metadata = create_expression_metadata(); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/UpdateExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/UpdateExpression.js index 13f4b9019e..ed48e026ac 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/UpdateExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/UpdateExpression.js @@ -21,5 +21,9 @@ export function UpdateExpression(node, context) { } } + if (context.state.expression) { + context.state.expression.has_assignment = true; + } + context.next(); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js index c892efd421..1776167850 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js @@ -13,6 +13,16 @@ export function visit_function(node, context) { scope: context.state.scope }; + if (context.state.expression) { + for (const [name] of context.state.scope.references) { + const binding = context.state.scope.get(name); + + if (binding && binding.scope.function_depth < context.state.scope.function_depth) { + context.state.expression.references.add(binding); + } + } + } + context.next({ ...context.state, function_depth: context.state.function_depth + 1, diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AttachTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AttachTag.js index 062604cacc..8b1570c7dc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AttachTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AttachTag.js @@ -1,21 +1,14 @@ -/** @import { Expression } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ import * as b from '../../../../utils/builders.js'; +import { build_expression } from './shared/utils.js'; /** * @param {AST.AttachTag} node * @param {ComponentContext} context */ export function AttachTag(node, context) { - context.state.init.push( - b.stmt( - b.call( - '$.attach', - context.state.node, - b.thunk(/** @type {Expression} */ (context.visit(node.expression))) - ) - ) - ); + const expression = build_expression(context, node.expression, node.metadata.expression); + context.state.init.push(b.stmt(b.call('$.attach', context.state.node, b.thunk(expression)))); context.next(); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js index 30e370327f..7873cf3ddb 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js @@ -1,10 +1,11 @@ -/** @import { BlockStatement, Expression, Pattern, Statement } from 'estree' */ +/** @import { BlockStatement, Pattern, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentClientTransformState, ComponentContext } from '../types' */ import { extract_identifiers } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; import { create_derived } from '../utils.js'; import { get_value } from './shared/declarations.js'; +import { build_expression } from './shared/utils.js'; /** * @param {AST.AwaitBlock} node @@ -14,7 +15,7 @@ export function AwaitBlock(node, context) { context.state.template.push_comment(); // Visit {#await } first to ensure that scopes are in the correct order - const expression = b.thunk(/** @type {Expression} */ (context.visit(node.expression))); + const expression = b.thunk(build_expression(context, node.expression, node.metadata.expression)); let then_block; let catch_block; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js index 2f3c0b3d0e..c1be1e3220 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ConstTag.js @@ -1,4 +1,4 @@ -/** @import { Expression, Pattern } from 'estree' */ +/** @import { Pattern } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ import { dev } from '../../../../state.js'; @@ -6,6 +6,7 @@ import { extract_identifiers } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; import { create_derived } from '../utils.js'; import { get_value } from './shared/declarations.js'; +import { build_expression } from './shared/utils.js'; /** * @param {AST.ConstTag} node @@ -15,15 +16,8 @@ export function ConstTag(node, context) { const declaration = node.declaration.declarations[0]; // TODO we can almost certainly share some code with $derived(...) if (declaration.id.type === 'Identifier') { - context.state.init.push( - b.const( - declaration.id, - create_derived( - context.state, - b.thunk(/** @type {Expression} */ (context.visit(declaration.init))) - ) - ) - ); + const init = build_expression(context, declaration.init, node.metadata.expression); + context.state.init.push(b.const(declaration.id, create_derived(context.state, b.thunk(init)))); context.state.transform[declaration.id.name] = { read: get_value }; @@ -48,13 +42,15 @@ export function ConstTag(node, context) { // TODO optimise the simple `{ x } = y` case — we can just return `y` // instead of destructuring it only to return a new object + const init = build_expression( + { ...context, state: child_state }, + declaration.init, + node.metadata.expression + ); const fn = b.arrow( [], b.block([ - b.const( - /** @type {Pattern} */ (context.visit(declaration.id, child_state)), - /** @type {Expression} */ (context.visit(declaration.init, child_state)) - ), + b.const(/** @type {Pattern} */ (context.visit(declaration.id, child_state)), init), b.return(b.object(identifiers.map((node) => b.prop('init', node, node)))) ]) ); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js index 6c651464f1..201c4b278f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js @@ -1,4 +1,4 @@ -/** @import { BlockStatement, Expression, Identifier, Pattern, SequenceExpression, Statement } from 'estree' */ +/** @import { BlockStatement, Expression, Identifier, Pattern, Statement } from 'estree' */ /** @import { AST, Binding } from '#compiler' */ /** @import { ComponentContext } from '../types' */ /** @import { Scope } from '../../../scope' */ @@ -12,8 +12,8 @@ import { import { dev } from '../../../../state.js'; import { extract_paths, object } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; -import { build_getter } from '../utils.js'; import { get_value } from './shared/declarations.js'; +import { build_expression } from './shared/utils.js'; /** * @param {AST.EachBlock} node @@ -24,11 +24,18 @@ export function EachBlock(node, context) { // expression should be evaluated in the parent scope, not the scope // created by the each block itself - const collection = /** @type {Expression} */ ( - context.visit(node.expression, { - ...context.state, - scope: /** @type {Scope} */ (context.state.scope.parent) - }) + const parent_scope_state = { + ...context.state, + scope: /** @type {Scope} */ (context.state.scope.parent) + }; + + const collection = build_expression( + { + ...context, + state: parent_scope_state + }, + node.expression, + node.metadata.expression ); if (!each_node_meta.is_controlled) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js index 405b400b42..fb59967996 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js @@ -1,8 +1,8 @@ -/** @import { Expression } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ import { is_ignored } from '../../../../state.js'; import * as b from '#compiler/builders'; +import { build_expression } from './shared/utils.js'; /** * @param {AST.HtmlTag} node @@ -11,7 +11,7 @@ import * as b from '#compiler/builders'; export function HtmlTag(node, context) { context.state.template.push_comment(); - const expression = /** @type {Expression} */ (context.visit(node.expression)); + const expression = build_expression(context, node.expression, node.metadata.expression); const is_svg = context.state.metadata.namespace === 'svg'; const is_mathml = context.state.metadata.namespace === 'mathml'; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js index 3702a47bc9..deab040e50 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js @@ -2,6 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ import * as b from '#compiler/builders'; +import { build_expression } from './shared/utils.js'; /** * @param {AST.IfBlock} node @@ -31,6 +32,8 @@ export function IfBlock(node, context) { statements.push(b.var(b.id(alternate_id), b.arrow(alternate_args, alternate))); } + const test = build_expression(context, node.test, node.metadata.expression); + /** @type {Expression[]} */ const args = [ node.elseif ? b.id('$$anchor') : context.state.node, @@ -38,7 +41,7 @@ export function IfBlock(node, context) { [b.id('$$render')], b.block([ b.if( - /** @type {Expression} */ (context.visit(node.test)), + test, b.stmt(b.call(b.id('$$render'), b.id(consequent_id))), alternate_id ? b.stmt(b.call(b.id('$$render'), b.id(alternate_id), b.false)) : undefined ) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js index 5e63f7e872..2f17479c7e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js @@ -2,6 +2,7 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ import * as b from '#compiler/builders'; +import { build_expression } from './shared/utils.js'; /** * @param {AST.KeyBlock} node @@ -10,7 +11,7 @@ import * as b from '#compiler/builders'; export function KeyBlock(node, context) { context.state.template.push_comment(); - const key = /** @type {Expression} */ (context.visit(node.expression)); + const key = build_expression(context, node.expression, node.metadata.expression); const body = /** @type {Expression} */ (context.visit(node.fragment)); context.state.init.push( 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 e823792993..1aefff0db0 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 @@ -331,7 +331,7 @@ export function RegularElement(node, context) { trimmed.some((node) => node.type === 'ExpressionTag'); if (use_text_content) { - const { value } = build_template_chunk(trimmed, context.visit, child_state); + const { value } = build_template_chunk(trimmed, context, child_state); const empty_string = value.type === 'Literal' && value.value === ''; if (!empty_string) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js index fec7b5762a..c3615d9d50 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js @@ -3,6 +3,7 @@ /** @import { ComponentContext } from '../types' */ import { unwrap_optional } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; +import { build_expression } from './shared/utils.js'; /** * @param {AST.RenderTag} node @@ -19,7 +20,10 @@ export function RenderTag(node, context) { /** @type {Expression[]} */ let args = []; for (let i = 0; i < raw_args.length; i++) { - let thunk = b.thunk(/** @type {Expression} */ (context.visit(raw_args[i]))); + let thunk = b.thunk( + build_expression(context, /** @type {Expression} */ (raw_args[i]), node.metadata.arguments[i]) + ); + const { has_call } = node.metadata.arguments[i]; if (has_call) { @@ -31,7 +35,11 @@ export function RenderTag(node, context) { } } - let snippet_function = /** @type {Expression} */ (context.visit(callee)); + let snippet_function = build_expression( + context, + /** @type {Expression} */ (callee), + node.metadata.expression + ); if (node.metadata.dynamic) { // If we have a chain expression then ensure a nullish snippet function gets turned into an empty one diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/TitleElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/TitleElement.js index 7bfdaf1850..e6f4202a01 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/TitleElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/TitleElement.js @@ -10,8 +10,7 @@ import { build_template_chunk } from './shared/utils.js'; export function TitleElement(node, context) { const { has_state, value } = build_template_chunk( /** @type {any} */ (node.fragment.nodes), - context.visit, - context.state + context ); const statement = b.stmt(b.assignment('=', b.id('$.document.title'), value)); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js index 67de25b770..10f942b7d4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js @@ -7,7 +7,7 @@ import { is_ignored } from '../../../../../state.js'; import { is_event_attribute } from '../../../../../utils/ast.js'; import * as b from '#compiler/builders'; import { build_class_directives_object, build_style_directives_object } from '../RegularElement.js'; -import { build_template_chunk, get_expression_id } from './utils.js'; +import { build_expression, build_template_chunk, get_expression_id } from './utils.js'; /** * @param {Array} attributes @@ -121,7 +121,7 @@ export function build_attribute_value(value, context, memoize = (value) => value return { value: b.literal(chunk.data), has_state: false }; } - let expression = /** @type {Expression} */ (context.visit(chunk.expression)); + let expression = build_expression(context, chunk.expression, chunk.metadata.expression); return { value: memoize(expression, chunk.metadata.expression), @@ -129,7 +129,7 @@ export function build_attribute_value(value, context, memoize = (value) => value }; } - return build_template_chunk(value, context.visit, context.state, memoize); + return build_template_chunk(value, context, context.state, memoize); } /** diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js index 7af2c2d4aa..62d07014ee 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js @@ -16,8 +16,8 @@ import { build_template_chunk } from './utils.js'; * @param {boolean} is_element * @param {ComponentContext} context */ -export function process_children(nodes, initial, is_element, { visit, state }) { - const within_bound_contenteditable = state.metadata.bound_contenteditable; +export function process_children(nodes, initial, is_element, context) { + const within_bound_contenteditable = context.state.metadata.bound_contenteditable; let prev = initial; let skipped = 0; @@ -48,8 +48,8 @@ export function process_children(nodes, initial, is_element, { visit, state }) { let id = expression; if (id.type !== 'Identifier') { - id = b.id(state.scope.generate(name)); - state.init.push(b.var(id, expression)); + id = b.id(context.state.scope.generate(name)); + context.state.init.push(b.var(id, expression)); } prev = () => id; @@ -64,13 +64,13 @@ export function process_children(nodes, initial, is_element, { visit, state }) { function flush_sequence(sequence) { if (sequence.every((node) => node.type === 'Text')) { skipped += 1; - state.template.push_text(sequence); + context.state.template.push_text(sequence); return; } - state.template.push_text([{ type: 'Text', data: ' ', raw: ' ', start: -1, end: -1 }]); + context.state.template.push_text([{ type: 'Text', data: ' ', raw: ' ', start: -1, end: -1 }]); - const { has_state, value } = build_template_chunk(sequence, visit, state); + const { has_state, value } = build_template_chunk(sequence, context); // if this is a standalone `{expression}`, make sure we handle the case where // no text node was created because the expression was empty during SSR @@ -80,9 +80,9 @@ export function process_children(nodes, initial, is_element, { visit, state }) { const update = b.stmt(b.call('$.set_text', id, value)); if (has_state && !within_bound_contenteditable) { - state.update.push(update); + context.state.update.push(update); } else { - state.init.push(b.stmt(b.assignment('=', b.member(id, 'nodeValue'), value))); + context.state.init.push(b.stmt(b.assignment('=', b.member(id, 'nodeValue'), value))); } } @@ -95,18 +95,18 @@ export function process_children(nodes, initial, is_element, { visit, state }) { sequence = []; } - let child_state = state; + let child_state = context.state; - if (is_static_element(node, state)) { + if (is_static_element(node, context.state)) { skipped += 1; } else if (node.type === 'EachBlock' && nodes.length === 1 && is_element) { node.metadata.is_controlled = true; } else { const id = flush_node(false, node.type === 'RegularElement' ? node.name : 'node'); - child_state = { ...state, node: id }; + child_state = { ...context.state, node: id }; } - visit(node, child_state); + context.visit(node, child_state); } } @@ -118,7 +118,7 @@ export function process_children(nodes, initial, is_element, { visit, state }) { // traverse to the last (n - 1) one when hydrating if (skipped > 1) { skipped -= 1; - state.init.push(b.stmt(b.call('$.next', skipped !== 1 && b.literal(skipped)))); + context.state.init.push(b.stmt(b.call('$.next', skipped !== 1 && b.literal(skipped)))); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index ebf88e878f..15982899c9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -1,6 +1,6 @@ -/** @import { AssignmentExpression, Expression, ExpressionStatement, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression } from 'estree' */ +/** @import { AssignmentExpression, Expression, ExpressionStatement, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression, Pattern } from 'estree' */ /** @import { AST, ExpressionMetadata } from '#compiler' */ -/** @import { ComponentClientTransformState, Context } from '../../types' */ +/** @import { ComponentClientTransformState, ComponentContext, Context } from '../../types' */ import { walk } from 'zimmerframe'; import { object } from '../../../../../utils/ast.js'; import * as b from '#compiler/builders'; @@ -8,7 +8,7 @@ import { sanitize_template_string } from '../../../../../utils/sanitize_template import { regex_is_valid_identifier } from '../../../../patterns.js'; import is_reference from 'is-reference'; import { dev, is_ignored, locator } from '../../../../../state.js'; -import { create_derived } from '../../utils.js'; +import { build_getter, create_derived } from '../../utils.js'; /** * @param {ComponentClientTransformState} state @@ -31,15 +31,15 @@ export function get_expression_id(expressions, value) { /** * @param {Array} values - * @param {(node: AST.SvelteNode, state: any) => any} visit + * @param {ComponentContext} context * @param {ComponentClientTransformState} state * @param {(value: Expression, metadata: ExpressionMetadata) => Expression} memoize * @returns {{ value: Expression, has_state: boolean }} */ export function build_template_chunk( values, - visit, - state, + context, + state = context.state, memoize = (value, metadata) => metadata.has_call ? get_expression_id(state.expressions, value) : value ) { @@ -66,7 +66,7 @@ export function build_template_chunk( state.scope.get('undefined') ) { let value = memoize( - /** @type {Expression} */ (visit(node.expression, state)), + build_expression(context, node.expression, node.metadata.expression, state), node.metadata.expression ); @@ -360,3 +360,48 @@ export function validate_mutation(node, context, expression) { loc && b.literal(loc.column) ); } + +/** + * + * @param {ComponentContext} context + * @param {Expression} expression + * @param {ExpressionMetadata} metadata + */ +export function build_expression(context, expression, metadata, state = context.state) { + const value = /** @type {Expression} */ (context.visit(expression, state)); + + if (context.state.analysis.runes) { + return value; + } + + if (!metadata.has_call && !metadata.has_member_expression && !metadata.has_assignment) { + return value; + } + + // Legacy reactivity is coarse-grained, looking at the statically visible dependencies. Replicate that here + const sequence = b.sequence([]); + + for (const binding of metadata.references) { + if (binding.kind === 'normal' && binding.declaration_kind !== 'import') { + continue; + } + + var getter = build_getter({ ...binding.node }, state); + + if ( + binding.kind === 'bindable_prop' || + binding.kind === 'template' || + binding.declaration_kind === 'import' || + binding.node.name === '$$props' || + binding.node.name === '$$restProps' + ) { + getter = b.call('$.deep_read_state', getter); + } + + sequence.expressions.push(getter); + } + + sequence.expressions.push(b.call('$.untrack', b.thunk(value))); + + return sequence; +} diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index 2043747ed0..c35f194b75 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -62,8 +62,11 @@ export function create_attribute(name, start, end, value) { export function create_expression_metadata() { return { dependencies: new Set(), + references: new Set(), has_state: false, - has_call: false + has_call: false, + has_member_expression: false, + has_assignment: false }; } diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index fdd6024726..558ee558f7 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -279,12 +279,18 @@ export type DeclarationKind = | 'synthetic'; export interface ExpressionMetadata { - /** All the bindings that are referenced inside this expression */ + /** All the bindings that are referenced eagerly (not inside functions) in this expression */ dependencies: Set; + /** All the bindings that are referenced inside this expression, including inside functions */ + references: Set; /** True if the expression references state directly, or _might_ (via member/call expressions) */ has_state: boolean; /** True if the expression involves a call expression (often, it will need to be wrapped in a derived) */ has_call: boolean; + /** True if the expression includes a member expression */ + has_member_expression: boolean; + /** True if the expression includes an assignment or an update */ + has_assignment: boolean; } export interface StateField { diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index cefc7fa7a2..2a7ec7b5c6 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -135,6 +135,10 @@ export namespace AST { export interface HtmlTag extends BaseNode { type: 'HtmlTag'; expression: Expression; + /** @internal */ + metadata: { + expression: ExpressionMetadata; + }; } /** An HTML comment */ @@ -151,6 +155,10 @@ export namespace AST { declaration: VariableDeclaration & { declarations: [VariableDeclarator & { id: Pattern; init: Expression }]; }; + /** @internal */ + metadata: { + expression: ExpressionMetadata; + }; } /** A `{@debug ...}` tag */ @@ -165,6 +173,7 @@ export namespace AST { expression: SimpleCallExpression | (ChainExpression & { expression: SimpleCallExpression }); /** @internal */ metadata: { + expression: ExpressionMetadata; dynamic: boolean; arguments: ExpressionMetadata[]; path: SvelteNode[]; @@ -447,6 +456,10 @@ export namespace AST { test: Expression; consequent: Fragment; alternate: Fragment | null; + /** @internal */ + metadata: { + expression: ExpressionMetadata; + }; } /** An `{#await ...}` block */ @@ -461,12 +474,20 @@ export namespace AST { pending: Fragment | null; then: Fragment | null; catch: Fragment | null; + /** @internal */ + metadata: { + expression: ExpressionMetadata; + }; } export interface KeyBlock extends BaseNode { type: 'KeyBlock'; expression: Expression; fragment: Fragment; + /** @internal */ + metadata: { + expression: ExpressionMetadata; + }; } export interface SnippetBlock extends BaseNode { diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/_config.js b/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/_config.js new file mode 100644 index 0000000000..15adef2c9b --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + const button = target.querySelector('button'); + + assert.htmlEqual(target.innerHTML, `
[0,0,0,0,0,0,0,0,0]`); + flushSync(() => button?.click()); + assert.htmlEqual(target.innerHTML, `
[0,0,0,0,0,0,0,0,0]`); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/main.svelte b/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/main.svelte new file mode 100644 index 0000000000..67190669ed --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-assign/main.svelte @@ -0,0 +1,45 @@ + + +{#if a = 0}{/if} + +{#each [b = 0] as x}{x,''}{/each} + +{#key c = 0}{/key} + +{#await d = 0}{/await} + +{#snippet snip()}{/snippet} + +{@render (e = 0, snip)()} + +{@html f = 0, ''} + +
+ +{#key 1} + {@const x = (h = 0)} + {x, ''} +{/key} + +{#if 1} + {@const x = (i = 0)} + {x, ''} +{/if} + + +[{a},{b},{c},{d},{e},{f},{g},{h},{i}] + + diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/_config.js b/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/_config.js new file mode 100644 index 0000000000..523dcd625d --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + const button = target.querySelector('button'); + + assert.htmlEqual(target.innerHTML, `
12 - 12`); + flushSync(() => button?.click()); + assert.htmlEqual(target.innerHTML, `
13 - 12`); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/main.svelte b/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/main.svelte new file mode 100644 index 0000000000..37838f091f --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-fn-call/main.svelte @@ -0,0 +1,36 @@ + + +{#if fn(false)}{:else if fn(true)}{/if} + +{#each fn([]) as x}{x, ''}{/each} + +{#key fn(1)}{/key} + +{#await fn(Promise.resolve())}{/await} + +{#snippet snip()}{/snippet} + +{@render fn(snip)()} + +{@html fn('')} + +
{})}>
+ +{#key 1} + {@const x = fn('')} + {x} +{/key} + + +{count1} - {count2} + + diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/_config.js b/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/_config.js new file mode 100644 index 0000000000..0e1a5a8150 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + const button = target.querySelector('button'); + + assert.htmlEqual(target.innerHTML, `
10 - 10`); + flushSync(() => button?.click()); + assert.htmlEqual(target.innerHTML, `
11 - 10`); + } +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/main.svelte b/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/main.svelte new file mode 100644 index 0000000000..4041be4f6f --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/block-expression-member-access/main.svelte @@ -0,0 +1,46 @@ + + +{#if obj.false}{:else if obj.true}{/if} + +{#each obj.array as x}{x, ''}{/each} + +{#key obj.string}{/key} + +{#await obj.promise}{/await} + +{#snippet snip()}{/snippet} + +{@render obj.snippet()} + +{@html obj.string} + +
+ +{#key 1} + {@const x = obj.string} + {x} +{/key} + + +{count1} - {count2} + + diff --git a/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/Item.svelte b/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/Item.svelte index b2e6cd046c..4127e857d5 100644 --- a/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/Item.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/Item.svelte @@ -5,7 +5,7 @@ export let index; export let n; - function logRender () { + function logRender (n) { order.push(`${index}: render ${n}`); return index; } @@ -24,5 +24,5 @@
  • - {logRender()} + {logRender(n)}
  • diff --git a/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/main.svelte b/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/main.svelte index b05b1476fd..51dee3bc0c 100644 --- a/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/main.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/main.svelte @@ -5,7 +5,7 @@ export let n = 0; - function logRender () { + function logRender (n) { order.push(`parent: render ${n}`); return 'parent'; } @@ -23,7 +23,7 @@ }) -{logRender()} +{logRender(n)}
      {#each [1,2,3] as index} diff --git a/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js index a351851875..da6fdf44d8 100644 --- a/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/purity/_expected/client/index.svelte.js @@ -8,11 +8,13 @@ export default function Purity($$anchor) { var fragment = root(); var p = $.first_child(fragment); - p.textContent = '0'; + p.textContent = ( + $.untrack(() => Math.max(0, Math.min(0, 100))) + ); var p_1 = $.sibling(p, 2); - p_1.textContent = location.href; + p_1.textContent = ($.untrack(() => location.href)); var node = $.sibling(p_1, 2); From 0524d16215a1d7dd1079ea1d63080a05cd84d948 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 13:07:28 -0400 Subject: [PATCH 03/15] Version Packages (#16181) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/beige-plants-laugh.md | 5 ----- .changeset/popular-dancers-switch.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/beige-plants-laugh.md delete mode 100644 .changeset/popular-dancers-switch.md diff --git a/.changeset/beige-plants-laugh.md b/.changeset/beige-plants-laugh.md deleted file mode 100644 index d0c771736a..0000000000 --- a/.changeset/beige-plants-laugh.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: don't set state withing `with_parent` in proxy diff --git a/.changeset/popular-dancers-switch.md b/.changeset/popular-dancers-switch.md deleted file mode 100644 index b8c26c210e..0000000000 --- a/.changeset/popular-dancers-switch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: use compiler-driven reactivity in legacy mode template expressions diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 020942f5fd..1814d59fb3 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,13 @@ # svelte +## 5.34.4 + +### Patch Changes + +- fix: don't set state withing `with_parent` in proxy ([#16176](https://github.com/sveltejs/svelte/pull/16176)) + +- fix: use compiler-driven reactivity in legacy mode template expressions ([#16100](https://github.com/sveltejs/svelte/pull/16100)) + ## 5.34.3 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index d2fbdb32f7..89c627dc0e 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.34.3", + "version": "5.34.4", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 01888eaa78..0fa8576105 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.34.3'; +export const VERSION = '5.34.4'; export const PUBLIC_VERSION = '5'; From 061ab31d23b2bf882b490024deac9b433d74e048 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 17 Jun 2025 23:07:21 +0200 Subject: [PATCH 04/15] fix: ensure sources within nested effects still register correctly (#16193) * fix: ensure sources within nested effects still register correctly When an effect creates another effect and a proxy property is read that wasn't before, the proxy will have logic where it creates the new signal in its original reaction context. But it did not have logic to correctly handle the `reaction_sources` array which prevents effects/deriveds rerunning when state created inside themselves is read and then changed inside them: Since `reaction_sources` wasn't take the reaction context into account, false positives could occur where nested effects would not register the sources as dependencies. Fixes #15870 * oops forgot to push this --- .changeset/rich-emus-study.md | 5 +++ packages/svelte/src/internal/client/proxy.js | 1 + .../src/internal/client/reactivity/sources.js | 2 +- .../svelte/src/internal/client/runtime.js | 12 +++---- packages/svelte/tests/signals/test.ts | 35 +++++++++++++++++++ 5 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 .changeset/rich-emus-study.md diff --git a/.changeset/rich-emus-study.md b/.changeset/rich-emus-study.md new file mode 100644 index 0000000000..dcadafacb1 --- /dev/null +++ b/.changeset/rich-emus-study.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure sources within nested effects still register correctly diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index 60eba6aa87..d9063aee34 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -44,6 +44,7 @@ export function proxy(value) { var reaction = active_reaction; /** + * Executes the proxy in the context of the reaction it was originally created in, if any * @template T * @param {() => T} fn */ diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 56f4138252..4959bc1abc 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -138,7 +138,7 @@ export function set(source, value, should_proxy = false) { !untracking && is_runes() && (active_reaction.f & (DERIVED | BLOCK_EFFECT)) !== 0 && - !reaction_sources?.includes(source) + !(reaction_sources?.[1].includes(source) && reaction_sources[0] === active_reaction) ) { e.state_unsafe_mutation(); } diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 9544060959..56bc157f33 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -84,8 +84,8 @@ export function set_active_effect(effect) { /** * When sources are created within a reaction, reading and writing - * them should not cause a re-run - * @type {null | Source[]} + * them within that reaction should not cause a re-run + * @type {null | [active_reaction: Reaction, sources: Source[]]} */ export let reaction_sources = null; @@ -93,9 +93,9 @@ export let reaction_sources = null; export function push_reaction_value(value) { if (active_reaction !== null && active_reaction.f & EFFECT_IS_UPDATING) { if (reaction_sources === null) { - reaction_sources = [value]; + reaction_sources = [active_reaction, [value]]; } else { - reaction_sources.push(value); + reaction_sources[1].push(value); } } } @@ -234,7 +234,7 @@ function schedule_possible_effect_self_invalidation(signal, effect, root = true) for (var i = 0; i < reactions.length; i++) { var reaction = reactions[i]; - if (reaction_sources?.includes(signal)) continue; + if (reaction_sources?.[1].includes(signal) && reaction_sources[0] === active_reaction) continue; if ((reaction.f & DERIVED) !== 0) { schedule_possible_effect_self_invalidation(/** @type {Derived} */ (reaction), effect, false); @@ -724,7 +724,7 @@ export function get(signal) { // Register the dependency on the current reaction signal. if (active_reaction !== null && !untracking) { - if (!reaction_sources?.includes(signal)) { + if (!reaction_sources?.[1].includes(signal) || reaction_sources[0] !== active_reaction) { var deps = active_reaction.deps; if (signal.rv < read_version) { signal.rv = read_version; diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts index 8421ae4a7c..78d7919e0f 100644 --- a/packages/svelte/tests/signals/test.ts +++ b/packages/svelte/tests/signals/test.ts @@ -1021,6 +1021,41 @@ describe('signals', () => { }; }); + test('nested effects depend on state of upper effects', () => { + const logs: number[] = []; + + user_effect(() => { + const raw = state(0); + const proxied = proxy({ current: 0 }); + + // We need those separate, else one working and rerunning the effect + // could mask the other one not rerunning + user_effect(() => { + logs.push($.get(raw)); + }); + + user_effect(() => { + logs.push(proxied.current); + }); + + // Important so that the updating effect is not running + // together with the reading effects + flushSync(); + + user_effect(() => { + $.untrack(() => { + set(raw, $.get(raw) + 1); + proxied.current += 1; + }); + }); + }); + + return () => { + flushSync(); + assert.deepEqual(logs, [0, 0, 1, 1]); + }; + }); + test('proxy version state does not trigger self-dependency guard', () => { return () => { const s = proxy({ a: { b: 1 } }); From 838f881550e98d5fd6492f4a0d083d264dd9d376 Mon Sep 17 00:00:00 2001 From: 7nik Date: Wed, 18 Jun 2025 00:12:40 +0300 Subject: [PATCH 05/15] fix: avoid shadowing a variable in dynamic components (#16185) * fix: avoid shadowing a variable in dynamic components * split component name and intermediate name --------- Co-authored-by: 7nik --- .changeset/short-mails-know.md | 5 +++++ .../3-transform/client/visitors/Component.js | 8 +------ .../client/visitors/shared/component.js | 21 +++++++++++++------ .../samples/dynamic-component-nested/A.svelte | 5 +++++ .../dynamic-component-nested/_config.js | 8 +++++++ .../dynamic-component-nested/main.svelte | 9 ++++++++ 6 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 .changeset/short-mails-know.md create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/A.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/main.svelte diff --git a/.changeset/short-mails-know.md b/.changeset/short-mails-know.md new file mode 100644 index 0000000000..0b8b62d575 --- /dev/null +++ b/.changeset/short-mails-know.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: avoid shadowing a variable in dynamic components diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Component.js index d58a24b455..9b86557536 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Component.js @@ -8,12 +8,6 @@ import { build_component } from './shared/component.js'; * @param {ComponentContext} context */ export function Component(node, context) { - const component = build_component( - node, - // if it's not dynamic we will just use the node name, if it is dynamic we will use the node name - // only if it's a valid identifier, otherwise we will use a default name - !node.metadata.dynamic || regex_is_valid_identifier.test(node.name) ? node.name : '$$component', - context - ); + const component = build_component(node, node.name, context); context.state.init.push(component); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index 19ed9cdeb8..cb6e4de478 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -52,6 +52,15 @@ export function build_component(node, component_name, context) { /** @type {ExpressionStatement[]} */ const binding_initializers = []; + const is_component_dynamic = + node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic); + + // The variable name used for the component inside $.component() + const intermediate_name = + node.type === 'Component' && node.metadata.dynamic + ? context.state.scope.generate(node.name) + : '$$component'; + /** * If this component has a slot property, it is a named slot within another component. In this case * the slot scope applies to the component itself, too, and not just its children. @@ -199,7 +208,7 @@ export function build_component(node, component_name, context) { b.call( '$$ownership_validator.binding', b.literal(binding.node.name), - b.id(component_name), + b.id(is_component_dynamic ? intermediate_name : component_name), b.thunk(expression) ) ) @@ -414,8 +423,8 @@ export function build_component(node, component_name, context) { // TODO We can remove this ternary once we remove legacy mode, since in runes mode dynamic components // will be handled separately through the `$.component` function, and then the component name will // always be referenced through just the identifier here. - node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic) - ? component_name + is_component_dynamic + ? intermediate_name : /** @type {Expression} */ (context.visit(b.member_id(component_name))), node_id, props_expression @@ -432,7 +441,7 @@ export function build_component(node, component_name, context) { const statements = [...snippet_declarations]; - if (node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic)) { + if (is_component_dynamic) { const prev = fn; fn = (node_id) => { @@ -441,11 +450,11 @@ export function build_component(node, component_name, context) { node_id, b.thunk( /** @type {Expression} */ ( - context.visit(node.type === 'Component' ? b.member_id(node.name) : node.expression) + context.visit(node.type === 'Component' ? b.member_id(component_name) : node.expression) ) ), b.arrow( - [b.id('$$anchor'), b.id(component_name)], + [b.id('$$anchor'), b.id(intermediate_name)], b.block([...binding_initializers, b.stmt(prev(b.id('$$anchor')))]) ) ); diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/A.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/A.svelte new file mode 100644 index 0000000000..d37c929273 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/A.svelte @@ -0,0 +1,5 @@ + + +{@render children()} diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/_config.js b/packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/_config.js new file mode 100644 index 0000000000..cd1fa2b1b9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; +import { flushSync } from 'svelte'; + +export default test({ + async test({ assert, target }) { + assert.htmlEqual(target.innerHTML, 'test'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/main.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/main.svelte new file mode 100644 index 0000000000..d0646b319b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-nested/main.svelte @@ -0,0 +1,9 @@ + + + + test + From a5f500e7c03320aa236122f1c0d06f7d886110df Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Tue, 17 Jun 2025 23:13:27 +0200 Subject: [PATCH 06/15] fix: keep spread non-delegated event handlers up to date (#16180) * fix: keep spread non-delegated event handlers up to date #15961 introduced a regression where non-delegated events that were spread and updated were not getting updated. This fixes that by ensuring prev is actually updated to the most recent value * fix --- .changeset/odd-readers-laugh.md | 5 ++++ .../client/dom/elements/attributes.js | 8 +++--- .../event-attribute-spread-update/_config.js | 18 +++++++++++++ .../event-attribute-spread-update/main.svelte | 27 +++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 .changeset/odd-readers-laugh.md create mode 100644 packages/svelte/tests/runtime-runes/samples/event-attribute-spread-update/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/event-attribute-spread-update/main.svelte diff --git a/.changeset/odd-readers-laugh.md b/.changeset/odd-readers-laugh.md new file mode 100644 index 0000000000..e1401fdf10 --- /dev/null +++ b/.changeset/odd-readers-laugh.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: keep spread non-delegated event handlers up to date diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index fcce0b444f..a663450b4a 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -483,8 +483,8 @@ export function attribute_effect( block(() => { var next = fn(...deriveds.map(get)); - - set_attributes(element, prev, next, css_hash, skip_warning); + /** @type {Record} */ + var current = set_attributes(element, prev, next, css_hash, skip_warning); if (inited && is_select && 'value' in next) { select_option(/** @type {HTMLSelectElement} */ (element), next.value, false); @@ -501,9 +501,11 @@ export function attribute_effect( if (effects[symbol]) destroy_effect(effects[symbol]); effects[symbol] = branch(() => attach(element, () => n)); } + + current[symbol] = n; } - prev = next; + prev = current; }); if (is_select) { diff --git a/packages/svelte/tests/runtime-runes/samples/event-attribute-spread-update/_config.js b/packages/svelte/tests/runtime-runes/samples/event-attribute-spread-update/_config.js new file mode 100644 index 0000000000..af03eed4c9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/event-attribute-spread-update/_config.js @@ -0,0 +1,18 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + const [change, increment] = target.querySelectorAll('button'); + + increment.click(); + flushSync(); + assert.htmlEqual(target.innerHTML, ''); + + change.click(); + flushSync(); + increment.click(); + flushSync(); + assert.htmlEqual(target.innerHTML, ''); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/event-attribute-spread-update/main.svelte b/packages/svelte/tests/runtime-runes/samples/event-attribute-spread-update/main.svelte new file mode 100644 index 0000000000..32d4b242cc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/event-attribute-spread-update/main.svelte @@ -0,0 +1,27 @@ + + + + From 579d0e663664d30c206ed955bbfbc396063dbad4 Mon Sep 17 00:00:00 2001 From: floriskn <48930050+floriskn@users.noreply.github.com> Date: Tue, 17 Jun 2025 23:16:07 +0200 Subject: [PATCH 07/15] fix: ensure undefined attributes are removed during hydration (#16178) * fix: ensure undefined attributes are removed during hydration Attributes that are `undefined` on the client should be removed during hydration, even if their value hasn't changed compared to `prev_value`. * Create plenty-wasps-sleep.md * added test * Update .changeset/plenty-wasps-sleep.md --------- Co-authored-by: Rich Harris --- .changeset/plenty-wasps-sleep.md | 5 +++++ .../src/internal/client/dom/elements/attributes.js | 6 +++++- .../samples/removes-undefined-attributes/_config.js | 11 +++++++++++ .../removes-undefined-attributes/_expected.html | 1 + .../samples/removes-undefined-attributes/main.svelte | 9 +++++++++ 5 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .changeset/plenty-wasps-sleep.md create mode 100644 packages/svelte/tests/hydration/samples/removes-undefined-attributes/_config.js create mode 100644 packages/svelte/tests/hydration/samples/removes-undefined-attributes/_expected.html create mode 100644 packages/svelte/tests/hydration/samples/removes-undefined-attributes/main.svelte diff --git a/.changeset/plenty-wasps-sleep.md b/.changeset/plenty-wasps-sleep.md new file mode 100644 index 0000000000..aba96bc419 --- /dev/null +++ b/.changeset/plenty-wasps-sleep.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: remove undefined attributes on hydration diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index a663450b4a..2d3d6a921d 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -345,7 +345,11 @@ export function set_attributes(element, prev, next, css_hash, skip_warning = fal } var prev_value = current[key]; - if (value === prev_value) continue; + + // Skip if value is unchanged, unless it's `undefined` and the element still has the attribute + if (value === prev_value && !(value === undefined && element.hasAttribute(key))) { + continue; + } current[key] = value; diff --git a/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_config.js b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_config.js new file mode 100644 index 0000000000..bc74f23aac --- /dev/null +++ b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_config.js @@ -0,0 +1,11 @@ +import { test } from '../../test'; + +export default test({ + server_props: { + browser: false + }, + + props: { + browser: true + } +}); diff --git a/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_expected.html b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_expected.html new file mode 100644 index 0000000000..cc789c8f51 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/_expected.html @@ -0,0 +1 @@ +
      diff --git a/packages/svelte/tests/hydration/samples/removes-undefined-attributes/main.svelte b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/main.svelte new file mode 100644 index 0000000000..1a587eeeeb --- /dev/null +++ b/packages/svelte/tests/hydration/samples/removes-undefined-attributes/main.svelte @@ -0,0 +1,9 @@ + + +
      From de90fc8dceae076f7fc0e5adf7fa72e0b6360479 Mon Sep 17 00:00:00 2001 From: Ray Thurn Void <53383860+raythurnvoid@users.noreply.github.com> Date: Tue, 17 Jun 2025 22:42:39 +0100 Subject: [PATCH 08/15] Test array sort in effect to prevent regressions of this use-case (#16175) --- .../samples/array-sort-in-effect/_config.js | 52 +++++++++++++++++++ .../samples/array-sort-in-effect/main.svelte | 21 ++++++++ 2 files changed, 73 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/array-sort-in-effect/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/array-sort-in-effect/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/array-sort-in-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/array-sort-in-effect/_config.js new file mode 100644 index 0000000000..cbac36fee8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/array-sort-in-effect/_config.js @@ -0,0 +1,52 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + /** + * Ensure that sorting an array inside an $effect works correctly + * and re-runs when the array changes (e.g., when items are added). + */ + test({ assert, target }) { + const button = target.querySelector('button'); + + // initial render — array should be sorted + assert.htmlEqual( + target.innerHTML, + ` + +

      0

      +

      50

      +

      100

      + ` + ); + + // add first item (20); effect should re-run and sort the array + flushSync(() => button?.click()); + + assert.htmlEqual( + target.innerHTML, + ` + +

      0

      +

      20

      +

      50

      +

      100

      + ` + ); + + // add second item (80); effect should re-run and sort the array + flushSync(() => button?.click()); + + assert.htmlEqual( + target.innerHTML, + ` + +

      0

      +

      20

      +

      50

      +

      80

      +

      100

      + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/array-sort-in-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/array-sort-in-effect/main.svelte new file mode 100644 index 0000000000..c529f67cf4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/array-sort-in-effect/main.svelte @@ -0,0 +1,21 @@ + + + +{#each arr as x} +

      {x}

      +{/each} From 402434e6cde008d5c7b1b8e95f72850139ad3d07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 17:46:58 -0400 Subject: [PATCH 09/15] Version Packages (#16195) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/odd-readers-laugh.md | 5 ----- .changeset/plenty-wasps-sleep.md | 5 ----- .changeset/rich-emus-study.md | 5 ----- .changeset/short-mails-know.md | 5 ----- packages/svelte/CHANGELOG.md | 12 ++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 7 files changed, 14 insertions(+), 22 deletions(-) delete mode 100644 .changeset/odd-readers-laugh.md delete mode 100644 .changeset/plenty-wasps-sleep.md delete mode 100644 .changeset/rich-emus-study.md delete mode 100644 .changeset/short-mails-know.md diff --git a/.changeset/odd-readers-laugh.md b/.changeset/odd-readers-laugh.md deleted file mode 100644 index e1401fdf10..0000000000 --- a/.changeset/odd-readers-laugh.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: keep spread non-delegated event handlers up to date diff --git a/.changeset/plenty-wasps-sleep.md b/.changeset/plenty-wasps-sleep.md deleted file mode 100644 index aba96bc419..0000000000 --- a/.changeset/plenty-wasps-sleep.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: remove undefined attributes on hydration diff --git a/.changeset/rich-emus-study.md b/.changeset/rich-emus-study.md deleted file mode 100644 index dcadafacb1..0000000000 --- a/.changeset/rich-emus-study.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure sources within nested effects still register correctly diff --git a/.changeset/short-mails-know.md b/.changeset/short-mails-know.md deleted file mode 100644 index 0b8b62d575..0000000000 --- a/.changeset/short-mails-know.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: avoid shadowing a variable in dynamic components diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 1814d59fb3..618a25c638 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,17 @@ # svelte +## 5.34.5 + +### Patch Changes + +- fix: keep spread non-delegated event handlers up to date ([#16180](https://github.com/sveltejs/svelte/pull/16180)) + +- fix: remove undefined attributes on hydration ([#16178](https://github.com/sveltejs/svelte/pull/16178)) + +- fix: ensure sources within nested effects still register correctly ([#16193](https://github.com/sveltejs/svelte/pull/16193)) + +- fix: avoid shadowing a variable in dynamic components ([#16185](https://github.com/sveltejs/svelte/pull/16185)) + ## 5.34.4 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 89c627dc0e..4ace75f3c4 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.34.4", + "version": "5.34.5", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 0fa8576105..bffca48eec 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.34.4'; +export const VERSION = '5.34.5'; export const PUBLIC_VERSION = '5'; From 2b20a2d16b4536b37c3725754d5acda29797f427 Mon Sep 17 00:00:00 2001 From: 7nik Date: Wed, 18 Jun 2025 00:53:35 +0300 Subject: [PATCH 10/15] fix: match class and style directives against attribute selector (#16179) Co-authored-by: 7nik --- .changeset/fair-laws-appear.md | 5 +++++ .../phases/2-analyze/css/css-prune.js | 17 ++++++++++------ .../expected.css | 2 ++ .../input.svelte | 7 +++++++ .../css/samples/class-directive/_config.js | 20 +++++++++++++++++++ .../css/samples/class-directive/expected.css | 3 +++ .../css/samples/class-directive/input.svelte | 7 +++++++ 7 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 .changeset/fair-laws-appear.md create mode 100644 packages/svelte/tests/css/samples/attribute-selector-matches-derictive/expected.css create mode 100644 packages/svelte/tests/css/samples/attribute-selector-matches-derictive/input.svelte create mode 100644 packages/svelte/tests/css/samples/class-directive/_config.js create mode 100644 packages/svelte/tests/css/samples/class-directive/expected.css create mode 100644 packages/svelte/tests/css/samples/class-directive/input.svelte diff --git a/.changeset/fair-laws-appear.md b/.changeset/fair-laws-appear.md new file mode 100644 index 0000000000..9a1149ff27 --- /dev/null +++ b/.changeset/fair-laws-appear.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: match class and style directives against attribute selector diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js index fbe6ca1cd3..b9a5688a87 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js @@ -532,12 +532,7 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element, } case 'ClassSelector': { - if ( - !attribute_matches(element, 'class', name, '~=', false) && - !element.attributes.some( - (attribute) => attribute.type === 'ClassDirective' && attribute.name === name - ) - ) { + if (!attribute_matches(element, 'class', name, '~=', false)) { return false; } @@ -633,6 +628,16 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv if (attribute.type === 'SpreadAttribute') return true; if (attribute.type === 'BindDirective' && attribute.name === name) return true; + // match attributes against the corresponding directive but bail out on exact matching + if (attribute.type === 'StyleDirective' && name.toLowerCase() === 'style') return true; + if (attribute.type === 'ClassDirective' && name.toLowerCase() === 'class') { + if (operator == '~=') { + if (attribute.name === expected_value) return true; + } else { + return true; + } + } + if (attribute.type !== 'Attribute') continue; if (attribute.name.toLowerCase() !== name.toLowerCase()) continue; diff --git a/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/expected.css b/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/expected.css new file mode 100644 index 0000000000..4b5e4bfd09 --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/expected.css @@ -0,0 +1,2 @@ + span[class].svelte-xyz { color: green } + div[style].svelte-xyz { color: green } \ No newline at end of file diff --git a/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/input.svelte b/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/input.svelte new file mode 100644 index 0000000000..2f9ab202ca --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-matches-derictive/input.svelte @@ -0,0 +1,7 @@ + +
      + + diff --git a/packages/svelte/tests/css/samples/class-directive/_config.js b/packages/svelte/tests/css/samples/class-directive/_config.js new file mode 100644 index 0000000000..28e9fbc815 --- /dev/null +++ b/packages/svelte/tests/css/samples/class-directive/_config.js @@ -0,0 +1,20 @@ +import { test } from '../../test'; + +export default test({ + warnings: [ + { + code: 'css_unused_selector', + message: 'Unused CSS selector ".third"\nhttps://svelte.dev/e/css_unused_selector', + start: { + line: 6, + column: 2, + character: 115 + }, + end: { + line: 6, + column: 8, + character: 121 + } + } + ] +}); diff --git a/packages/svelte/tests/css/samples/class-directive/expected.css b/packages/svelte/tests/css/samples/class-directive/expected.css new file mode 100644 index 0000000000..1d7d3d4dee --- /dev/null +++ b/packages/svelte/tests/css/samples/class-directive/expected.css @@ -0,0 +1,3 @@ + .first.svelte-xyz { color: green } + .second.svelte-xyz { color: green } + /* (unused) .third { color: red }*/ diff --git a/packages/svelte/tests/css/samples/class-directive/input.svelte b/packages/svelte/tests/css/samples/class-directive/input.svelte new file mode 100644 index 0000000000..cf00335964 --- /dev/null +++ b/packages/svelte/tests/css/samples/class-directive/input.svelte @@ -0,0 +1,7 @@ +
      + + \ No newline at end of file From d941cf5d3be93243f7c13333bafbf362bbfd1d54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 11:24:09 -0400 Subject: [PATCH 11/15] Version Packages (#16196) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/fair-laws-appear.md | 5 ----- packages/svelte/CHANGELOG.md | 6 ++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/fair-laws-appear.md diff --git a/.changeset/fair-laws-appear.md b/.changeset/fair-laws-appear.md deleted file mode 100644 index 9a1149ff27..0000000000 --- a/.changeset/fair-laws-appear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: match class and style directives against attribute selector diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 618a25c638..7dfcfb9039 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,11 @@ # svelte +## 5.34.6 + +### Patch Changes + +- fix: match class and style directives against attribute selector ([#16179](https://github.com/sveltejs/svelte/pull/16179)) + ## 5.34.5 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 4ace75f3c4..0971b05228 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.34.5", + "version": "5.34.6", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index bffca48eec..98a526d8bd 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.34.5'; +export const VERSION = '5.34.6'; export const PUBLIC_VERSION = '5'; From 659198a95c0f547061600487c45131b00368bd2a Mon Sep 17 00:00:00 2001 From: 7nik Date: Thu, 19 Jun 2025 13:19:51 +0300 Subject: [PATCH 12/15] fix: address css class matching regression (#16204) * fix: address css class matching regression * address feedback --------- Co-authored-by: 7nik --- .changeset/big-carpets-stare.md | 5 +++++ .../phases/2-analyze/css/css-prune.js | 21 +++++++++++++------ .../css/samples/class-directive/_config.js | 10 ++++----- .../css/samples/class-directive/expected.css | 6 ++++-- .../css/samples/class-directive/input.svelte | 8 ++++--- 5 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 .changeset/big-carpets-stare.md diff --git a/.changeset/big-carpets-stare.md b/.changeset/big-carpets-stare.md new file mode 100644 index 0000000000..eabe29bb88 --- /dev/null +++ b/.changeset/big-carpets-stare.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: address css class matching regression diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js index b9a5688a87..79e8fbb02c 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js @@ -628,10 +628,11 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv if (attribute.type === 'SpreadAttribute') return true; if (attribute.type === 'BindDirective' && attribute.name === name) return true; + const name_lower = name.toLowerCase(); // match attributes against the corresponding directive but bail out on exact matching - if (attribute.type === 'StyleDirective' && name.toLowerCase() === 'style') return true; - if (attribute.type === 'ClassDirective' && name.toLowerCase() === 'class') { - if (operator == '~=') { + if (attribute.type === 'StyleDirective' && name_lower === 'style') return true; + if (attribute.type === 'ClassDirective' && name_lower === 'class') { + if (operator === '~=') { if (attribute.name === expected_value) return true; } else { return true; @@ -639,13 +640,21 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv } if (attribute.type !== 'Attribute') continue; - if (attribute.name.toLowerCase() !== name.toLowerCase()) continue; + if (attribute.name.toLowerCase() !== name_lower) continue; if (attribute.value === true) return operator === null; if (expected_value === null) return true; if (is_text_attribute(attribute)) { - return test_attribute(operator, expected_value, case_insensitive, attribute.value[0].data); + const matches = test_attribute( + operator, + expected_value, + case_insensitive, + attribute.value[0].data + ); + // continue if we still may match against a class/style directive + if (!matches && (name_lower === 'class' || name_lower === 'style')) continue; + return matches; } const chunks = get_attribute_chunks(attribute.value); @@ -654,7 +663,7 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv /** @type {string[]} */ let prev_values = []; for (const chunk of chunks) { - const current_possible_values = get_possible_values(chunk, name === 'class'); + const current_possible_values = get_possible_values(chunk, name_lower === 'class'); // impossible to find out all combinations if (!current_possible_values) return true; diff --git a/packages/svelte/tests/css/samples/class-directive/_config.js b/packages/svelte/tests/css/samples/class-directive/_config.js index 28e9fbc815..abeb8b6329 100644 --- a/packages/svelte/tests/css/samples/class-directive/_config.js +++ b/packages/svelte/tests/css/samples/class-directive/_config.js @@ -4,16 +4,16 @@ export default test({ warnings: [ { code: 'css_unused_selector', - message: 'Unused CSS selector ".third"\nhttps://svelte.dev/e/css_unused_selector', + message: 'Unused CSS selector ".forth"\nhttps://svelte.dev/e/css_unused_selector', start: { - line: 6, + line: 8, column: 2, - character: 115 + character: 190 }, end: { - line: 6, + line: 8, column: 8, - character: 121 + character: 196 } } ] diff --git a/packages/svelte/tests/css/samples/class-directive/expected.css b/packages/svelte/tests/css/samples/class-directive/expected.css index 1d7d3d4dee..b3a74baee5 100644 --- a/packages/svelte/tests/css/samples/class-directive/expected.css +++ b/packages/svelte/tests/css/samples/class-directive/expected.css @@ -1,3 +1,5 @@ - .first.svelte-xyz { color: green } + + .zero.first.svelte-xyz { color: green } .second.svelte-xyz { color: green } - /* (unused) .third { color: red }*/ + .third.svelte-xyz { color: green } + /* (unused) .forth { color: red }*/ diff --git a/packages/svelte/tests/css/samples/class-directive/input.svelte b/packages/svelte/tests/css/samples/class-directive/input.svelte index cf00335964..70075f89d4 100644 --- a/packages/svelte/tests/css/samples/class-directive/input.svelte +++ b/packages/svelte/tests/css/samples/class-directive/input.svelte @@ -1,7 +1,9 @@ -
      +
      +
      \ No newline at end of file From 669a2233eef99e7a300e7e3d68a869e0a0db76af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 19 Jun 2025 09:19:10 -0400 Subject: [PATCH 13/15] Version Packages (#16205) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/big-carpets-stare.md | 5 ----- packages/svelte/CHANGELOG.md | 6 ++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/big-carpets-stare.md diff --git a/.changeset/big-carpets-stare.md b/.changeset/big-carpets-stare.md deleted file mode 100644 index eabe29bb88..0000000000 --- a/.changeset/big-carpets-stare.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: address css class matching regression diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 7dfcfb9039..b29ecc3fe3 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,11 @@ # svelte +## 5.34.7 + +### Patch Changes + +- fix: address css class matching regression ([#16204](https://github.com/sveltejs/svelte/pull/16204)) + ## 5.34.6 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 0971b05228..bdf7576e22 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.34.6", + "version": "5.34.7", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 98a526d8bd..748cc3ddd6 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.34.6'; +export const VERSION = '5.34.7'; export const PUBLIC_VERSION = '5'; From 4db4ee53305dbf1f29d2f6ddadb5201a4854c590 Mon Sep 17 00:00:00 2001 From: 7nik Date: Mon, 23 Jun 2025 13:18:14 +0300 Subject: [PATCH 14/15] chore: rid of nodeType magic numbers (#16208) * chore: rid of nodeType magic numbers * lint --------- Co-authored-by: 7nik --- .../svelte/src/internal/client/constants.js | 5 +++++ .../src/internal/client/dev/elements.js | 7 ++++--- .../src/internal/client/dom/blocks/each.js | 4 ++-- .../src/internal/client/dom/blocks/html.js | 6 +++++- .../src/internal/client/dom/blocks/snippet.js | 4 ++-- .../client/dom/blocks/svelte-element.js | 4 ++-- .../internal/client/dom/blocks/svelte-head.js | 5 +++-- .../src/internal/client/dom/hydration.js | 5 +++-- .../src/internal/client/dom/operations.js | 9 ++++----- .../src/internal/client/dom/template.js | 7 ++++--- packages/svelte/src/internal/client/render.js | 5 +++-- packages/svelte/tests/html_equal.js | 19 ++++++++++--------- .../svelte/tests/runtime-browser/assert.js | 5 ++++- .../samples/template/_config.js | 3 ++- 14 files changed, 53 insertions(+), 35 deletions(-) diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 98cef658bf..3ca915f98e 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -26,3 +26,8 @@ export const STATE_SYMBOL = Symbol('$state'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); export const PROXY_PATH_SYMBOL = Symbol('proxy path'); + +export const ELEMENT_NODE = 1; +export const TEXT_NODE = 3; +export const COMMENT_NODE = 8; +export const DOCUMENT_FRAGMENT_NODE = 11; diff --git a/packages/svelte/src/internal/client/dev/elements.js b/packages/svelte/src/internal/client/dev/elements.js index 62ac09d784..f70f893d1e 100644 --- a/packages/svelte/src/internal/client/dev/elements.js +++ b/packages/svelte/src/internal/client/dev/elements.js @@ -1,4 +1,5 @@ /** @import { SourceLocation } from '#client' */ +import { COMMENT_NODE, DOCUMENT_FRAGMENT_NODE, ELEMENT_NODE } from '#client/constants'; import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../constants.js'; import { hydrating } from '../dom/hydration.js'; @@ -12,7 +13,7 @@ export function add_locations(fn, filename, locations) { return (/** @type {any[]} */ ...args) => { const dom = fn(...args); - var node = hydrating ? dom : dom.nodeType === 11 ? dom.firstChild : dom; + var node = hydrating ? dom : dom.nodeType === DOCUMENT_FRAGMENT_NODE ? dom.firstChild : dom; assign_locations(node, filename, locations); return dom; @@ -45,13 +46,13 @@ function assign_locations(node, filename, locations) { var depth = 0; while (node && i < locations.length) { - if (hydrating && node.nodeType === 8) { + if (hydrating && node.nodeType === COMMENT_NODE) { var comment = /** @type {Comment} */ (node); if (comment.data === HYDRATION_START || comment.data === HYDRATION_START_ELSE) depth += 1; else if (comment.data[0] === HYDRATION_END) depth -= 1; } - if (depth === 0 && node.nodeType === 1) { + if (depth === 0 && node.nodeType === ELEMENT_NODE) { assign_location(/** @type {Element} */ (node), filename, locations[i++]); } diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index b638a6d2da..7b12be58e8 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -34,7 +34,7 @@ import { } from '../../reactivity/effects.js'; import { source, mutable_source, internal_set } from '../../reactivity/sources.js'; import { array_from, is_array } from '../../../shared/utils.js'; -import { INERT } from '#client/constants'; +import { COMMENT_NODE, INERT } from '#client/constants'; import { queue_micro_task } from '../task.js'; import { active_effect, get } from '../../runtime.js'; import { DEV } from 'esm-env'; @@ -183,7 +183,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f for (var i = 0; i < length; i++) { if ( - hydrate_node.nodeType === 8 && + hydrate_node.nodeType === COMMENT_NODE && /** @type {Comment} */ (hydrate_node).data === HYDRATION_END ) { // The server rendered fewer items than expected, diff --git a/packages/svelte/src/internal/client/dom/blocks/html.js b/packages/svelte/src/internal/client/dom/blocks/html.js index 92c8243478..d7190abc66 100644 --- a/packages/svelte/src/internal/client/dom/blocks/html.js +++ b/packages/svelte/src/internal/client/dom/blocks/html.js @@ -10,6 +10,7 @@ import { DEV } from 'esm-env'; import { dev_current_component_function } from '../../context.js'; import { get_first_child, get_next_sibling } from '../operations.js'; import { active_effect } from '../../runtime.js'; +import { COMMENT_NODE } from '#client/constants'; /** * @param {Element} element @@ -67,7 +68,10 @@ export function html(node, get_value, svg = false, mathml = false, skip_warning var next = hydrate_next(); var last = next; - while (next !== null && (next.nodeType !== 8 || /** @type {Comment} */ (next).data !== '')) { + while ( + next !== null && + (next.nodeType !== COMMENT_NODE || /** @type {Comment} */ (next).data !== '') + ) { last = next; next = /** @type {TemplateNode} */ (get_next_sibling(next)); } diff --git a/packages/svelte/src/internal/client/dom/blocks/snippet.js b/packages/svelte/src/internal/client/dom/blocks/snippet.js index c6dce26bfe..32d88d4c60 100644 --- a/packages/svelte/src/internal/client/dom/blocks/snippet.js +++ b/packages/svelte/src/internal/client/dom/blocks/snippet.js @@ -1,7 +1,7 @@ /** @import { Snippet } from 'svelte' */ /** @import { Effect, TemplateNode } from '#client' */ /** @import { Getters } from '#shared' */ -import { EFFECT_TRANSPARENT } from '#client/constants'; +import { EFFECT_TRANSPARENT, ELEMENT_NODE } from '#client/constants'; import { branch, block, destroy_effect, teardown } from '../../reactivity/effects.js'; import { dev_current_component_function, @@ -102,7 +102,7 @@ export function createRawSnippet(fn) { var fragment = create_fragment_from_html(html); element = /** @type {Element} */ (get_first_child(fragment)); - if (DEV && (get_next_sibling(element) !== null || element.nodeType !== 1)) { + if (DEV && (get_next_sibling(element) !== null || element.nodeType !== ELEMENT_NODE)) { w.invalid_raw_snippet_render(); } diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js index 43f669e844..ffa57b2d8b 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js @@ -20,7 +20,7 @@ import { current_each_item, set_current_each_item } from './each.js'; import { active_effect } from '../../runtime.js'; import { component_context } from '../../context.js'; import { DEV } from 'esm-env'; -import { EFFECT_TRANSPARENT } from '#client/constants'; +import { EFFECT_TRANSPARENT, ELEMENT_NODE } from '#client/constants'; import { assign_nodes } from '../template.js'; import { is_raw_text_element } from '../../../../utils.js'; @@ -51,7 +51,7 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio /** @type {null | Element} */ var element = null; - if (hydrating && hydrate_node.nodeType === 1) { + if (hydrating && hydrate_node.nodeType === ELEMENT_NODE) { element = /** @type {Element} */ (hydrate_node); hydrate_next(); } diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js index db2a0c4ef1..66d3371836 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js @@ -2,7 +2,7 @@ import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js'; import { create_text, get_first_child, get_next_sibling } from '../operations.js'; import { block } from '../../reactivity/effects.js'; -import { HEAD_EFFECT } from '#client/constants'; +import { COMMENT_NODE, HEAD_EFFECT } from '#client/constants'; import { HYDRATION_START } from '../../../../constants.js'; /** @@ -37,7 +37,8 @@ export function head(render_fn) { while ( head_anchor !== null && - (head_anchor.nodeType !== 8 || /** @type {Comment} */ (head_anchor).data !== HYDRATION_START) + (head_anchor.nodeType !== COMMENT_NODE || + /** @type {Comment} */ (head_anchor).data !== HYDRATION_START) ) { head_anchor = /** @type {TemplateNode} */ (get_next_sibling(head_anchor)); } diff --git a/packages/svelte/src/internal/client/dom/hydration.js b/packages/svelte/src/internal/client/dom/hydration.js index ab3256da82..1f80b7922b 100644 --- a/packages/svelte/src/internal/client/dom/hydration.js +++ b/packages/svelte/src/internal/client/dom/hydration.js @@ -1,5 +1,6 @@ /** @import { TemplateNode } from '#client' */ +import { COMMENT_NODE } from '#client/constants'; import { HYDRATION_END, HYDRATION_ERROR, @@ -87,7 +88,7 @@ export function remove_nodes() { var node = hydrate_node; while (true) { - if (node.nodeType === 8) { + if (node.nodeType === COMMENT_NODE) { var data = /** @type {Comment} */ (node).data; if (data === HYDRATION_END) { @@ -109,7 +110,7 @@ export function remove_nodes() { * @param {TemplateNode} node */ export function read_hydration_instruction(node) { - if (!node || node.nodeType !== 8) { + if (!node || node.nodeType !== COMMENT_NODE) { w.hydration_mismatch(); throw HYDRATION_ERROR; } diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 97062f04e3..4b35f0802f 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -3,6 +3,7 @@ import { hydrate_node, hydrating, set_hydrate_node } from './hydration.js'; import { DEV } from 'esm-env'; import { init_array_prototype_warnings } from '../dev/equality.js'; import { get_descriptor, is_extensible } from '../../shared/utils.js'; +import { TEXT_NODE } from '#client/constants'; // export these for reference in the compiled code, making global name deduplication unnecessary /** @type {Window} */ @@ -113,7 +114,7 @@ export function child(node, is_text) { // Child can be null if we have an element with a single child, like `

      {text}

      `, where `text` is empty if (child === null) { child = hydrate_node.appendChild(create_text()); - } else if (is_text && child.nodeType !== 3) { + } else if (is_text && child.nodeType !== TEXT_NODE) { var text = create_text(); child?.before(text); set_hydrate_node(text); @@ -143,7 +144,7 @@ export function first_child(fragment, is_text) { // if an {expression} is empty during SSR, there might be no // text node to hydrate — we must therefore create one - if (is_text && hydrate_node?.nodeType !== 3) { + if (is_text && hydrate_node?.nodeType !== TEXT_NODE) { var text = create_text(); hydrate_node?.before(text); @@ -174,11 +175,9 @@ export function sibling(node, count = 1, is_text = false) { return next_sibling; } - var type = next_sibling?.nodeType; - // if a sibling {expression} is empty during SSR, there might be no // text node to hydrate — we must therefore create one - if (is_text && type !== 3) { + if (is_text && next_sibling?.nodeType !== TEXT_NODE) { var text = create_text(); // If the next sibling is `null` and we're handling text then it's because // the SSR content was empty for the text, so we need to generate a new text diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index 0b77ab1396..ebbf0039b2 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -20,6 +20,7 @@ import { TEMPLATE_USE_MATHML, TEMPLATE_USE_SVG } from '../../../constants.js'; +import { COMMENT_NODE, DOCUMENT_FRAGMENT_NODE, TEXT_NODE } from '#client/constants'; /** * @param {TemplateNode} start @@ -264,7 +265,7 @@ function run_scripts(node) { // scripts were SSR'd, in which case they will run if (hydrating) return node; - const is_fragment = node.nodeType === 11; + const is_fragment = node.nodeType === DOCUMENT_FRAGMENT_NODE; const scripts = /** @type {HTMLElement} */ (node).tagName === 'SCRIPT' ? [/** @type {HTMLScriptElement} */ (node)] @@ -305,7 +306,7 @@ export function text(value = '') { var node = hydrate_node; - if (node.nodeType !== 3) { + if (node.nodeType !== TEXT_NODE) { // if an {expression} is empty during SSR, we need to insert an empty text node node.before((node = create_text())); set_hydrate_node(node); @@ -360,7 +361,7 @@ export function props_id() { if ( hydrating && hydrate_node && - hydrate_node.nodeType === 8 && + hydrate_node.nodeType === COMMENT_NODE && hydrate_node.textContent?.startsWith(`#`) ) { const id = hydrate_node.textContent.substring(1); diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index 3256fe8274..ff6844453d 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -30,6 +30,7 @@ import * as w from './warnings.js'; import * as e from './errors.js'; import { assign_nodes } from './dom/template.js'; import { is_passive_event } from '../../utils.js'; +import { COMMENT_NODE } from './constants.js'; /** * This is normally true — block effects should run their intro transitions — @@ -107,7 +108,7 @@ export function hydrate(component, options) { var anchor = /** @type {TemplateNode} */ (get_first_child(target)); while ( anchor && - (anchor.nodeType !== 8 || /** @type {Comment} */ (anchor).data !== HYDRATION_START) + (anchor.nodeType !== COMMENT_NODE || /** @type {Comment} */ (anchor).data !== HYDRATION_START) ) { anchor = /** @type {TemplateNode} */ (get_next_sibling(anchor)); } @@ -124,7 +125,7 @@ export function hydrate(component, options) { if ( hydrate_node === null || - hydrate_node.nodeType !== 8 || + hydrate_node.nodeType !== COMMENT_NODE || /** @type {Comment} */ (hydrate_node).data !== HYDRATION_END ) { w.hydration_mismatch(); diff --git a/packages/svelte/tests/html_equal.js b/packages/svelte/tests/html_equal.js index 6948d8db32..b637e4d538 100644 --- a/packages/svelte/tests/html_equal.js +++ b/packages/svelte/tests/html_equal.js @@ -1,3 +1,4 @@ +import { COMMENT_NODE, ELEMENT_NODE, TEXT_NODE } from '#client/constants'; import { assert } from 'vitest'; /** @@ -35,7 +36,7 @@ function clean_children(node, opts) { }); for (let child of [...node.childNodes]) { - if (child.nodeType === 3) { + if (child.nodeType === TEXT_NODE) { let text = /** @type {Text} */ (child); if ( @@ -49,7 +50,7 @@ function clean_children(node, opts) { text.data = text.data.replace(/[^\S]+/g, ' '); - if (previous && previous.nodeType === 3) { + if (previous && previous.nodeType === TEXT_NODE) { const prev = /** @type {Text} */ (previous); prev.data += text.data; @@ -62,22 +63,22 @@ function clean_children(node, opts) { } } - if (child.nodeType === 8 && !opts.preserveComments) { + if (child.nodeType === COMMENT_NODE && !opts.preserveComments) { // comment child.remove(); continue; } // add newlines for better readability and potentially recurse into children - if (child.nodeType === 1 || child.nodeType === 8) { - if (previous?.nodeType === 3) { + if (child.nodeType === ELEMENT_NODE || child.nodeType === COMMENT_NODE) { + if (previous?.nodeType === TEXT_NODE) { const prev = /** @type {Text} */ (previous); prev.data = prev.data.replace(/^[^\S]+$/, '\n'); - } else if (previous?.nodeType === 1 || previous?.nodeType === 8) { + } else if (previous?.nodeType === ELEMENT_NODE || previous?.nodeType === COMMENT_NODE) { node.insertBefore(document.createTextNode('\n'), child); } - if (child.nodeType === 1) { + if (child.nodeType === ELEMENT_NODE) { has_element_children = true; clean_children(/** @type {Element} */ (child), opts); } @@ -87,12 +88,12 @@ function clean_children(node, opts) { } // collapse whitespace - if (node.firstChild && node.firstChild.nodeType === 3) { + if (node.firstChild && node.firstChild.nodeType === TEXT_NODE) { const text = /** @type {Text} */ (node.firstChild); text.data = text.data.trimStart(); } - if (node.lastChild && node.lastChild.nodeType === 3) { + if (node.lastChild && node.lastChild.nodeType === TEXT_NODE) { const text = /** @type {Text} */ (node.lastChild); text.data = text.data.trimEnd(); } diff --git a/packages/svelte/tests/runtime-browser/assert.js b/packages/svelte/tests/runtime-browser/assert.js index fb460c722a..48bde01410 100644 --- a/packages/svelte/tests/runtime-browser/assert.js +++ b/packages/svelte/tests/runtime-browser/assert.js @@ -1,5 +1,8 @@ /** @import { assert } from 'vitest' */ /** @import { CompileOptions, Warning } from '#compiler' */ + +import { ELEMENT_NODE } from '#client/constants'; + /** * @param {any} a * @param {any} b @@ -102,7 +105,7 @@ function normalize_children(node) { } for (let child of [...node.childNodes]) { - if (child.nodeType === 1 /* Element */) { + if (child.nodeType === ELEMENT_NODE) { normalize_children(child); } } diff --git a/packages/svelte/tests/runtime-legacy/samples/template/_config.js b/packages/svelte/tests/runtime-legacy/samples/template/_config.js index f827168542..7576b6fbb8 100644 --- a/packages/svelte/tests/runtime-legacy/samples/template/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/template/_config.js @@ -1,3 +1,4 @@ +import { COMMENT_NODE } from '#client/constants'; import { ok, test } from '../../test'; export default test({ @@ -41,7 +42,7 @@ export default test({ // get all childNodes of template3 except comments let childNodes = []; for (const node of template3.content.childNodes) { - if (node.nodeType !== 8) { + if (node.nodeType !== COMMENT_NODE) { childNodes.push(/** @type {Element} */ (node)); } } From c4b32c2bffed5668a1d1383ca7311e43e6376e27 Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Mon, 23 Jun 2025 12:56:36 +0200 Subject: [PATCH 15/15] fix: untrack `$inspect.with` and add check for unsafe mutation (#16209) Co-authored-by: Rich Harris --- .changeset/clever-dodos-jam.md | 5 +++++ .../98-reference/.generated/client-errors.md | 2 +- .../svelte/messages/client-errors/errors.md | 2 +- .../svelte/src/internal/client/dev/inspect.js | 6 +++++- packages/svelte/src/internal/client/errors.js | 4 ++-- .../src/internal/client/reactivity/sources.js | 6 ++++-- .../inspect-state-unsafe-mutation/_config.js | 9 +++++++++ .../inspect-state-unsafe-mutation/main.svelte | 10 ++++++++++ .../samples/inspect-with-untracked/_config.js | 20 +++++++++++++++++++ .../inspect-with-untracked/main.svelte | 13 ++++++++++++ 10 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 .changeset/clever-dodos-jam.md create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-state-unsafe-mutation/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-state-unsafe-mutation/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-with-untracked/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-with-untracked/main.svelte diff --git a/.changeset/clever-dodos-jam.md b/.changeset/clever-dodos-jam.md new file mode 100644 index 0000000000..bdeb979184 --- /dev/null +++ b/.changeset/clever-dodos-jam.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: untrack `$inspect.with` and add check for unsafe mutation diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 32348bb781..111b0b8940 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -125,7 +125,7 @@ Cannot set prototype of `$state` object ### state_unsafe_mutation ``` -Updating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without `$state` +Updating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state` ``` This error occurs when state is updated while evaluating a `$derived`. You might encounter it while trying to 'derive' two pieces of state in one go: diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index c4e68f8fee..6d96770eba 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -82,7 +82,7 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long ## state_unsafe_mutation -> Updating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without `$state` +> Updating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state` This error occurs when state is updated while evaluating a `$derived`. You might encounter it while trying to 'derive' two pieces of state in one go: diff --git a/packages/svelte/src/internal/client/dev/inspect.js b/packages/svelte/src/internal/client/dev/inspect.js index e13ef470cf..e15c66901c 100644 --- a/packages/svelte/src/internal/client/dev/inspect.js +++ b/packages/svelte/src/internal/client/dev/inspect.js @@ -1,6 +1,7 @@ import { UNINITIALIZED } from '../../../constants.js'; import { snapshot } from '../../shared/clone.js'; import { inspect_effect, validate_effect } from '../reactivity/effects.js'; +import { untrack } from '../runtime.js'; /** * @param {() => any[]} get_value @@ -28,7 +29,10 @@ export function inspect(get_value, inspector = console.log) { } if (value !== UNINITIALIZED) { - inspector(initial ? 'init' : 'update', ...snapshot(value, true)); + var snap = snapshot(value, true); + untrack(() => { + inspector(initial ? 'init' : 'update', ...snap); + }); } initial = false; diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 429dd99da9..962593b48d 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -307,12 +307,12 @@ export function state_prototype_fixed() { } /** - * Updating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without `$state` + * Updating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state` * @returns {never} */ export function state_unsafe_mutation() { if (DEV) { - const error = new Error(`state_unsafe_mutation\nUpdating state inside a derived or a template expression is forbidden. If the value should not be reactive, declare it without \`$state\`\nhttps://svelte.dev/e/state_unsafe_mutation`); + const error = new Error(`state_unsafe_mutation\nUpdating state inside \`$derived(...)\`, \`$inspect(...)\` or a template expression is forbidden. If the value should not be reactive, declare it without \`$state\`\nhttps://svelte.dev/e/state_unsafe_mutation`); error.name = 'Svelte error'; throw error; diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 4959bc1abc..0db3530232 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -135,9 +135,11 @@ export function mutate(source, value) { export function set(source, value, should_proxy = false) { if ( active_reaction !== null && - !untracking && + // since we are untracking the function inside `$inspect.with` we need to add this check + // to ensure we error if state is set inside an inspect effect + (!untracking || (active_reaction.f & INSPECT_EFFECT) !== 0) && is_runes() && - (active_reaction.f & (DERIVED | BLOCK_EFFECT)) !== 0 && + (active_reaction.f & (DERIVED | BLOCK_EFFECT | INSPECT_EFFECT)) !== 0 && !(reaction_sources?.[1].includes(source) && reaction_sources[0] === active_reaction) ) { e.state_unsafe_mutation(); diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-state-unsafe-mutation/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect-state-unsafe-mutation/_config.js new file mode 100644 index 0000000000..7e8fcd2d48 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-state-unsafe-mutation/_config.js @@ -0,0 +1,9 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + error: 'state_unsafe_mutation' +}); diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-state-unsafe-mutation/main.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-state-unsafe-mutation/main.svelte new file mode 100644 index 0000000000..3361087ff7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-state-unsafe-mutation/main.svelte @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-with-untracked/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect-with-untracked/_config.js new file mode 100644 index 0000000000..cdb242c416 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-with-untracked/_config.js @@ -0,0 +1,20 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + async test({ assert, target, logs }) { + const [a, b] = target.querySelectorAll('button'); + assert.deepEqual(logs, ['init', 0]); + flushSync(() => { + b?.click(); + }); + assert.deepEqual(logs, ['init', 0]); + flushSync(() => { + a?.click(); + }); + assert.deepEqual(logs, ['init', 0, 'update', 1]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-with-untracked/main.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-with-untracked/main.svelte new file mode 100644 index 0000000000..5bcf2bd348 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-with-untracked/main.svelte @@ -0,0 +1,13 @@ + + + + \ No newline at end of file