chore: merge main into async branch (#16197)

* chore: merge main into async branch

* adjust test

* fix: make effects depend on state created inside them (#16198)

* make effects depend on state created inside them

* fix, add github action

* disable test in async mode
pull/15844/head
Simon H 3 months ago committed by GitHub
parent 6efdc23ecd
commit 61a11a57e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: match class and style directives against attribute selector

@ -43,6 +43,23 @@ jobs:
- run: pnpm test - run: pnpm test
env: env:
CI: true CI: true
TestNoAsync:
permissions: {}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm playwright install chromium
- run: pnpm test runtime-runes
env:
CI: true
SVELTE_NO_ASYNC: true
Lint: Lint:
permissions: {} permissions: {}
runs-on: ubuntu-latest runs-on: ubuntu-latest

@ -129,12 +129,12 @@ test('Effect', () => {
// effects normally run after a microtask, // effects normally run after a microtask,
// use flushSync to execute all pending effects synchronously // use flushSync to execute all pending effects synchronously
flushSync(); flushSync();
expect(log.value).toEqual([0]); expect(log).toEqual([0]);
count = 1; count = 1;
flushSync(); flushSync();
expect(log.value).toEqual([0, 1]); expect(log).toEqual([0, 1]);
}); });
cleanup(); cleanup();
@ -148,17 +148,13 @@ test('Effect', () => {
*/ */
export function logger(getValue) { export function logger(getValue) {
/** @type {any[]} */ /** @type {any[]} */
let log = $state([]); let log = [];
$effect(() => { $effect(() => {
log.push(getValue()); log.push(getValue());
}); });
return { return log;
get value() {
return log;
}
};
} }
``` ```

@ -1,5 +1,25 @@
# svelte # 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
- 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 ## 5.34.3
### Patch Changes ### Patch Changes

@ -2,7 +2,7 @@
"name": "svelte", "name": "svelte",
"description": "Cybernetically enhanced web apps", "description": "Cybernetically enhanced web apps",
"license": "MIT", "license": "MIT",
"version": "5.34.3", "version": "5.34.5",
"type": "module", "type": "module",
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
"engines": { "engines": {

@ -247,7 +247,10 @@ function open(parser) {
error: null, error: null,
pending: null, pending: null,
then: null, then: null,
catch: null catch: null,
metadata: {
expression: create_expression_metadata()
}
}); });
if (parser.eat('then')) { if (parser.eat('then')) {
@ -711,6 +714,9 @@ function special(parser) {
declarations: [{ type: 'VariableDeclarator', id, init, start: id.start, end: init.end }], declarations: [{ type: 'VariableDeclarator', id, init, start: id.start, end: init.end }],
start: start + 2, // start at const, not at @const start: start + 2, // start at const, not at @const
end: parser.index - 1 end: parser.index - 1
},
metadata: {
expression: create_expression_metadata()
} }
}); });
} }
@ -737,6 +743,7 @@ function special(parser) {
end: parser.index, end: parser.index,
expression: /** @type {AST.RenderTag['expression']} */ (expression), expression: /** @type {AST.RenderTag['expression']} */ (expression),
metadata: { metadata: {
expression: create_expression_metadata(),
dynamic: false, dynamic: false,
arguments: [], arguments: [],
path: [], path: [],

@ -532,12 +532,7 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
} }
case 'ClassSelector': { case 'ClassSelector': {
if ( if (!attribute_matches(element, 'class', name, '~=', false)) {
!attribute_matches(element, 'class', name, '~=', false) &&
!element.attributes.some(
(attribute) => attribute.type === 'ClassDirective' && attribute.name === name
)
) {
return 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 === 'SpreadAttribute') return true;
if (attribute.type === 'BindDirective' && attribute.name === name) 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.type !== 'Attribute') continue;
if (attribute.name.toLowerCase() !== name.toLowerCase()) continue; if (attribute.name.toLowerCase() !== name.toLowerCase()) continue;

@ -23,5 +23,9 @@ export function AssignmentExpression(node, context) {
} }
} }
if (context.state.expression) {
context.state.expression.has_assignment = true;
}
context.next(); context.next();
} }

@ -41,5 +41,8 @@ export function AwaitBlock(node, context) {
mark_subtree_dynamic(context.path); 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);
} }

@ -32,5 +32,8 @@ export function ConstTag(node, context) {
e.const_tag_invalid_placement(node); 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 });
} }

@ -15,8 +15,5 @@ export function HtmlTag(node, context) {
// unfortunately this is necessary in order to fix invalid HTML // unfortunately this is necessary in order to fix invalid HTML
mark_subtree_dynamic(context.path); mark_subtree_dynamic(context.path);
context.next({ context.next({ ...context.state, expression: node.metadata.expression });
...context.state,
expression: node.metadata.expression
});
} }

@ -90,6 +90,7 @@ export function Identifier(node, context) {
if (binding) { if (binding) {
if (context.state.expression) { if (context.state.expression) {
context.state.expression.dependencies.add(binding); context.state.expression.dependencies.add(binding);
context.state.expression.references.add(binding);
context.state.expression.has_state ||= context.state.expression.has_state ||=
binding.kind !== 'static' && binding.kind !== 'static' &&
!binding.is_function() && !binding.is_function() &&

@ -16,10 +16,6 @@ export function KeyBlock(node, context) {
mark_subtree_dynamic(context.path); mark_subtree_dynamic(context.path);
context.visit(node.expression, { context.visit(node.expression, { ...context.state, expression: node.metadata.expression });
...context.state,
expression: node.metadata.expression
});
context.visit(node.fragment); context.visit(node.fragment);
} }

@ -15,8 +15,9 @@ export function MemberExpression(node, context) {
} }
} }
if (context.state.expression && !is_pure(node, context)) { if (context.state.expression) {
context.state.expression.has_state = true; context.state.expression.has_member_expression = true;
context.state.expression.has_state ||= !is_pure(node, context);
} }
if (!is_safe_identifier(node, context.state.scope)) { if (!is_safe_identifier(node, context.state.scope)) {

@ -54,7 +54,7 @@ export function RenderTag(node, context) {
mark_subtree_dynamic(context.path); mark_subtree_dynamic(context.path);
context.visit(callee); context.visit(callee, { ...context.state, expression: node.metadata.expression });
for (const arg of expression.arguments) { for (const arg of expression.arguments) {
const metadata = create_expression_metadata(); const metadata = create_expression_metadata();

@ -21,5 +21,9 @@ export function UpdateExpression(node, context) {
} }
} }
if (context.state.expression) {
context.state.expression.has_assignment = true;
}
context.next(); context.next();
} }

@ -13,6 +13,16 @@ export function visit_function(node, context) {
scope: context.state.scope 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.next({
...context.state, ...context.state,
function_depth: context.state.function_depth + 1, function_depth: context.state.function_depth + 1,

@ -1,21 +1,14 @@
/** @import { Expression } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
import * as b from '../../../../utils/builders.js'; import * as b from '../../../../utils/builders.js';
import { build_expression } from './shared/utils.js';
/** /**
* @param {AST.AttachTag} node * @param {AST.AttachTag} node
* @param {ComponentContext} context * @param {ComponentContext} context
*/ */
export function AttachTag(node, context) { export function AttachTag(node, context) {
context.state.init.push( const expression = build_expression(context, node.expression, node.metadata.expression);
b.stmt( context.state.init.push(b.stmt(b.call('$.attach', context.state.node, b.thunk(expression))));
b.call(
'$.attach',
context.state.node,
b.thunk(/** @type {Expression} */ (context.visit(node.expression)))
)
)
);
context.next(); context.next();
} }

@ -1,10 +1,11 @@
/** @import { BlockStatement, Expression, Pattern, Statement } from 'estree' */ /** @import { BlockStatement, Pattern, Statement } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */ /** @import { ComponentClientTransformState, ComponentContext } from '../types' */
import { extract_identifiers } from '../../../../utils/ast.js'; import { extract_identifiers } from '../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { create_derived } from '../utils.js'; import { create_derived } from '../utils.js';
import { get_value } from './shared/declarations.js'; import { get_value } from './shared/declarations.js';
import { build_expression } from './shared/utils.js';
/** /**
* @param {AST.AwaitBlock} node * @param {AST.AwaitBlock} node
@ -14,7 +15,7 @@ export function AwaitBlock(node, context) {
context.state.template.push_comment(); context.state.template.push_comment();
// Visit {#await <expression>} first to ensure that scopes are in the correct order // Visit {#await <expression>} 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 then_block;
let catch_block; let catch_block;

@ -8,12 +8,6 @@ import { build_component } from './shared/component.js';
* @param {ComponentContext} context * @param {ComponentContext} context
*/ */
export function Component(node, context) { export function Component(node, context) {
const component = build_component( const component = build_component(node, node.name, context);
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
);
context.state.init.push(component); context.state.init.push(component);
} }

@ -1,4 +1,4 @@
/** @import { Expression, Pattern } from 'estree' */ /** @import { Pattern } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
import { dev } from '../../../../state.js'; import { dev } from '../../../../state.js';
@ -6,6 +6,7 @@ import { extract_identifiers } from '../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { create_derived } from '../utils.js'; import { create_derived } from '../utils.js';
import { get_value } from './shared/declarations.js'; import { get_value } from './shared/declarations.js';
import { build_expression } from './shared/utils.js';
/** /**
* @param {AST.ConstTag} node * @param {AST.ConstTag} node
@ -15,15 +16,8 @@ export function ConstTag(node, context) {
const declaration = node.declaration.declarations[0]; const declaration = node.declaration.declarations[0];
// TODO we can almost certainly share some code with $derived(...) // TODO we can almost certainly share some code with $derived(...)
if (declaration.id.type === 'Identifier') { if (declaration.id.type === 'Identifier') {
context.state.init.push( const init = build_expression(context, declaration.init, node.metadata.expression);
b.const( context.state.init.push(b.const(declaration.id, create_derived(context.state, b.thunk(init))));
declaration.id,
create_derived(
context.state,
b.thunk(/** @type {Expression} */ (context.visit(declaration.init)))
)
)
);
context.state.transform[declaration.id.name] = { read: get_value }; 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` // TODO optimise the simple `{ x } = y` case — we can just return `y`
// instead of destructuring it only to return a new object // 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( const fn = b.arrow(
[], [],
b.block([ b.block([
b.const( b.const(/** @type {Pattern} */ (context.visit(declaration.id, child_state)), init),
/** @type {Pattern} */ (context.visit(declaration.id, child_state)),
/** @type {Expression} */ (context.visit(declaration.init, child_state))
),
b.return(b.object(identifiers.map((node) => b.prop('init', node, node)))) b.return(b.object(identifiers.map((node) => b.prop('init', node, node))))
]) ])
); );

@ -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 { AST, Binding } from '#compiler' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
/** @import { Scope } from '../../../scope' */ /** @import { Scope } from '../../../scope' */
@ -12,8 +12,8 @@ import {
import { dev } from '../../../../state.js'; import { dev } from '../../../../state.js';
import { extract_paths, object } from '../../../../utils/ast.js'; import { extract_paths, object } from '../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { build_getter } from '../utils.js';
import { get_value } from './shared/declarations.js'; import { get_value } from './shared/declarations.js';
import { build_expression } from './shared/utils.js';
/** /**
* @param {AST.EachBlock} node * @param {AST.EachBlock} node
@ -24,11 +24,18 @@ export function EachBlock(node, context) {
// expression should be evaluated in the parent scope, not the scope // expression should be evaluated in the parent scope, not the scope
// created by the each block itself // created by the each block itself
const collection = /** @type {Expression} */ ( const parent_scope_state = {
context.visit(node.expression, { ...context.state,
...context.state, scope: /** @type {Scope} */ (context.state.scope.parent)
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) { if (!each_node_meta.is_controlled) {

@ -1,8 +1,8 @@
/** @import { Expression } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
import { is_ignored } from '../../../../state.js'; import { is_ignored } from '../../../../state.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { build_expression } from './shared/utils.js';
/** /**
* @param {AST.HtmlTag} node * @param {AST.HtmlTag} node
@ -12,8 +12,7 @@ export function HtmlTag(node, context) {
context.state.template.push_comment(); context.state.template.push_comment();
const { has_await } = node.metadata.expression; const { has_await } = node.metadata.expression;
const expression = build_expression(context, node.expression, node.metadata.expression);
const expression = /** @type {Expression} */ (context.visit(node.expression));
const html = has_await ? b.call('$.get', b.id('$$html')) : expression; const html = has_await ? b.call('$.get', b.id('$$html')) : expression;
const is_svg = context.state.metadata.namespace === 'svg'; const is_svg = context.state.metadata.namespace === 'svg';

@ -2,6 +2,7 @@
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { build_expression } from './shared/utils.js';
/** /**
* @param {AST.IfBlock} node * @param {AST.IfBlock} node
@ -25,8 +26,7 @@ export function IfBlock(node, context) {
} }
const { has_await } = node.metadata.expression; const { has_await } = node.metadata.expression;
const expression = build_expression(context, node.test, node.metadata.expression);
const expression = /** @type {Expression} */ (context.visit(node.test));
const test = has_await ? b.call('$.get', b.id('$$condition')) : expression; const test = has_await ? b.call('$.get', b.id('$$condition')) : expression;
/** @type {Expression[]} */ /** @type {Expression[]} */

@ -2,6 +2,7 @@
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { build_expression } from './shared/utils.js';
/** /**
* @param {AST.KeyBlock} node * @param {AST.KeyBlock} node
@ -10,7 +11,7 @@ import * as b from '#compiler/builders';
export function KeyBlock(node, context) { export function KeyBlock(node, context) {
context.state.template.push_comment(); 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)); const body = /** @type {Expression} */ (context.visit(node.fragment));
if (node.metadata.expression.has_await) { if (node.metadata.expression.has_await) {

@ -340,7 +340,7 @@ export function RegularElement(node, context) {
trimmed.some((node) => node.type === 'ExpressionTag'); trimmed.some((node) => node.type === 'ExpressionTag');
if (use_text_content) { 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 === ''; const empty_string = value.type === 'Literal' && value.value === '';
if (!empty_string) { if (!empty_string) {

@ -4,7 +4,7 @@
import { unwrap_optional } from '../../../../utils/ast.js'; import { unwrap_optional } from '../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { create_derived } from '../utils.js'; import { create_derived } from '../utils.js';
import { get_expression_id } from './shared/utils.js'; import { get_expression_id, build_expression } from './shared/utils.js';
/** /**
* @param {AST.RenderTag} node * @param {AST.RenderTag} node
@ -28,7 +28,11 @@ export function RenderTag(node, context) {
const async_expressions = []; const async_expressions = [];
for (let i = 0; i < raw_args.length; i++) { for (let i = 0; i < raw_args.length; i++) {
let expression = /** @type {Expression} */ (context.visit(raw_args[i])); let expression = build_expression(
context,
/** @type {Expression} */ (raw_args[i]),
node.metadata.arguments[i]
);
const { has_call, has_await } = node.metadata.arguments[i]; const { has_call, has_await } = node.metadata.arguments[i];
if (has_await || has_call) { if (has_await || has_call) {
@ -50,7 +54,11 @@ export function RenderTag(node, context) {
b.var(memo.id, create_derived(context.state, b.thunk(memo.expression))) b.var(memo.id, create_derived(context.state, b.thunk(memo.expression)))
); );
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 (node.metadata.dynamic) {
// If we have a chain expression then ensure a nullish snippet function gets turned into an empty one // If we have a chain expression then ensure a nullish snippet function gets turned into an empty one

@ -10,8 +10,7 @@ import { build_template_chunk } from './shared/utils.js';
export function TitleElement(node, context) { export function TitleElement(node, context) {
const { has_state, value } = build_template_chunk( const { has_state, value } = build_template_chunk(
/** @type {any} */ (node.fragment.nodes), /** @type {any} */ (node.fragment.nodes),
context.visit, context
context.state
); );
const statement = b.stmt(b.assignment('=', b.id('$.document.title'), value)); const statement = b.stmt(b.assignment('=', b.id('$.document.title'), value));

@ -59,6 +59,15 @@ export function build_component(node, component_name, context) {
/** @type {ExpressionStatement[]} */ /** @type {ExpressionStatement[]} */
const binding_initializers = []; 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 * 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. * the slot scope applies to the component itself, too, and not just its children.
@ -223,7 +232,7 @@ export function build_component(node, component_name, context) {
b.call( b.call(
'$$ownership_validator.binding', '$$ownership_validator.binding',
b.literal(binding.node.name), b.literal(binding.node.name),
b.id(component_name), b.id(is_component_dynamic ? intermediate_name : component_name),
b.thunk(expression) b.thunk(expression)
) )
) )
@ -299,7 +308,7 @@ export function build_component(node, component_name, context) {
); );
} }
push_prop(b.prop('get', b.call('$.attachment'), expression, true)); push_prop(b.prop('init', b.call('$.attachment'), expression, true));
} }
} }
@ -438,8 +447,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 // 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 // will be handled separately through the `$.component` function, and then the component name will
// always be referenced through just the identifier here. // always be referenced through just the identifier here.
node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic) is_component_dynamic
? component_name ? intermediate_name
: /** @type {Expression} */ (context.visit(b.member_id(component_name))), : /** @type {Expression} */ (context.visit(b.member_id(component_name))),
node_id, node_id,
props_expression props_expression
@ -461,7 +470,7 @@ export function build_component(node, component_name, context) {
) )
]; ];
if (node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic)) { if (is_component_dynamic) {
const prev = fn; const prev = fn;
fn = (node_id) => { fn = (node_id) => {
@ -470,11 +479,11 @@ export function build_component(node, component_name, context) {
node_id, node_id,
b.thunk( b.thunk(
/** @type {Expression} */ ( /** @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.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')))]) b.block([...binding_initializers, b.stmt(prev(b.id('$$anchor')))])
) )
); );

@ -7,7 +7,7 @@ import { is_ignored } from '../../../../../state.js';
import { is_event_attribute } from '../../../../../utils/ast.js'; import { is_event_attribute } from '../../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { build_class_directives_object, build_style_directives_object } from '../RegularElement.js'; 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<AST.Attribute | AST.SpreadAttribute>} attributes * @param {Array<AST.Attribute | AST.SpreadAttribute>} attributes
@ -125,7 +125,7 @@ export function build_attribute_value(value, context, memoize = (value) => value
return { value: b.literal(chunk.data), has_state: false }; 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 { return {
value: memoize(expression, chunk.metadata.expression), value: memoize(expression, chunk.metadata.expression),
@ -133,7 +133,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);
} }
/** /**

@ -16,8 +16,8 @@ import { build_template_chunk } from './utils.js';
* @param {boolean} is_element * @param {boolean} is_element
* @param {ComponentContext} context * @param {ComponentContext} context
*/ */
export function process_children(nodes, initial, is_element, { visit, state }) { export function process_children(nodes, initial, is_element, context) {
const within_bound_contenteditable = state.metadata.bound_contenteditable; const within_bound_contenteditable = context.state.metadata.bound_contenteditable;
let prev = initial; let prev = initial;
let skipped = 0; let skipped = 0;
@ -48,8 +48,8 @@ export function process_children(nodes, initial, is_element, { visit, state }) {
let id = expression; let id = expression;
if (id.type !== 'Identifier') { if (id.type !== 'Identifier') {
id = b.id(state.scope.generate(name)); id = b.id(context.state.scope.generate(name));
state.init.push(b.var(id, expression)); context.state.init.push(b.var(id, expression));
} }
prev = () => id; prev = () => id;
@ -64,13 +64,13 @@ export function process_children(nodes, initial, is_element, { visit, state }) {
function flush_sequence(sequence) { function flush_sequence(sequence) {
if (sequence.every((node) => node.type === 'Text')) { if (sequence.every((node) => node.type === 'Text')) {
skipped += 1; skipped += 1;
state.template.push_text(sequence); context.state.template.push_text(sequence);
return; 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 // 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 // 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)); const update = b.stmt(b.call('$.set_text', id, value));
if (has_state && !within_bound_contenteditable) { if (has_state && !within_bound_contenteditable) {
state.update.push(update); context.state.update.push(update);
} else { } 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 = []; 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; skipped += 1;
} else if (node.type === 'EachBlock' && nodes.length === 1 && is_element) { } else if (node.type === 'EachBlock' && nodes.length === 1 && is_element) {
node.metadata.is_controlled = true; node.metadata.is_controlled = true;
} else { } else {
const id = flush_node(false, node.type === 'RegularElement' ? node.name : 'node'); 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 // traverse to the last (n - 1) one when hydrating
if (skipped > 1) { if (skipped > 1) {
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))));
} }
} }

@ -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 { AST, ExpressionMetadata } from '#compiler' */
/** @import { ComponentClientTransformState, Context, MemoizedExpression } from '../../types' */ /** @import { ComponentClientTransformState, ComponentContext, Context, MemoizedExpression } from '../../types' */
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
import { object } from '../../../../../utils/ast.js'; import { object } from '../../../../../utils/ast.js';
import * as b from '#compiler/builders'; 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 { regex_is_valid_identifier } from '../../../../patterns.js';
import is_reference from 'is-reference'; import is_reference from 'is-reference';
import { dev, is_ignored, locator } from '../../../../../state.js'; 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 * @param {ComponentClientTransformState} state
@ -35,15 +35,15 @@ export function get_expression_id(expressions, expression) {
/** /**
* @param {Array<AST.Text | AST.ExpressionTag>} values * @param {Array<AST.Text | AST.ExpressionTag>} values
* @param {(node: AST.SvelteNode, state: any) => any} visit * @param {ComponentContext} context
* @param {ComponentClientTransformState} state * @param {ComponentClientTransformState} state
* @param {(value: Expression, metadata: ExpressionMetadata) => Expression} memoize * @param {(value: Expression, metadata: ExpressionMetadata) => Expression} memoize
* @returns {{ value: Expression, has_state: boolean }} * @returns {{ value: Expression, has_state: boolean }}
*/ */
export function build_template_chunk( export function build_template_chunk(
values, values,
visit, context,
state, state = context.state,
memoize = (value, metadata) => memoize = (value, metadata) =>
metadata.has_call || metadata.has_await metadata.has_call || metadata.has_await
? get_expression_id(metadata.has_await ? state.async_expressions : state.expressions, value) ? get_expression_id(metadata.has_await ? state.async_expressions : state.expressions, value)
@ -73,7 +73,7 @@ export function build_template_chunk(
state.scope.get('undefined') state.scope.get('undefined')
) { ) {
let value = memoize( let value = memoize(
/** @type {Expression} */ (visit(node.expression, state)), build_expression(context, node.expression, node.metadata.expression, state),
node.metadata.expression node.metadata.expression
); );
@ -377,3 +377,48 @@ export function validate_mutation(node, context, expression) {
loc && b.literal(loc.column) 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;
}

@ -62,8 +62,11 @@ export function create_attribute(name, start, end, value) {
export function create_expression_metadata() { export function create_expression_metadata() {
return { return {
dependencies: new Set(), dependencies: new Set(),
references: new Set(),
has_state: false, has_state: false,
has_call: false, has_call: false,
has_member_expression: false,
has_assignment: false,
has_await: false has_await: false
}; };
} }

@ -284,14 +284,20 @@ export type DeclarationKind =
| 'synthetic'; | 'synthetic';
export interface ExpressionMetadata { 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<Binding>; dependencies: Set<Binding>;
/** All the bindings that are referenced inside this expression, including inside functions */
references: Set<Binding>;
/** True if the expression references state directly, or _might_ (via member/call expressions) */ /** True if the expression references state directly, or _might_ (via member/call expressions) */
has_state: boolean; has_state: boolean;
/** True if the expression involves a call expression (often, it will need to be wrapped in a derived) */ /** True if the expression involves a call expression (often, it will need to be wrapped in a derived) */
has_call: boolean; has_call: boolean;
/** True if the expression contains `await` */ /** True if the expression contains `await` */
has_await: boolean; has_await: 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 { export interface StateField {

@ -155,6 +155,10 @@ export namespace AST {
declaration: VariableDeclaration & { declaration: VariableDeclaration & {
declarations: [VariableDeclarator & { id: Pattern; init: Expression }]; declarations: [VariableDeclarator & { id: Pattern; init: Expression }];
}; };
/** @internal */
metadata: {
expression: ExpressionMetadata;
};
} }
/** A `{@debug ...}` tag */ /** A `{@debug ...}` tag */
@ -169,6 +173,7 @@ export namespace AST {
expression: SimpleCallExpression | (ChainExpression & { expression: SimpleCallExpression }); expression: SimpleCallExpression | (ChainExpression & { expression: SimpleCallExpression });
/** @internal */ /** @internal */
metadata: { metadata: {
expression: ExpressionMetadata;
dynamic: boolean; dynamic: boolean;
arguments: ExpressionMetadata[]; arguments: ExpressionMetadata[];
path: SvelteNode[]; path: SvelteNode[];
@ -470,6 +475,10 @@ export namespace AST {
pending: Fragment | null; pending: Fragment | null;
then: Fragment | null; then: Fragment | null;
catch: Fragment | null; catch: Fragment | null;
/** @internal */
metadata: {
expression: ExpressionMetadata;
};
} }
export interface KeyBlock extends BaseNode { export interface KeyBlock extends BaseNode {

@ -345,7 +345,11 @@ export function set_attributes(element, prev, next, css_hash, skip_warning = fal
} }
var prev_value = current[key]; 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; current[key] = value;
@ -483,8 +487,8 @@ export function attribute_effect(
block(() => { block(() => {
var next = fn(...deriveds.map(get)); var next = fn(...deriveds.map(get));
/** @type {Record<string | symbol, any>} */
set_attributes(element, prev, next, css_hash, skip_warning); var current = set_attributes(element, prev, next, css_hash, skip_warning);
if (inited && is_select && 'value' in next) { if (inited && is_select && 'value' in next) {
select_option(/** @type {HTMLSelectElement} */ (element), next.value, false); select_option(/** @type {HTMLSelectElement} */ (element), next.value, false);
@ -501,9 +505,11 @@ export function attribute_effect(
if (effects[symbol]) destroy_effect(effects[symbol]); if (effects[symbol]) destroy_effect(effects[symbol]);
effects[symbol] = branch(() => attach(element, () => n)); effects[symbol] = branch(() => attach(element, () => n));
} }
current[symbol] = n;
} }
prev = next; prev = current;
}); });
if (is_select) { if (is_select) {

@ -44,6 +44,7 @@ export function proxy(value) {
var reaction = active_reaction; var reaction = active_reaction;
/** /**
* Executes the proxy in the context of the reaction it was originally created in, if any
* @template T * @template T
* @param {() => T} fn * @param {() => T} fn
*/ */
@ -93,21 +94,19 @@ export function proxy(value) {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/getOwnPropertyDescriptor#invariants // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/getOwnPropertyDescriptor#invariants
e.state_descriptors_fixed(); e.state_descriptors_fixed();
} }
var s = sources.get(prop);
with_parent(() => { if (s === undefined) {
var s = sources.get(prop); s = with_parent(() => {
var s = source(descriptor.value, stack);
if (s === undefined) {
s = source(descriptor.value, stack);
sources.set(prop, s); sources.set(prop, s);
if (DEV && typeof prop === 'string') { if (DEV && typeof prop === 'string') {
tag(s, get_label(path, prop)); tag(s, get_label(path, prop));
} }
} else { return s;
set(s, descriptor.value, true); });
} } else {
}); set(s, descriptor.value, true);
}
return true; return true;
}, },
@ -268,11 +267,8 @@ export function proxy(value) {
// object property before writing to that property. // object property before writing to that property.
if (s === undefined) { if (s === undefined) {
if (!has || get_descriptor(target, prop)?.writable) { if (!has || get_descriptor(target, prop)?.writable) {
s = with_parent(() => { s = with_parent(() => source(undefined, stack));
var s = source(undefined, stack); set(s, proxy(value));
set(s, proxy(value));
return s;
});
sources.set(prop, s); sources.set(prop, s);

@ -143,7 +143,7 @@ export function set(source, value, should_proxy = false) {
!untracking && !untracking &&
is_runes() && is_runes() &&
(active_reaction.f & (DERIVED | BLOCK_EFFECT | EFFECT_ASYNC)) !== 0 && (active_reaction.f & (DERIVED | BLOCK_EFFECT | EFFECT_ASYNC)) !== 0 &&
!reaction_sources?.includes(source) !(reaction_sources?.[1].includes(source) && reaction_sources[0] === active_reaction)
) { ) {
e.state_unsafe_mutation(); e.state_unsafe_mutation();
} }

@ -57,7 +57,6 @@ import {
import * as w from './warnings.js'; import * as w from './warnings.js';
import { current_batch, Batch, batch_deriveds } from './reactivity/batch.js'; import { current_batch, Batch, batch_deriveds } from './reactivity/batch.js';
import { handle_error, invoke_error_boundary } from './error-handling.js'; import { handle_error, invoke_error_boundary } from './error-handling.js';
import { snapshot } from '../shared/clone.js';
/** @type {Effect | null} */ /** @type {Effect | null} */
let last_scheduled_effect = null; let last_scheduled_effect = null;
@ -105,8 +104,8 @@ export function set_active_effect(effect) {
/** /**
* When sources are created within a reaction, reading and writing * When sources are created within a reaction, reading and writing
* them should not cause a re-run * them within that reaction should not cause a re-run
* @type {null | Source[]} * @type {null | [active_reaction: Reaction, sources: Source[]]}
*/ */
export let reaction_sources = null; export let reaction_sources = null;
@ -114,9 +113,9 @@ export let reaction_sources = null;
export function push_reaction_value(value) { export function push_reaction_value(value) {
if (active_reaction !== null && active_reaction.f & EFFECT_IS_UPDATING) { if (active_reaction !== null && active_reaction.f & EFFECT_IS_UPDATING) {
if (reaction_sources === null) { if (reaction_sources === null) {
reaction_sources = [value]; reaction_sources = [active_reaction, [value]];
} else { } else {
reaction_sources.push(value); reaction_sources[1].push(value);
} }
} }
} }
@ -259,7 +258,12 @@ function schedule_possible_effect_self_invalidation(signal, effect, root = true)
for (var i = 0; i < reactions.length; i++) { for (var i = 0; i < reactions.length; i++) {
var reaction = reactions[i]; var reaction = reactions[i];
if (reaction_sources?.includes(signal)) continue; if (
!async_mode_flag &&
reaction_sources?.[1].includes(signal) &&
reaction_sources[0] === active_reaction
)
continue;
if ((reaction.f & DERIVED) !== 0) { if ((reaction.f & DERIVED) !== 0) {
schedule_possible_effect_self_invalidation(/** @type {Derived} */ (reaction), effect, false); schedule_possible_effect_self_invalidation(/** @type {Derived} */ (reaction), effect, false);
@ -299,7 +303,9 @@ export function update_reaction(reaction) {
untracking = false; untracking = false;
read_version++; read_version++;
reaction.f |= EFFECT_IS_UPDATING; if (!async_mode_flag || (reaction.f & DERIVED) !== 0) {
reaction.f |= EFFECT_IS_UPDATING;
}
if (reaction.ac !== null) { if (reaction.ac !== null) {
reaction.ac?.abort(STALE_REACTION); reaction.ac?.abort(STALE_REACTION);
@ -383,7 +389,9 @@ export function update_reaction(reaction) {
set_component_context(previous_component_context); set_component_context(previous_component_context);
untracking = previous_untracking; untracking = previous_untracking;
reaction.f ^= EFFECT_IS_UPDATING; if (!async_mode_flag || (reaction.f & DERIVED) !== 0) {
reaction.f ^= EFFECT_IS_UPDATING;
}
} }
} }
@ -774,7 +782,12 @@ export function get(signal) {
// we don't add the dependency, because that would create a memory leak // we don't add the dependency, because that would create a memory leak
var destroyed = active_effect !== null && (active_effect.f & DESTROYED) !== 0; var destroyed = active_effect !== null && (active_effect.f & DESTROYED) !== 0;
if (!destroyed && !reaction_sources?.includes(signal)) { if (
!destroyed &&
((async_mode_flag && (active_reaction.f & DERIVED) === 0) ||
!reaction_sources?.[1].includes(signal) ||
reaction_sources[0] !== active_reaction)
) {
var deps = active_reaction.deps; var deps = active_reaction.deps;
if ((active_reaction.f & REACTION_IS_UPDATING) !== 0) { if ((active_reaction.f & REACTION_IS_UPDATING) !== 0) {

@ -6,6 +6,11 @@ export function enable_async_mode_flag() {
async_mode_flag = true; async_mode_flag = true;
} }
/** ONLY USE THIS DURING TESTING */
export function disable_async_mode_flag() {
async_mode_flag = false;
}
export function enable_legacy_mode_flag() { export function enable_legacy_mode_flag() {
legacy_mode_flag = true; legacy_mode_flag = true;
} }

@ -4,5 +4,5 @@
* The current version, as set in package.json. * The current version, as set in package.json.
* @type {string} * @type {string}
*/ */
export const VERSION = '5.34.3'; export const VERSION = '5.34.5';
export const PUBLIC_VERSION = '5'; export const PUBLIC_VERSION = '5';

@ -0,0 +1,2 @@
span[class].svelte-xyz { color: green }
div[style].svelte-xyz { color: green }

@ -0,0 +1,7 @@
<span class:foo={true}></span>
<div style:--foo="bar"></div>
<style>
span[class] { color: green }
div[style] { color: green }
</style>

@ -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
}
}
]
});

@ -0,0 +1,3 @@
.first.svelte-xyz { color: green }
.second.svelte-xyz { color: green }
/* (unused) .third { color: red }*/

@ -0,0 +1,7 @@
<div class:first={true} class:second={true}></div>
<style>
.first { color: green }
.second { color: green }
.third { color: red }
</style>

@ -194,6 +194,8 @@ if (typeof window !== 'undefined') {
export const fragments = /** @type {'html' | 'tree'} */ (process.env.FRAGMENTS) ?? 'html'; export const fragments = /** @type {'html' | 'tree'} */ (process.env.FRAGMENTS) ?? 'html';
export const async_mode = process.env.SVELTE_NO_ASYNC !== 'true';
/** /**
* @param {any[]} logs * @param {any[]} logs
*/ */

@ -0,0 +1,11 @@
import { test } from '../../test';
export default test({
server_props: {
browser: false
},
props: {
browser: true
}
});

@ -0,0 +1,9 @@
<script>
const { browser } = $props();
const attributes = {
"data-test": browser ? undefined : ""
};
</script>
<div {...attributes}></div>

@ -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, `<div></div><button>inc</button> [0,0,0,0,0,0,0,0,0]`);
flushSync(() => button?.click());
assert.htmlEqual(target.innerHTML, `<div></div><button>inc</button> [0,0,0,0,0,0,0,0,0]`);
}
});

@ -0,0 +1,45 @@
<script>
let a = 0, b = 0, c = 0, d = 0, e = 0, f = 0, g = 0, h = 0, i = 0;
function inc() {
a++;
b++;
c++;
d++;
e++;
f++;
g++;
h++;
i++;
}
</script>
{#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, ''}
<div {@attach !!(g = 0)}></div>
{#key 1}
{@const x = (h = 0)}
{x, ''}
{/key}
{#if 1}
{@const x = (i = 0)}
{x, ''}
{/if}
<button on:click={inc}>inc</button>
[{a},{b},{c},{d},{e},{f},{g},{h},{i}]

@ -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, `<div></div><button data-foo="true">inc</button> 12 - 12`);
flushSync(() => button?.click());
assert.htmlEqual(target.innerHTML, `<div></div><button data-foo="true">inc</button> 13 - 12`);
}
});

@ -0,0 +1,36 @@
<script>
let count1 = 1;
let count2 = 1;
function fn(ret) {
if (count1 > 100) return ret;
count1++;
count2++;
return ret;
}
</script>
{#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('')}
<div {@attach fn(() => {})}></div>
{#key 1}
{@const x = fn('')}
{x}
{/key}
<button data-foo={fn(true)} on:click={() => count1++}>{fn('inc')}</button>
{count1} - {count2}

@ -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, `<div></div><button>inc</button> 10 - 10`);
flushSync(() => button?.click());
assert.htmlEqual(target.innerHTML, `<div></div><button>inc</button> 11 - 10`);
}
});

@ -0,0 +1,46 @@
<script>
let count1 = 1;
let count2 = 1;
function fn(ret) {
if (count1 > 100) return ret;
count1++;
count2++;
return ret;
}
const obj = {
get true() { return fn(true) },
get false() { return fn(false) },
get array() { return fn([]) },
get string() { return fn('') },
get promise() { return fn(Promise.resolve()) },
get snippet() { return fn(snip) },
get attachment() { return fn(() => {}) },
}
</script>
{#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}
<div {@attach obj.attachment}></div>
{#key 1}
{@const x = obj.string}
{x}
{/key}
<button on:click={() => count1++}>inc</button>
{count1} - {count2}

@ -5,7 +5,7 @@
export let index; export let index;
export let n; export let n;
function logRender () { function logRender (n) {
order.push(`${index}: render ${n}`); order.push(`${index}: render ${n}`);
return index; return index;
} }
@ -24,5 +24,5 @@
</script> </script>
<li> <li>
{logRender()} {logRender(n)}
</li> </li>

@ -5,7 +5,7 @@
export let n = 0; export let n = 0;
function logRender () { function logRender (n) {
order.push(`parent: render ${n}`); order.push(`parent: render ${n}`);
return 'parent'; return 'parent';
} }
@ -23,7 +23,7 @@
}) })
</script> </script>
{logRender()} {logRender(n)}
<ul> <ul>
{#each [1,2,3] as index} {#each [1,2,3] as index}
<Item {index} {n} /> <Item {index} {n} />

@ -3,10 +3,10 @@ import { setImmediate } from 'node:timers/promises';
import { globSync } from 'tinyglobby'; import { globSync } from 'tinyglobby';
import { createClassComponent } from 'svelte/legacy'; import { createClassComponent } from 'svelte/legacy';
import { proxy } from 'svelte/internal/client'; import { proxy } from 'svelte/internal/client';
import { flushSync, hydrate, mount, unmount, untrack } from 'svelte'; import { flushSync, hydrate, mount, unmount } from 'svelte';
import { render } from 'svelte/server'; import { render } from 'svelte/server';
import { afterAll, assert, beforeAll } from 'vitest'; import { afterAll, assert, beforeAll } from 'vitest';
import { compile_directory, fragments } from '../helpers.js'; import { async_mode, compile_directory, fragments } from '../helpers.js';
import { assert_html_equal, assert_html_equal_with_options } from '../html_equal.js'; import { assert_html_equal, assert_html_equal_with_options } from '../html_equal.js';
import { raf } from '../animation-helpers.js'; import { raf } from '../animation-helpers.js';
import type { CompileOptions } from '#compiler'; import type { CompileOptions } from '#compiler';
@ -45,6 +45,10 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
mode?: Array<'server' | 'client' | 'hydrate'>; mode?: Array<'server' | 'client' | 'hydrate'>;
/** Temporarily skip specific modes, without skipping the entire test */ /** Temporarily skip specific modes, without skipping the entire test */
skip_mode?: Array<'server' | 'client' | 'hydrate'>; skip_mode?: Array<'server' | 'client' | 'hydrate'>;
/** Skip if running with process.env.NO_ASYNC */
skip_no_async?: boolean;
/** Skip if running without process.env.NO_ASYNC */
skip_async?: boolean;
html?: string; html?: string;
ssrHtml?: string; ssrHtml?: string;
compileOptions?: Partial<CompileOptions>; compileOptions?: Partial<CompileOptions>;
@ -121,7 +125,15 @@ let console_error = console.error;
export function runtime_suite(runes: boolean) { export function runtime_suite(runes: boolean) {
return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'dom', CompileOptions>( return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'dom', CompileOptions>(
['dom', 'hydrate', 'ssr'], ['dom', 'hydrate', 'ssr'],
(variant, config) => { (variant, config, test_name) => {
if (!async_mode && (config.skip_no_async || test_name.startsWith('async-'))) {
return true;
}
if (async_mode && config.skip_async) {
return true;
}
if (variant === 'hydrate') { if (variant === 'hydrate') {
if (config.mode && !config.mode.includes('hydrate')) return 'no-test'; if (config.mode && !config.mode.includes('hydrate')) return 'no-test';
if (config.skip_mode?.includes('hydrate')) return true; if (config.skip_mode?.includes('hydrate')) return true;
@ -169,7 +181,7 @@ async function common_setup(cwd: string, runes: boolean | undefined, config: Run
dev: force_hmr ? true : undefined, dev: force_hmr ? true : undefined,
hmr: force_hmr ? true : undefined, hmr: force_hmr ? true : undefined,
experimental: { experimental: {
async: runes async: runes && async_mode
}, },
fragments, fragments,
...config.compileOptions, ...config.compileOptions,

@ -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,
`
<button>add item</button>
<p>0</p>
<p>50</p>
<p>100</p>
`
);
// add first item (20); effect should re-run and sort the array
flushSync(() => button?.click());
assert.htmlEqual(
target.innerHTML,
`
<button>add item</button>
<p>0</p>
<p>20</p>
<p>50</p>
<p>100</p>
`
);
// add second item (80); effect should re-run and sort the array
flushSync(() => button?.click());
assert.htmlEqual(
target.innerHTML,
`
<button>add item</button>
<p>0</p>
<p>20</p>
<p>50</p>
<p>80</p>
<p>100</p>
`
);
}
});

@ -0,0 +1,21 @@
<script>
let arr = $state([100, 0, 50]);
let nextValues = [20, 80];
let valueIndex = 0;
$effect(() => {
arr.sort((a, b) => a - b);
});
function addItem() {
if (valueIndex < nextValues.length) {
arr.push(nextValues[valueIndex]);
valueIndex++;
}
}
</script>
<button onclick={addItem}>add item</button>
{#each arr as x}
<p>{x}</p>
{/each}

@ -0,0 +1,5 @@
<script>
const { children } = $props()
</script>
{@render children()}

@ -0,0 +1,8 @@
import { test } from '../../test';
import { flushSync } from 'svelte';
export default test({
async test({ assert, target }) {
assert.htmlEqual(target.innerHTML, 'test');
}
});

@ -0,0 +1,9 @@
<script>
import A from './A.svelte';
const B = $derived(A);
</script>
<B>
<B>test</B>
</B>

@ -1,3 +1,4 @@
import { async_mode } from '../../../helpers';
import { test } from '../../test'; import { test } from '../../test';
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
@ -10,6 +11,12 @@ export default test({
flushSync(() => { flushSync(() => {
b1.click(); b1.click();
}); });
assert.deepEqual(logs, ['init 0']);
// With async mode (which is on by default for runtime-runes) this works as expected, without it
// it works differently: https://github.com/sveltejs/svelte/pull/15564
assert.deepEqual(
logs,
async_mode ? ['init 0', 'cleanup 2', null, 'init 2', 'cleanup 4', null, 'init 4'] : ['init 0']
);
} }
}); });

@ -14,4 +14,4 @@
}) })
</script> </script>
<button on:click={() => count++ }>Click</button> <button onclick={() => count++ }>Click</button>

@ -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, '<button>change handlers</button><button>1 / 1</button>');
change.click();
flushSync();
increment.click();
flushSync();
assert.htmlEqual(target.innerHTML, '<button>change handlers</button><button>3 / 3</button>');
}
});

@ -0,0 +1,27 @@
<script>
let delegated = $state(0);
let non_delegated = $state(0);
let attrs = $state({
onclick: () => {
delegated += 1;
},
onclickcapture: () => {
non_delegated += 1;
}
});
</script>
<button
onclick={() =>
(attrs = {
onclick: () => {
delegated += 2;
},
onclickcapture: () => {
non_delegated += 2;
}
})}
>
change handlers
</button>
<button {...attrs}>{delegated} / {non_delegated}</button>

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
async test() {}
});

@ -0,0 +1,15 @@
<script>
function with_writes(initialState) {
const derive = $state(initialState)
return derive
}
let data = $state({ example: 'Example' })
let my_derived = $derived(with_writes({ example: data.example }))
$effect(() => {
my_derived.example = 'Bar'
});
</script>
<input bind:value={data.example} />

@ -1,6 +1,7 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
skip_no_async: true,
async test({ assert, logs }) { async test({ assert, logs }) {
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();

@ -1,11 +1,16 @@
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
import { async_mode } from '../../../helpers';
export default test({ export default test({
async test({ target, assert, logs }) { async test({ target, assert, logs }) {
const button = target.querySelector('button'); const button = target.querySelector('button');
flushSync(() => button?.click()); flushSync(() => button?.click());
assert.ok(logs[0].startsWith('set_context_after_init')); assert.ok(
async_mode
? logs[0].startsWith('set_context_after_init')
: logs[0] === 'works without experimental async but really shouldnt'
);
} }
}); });

@ -7,6 +7,7 @@
if (condition) { if (condition) {
try { try {
setContext('potato', {}); setContext('potato', {});
console.log('works without experimental async but really shouldnt')
} catch (e) { } catch (e) {
console.log(e.message); console.log(e.message);
} }

@ -2,6 +2,8 @@ import { flushSync } from 'svelte';
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
// In async mode we _do_ want to run effects that react to their own state changing
skip_async: true,
test({ assert, target, logs }) { test({ assert, target, logs }) {
const button = target.querySelector('button'); const button = target.querySelector('button');

@ -9,13 +9,14 @@ import {
user_effect user_effect
} from '../../src/internal/client/reactivity/effects'; } from '../../src/internal/client/reactivity/effects';
import { state, set, update, update_pre } from '../../src/internal/client/reactivity/sources'; import { state, set, update, update_pre } from '../../src/internal/client/reactivity/sources';
import type { Derived, Effect, Value } from '../../src/internal/client/types'; import type { Derived, Effect, Source, Value } from '../../src/internal/client/types';
import { proxy } from '../../src/internal/client/proxy'; import { proxy } from '../../src/internal/client/proxy';
import { derived } from '../../src/internal/client/reactivity/deriveds'; import { derived } from '../../src/internal/client/reactivity/deriveds';
import { snapshot } from '../../src/internal/shared/clone.js'; import { snapshot } from '../../src/internal/shared/clone.js';
import { SvelteSet } from '../../src/reactivity/set'; import { SvelteSet } from '../../src/reactivity/set';
import { DESTROYED } from '../../src/internal/client/constants'; import { DESTROYED } from '../../src/internal/client/constants';
import { noop } from 'svelte/internal/client'; import { noop } from 'svelte/internal/client';
import { disable_async_mode_flag, enable_async_mode_flag } from '../../src/internal/flags';
/** /**
* @param runes runes mode * @param runes runes mode
@ -518,7 +519,7 @@ describe('signals', () => {
}; };
}); });
test('schedules rerun when writing to signal before reading it', (runes) => { test.skip('schedules rerun when writing to signal before reading it', (runes) => {
if (!runes) return () => {}; if (!runes) return () => {};
const error = console.error; const error = console.error;
@ -1010,14 +1011,68 @@ describe('signals', () => {
}; };
}); });
test('effects do not depend on state they own', () => { test('effects do depend on state they own', (runes) => {
// This behavior is important for use cases like a Resource class
// which shares its instance between multiple effects and triggers
// rerenders by self-invalidating its state.
const log: number[] = [];
let count: any;
if (runes) {
// We will make this the new default behavior once it's stable but until then
// we need to keep the old behavior to not break existing code.
enable_async_mode_flag();
}
effect(() => {
if (!count || $.get<number>(count) < 2) {
count ||= state(0);
log.push($.get(count));
set(count, $.get<number>(count) + 1);
}
});
return () => {
try {
flushSync();
if (runes) {
assert.deepEqual(log, [0, 1]);
} else {
assert.deepEqual(log, [0]);
}
} finally {
disable_async_mode_flag();
}
};
});
test('nested effects depend on state of upper effects', () => {
const logs: number[] = [];
let raw: Source<number>;
let proxied: { current: number };
user_effect(() => { user_effect(() => {
const value = state(0); raw = state(0);
set(value, $.get(value) + 1); 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);
});
}); });
return () => { return () => {
flushSync(); flushSync();
set(raw, $.get(raw) + 1);
proxied.current += 1;
flushSync();
assert.deepEqual(logs, [0, 0, 1, 1]);
}; };
}); });

@ -8,11 +8,13 @@ export default function Purity($$anchor) {
var fragment = root(); var fragment = root();
var p = $.first_child(fragment); var p = $.first_child(fragment);
p.textContent = '0'; p.textContent = (
$.untrack(() => Math.max(0, Math.min(0, 100)))
);
var p_1 = $.sibling(p, 2); var p_1 = $.sibling(p, 2);
p_1.textContent = location.href; p_1.textContent = ($.untrack(() => location.href));
var node = $.sibling(p_1, 2); var node = $.sibling(p_1, 2);

@ -35,7 +35,7 @@ export function suite<Test extends BaseTest>(fn: (config: Test, test_dir: string
export function suite_with_variants<Test extends BaseTest, Variants extends string, Common>( export function suite_with_variants<Test extends BaseTest, Variants extends string, Common>(
variants: Variants[], variants: Variants[],
should_skip_variant: (variant: Variants, config: Test) => boolean | 'no-test', should_skip_variant: (variant: Variants, config: Test, test_name: string) => boolean | 'no-test',
common_setup: (config: Test, test_dir: string) => Promise<Common> | Common, common_setup: (config: Test, test_dir: string) => Promise<Common> | Common,
fn: (config: Test, test_dir: string, variant: Variants, common: Common) => void fn: (config: Test, test_dir: string, variant: Variants, common: Common) => void
) { ) {
@ -46,11 +46,11 @@ export function suite_with_variants<Test extends BaseTest, Variants extends stri
let called_common = false; let called_common = false;
let common: any = undefined; let common: any = undefined;
for (const variant of variants) { for (const variant of variants) {
if (should_skip_variant(variant, config) === 'no-test') { if (should_skip_variant(variant, config, dir) === 'no-test') {
continue; continue;
} }
// TODO unify test interfaces // TODO unify test interfaces
const skip = config.skip || should_skip_variant(variant, config); const skip = config.skip || should_skip_variant(variant, config, dir);
const solo = config.solo; const solo = config.solo;
let it_fn = skip ? it.skip : solo ? it.only : it; let it_fn = skip ? it.skip : solo ? it.only : it;

Loading…
Cancel
Save