Merge remote-tracking branch 'origin/main' into svelte-custom-renderer

svelte-custom-renderer
paoloricciuti 2 weeks ago
commit af6419bd15

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: reuse the cached value in the `<option>`/`<select>` value guard

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: prevent malformed AST output for `<select>` with static `value` attribute

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: apply ownership mutation ignores to binding assignments

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: fold SSR block-open markers into the branch's first push

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: report `derived_invalid_export` for `export let x = $derived(...)` in runes mode

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: keep `defaultChecked` on hydrated radio inputs with spread attributes

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: measure nested transitions before applying their starting styles

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: emit `$.only_child` for elements with a single child

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: more robust rendering of Svelte custom element slots

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: end a restored reaction context at the end of its synchronous segment

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: keep the dependencies of a reaction that throws, so deriveds it read are neither leaked nor stuck in their error

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: use `$.comment()` for single-comment templates

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: store setters cache as `Set` instead of `Array`

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: transform derived assignments and select function bindings correctly during server-side rendering

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: keep boolean attributes with an empty string value when rendering attribute objects on the server

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: sync `SvelteURL` port signal when the protocol setter clears the port

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: support `defaultValue` on `<select>`

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: block declaration tags and `{@const}` on async values read inside closures

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: avoid css tree-shaking for exported Snippet

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: treat `<img loading>` as a static element again

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: preserve line feed character references in attribute values

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: decode uppercase-`X` hex numeric character references (`&#X...;`)

@ -251,6 +251,19 @@ You can give the `<select>` a default value by adding a `selected` attribute to
</select> </select>
``` ```
Since 5.57.0, if a `<select>` has a `defaultValue` and is part of a form, it will revert to that value instead of the empty string when the form is reset. Note that for the initial render the value of the binding takes precedence unless it is `null` or `undefined`.
```svelte
<form>
<select bind:value defaultValue="b">
<option>a</option>
<option>b</option>
<option>c</option>
</select>
<input type="reset" value="Reset">
</form>
```
## `<audio>` ## `<audio>`
`<audio>` elements have their own set of bindings — five two-way ones... `<audio>` elements have their own set of bindings — five two-way ones...

@ -165,9 +165,9 @@ Svelte will warn you if you get it wrong.
Similarly, to pass primitive values through context, use functions as described in [Passing state into functions]($state#Passing-state-into-functions). Similarly, to pass primitive values through context, use functions as described in [Passing state into functions]($state#Passing-state-into-functions).
## Component testing ## Mounting components with context
When writing [component tests](testing#Unit-and-component-tests-with-Vitest-Component-testing), it can be useful to create a wrapper component that sets the context in order to check the behaviour of a component that uses it. As of version 5.49, you can do this sort of thing: To mount a component with specific context, create a wrapper component that sets the context before rendering the component. This is useful for [component tests](testing#Unit-and-component-tests-with-Vitest-Component-testing), or any other scenario that needs to provide context through `mount`. As of version 5.49, you can do this sort of thing:
```js ```js
import { mount, unmount } from 'svelte'; import { mount, unmount } from 'svelte';
@ -193,6 +193,8 @@ test('MyComponent', () => {
This approach also works with [`hydrate`](imperative-component-api#hydrate) and [`render`](imperative-component-api#render). This approach also works with [`hydrate`](imperative-component-api#hydrate) and [`render`](imperative-component-api#render).
The context set by the wrapper only applies to that mounted component tree. Each call to `mount`, `hydrate` or `render` creates a separate wrapper instance, so the context does not leak into other mounted components.
## Replacing global state ## Replacing global state
When you have state shared by many different components, you might be tempted to put it in its own module and just import it wherever it's needed: When you have state shared by many different components, you might be tempted to put it in its own module and just import it wherever it's needed:

@ -1352,6 +1352,9 @@ export interface HTMLSelectAttributes extends HTMLAttributes<HTMLSelectElement>
required?: boolean | undefined | null; required?: boolean | undefined | null;
size?: number | undefined | null; size?: number | undefined | null;
value?: any; value?: any;
// needs both casing variants because language tools does lowercase names of non-shorthand attributes
defaultValue?: any;
defaultvalue?: any;
'on:change'?: ChangeEventHandler<HTMLSelectElement> | undefined | null; 'on:change'?: ChangeEventHandler<HTMLSelectElement> | undefined | null;
onchange?: ChangeEventHandler<HTMLSelectElement> | undefined | null; onchange?: ChangeEventHandler<HTMLSelectElement> | undefined | null;

@ -20,7 +20,7 @@ function reg_exp_entity(entity_name, is_attribute_value) {
/** @param {boolean} is_attribute_value */ /** @param {boolean} is_attribute_value */
function get_entity_pattern(is_attribute_value) { function get_entity_pattern(is_attribute_value) {
const reg_exp_num = '#(?:x[a-fA-F\\d]+|\\d+)(?:;)?'; const reg_exp_num = '#(?:[xX][a-fA-F\\d]+|\\d+)(?:;)?';
const reg_exp_entities = Object.keys(entities).map( const reg_exp_entities = Object.keys(entities).map(
/** @param {any} entity_name */ (entity_name) => reg_exp_entity(entity_name, is_attribute_value) /** @param {any} entity_name */ (entity_name) => reg_exp_entity(entity_name, is_attribute_value)
); );
@ -50,7 +50,7 @@ export function decode_character_references(html, is_attribute_value) {
// Handle named entities // Handle named entities
if (entity[0] !== '#') { if (entity[0] !== '#') {
code = entities[entity]; code = entities[entity];
} else if (entity[1] === 'x') { } else if (entity[1] === 'x' || entity[1] === 'X') {
code = parseInt(entity.substring(2), 16); code = parseInt(entity.substring(2), 16);
} else { } else {
code = parseInt(entity.substring(1), 10); code = parseInt(entity.substring(1), 10);
@ -60,7 +60,7 @@ export function decode_character_references(html, is_attribute_value) {
return match; return match;
} }
return String.fromCodePoint(validate_code(code)); return String.fromCodePoint(validate_code(code, is_attribute_value));
} }
); );
} }
@ -75,10 +75,15 @@ const NUL = 0;
// Also see: https://en.wikipedia.org/wiki/Plane_(Unicode) // Also see: https://en.wikipedia.org/wiki/Plane_(Unicode)
// Also see: https://html.spec.whatwg.org/multipage/parsing.html#preprocessing-the-input-stream // Also see: https://html.spec.whatwg.org/multipage/parsing.html#preprocessing-the-input-stream
/** @param {number} code */ /**
function validate_code(code) { * @param {number} code
// line feed becomes generic whitespace * @param {boolean} is_attribute_value
if (code === 10) { */
function validate_code(code, is_attribute_value) {
// line feed becomes generic whitespace, since it is collapsed along with the
// surrounding whitespace anyway. In an attribute value it is significant, so it
// is left alone there
if (code === 10 && !is_attribute_value) {
return 32; return 32;
} }

@ -285,7 +285,7 @@ export function analyze_module(source, options) {
runes: true, runes: true,
immutable: true, immutable: true,
tracing: false, tracing: false,
async_deriveds: new Set(), async_deriveds: new Map(),
comments, comments,
classes: new Map(), classes: new Map(),
pickled_awaits: new Set() pickled_awaits: new Set()
@ -566,7 +566,7 @@ export function analyze_component(root, source, options) {
source, source,
snippet_renderers: new Map(), snippet_renderers: new Map(),
snippets: new Set(), snippets: new Set(),
async_deriveds: new Set(), async_deriveds: new Map(),
pickled_awaits: new Set(), pickled_awaits: new Set(),
instance_body: { instance_body: {
sync: [], sync: [],
@ -841,6 +841,11 @@ export function analyze_component(root, source, options) {
} else { } else {
e.export_undefined(specifier, name); e.export_undefined(specifier, name);
} }
} else if (binding.initial?.type === 'SnippetBlock') {
// If a snippet is exported, a consumer could only import this named export and not the default export (the component).
// In this case we need to set hasGlobal of our output to true so that e.g. vite-plugin-svelte does not tell Vite to
// tree-shake the CSS if the default export is not used.
analysis.css.has_global = true;
} }
} }
} }

@ -1,6 +1,7 @@
/** @import { AwaitExpression, Expression, SpreadElement, Property } from 'estree' */ /** @import { AwaitExpression, Expression, SpreadElement, Property } from 'estree' */
/** @import { Context } from '../types' */ /** @import { Context } from '../types' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ExpressionMetadata } from '../../nodes.js' */
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
/** /**
@ -10,15 +11,20 @@ import * as e from '../../../errors.js';
export function AwaitExpression(node, context) { export function AwaitExpression(node, context) {
const tla = context.state.ast_type === 'instance' && context.state.function_depth === 1; const tla = context.state.ast_type === 'instance' && context.state.function_depth === 1;
// preserve context for awaits that precede other expressions in template or `$derived(...)`
if ( if (
is_reactive_expression( is_reactive_expression(
context.path, context.path,
context.state.derived_function_depth === context.state.function_depth context.state.derived_function_depth === context.state.function_depth
) && )
!is_last_evaluated_expression(context.path, node)
) { ) {
context.state.analysis.pickled_awaits.add(node); const expression = /** @type {ExpressionMetadata} */ (context.state.expression);
// preserve context for awaits that precede other expressions in template or `$derived(...)`,
// and for any await that follows one, so the restored context ends at the next suspension
if (expression.has_pickled_await || !is_last_evaluated_expression(context.path, node)) {
context.state.analysis.pickled_awaits.add(node);
expression.has_pickled_await = true;
}
} }
let suspend = tla; let suspend = tla;

@ -253,7 +253,7 @@ export function CallExpression(node, context) {
}); });
if (expression.has_await) { if (expression.has_await) {
context.state.analysis.async_deriveds.add(node); context.state.analysis.async_deriveds.set(node, expression);
} }
// Tell surrounding declaration tag about metadata for correct calculation of blockers etc // Tell surrounding declaration tag about metadata for correct calculation of blockers etc

@ -43,7 +43,8 @@ export function DeclarationTag(node, context) {
*/ */
export function mark_async_declaration(context, metadata, declarations) { export function mark_async_declaration(context, metadata, declarations) {
const has_await = metadata.expression.has_await; const has_await = metadata.expression.has_await;
const blockers = [...metadata.expression.dependencies] // reads inside closures must block too, like they do in template expressions
const blockers = [...metadata.expression.references]
.map((dep) => dep.blocker) .map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id); .filter((b) => b !== null && b.object !== context.state.async_consts?.id);

@ -1,4 +1,4 @@
/** @import { ExportNamedDeclaration, Identifier } from 'estree' */ /** @import { ExportNamedDeclaration, Identifier, VariableDeclaration } from 'estree' */
/** @import { Context } from '../types' */ /** @import { Context } from '../types' */
import * as e from '../../../errors.js'; import * as e from '../../../errors.js';
import { extract_identifiers } from '../../../utils/ast.js'; import { extract_identifiers } from '../../../utils/ast.js';
@ -23,15 +23,6 @@ export function ExportNamedDeclaration(node, context) {
} }
if (node.declaration?.type === 'VariableDeclaration') { if (node.declaration?.type === 'VariableDeclaration') {
// in runes mode, forbid `export let`
if (
context.state.analysis.runes &&
context.state.ast_type === 'instance' &&
node.declaration.kind === 'let'
) {
e.legacy_export_invalid(node);
}
for (const declarator of node.declaration.declarations) { for (const declarator of node.declaration.declarations) {
for (const id of extract_identifiers(declarator.id)) { for (const id of extract_identifiers(declarator.id)) {
const binding = context.state.scope.get(id.name); const binding = context.state.scope.get(id.name);
@ -46,6 +37,15 @@ export function ExportNamedDeclaration(node, context) {
} }
} }
} }
// in runes mode, forbid `export let`
if (
context.state.analysis.runes &&
context.state.ast_type === 'instance' &&
node.declaration.kind === 'let'
) {
e.legacy_export_invalid(node);
}
} }
if (context.state.analysis.runes) { if (context.state.analysis.runes) {

@ -40,6 +40,12 @@ export function transform_template(state, name, flags = 0) {
// custom renderers needs a tree to work because there's no template element we can use // custom renderers needs a tree to work because there's no template element we can use
const tree = state.options.fragments === 'tree' || custom_renderer; const tree = state.options.fragments === 'tree' || custom_renderer;
const { nodes } = state.template;
const is_lone_anchor = nodes.length === 1 && nodes[0].type === 'comment';
// special case - `$.comment` creates the anchor more cheaply than cloning a template
if (is_lone_anchor) return b.id('$.comment');
const expression = tree ? state.template.as_tree() : state.template.as_html(); const expression = tree ? state.template.as_tree() : state.template.as_html();
const key = const key =

@ -2,6 +2,7 @@
/** @import { Binding } from '#compiler' */ /** @import { Binding } from '#compiler' */
/** @import { ClientTransformState, ComponentClientTransformState } from './types.js' */ /** @import { ClientTransformState, ComponentClientTransformState } from './types.js' */
/** @import { Analysis } from '../../types.js' */ /** @import { Analysis } from '../../types.js' */
/** @import { ExpressionMetadata } from '../../nodes.js' */
/** @import { Scope } from '../../scope.js' */ /** @import { Scope } from '../../scope.js' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { is_simple_expression, save } from '../../../utils/ast.js'; import { is_simple_expression, save } from '../../../utils/ast.js';
@ -164,20 +165,46 @@ export function should_proxy(node, scope) {
return true; return true;
} }
/**
* An async thunk. If an `await` inside restores the reaction context via `$.save`,
* the body exits through `$.unsave` so the context cannot leak into foreign microtasks
* that run before the returned promise settles
* @param {Expression | BlockStatement} body
* @param {ExpressionMetadata} metadata
*/
export function async_thunk(body, metadata) {
if (!metadata.has_pickled_await) {
return b.arrow([], body, true);
}
const block = body.type === 'BlockStatement' ? body : b.block([b.return(body)]);
return b.arrow(
[],
b.block([
{
type: 'TryStatement',
block,
handler: null,
finalizer: b.block([b.stmt(b.call('$.unsave'))])
}
]),
true
);
}
/** /**
* Svelte legacy mode should use safe equals in most places, runes mode shouldn't * Svelte legacy mode should use safe equals in most places, runes mode shouldn't
* @param {ComponentClientTransformState} state * @param {ComponentClientTransformState} state
* @param {Expression | BlockStatement} expression * @param {Expression | BlockStatement} expression
* @param {boolean} [async] * @param {ExpressionMetadata} [metadata]
*/ */
export function create_derived(state, expression, async = false) { export function create_derived(state, expression, metadata) {
const thunk = b.thunk(expression, async); if (metadata?.has_await) {
return save(b.call('$.async_derived', async_thunk(expression, metadata)));
if (async) {
return save(b.call('$.async_derived', thunk));
} else {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', thunk);
} }
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', b.thunk(expression));
} }
/** /**

@ -1,9 +1,9 @@
/** @import { BlockStatement, 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, is_expression_async } 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 { async_thunk, create_derived } from '../utils.js';
import { get_value } from './shared/declarations.js'; import { get_value } from './shared/declarations.js';
import { build_expression, add_svelte_meta } from './shared/utils.js'; import { build_expression, add_svelte_meta } from './shared/utils.js';
@ -15,10 +15,10 @@ 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( const input = build_expression(context, node.expression, node.metadata.expression);
build_expression(context, node.expression, node.metadata.expression), const expression = node.metadata.expression.has_await
node.metadata.expression.has_await ? async_thunk(input, node.metadata.expression)
); : b.thunk(input);
let then_block; let then_block;
let catch_block; let catch_block;

@ -1,7 +1,7 @@
/** @import { CallExpression, Expression, Pattern } from 'estree' */ /** @import { CallExpression, Expression, Pattern } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
import { dev, is_ignored } from '../../../../state.js'; import { dev, ignore_map, is_ignored } from '../../../../state.js';
import { is_text_attribute } from '../../../../utils/ast.js'; import { is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { binding_properties } from '../../../bindings.js'; import { binding_properties } from '../../../bindings.js';
@ -40,9 +40,15 @@ export function BindDirective(node, context) {
validate_binding(context.state, node, expression); validate_binding(context.state, node, expression);
} }
const assignment = /** @type {Expression} */ ( const raw_assignment = b.assignment(
context.visit(b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value'))) '=',
/** @type {Pattern} */ (node.expression),
b.id('$$value')
); );
// The assignment is generated, so inherit any ignores attached to the binding
ignore_map.set(raw_assignment, ignore_map.get(node) ?? []);
const assignment = /** @type {Expression} */ (context.visit(raw_assignment));
if (dev) { if (dev) {
// in dev, create named functions, so that `$inspect(...)` delivers // in dev, create named functions, so that `$inspect(...)` delivers
@ -58,16 +64,7 @@ export function BindDirective(node, context) {
get = b.thunk(expression); get = b.thunk(expression);
/** @type {Expression | undefined} */ /** @type {Expression | undefined} */
set = b.unthunk( set = b.unthunk(b.arrow([b.id('$$value')], assignment));
b.arrow(
[b.id('$$value')],
/** @type {Expression} */ (
context.visit(
b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value'))
)
)
)
);
if (get === set) { if (get === set) {
set = undefined; set = undefined;

@ -19,7 +19,7 @@ export function ConstTag(node, context) {
if (declaration.id.type === 'Identifier') { if (declaration.id.type === 'Identifier') {
const init = build_expression(context, declaration.init, node.metadata.expression); const init = build_expression(context, declaration.init, node.metadata.expression);
let expression = create_derived(context.state, init, node.metadata.expression.has_await); let expression = create_derived(context.state, init, node.metadata.expression);
if (dev) { if (dev) {
expression = b.call('$.tag', expression, b.literal(declaration.id.name)); expression = b.call('$.tag', expression, b.literal(declaration.id.name));
@ -69,7 +69,7 @@ export function ConstTag(node, context) {
b.return(b.object(identifiers.map((node) => b.prop('init', node, node)))) b.return(b.object(identifiers.map((node) => b.prop('init', node, node))))
]); ]);
let expression = create_derived(context.state, block, node.metadata.expression.has_await); let expression = create_derived(context.state, block, node.metadata.expression);
if (dev) { if (dev) {
expression = b.call('$.tag', expression, b.literal('[@const]')); expression = b.call('$.tag', expression, b.literal('[@const]'));

@ -3,6 +3,7 @@
/** @import { ComponentContext } from '../types' */ /** @import { ComponentContext } from '../types' */
import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js'; import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { add_state_transformers } from './shared/declarations.js'; import { add_state_transformers } from './shared/declarations.js';
/** /**
@ -70,7 +71,7 @@ export function add_async_declaration(context, metadata, ids, assignments, kind
context.state.consts.push(kind === 'var' ? b.var(id.name) : b.let(id.name)); context.state.consts.push(kind === 'var' ? b.var(id.name) : b.let(id.name));
} }
const blockers = [...metadata.expression.dependencies] const blockers = [...metadata.expression.references]
.map((dep) => dep.blocker) .map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id); .filter((b) => b !== null && b.object !== context.state.async_consts?.id);
@ -85,5 +86,5 @@ export function add_async_declaration(context, metadata, ids, assignments, kind
metadata.expression.has_await || metadata.expression.has_await ||
assignments.some((assignment) => has_await_expression(assignment)); assignments.some((assignment) => has_await_expression(assignment));
const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments); const body = assignments.length === 1 ? assignments[0].expression : b.block(assignments);
run.thunks.push(b.thunk(body, has_await)); run.thunks.push(has_await ? async_thunk(body, metadata.expression) : b.thunk(body));
} }

@ -12,6 +12,7 @@ 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 { async_thunk } from '../utils.js';
import { get_value } from './shared/declarations.js'; import { get_value } from './shared/declarations.js';
import { build_expression, add_svelte_meta } from './shared/utils.js'; import { build_expression, add_svelte_meta } from './shared/utils.js';
@ -313,7 +314,9 @@ export function EachBlock(node, context) {
const has_await = node.metadata.expression.has_await; const has_await = node.metadata.expression.has_await;
const get_collection = b.thunk(collection, has_await); const get_collection = has_await
? async_thunk(collection, node.metadata.expression)
: b.thunk(collection);
const thunk = has_await ? b.thunk(b.call('$.get', b.id('$$collection'))) : get_collection; const thunk = has_await ? b.thunk(b.call('$.get', b.id('$$collection'))) : get_collection;
const render_args = [b.id('$$anchor'), item]; const render_args = [b.id('$$anchor'), item];

@ -141,14 +141,9 @@ export function Fragment(node, context) {
flags |= TEMPLATE_USE_IMPORT_NODE; flags |= TEMPLATE_USE_IMPORT_NODE;
} }
if (state.template.nodes.length === 1 && state.template.nodes[0].type === 'comment') { const template_name = transform_template(state, 'root', flags);
// special case — we can use `$.comment` instead of creating a unique template
state.init.unshift(b.var(id, b.call('$.comment')));
} else {
const template_name = transform_template(state, 'root', flags);
state.init.unshift(b.var(id, b.call(template_name))); state.init.unshift(b.var(id, b.call(template_name)));
}
close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
} }

@ -2,6 +2,7 @@
/** @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 { async_thunk } from '../utils.js';
import { build_expression } from './shared/utils.js'; import { build_expression } from './shared/utils.js';
/** /**
@ -46,7 +47,7 @@ export function HtmlTag(node, context) {
'$.async', '$.async',
context.state.node, context.state.node,
node.metadata.expression.blockers(), node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0, has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow( b.arrow(
has_await ? [context.state.node, b.id('$$html')] : [context.state.node], has_await ? [context.state.node, b.id('$$html')] : [context.state.node],
b.block([statement]) b.block([statement])

@ -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 { async_thunk } from '../utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js'; import { build_expression, add_svelte_meta } from './shared/utils.js';
/** /**
@ -117,7 +118,7 @@ export function IfBlock(node, context) {
'$.async', '$.async',
context.state.node, context.state.node,
node.metadata.expression.blockers(), node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0, has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow( b.arrow(
has_await ? [context.state.node, b.id('$$condition')] : [context.state.node], has_await ? [context.state.node, b.id('$$condition')] : [context.state.node],
b.block(statements) b.block(statements)

@ -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 { async_thunk } from '../utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js'; import { build_expression, add_svelte_meta } from './shared/utils.js';
/** /**
@ -31,7 +32,7 @@ export function KeyBlock(node, context) {
'$.async', '$.async',
context.state.node, context.state.node,
node.metadata.expression.blockers(), node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0, has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow( b.arrow(
has_await ? [context.state.node, b.id('$$key')] : [context.state.node], has_await ? [context.state.node, b.id('$$key')] : [context.state.node],
b.block([statement]) b.block([statement])

@ -1,4 +1,4 @@
/** @import { ArrayExpression, Expression, ExpressionStatement, Identifier, MemberExpression, ObjectExpression } from 'estree' */ /** @import { ArrayExpression, Expression, ExpressionStatement, Identifier, MemberExpression, ObjectExpression, Statement } from 'estree' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */ /** @import { ComponentClientTransformState, ComponentContext } from '../types' */
/** @import { Scope } from '../../../scope' */ /** @import { Scope } from '../../../scope' */
@ -232,6 +232,12 @@ export function RegularElement(node, context) {
continue; continue;
} }
// `<select defaultValue>` needs the options to exist before it can mark one
// as selected, so it is handled after the children, alongside `value`
if (node.name === 'select' && get_attribute_name(node, attribute) === 'defaultValue') {
continue;
}
const name = get_attribute_name(node, attribute); const name = get_attribute_name(node, attribute);
if ( if (
@ -451,7 +457,7 @@ export function RegularElement(node, context) {
state: child_state state: child_state
}); });
if (needs_reset) { if (needs_reset && !fold_reset_into_child(child_state.init, context.state.node)) {
child_state.init.push(b.stmt(b.call('$.reset', context.state.node))); child_state.init.push(b.stmt(b.call('$.reset', context.state.node)));
} }
} }
@ -530,6 +536,34 @@ export function RegularElement(node, context) {
} }
} }
// deferred from the attribute loop above, so that the options it selects from
// have been created and had their values assigned
if (!has_spread && name === 'select') {
const default_value = /** @type {AST.Attribute[]} */ (attributes).find(
(attribute) => get_attribute_name(node, attribute) === 'defaultValue'
);
if (default_value) {
const { value, has_state } = build_attribute_value(default_value.value, context, (v, m) =>
context.state.memoizer.add(v, m)
);
(has_state ? context.state.update : context.state.init).push(
b.stmt(b.call('$.set_default_select_value', node_id, value))
);
}
const value_attribute = lookup.get('value');
const dynamic_value =
value_attribute !== undefined &&
value_attribute.value !== true &&
!is_text_attribute(value_attribute);
if (default_value || dynamic_value || bindings.has('value')) {
context.state.init.push(b.stmt(b.call('$.init_select', node_id)));
}
}
context.state.template.pop_element(); context.state.template.pop_element();
} }
@ -720,28 +754,32 @@ function build_element_special_value_attribute(
); );
const evaluated = context.state.scope.evaluate(value); const evaluated = context.state.scope.evaluate(value);
const assignment = b.assignment('=', b.member(node_id, '__value'), value);
const set_value_assignment = b.assignment( /** @param {Expression} value */
'=', const build_update = (value) => {
b.member(node_id, 'value'), const assignment = b.assignment('=', b.member(node_id, '__value'), value);
evaluated.is_defined ? assignment : b.logical('??', assignment, b.literal(''))
);
const update = b.stmt( const set_value_assignment = b.assignment(
is_select_with_value '=',
? b.sequence([ b.member(node_id, 'value'),
set_value_assignment, evaluated.is_defined ? assignment : b.logical('??', assignment, b.literal(''))
// This ensures a one-way street to the DOM in case it's <select {value}> );
// and not <select bind:value>. We need it in addition to $.init_select
// because the select value is not reflected as an attribute, so the return b.stmt(
// mutation observer wouldn't notice. is_select_with_value
b.call('$.select_option', node_id, value) ? b.sequence([
]) set_value_assignment,
: synthetic // This ensures a one-way street to the DOM in case it's <select {value}>
? assignment // and not <select bind:value>. We need it in addition to $.init_select
: set_value_assignment // because the select value is not reflected as an attribute, so the
); // mutation observer wouldn't notice.
b.call('$.select_option', node_id, value)
])
: synthetic
? assignment
: set_value_assignment
);
};
if (has_state) { if (has_state) {
const id = b.id(state.scope.generate(`${node_id.name}_value`)); const id = b.id(state.scope.generate(`${node_id.name}_value`));
@ -752,12 +790,49 @@ function build_element_special_value_attribute(
const init = element === 'option' ? b.object([]) : undefined; const init = element === 'option' ? b.object([]) : undefined;
state.init.push(b.var(id, init)); state.init.push(b.var(id, init));
state.update.push(b.if(b.binary('!==', id, b.assignment('=', id, value)), b.block([update])));
// the guard already evaluated `value` into `id`, so read that back rather than
// evaluating the same expression (and its signal reads) a second time
state.update.push(
b.if(b.binary('!==', id, b.assignment('=', id, value)), b.block([build_update(id)]))
);
} else { } else {
state.init.push(update); state.init.push(build_update(value));
} }
}
if (is_select_with_value) { /**
state.init.push(b.stmt(b.call('$.init_select', node_id))); * `<p>{text}</p>` and friends produce `var x = $.child(p, true); $.reset(p);`. That pair is
* by far the most common shape in compiled output, and `$.only_child` does both, so fold the
* two together when the `$.child(...)` is the last thing we emitted for this element.
* @param {Statement[]} init
* @param {Expression} node_id
* @returns {boolean} whether the reset was folded in
*/
function fold_reset_into_child(init, node_id) {
const last = init.at(-1);
if (
node_id?.type !== 'Identifier' ||
last?.type !== 'VariableDeclaration' ||
last.declarations.length !== 1
) {
return false;
}
const call = last.declarations[0].init;
if (
call?.type !== 'CallExpression' ||
call.callee.type !== 'Identifier' ||
call.callee.name !== '$.child' ||
call.arguments[0]?.type !== 'Identifier' ||
call.arguments[0].name !== node_id.name
) {
return false;
} }
call.callee = b.id('$.only_child');
return true;
} }

@ -4,6 +4,7 @@
import { dev, locator } from '../../../../state.js'; import { dev, locator } from '../../../../state.js';
import { is_text_attribute } from '../../../../utils/ast.js'; import { is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { determine_namespace_for_children } from '../../utils.js'; import { determine_namespace_for_children } from '../../utils.js';
import { import {
build_attribute_value, build_attribute_value,
@ -147,7 +148,7 @@ export function SvelteElement(node, context) {
'$.async', '$.async',
context.state.node, context.state.node,
node.metadata.expression.blockers(), node.metadata.expression.blockers(),
has_await ? b.array([b.thunk(expression, true)]) : b.void0, has_await ? b.array([async_thunk(expression, node.metadata.expression)]) : b.void0,
b.arrow( b.arrow(
has_await ? [context.state.node, b.id('$$tag')] : [context.state.node], has_await ? [context.state.node, b.id('$$tag')] : [context.state.node],
b.block(statements) b.block(statements)

@ -6,7 +6,13 @@ import { extract_paths, save } from '../../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import * as assert from '../../../../utils/assert.js'; import * as assert from '../../../../utils/assert.js';
import { get_rune } from '../../../scope.js'; import { get_rune } from '../../../scope.js';
import { get_prop_source, is_prop_source, is_state_source, should_proxy } from '../utils.js'; import {
async_thunk,
get_prop_source,
is_prop_source,
is_state_source,
should_proxy
} from '../utils.js';
import { get_value } from './shared/declarations.js'; import { get_value } from './shared/declarations.js';
/** /**
@ -200,9 +206,10 @@ export function VariableDeclaration(node, context) {
} }
if (rune === '$derived' || rune === '$derived.by') { if (rune === '$derived' || rune === '$derived.by') {
const is_async = context.state.analysis.async_deriveds.has( const metadata = context.state.analysis.async_deriveds.get(
/** @type {CallExpression} */ (init) /** @type {CallExpression} */ (init)
); );
const is_async = metadata !== undefined;
if (declarator.id.type === 'Identifier') { if (declarator.id.type === 'Identifier') {
let expression = /** @type {Expression} */ (context.visit(value)); let expression = /** @type {Expression} */ (context.visit(value));
@ -213,7 +220,7 @@ export function VariableDeclaration(node, context) {
/** @type {Expression} */ /** @type {Expression} */
let call = b.call( let call = b.call(
'$.async_derived', '$.async_derived',
b.thunk(expression, true), async_thunk(expression, metadata),
dev && b.literal(declarator.id.name), dev && b.literal(declarator.id.name),
location ? b.literal(location) : undefined location ? b.literal(location) : undefined
); );
@ -246,7 +253,7 @@ export function VariableDeclaration(node, context) {
call = b.call( call = b.call(
'$.async_derived', '$.async_derived',
b.thunk(expression, true), async_thunk(expression, metadata),
dev && dev &&
b.literal( b.literal(
`[$derived ${declarator.id.type === 'ArrayPattern' ? 'iterable' : 'object'}]` `[$derived ${declarator.id.type === 'ArrayPattern' ? 'iterable' : 'object'}]`

@ -30,6 +30,7 @@ export function build_attribute_effect(
) { ) {
/** @type {ObjectExpression['properties']} */ /** @type {ObjectExpression['properties']} */
const values = []; const values = [];
const is_select = element.type === 'RegularElement' && element.name === 'select';
const memoizer = new Memoizer(); const memoizer = new Memoizer();
@ -48,7 +49,11 @@ export function build_attribute_effect(
context.state.init.push(b.var(id, value)); context.state.init.push(b.var(id, value));
values.push(b.init(attribute.name, b.id(id))); values.push(b.init(attribute.name, b.id(id)));
} else { } else {
values.push(b.init(attribute.name, value)); const name =
is_select && normalize_attribute(attribute.name) === 'defaultValue'
? 'defaultValue'
: attribute.name;
values.push(b.init(name, value));
} }
} else { } else {
let value = /** @type {Expression} */ (context.visit(attribute)); let value = /** @type {Expression} */ (context.visit(attribute));

@ -167,7 +167,7 @@ export function is_static_element(node) {
} }
if ( if (
['input', 'textarea'].includes(node.name) && ['input', 'textarea', 'select'].includes(node.name) &&
['value', 'checked'].includes(attribute.name) ['value', 'checked'].includes(attribute.name)
) { ) {
return false; return false;
@ -177,11 +177,6 @@ export function is_static_element(node) {
return false; return false;
} }
// We need to apply src and loading after appending the img to the DOM for lazy loading to work
if (node.name === 'img' && attribute.name === 'loading') {
return false;
}
if (attribute.value !== true && !is_text_attribute(attribute)) { if (attribute.value !== true && !is_text_attribute(attribute)) {
return false; return false;
} }

@ -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, component_name } from '../../../../../state.js'; import { dev, is_ignored, locator, component_name } from '../../../../../state.js';
import { build_getter, is_state_source } from '../../utils.js'; import { async_thunk, build_getter, is_state_source } from '../../utils.js';
import { ExpressionMetadata } from '../../../../nodes.js'; import { ExpressionMetadata } from '../../../../nodes.js';
/** /**
@ -16,10 +16,10 @@ import { ExpressionMetadata } from '../../../../nodes.js';
* from templates and replacing them with `$0`, `$1` etc * from templates and replacing them with `$0`, `$1` etc
*/ */
export class Memoizer { export class Memoizer {
/** @type {Array<{ id: Identifier, expression: Expression }>} */ /** @type {Array<{ id: Identifier, expression: Expression, metadata: ExpressionMetadata }>} */
#sync = []; #sync = [];
/** @type {Array<{ id: Identifier, expression: Expression }>} */ /** @type {Array<{ id: Identifier, expression: Expression, metadata: ExpressionMetadata }>} */
#async = []; #async = [];
/** @type {Set<Expression>} */ /** @type {Set<Expression>} */
@ -43,7 +43,7 @@ export class Memoizer {
const id = b.id('#'); // filled in later const id = b.id('#'); // filled in later
(metadata.has_await ? this.#async : this.#sync).push({ id, expression }); (metadata.has_await ? this.#async : this.#sync).push({ id, expression, metadata });
return id; return id;
} }
@ -84,7 +84,7 @@ export class Memoizer {
if (this.#async.length === 0) return; if (this.#async.length === 0) return;
// use `b.arrow` rather than `b.thunk` so that deferred async/template effects // use `b.arrow` rather than `b.thunk` so that deferred async/template effects
// always read live bindings rather than a possibly stale snapshot. // always read live bindings rather than a possibly stale snapshot.
return b.array(this.#async.map((memo) => b.arrow([], memo.expression, true))); return b.array(this.#async.map((memo) => async_thunk(memo.expression, memo.metadata)));
} }
sync_values() { sync_values() {

@ -110,7 +110,7 @@ function build_assignment(operator, left, right, context) {
context.visit(build_assignment_value(operator, left, right)) context.visit(build_assignment_value(operator, left, right))
); );
return b.call(binding.node, value); return b.call(object, value);
} }
return null; return null;

@ -66,7 +66,7 @@ export function add_async_declaration(context, metadata, ids, assignments, kind
context.state.init.push(kind === 'var' ? b.var(id.name) : b.let(id.name)); context.state.init.push(kind === 'var' ? b.var(id.name) : b.let(id.name));
} }
const blockers = [...metadata.expression.dependencies] const blockers = [...metadata.expression.references]
.map((dep) => dep.blocker) .map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id); .filter((b) => b !== null && b.object !== context.state.async_consts?.id);

@ -2,7 +2,13 @@
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */ /** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { block_close, block_open, block_open_else, create_child_block } from './shared/utils.js'; import {
block_close,
block_open,
block_open_else,
create_child_block,
prepend_block_marker
} from './shared/utils.js';
/** /**
* @param {AST.EachBlock} node * @param {AST.EachBlock} node
@ -51,7 +57,7 @@ export function EachBlock(node, context) {
const fallback = /** @type {BlockStatement} */ (context.visit(node.fallback)); const fallback = /** @type {BlockStatement} */ (context.visit(node.fallback));
fallback.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), block_open_else))); prepend_block_marker(fallback, /** @type {string} */ (block_open_else.value));
statements.push( statements.push(
b.if( b.if(

@ -2,7 +2,7 @@
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */ /** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { block_close, create_child_block } from './shared/utils.js'; import { block_close, create_child_block, prepend_block_marker } from './shared/utils.js';
/** /**
* @param {AST.IfBlock} node * @param {AST.IfBlock} node
@ -10,7 +10,7 @@ import { block_close, create_child_block } from './shared/utils.js';
*/ */
export function IfBlock(node, context) { export function IfBlock(node, context) {
const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent)); const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent));
consequent.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), b.literal(`<!--[0-->`)))); prepend_block_marker(consequent, `<!--[0-->`);
/** @type {IfStatement} */ /** @type {IfStatement} */
let if_statement = b.if(/** @type {Expression} */ (context.visit(node.test)), consequent); let if_statement = b.if(/** @type {Expression} */ (context.visit(node.test)), consequent);
@ -22,7 +22,7 @@ export function IfBlock(node, context) {
// Walk the else-if chain, flattening branches // Walk the else-if chain, flattening branches
for (const elseif of node.metadata.flattened ?? []) { for (const elseif of node.metadata.flattened ?? []) {
const branch = /** @type {BlockStatement} */ (context.visit(elseif.consequent)); const branch = /** @type {BlockStatement} */ (context.visit(elseif.consequent));
branch.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), b.literal(`<!--[${index++}-->`)))); prepend_block_marker(branch, `<!--[${index++}-->`);
current_if = current_if.alternate = b.if( current_if = current_if.alternate = b.if(
/** @type {Expression} */ (context.visit(elseif.test)), /** @type {Expression} */ (context.visit(elseif.test)),
@ -34,7 +34,7 @@ export function IfBlock(node, context) {
// Handle final else (or remaining async chain) // Handle final else (or remaining async chain)
const final_alternate = alt ? /** @type {BlockStatement} */ (context.visit(alt)) : b.block([]); const final_alternate = alt ? /** @type {BlockStatement} */ (context.visit(alt)) : b.block([]);
final_alternate.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), b.literal(`<!--[-1-->`)))); prepend_block_marker(final_alternate, `<!--[-1-->`);
current_if.alternate = final_alternate; current_if.alternate = final_alternate;
context.state.template.push( context.state.template.push(

@ -46,7 +46,7 @@ export function RegularElement(node, context) {
node.attributes.some( node.attributes.some(
(attribute) => (attribute) =>
((attribute.type === 'Attribute' || attribute.type === 'BindDirective') && ((attribute.type === 'Attribute' || attribute.type === 'BindDirective') &&
attribute.name === 'value') || (attribute.name === 'value' || attribute.name.toLowerCase() === 'defaultvalue')) ||
attribute.type === 'SpreadAttribute' attribute.type === 'SpreadAttribute'
); );
const is_option_special = name === 'option'; const is_option_special = name === 'option';

@ -306,12 +306,14 @@ function get_attribute_name(element, attribute) {
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform * @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
*/ */
export function build_spread_object(element, attributes, context, transform) { export function build_spread_object(element, attributes, context, transform) {
const is_select = element.type === 'RegularElement' && element.name === 'select';
const object = b.object( const object = b.object(
attributes.map((attribute) => { attributes.map((attribute) => {
if (attribute.type === 'transformed') { if (attribute.type === 'transformed') {
return b.prop('init', b.key(attribute.name), attribute.expression); return b.prop('init', b.key(attribute.name), attribute.expression);
} else if (attribute.type === 'Attribute') { } else if (attribute.type === 'Attribute') {
const name = get_attribute_name(element, attribute); let name = get_attribute_name(element, attribute);
if (is_select && name === 'defaultvalue') name = 'defaultValue';
const value = build_attribute_value( const value = build_attribute_value(
attribute.value, attribute.value,
context, context,
@ -322,10 +324,9 @@ export function build_spread_object(element, attributes, context, transform) {
return b.prop('init', b.key(name), value); return b.prop('init', b.key(name), value);
} else if (attribute.type === 'BindDirective') { } else if (attribute.type === 'BindDirective') {
const name = get_attribute_name(element, attribute); const name = get_attribute_name(element, attribute);
const expression = /** @type {Expression} */ (context.visit(attribute.expression));
const value = const value =
attribute.expression.type === 'SequenceExpression' expression.type === 'SequenceExpression' ? b.call(expression.expressions[0]) : expression;
? b.call(attribute.expression.expressions[0])
: /** @type {Expression} */ (context.visit(attribute.expression));
return b.prop('init', b.key(name), value); return b.prop('init', b.key(name), value);
} }

@ -180,6 +180,36 @@ export function build_template(template) {
return statements; return statements;
} }
/**
* Prepends a hydration marker (e.g. `<!--[0-->`) to a branch. The branch has already been
* turned into statements by `build_template`, so if it happens to start with a static
* `$$renderer.push(...)` we fold the marker into that call rather than emitting a second one.
* @param {BlockStatement} block
* @param {string} marker
*/
export function prepend_block_marker(block, marker) {
const first = block.body[0];
if (
first?.type === 'ExpressionStatement' &&
first.expression.type === 'CallExpression' &&
first.expression.callee.type === 'Identifier' &&
first.expression.callee.name === '$$renderer.push' &&
first.expression.arguments.length === 1 &&
first.expression.arguments[0].type === 'TemplateLiteral'
) {
const quasi = first.expression.arguments[0].quasis[0];
// markers never contain characters that need escaping in a template literal
quasi.value.cooked = marker + quasi.value.cooked;
quasi.value.raw = marker + quasi.value.raw;
return;
}
block.body.unshift(b.stmt(b.call(b.id('$$renderer.push'), b.literal(marker))));
}
/** /**
* *
* @param {AST.Attribute['value']} value * @param {AST.Attribute['value']} value

@ -77,6 +77,9 @@ export class ExpressionMetadata {
/** True if the expression contains `await` */ /** True if the expression contains `await` */
has_await = false; has_await = false;
/** True if an `await` restores the reaction context afterwards, so the thunk must end it */
has_pickled_await = false;
/** True if the expression includes a member expression */ /** True if the expression includes a member expression */
has_member_expression = false; has_member_expression = false;
@ -142,6 +145,7 @@ export class ExpressionMetadata {
this.has_state ||= source.has_state; this.has_state ||= source.has_state;
this.has_call ||= source.has_call; this.has_call ||= source.has_call;
this.has_await ||= source.has_await; this.has_await ||= source.has_await;
this.has_pickled_await ||= source.has_pickled_await;
this.has_member_expression ||= source.has_member_expression; this.has_member_expression ||= source.has_member_expression;
this.has_assignment ||= source.has_assignment; this.has_assignment ||= source.has_assignment;
this.#blockers = null; // so that blockers are recalculated this.#blockers = null; // so that blockers are recalculated

@ -63,7 +63,7 @@ export interface Analysis {
accessors: boolean; accessors: boolean;
/** A set of deriveds that contain `await` expressions */ /** A set of deriveds that contain `await` expressions */
async_deriveds: Set<CallExpression>; async_deriveds: Map<CallExpression, ExpressionMetadata>;
/** Awaits needing context preservation */ /** Awaits needing context preservation */
pickled_awaits: Set<AwaitExpression>; pickled_awaits: Set<AwaitExpression>;
} }

@ -1,14 +1,8 @@
/** @import { Effect, Source, TemplateNode, } from '#client' */ /** @import { Effect, Source, TemplateNode, } from '#client' */
import { import { BOUNDARY_EFFECT, EFFECT_PRESERVED, EFFECT_TRANSPARENT } from '#client/constants';
BOUNDARY_EFFECT,
DIRTY,
EFFECT_PRESERVED,
EFFECT_TRANSPARENT,
MAYBE_DIRTY
} from '#client/constants';
import { HYDRATION_START_ELSE, HYDRATION_START_FAILED } from '../../../../constants.js'; import { HYDRATION_START_ELSE, HYDRATION_START_FAILED } from '../../../../constants.js';
import { component_context, set_component_context } from '../../context.js'; import { component_context, set_component_context } from '../../context.js';
import { handle_error, invoke_error_boundary } from '../../error-handling.js'; import { invoke_error_boundary } from '../../error-handling.js';
import { import {
block, block,
branch, branch,
@ -285,13 +279,31 @@ export class Boundary {
var fragment = (this.#offscreen_fragment = create_fragment()); var fragment = (this.#offscreen_fragment = create_fragment());
var anchor = create_text(); var anchor = create_text();
var handled = false;
append_child(fragment, anchor); append_child(fragment, anchor);
this.#main_effect = this.#run(() => { this.#main_effect = this.#run(() => {
return branch(() => this.#children(anchor)); try {
return branch(() => this.#children(anchor));
} catch (error) {
try {
this.error(error);
handled = true;
} catch (error) {
invoke_error_boundary(error, this.#effect.parent);
}
return null;
}
}); });
if (this.#main_effect === null) {
this.#offscreen_fragment = null;
if (handled) this.#resolve(/** @type {Batch} */ (current_batch));
return;
}
if (this.#pending_count === 0) { if (this.#pending_count === 0) {
insert_before(this.#anchor, fragment); insert_before(this.#anchor, fragment);
this.#offscreen_fragment = null; this.#offscreen_fragment = null;
@ -380,9 +392,6 @@ export class Boundary {
try { try {
Batch.ensure(); Batch.ensure();
return fn(); return fn();
} catch (e) {
handle_error(e);
return null;
} finally { } finally {
set_active_effect(previous_effect); set_active_effect(previous_effect);
set_active_reaction(previous_reaction); set_active_reaction(previous_reaction);

@ -1,4 +1,6 @@
import { hydrate_next, hydrating } from '../hydration.js'; import { hydrate_next, hydrating } from '../hydration.js';
import { create_element, create_text } from '../operations.js';
import { append } from '../template.js';
/** /**
* @param {Comment} anchor * @param {Comment} anchor
@ -12,6 +14,23 @@ export function slot(anchor, $$props, name, slot_props, fallback_fn) {
hydrate_next(); hydrate_next();
} }
// Custom element slots are native DOM slots.
// Use the stored reference because the shadow root may be closed.
if ($$props.$$host?.$$shadowRoot) {
const element = create_element('slot');
if (name !== 'default') element.name = name;
append(anchor, element);
if (fallback_fn !== null) {
const fallback_anchor = create_text();
element.append(fallback_anchor);
fallback_fn(fallback_anchor);
}
return;
}
var slot_fn = $$props.$$slots?.[name]; var slot_fn = $$props.$$slots?.[name];
// Interop: Can use snippets to fill slots // Interop: Can use snippets to fill slots
var is_interop = false; var is_interop = false;

@ -26,7 +26,12 @@ import { set_class } from './class.js';
import { set_style } from './style.js'; import { set_style } from './style.js';
import { ATTACHMENT_KEY, NAMESPACE_HTML, UNINITIALIZED } from '../../../../constants.js'; import { ATTACHMENT_KEY, NAMESPACE_HTML, UNINITIALIZED } from '../../../../constants.js';
import { branch, destroy_effect, effect, managed } from '../../reactivity/effects.js'; import { branch, destroy_effect, effect, managed } from '../../reactivity/effects.js';
import { init_select, select_option } from './bindings/select.js'; import {
init_select,
select_option,
set_default_select_value,
set_selected
} from './bindings/select.js';
import { flatten } from '../../reactivity/async.js'; import { flatten } from '../../reactivity/async.js';
import { import {
has_attribute, has_attribute,
@ -135,25 +140,6 @@ export function set_checked(element, checked) {
set_element_checked(element, checked); set_element_checked(element, checked);
} }
/**
* Sets the `selected` attribute on an `option` element.
* Not set through the property because that doesn't reflect to the DOM,
* which means it wouldn't be taken into account when a form is reset.
* @param {HTMLOptionElement} element
* @param {boolean} selected
*/
export function set_selected(element, selected) {
if (selected) {
// The selected option could've changed via user selection, and
// setting the value without this check would set it back.
if (!has_attribute(element, 'selected')) {
set_attribute_op(element, 'selected', '');
}
} else {
remove_attribute(element, 'selected');
}
}
/** /**
* Applies the default checked property without influencing the current checked property. * Applies the default checked property without influencing the current checked property.
* @param {HTMLInputElement} element * @param {HTMLInputElement} element
@ -210,7 +196,7 @@ export function set_attribute(element, attribute, value, skip_warning) {
if (value == null) { if (value == null) {
remove_attribute(element, attribute); remove_attribute(element, attribute);
} else if (typeof value !== 'string' && get_setters(element).includes(attribute)) { } else if (typeof value !== 'string' && get_setters(element).has(attribute)) {
// @ts-ignore // @ts-ignore
element[attribute] = value; element[attribute] = value;
} else { } else {
@ -263,7 +249,7 @@ export function set_custom_element_data(node, prop, value) {
// customElements may not be available in browser extension contexts // customElements may not be available in browser extension contexts
!customElements || !customElements ||
customElements.get(get_attribute(node, 'is') || (node_name(node) ?? '').toLowerCase()) customElements.get(get_attribute(node, 'is') || (node_name(node) ?? '').toLowerCase())
? get_setters(node).includes(prop) ? get_setters(node).has(prop)
: value && typeof value === 'object') : value && typeof value === 'object')
) { ) {
// @ts-expect-error // @ts-expect-error
@ -302,11 +288,8 @@ function set_attributes(
skip_warning = false skip_warning = false
) { ) {
if (hydrating && should_remove_defaults && node_name(element) === INPUT_TAG) { if (hydrating && should_remove_defaults && node_name(element) === INPUT_TAG) {
var input = /** @type {HTMLInputElement} */ (element); if (!('defaultValue' in next || 'defaultChecked' in next)) {
var attribute = input.type === 'checkbox' ? 'defaultChecked' : 'defaultValue'; remove_input_defaults(/** @type {HTMLInputElement} */ (element));
if (!(attribute in next)) {
remove_input_defaults(input);
} }
} }
@ -324,6 +307,7 @@ function set_attributes(
var current = prev || {}; var current = prev || {};
var is_option_element = node_name(element) === OPTION_TAG; var is_option_element = node_name(element) === OPTION_TAG;
var is_select_element = node_name(element) === SELECT_TAG;
for (var key in prev) { for (var key in prev) {
// don't null our internal $$onX listeners // don't null our internal $$onX listeners
@ -477,6 +461,9 @@ function set_attributes(
var is_default = name === 'defaultValue' || name === 'defaultChecked'; var is_default = name === 'defaultValue' || name === 'defaultChecked';
// A select's default value is represented by selected options, not a property.
if (is_select_element && name === 'defaultValue') continue;
if (value == null && !is_custom_element && !is_default) { if (value == null && !is_custom_element && !is_default) {
attributes[key] = null; attributes[key] = null;
if ((name === 'value' || name === 'checked') && current_renderer == null) { if ((name === 'value' || name === 'checked') && current_renderer == null) {
@ -514,7 +501,7 @@ function set_attributes(
if (name in attributes) attributes[name] = UNINITIALIZED; if (name in attributes) attributes[name] = UNINITIALIZED;
} else if ( } else if (
is_default || is_default ||
(setters.includes(name) && (is_custom_element || typeof value !== 'string')) ((is_custom_element || typeof value !== 'string') && setters.has(name))
) { ) {
// @ts-ignore // @ts-ignore
element[name] = value; element[name] = value;
@ -575,8 +562,16 @@ export function attribute_effect(
skip_warning skip_warning
); );
if (inited && is_select && 'value' in next) { if (inited && is_select) {
select_option(/** @type {HTMLSelectElement} */ (element), next.value); var select = /** @type {HTMLSelectElement} */ (element);
if ('defaultValue' in next) {
set_default_select_value(select, next.defaultValue);
}
if ('value' in next) {
select_option(select, next.value);
}
} }
for (let symbol of Object.getOwnPropertySymbols(effects)) { for (let symbol of Object.getOwnPropertySymbols(effects)) {
@ -601,7 +596,13 @@ export function attribute_effect(
var select = /** @type {HTMLSelectElement} */ (element); var select = /** @type {HTMLSelectElement} */ (element);
effect(() => { effect(() => {
select_option(select, /** @type {Record<string | symbol, any>} */ (prev).value, true); var attrs = /** @type {Record<string | symbol, any>} */ (prev);
if ('defaultValue' in attrs) {
set_default_select_value(select, attrs.defaultValue);
}
select_option(select, attrs.value, true);
init_select(select); init_select(select);
}); });
} }
@ -623,7 +624,7 @@ function get_attributes(element) {
); );
} }
/** @type {Map<string, string[]>} */ /** @type {Map<string, Set<string>>} */
var setters_cache = new Map(); var setters_cache = new Map();
/** @param {Element} element */ /** @param {Element} element */
@ -633,7 +634,7 @@ function get_setters(element) {
var cache_key = get_attribute(element, 'is') || (node_name(element) ?? ''); var cache_key = get_attribute(element, 'is') || (node_name(element) ?? '');
var setters = setters_cache.get(cache_key); var setters = setters_cache.get(cache_key);
if (setters) return setters; if (setters) return setters;
setters_cache.set(cache_key, (setters = [])); setters_cache.set(cache_key, (setters = new Set()));
var descriptors; var descriptors;
var proto = element; // In the case of custom elements there might be setters on the instance var proto = element; // In the case of custom elements there might be setters on the instance
@ -652,7 +653,7 @@ function get_setters(element) {
key !== 'textContent' && key !== 'textContent' &&
key !== 'innerText' key !== 'innerText'
) { ) {
setters.push(key); setters.add(key);
} }
} }

@ -6,6 +6,71 @@ import * as w from '../../../warnings.js';
import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js'; import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js';
import { async_mode_flag } from '../../../../flags/index.js'; import { async_mode_flag } from '../../../../flags/index.js';
/**
* Sets the `selected` attribute on an option so form reset can restore it.
* @param {HTMLOptionElement} option
* @param {boolean} selected
*/
export function set_selected(option, selected) {
if (selected) {
if (!option.hasAttribute('selected')) option.setAttribute('selected', '');
} else {
option.removeAttribute('selected');
}
}
/**
* Sets the options a form reset should restore. The first call selects
* them if nothing has set a value, later calls leave the current selection alone.
* @param {HTMLSelectElement} select
* @param {any} value
*/
export function set_default_select_value(select, value) {
var mounting = !('__defaultValue' in select);
// @ts-expect-error
if (!mounting && select.__defaultValue === value) return;
// @ts-expect-error
select.__defaultValue = value;
apply_default_select_value(select, !mounting || '__value' in select);
}
/**
* Marks the options matching `__defaultValue` as selected. Without `preserve`
* a newly matching option gets selected, as an inserted `<option selected>` would.
* @param {HTMLSelectElement} select
* @param {boolean} preserve
*/
function apply_default_select_value(select, preserve) {
// @ts-expect-error
var value = select.__defaultValue;
var multiple = select.multiple;
var values = multiple ? value ?? [] : null;
if (multiple && !is_array(values)) return;
var index = select.selectedIndex;
var selected = preserve && multiple ? new Set(select.selectedOptions) : null;
for (var option of select.options) {
var option_value = get_option_value(option);
set_selected(
option,
multiple ? /** @type {any[]} */ (values).includes(option_value) : is(option_value, value)
);
}
if (!preserve) return;
if (selected !== null) {
for (option of select.options) {
var was_selected = selected.has(option);
if (option.selected !== was_selected) option.selected = was_selected;
}
} else if (select.selectedIndex !== index) {
select.selectedIndex = index;
}
}
/** /**
* Selects the correct option(s) (depending on whether this is a multiple select) * Selects the correct option(s) (depending on whether this is a multiple select)
* @template V * @template V
@ -47,11 +112,10 @@ export function select_option(select, value, mounting = false) {
} }
/** /**
* Selects the correct option(s) if `value` is given, * Sets up a mutation observer to sync the current selection
* and then sets up a mutation observer to sync the * and default to the dom when the options change, for example
* current selection to the dom when it changes. Such * when they are inside an `#each` block. Called once per `<select>`,
* changes could for example occur when options are * by the compiled output or by `attribute_effect` for spreads.
* inside an `#each` block.
* @param {HTMLSelectElement} select * @param {HTMLSelectElement} select
*/ */
export function init_select(select) { export function init_select(select) {
@ -60,9 +124,15 @@ export function init_select(select) {
// Reacting to them could revert a user-initiated selection change, because the // Reacting to them could revert a user-initiated selection change, because the
// records are delivered as soon as any listener returns (e.g. a delegated `input` // records are delivered as soon as any listener returns (e.g. a delegated `input`
// handler), which can happen before the `change` handler has updated `__value` // handler), which can happen before the `change` handler has updated `__value`
if (entries.every(is_selectedcontent_mutation) || !('__value' in select)) return; if (entries.every(is_selectedcontent_mutation)) return;
// @ts-ignore
select_option(select, select.__value); if ('__defaultValue' in select) {
apply_default_select_value(select, false);
}
if ('__value' in select) {
select_option(select, select.__value);
}
// Deliberately don't update the potential binding value, // Deliberately don't update the potential binding value,
// the model should be preserved unless explicitly changed // the model should be preserved unless explicitly changed
}); });
@ -154,8 +224,6 @@ export function bind_select_value(select, get, set = get) {
select.__value = value; select.__value = value;
mounting = false; mounting = false;
}); });
init_select(select);
} }
/** @param {HTMLOptionElement} option */ /** @param {HTMLOptionElement} option */

@ -337,6 +337,7 @@ export function transition(flags, element, get_fn, get_params) {
*/ */
function animate(element, options, counterpart, t2, on_begin, on_finish) { function animate(element, options, counterpart, t2, on_begin, on_finish) {
var is_intro = t2 === 1; var is_intro = t2 === 1;
var aborted = false;
if (is_function(options)) { if (is_function(options)) {
// In the case of a deferred transition (such as `crossfade`), `option` will be // In the case of a deferred transition (such as `crossfade`), `option` will be
@ -344,7 +345,6 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) {
// once the DOM has been updated... // once the DOM has been updated...
/** @type {Animation} */ /** @type {Animation} */
var a; var a;
var aborted = false;
queue_micro_task(() => { queue_micro_task(() => {
if (aborted) return; if (aborted) return;
@ -381,99 +381,112 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) {
const { delay = 0, css, tick, easing = linear } = options; const { delay = 0, css, tick, easing = linear } = options;
var keyframes = []; /** @type {globalThis.Animation} */
var animation;
if (is_intro && counterpart === undefined) { var get_t = () => 1 - t2;
if (tick) {
tick(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes?
}
if (css) { // wait a microtask before applying the initial styles and creating the dummy animation,
var styles = css_to_keyframe(css(0, 1)); // so that transitions created in the same batch (e.g. on nested elements) all measure
keyframes.push(styles, styles); // the DOM first (#18421). this still happens before the next paint, so the element
} // won't be rendered without styles applied (#14732)
} queue_micro_task(() => {
if (aborted) return;
var get_t = () => 1 - t2; var keyframes = [];
// create a dummy animation that lasts as long as the delay (but with whatever devtools if (is_intro && counterpart === undefined) {
// multiplier is in effect). in the common case that it is `0`, we keep it anyway so that if (tick) {
// the CSS keyframes aren't created until the DOM is updated tick(0, 1); // TODO put in nested effect, to avoid interleaved reads/writes?
// }
// fill forwards to prevent the element from rendering without styles applied
// see https://github.com/sveltejs/svelte/issues/14732
var animation = element.animate(keyframes, { duration: delay, fill: 'forwards' });
animation.onfinish = () => { if (css) {
// remove dummy animation from the stack to prevent conflict with main animation var styles = css_to_keyframe(css(0, 1));
animation.cancel(); keyframes.push(styles, styles);
}
}
on_begin(); // create a dummy animation that lasts as long as the delay (but with whatever devtools
// multiplier is in effect). in the common case that it is `0`, we keep it anyway so that
// the CSS keyframes aren't created until the DOM is updated
//
// fill forwards to prevent the element from rendering without styles applied
// see https://github.com/sveltejs/svelte/issues/14732
animation = element.animate(keyframes, { duration: delay, fill: 'forwards' });
// for bidirectional transitions, we start from the current position, animation.onfinish = () => {
// rather than doing a full intro/outro // remove dummy animation from the stack to prevent conflict with main animation
var t1 = counterpart?.t() ?? 1 - t2; animation.cancel();
counterpart?.abort();
var delta = t2 - t1; on_begin();
var duration = /** @type {number} */ (options.duration) * Math.abs(delta);
var keyframes = [];
if (duration > 0) { // for bidirectional transitions, we start from the current position,
/** // rather than doing a full intro/outro
* Whether or not the CSS includes `overflow: hidden`, in which case we need to var t1 = counterpart?.t() ?? 1 - t2;
* add it as an inline style to work around a Safari <18 bug counterpart?.abort();
* TODO 6.0 remove this, if possible
*/
var needs_overflow_hidden = false;
if (css) { var delta = t2 - t1;
var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value var duration = /** @type {number} */ (options.duration) * Math.abs(delta);
var keyframes = [];
if (duration > 0) {
/**
* Whether or not the CSS includes `overflow: hidden`, in which case we need to
* add it as an inline style to work around a Safari <18 bug
* TODO 6.0 remove this, if possible
*/
var needs_overflow_hidden = false;
for (var i = 0; i <= n; i += 1) { if (css) {
var t = t1 + delta * easing(i / n); var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
var styles = css_to_keyframe(css(t, 1 - t));
keyframes.push(styles);
needs_overflow_hidden ||= styles.overflow === 'hidden'; for (var i = 0; i <= n; i += 1) {
var t = t1 + delta * easing(i / n);
var styles = css_to_keyframe(css(t, 1 - t));
keyframes.push(styles);
needs_overflow_hidden ||= styles.overflow === 'hidden';
}
} }
}
if (needs_overflow_hidden) { if (needs_overflow_hidden) {
/** @type {HTMLElement} */ (element).style.overflow = 'hidden'; /** @type {HTMLElement} */ (element).style.overflow = 'hidden';
} }
get_t = () => { get_t = () => {
var time = /** @type {number} */ ( var time = /** @type {number} */ (
/** @type {globalThis.Animation} */ (animation).currentTime /** @type {globalThis.Animation} */ (animation).currentTime
); );
return t1 + delta * easing(time / duration); return t1 + delta * easing(time / duration);
}; };
if (tick) { if (tick) {
loop(() => { loop(() => {
if (animation.playState !== 'running') return false; if (animation.playState !== 'running') return false;
var t = get_t(); var t = get_t();
tick(t, 1 - t); tick(t, 1 - t);
return true; return true;
}); });
}
} }
}
animation = element.animate(keyframes, { duration, fill: 'forwards' }); animation = element.animate(keyframes, { duration, fill: 'forwards' });
animation.onfinish = () => { animation.onfinish = () => {
get_t = () => t2; get_t = () => t2;
tick?.(t2, 1 - t2); tick?.(t2, 1 - t2);
on_finish(); on_finish();
};
}; };
}; });
return { return {
abort: () => { abort: () => {
aborted = true;
if (animation) { if (animation) {
animation.cancel(); animation.cancel();
// This prevents memory leaks in Chromium // This prevents memory leaks in Chromium

@ -1,5 +1,5 @@
/** @import { Effect, TemplateNode } from '#client' */ /** @import { Effect, TemplateNode } from '#client' */
import { hydrate_node, hydrating, set_hydrate_node } from './hydration.js'; import { hydrate_node, hydrating, reset, set_hydrate_node } from './hydration.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { init_array_prototype_warnings } from '../dev/equality.js'; import { init_array_prototype_warnings } from '../dev/equality.js';
import { get_descriptor, is_extensible } from '../../shared/utils.js'; import { get_descriptor, is_extensible } from '../../shared/utils.js';
@ -173,6 +173,26 @@ export function first_child(node, is_text = false) {
return hydrate_node; return hydrate_node;
} }
/**
* `child`, for the very common case of an element with exactly one child. Resetting the
* hydration cursor is part of the same step, so the compiler doesn't have to emit a
* separate `reset` call for every `<p>{text}</p>` in an app.
* Don't mark this as side-effect-free, hydration needs to walk all nodes
* @param {TemplateNode} node
* @param {boolean} [is_text]
* @returns {TemplateNode | null}
*/
export function only_child(node, is_text = false) {
if (!hydrating) {
return get_first_child(node);
}
var first = child(node, is_text);
reset(node);
return first;
}
/** /**
* Don't mark this as side-effect-free, hydration needs to walk all nodes * Don't mark this as side-effect-free, hydration needs to walk all nodes
* @param {TemplateNode} node * @param {TemplateNode} node

@ -38,7 +38,6 @@ export {
set_xlink_attribute, set_xlink_attribute,
set_value, set_value,
set_checked, set_checked,
set_selected,
set_default_checked, set_default_checked,
set_default_value, set_default_value,
CLASS, CLASS,
@ -67,7 +66,13 @@ export {
} from './dom/elements/bindings/media.js'; } from './dom/elements/bindings/media.js';
export { bind_online } from './dom/elements/bindings/navigator.js'; export { bind_online } from './dom/elements/bindings/navigator.js';
export { bind_prop } from './dom/elements/bindings/props.js'; export { bind_prop } from './dom/elements/bindings/props.js';
export { bind_select_value, init_select, select_option } from './dom/elements/bindings/select.js'; export {
bind_select_value,
init_select,
select_option,
set_selected,
set_default_select_value
} from './dom/elements/bindings/select.js';
export { bind_element_size, bind_resize_observer } from './dom/elements/bindings/size.js'; export { bind_element_size, bind_resize_observer } from './dom/elements/bindings/size.js';
export { bind_this } from './dom/elements/bindings/this.js'; export { bind_this } from './dom/elements/bindings/this.js';
export { export {
@ -108,6 +113,7 @@ export {
run, run,
save, save,
track_reactivity_loss, track_reactivity_loss,
unsave,
run_after_blockers, run_after_blockers,
wait wait
} from './reactivity/async.js'; } from './reactivity/async.js';
@ -168,6 +174,7 @@ export { proxy } from './proxy.js';
export { create_custom_element } from './dom/elements/custom-element.js'; export { create_custom_element } from './dom/elements/custom-element.js';
export { export {
child, child,
only_child,
first_child, first_child,
sibling, sibling,
$window as window, $window as window,

@ -8,7 +8,6 @@ import {
set_component_context, set_component_context,
set_dev_stack set_dev_stack
} from '../context.js'; } from '../context.js';
import { Boundary } from '../dom/blocks/boundary.js';
import { current_renderer, set_renderer } from '../custom-renderer/state.js'; import { current_renderer, set_renderer } from '../custom-renderer/state.js';
import { invoke_error_boundary } from '../error-handling.js'; import { invoke_error_boundary } from '../error-handling.js';
import { import {
@ -26,7 +25,6 @@ import {
set_reactivity_loss_tracker set_reactivity_loss_tracker
} from './deriveds.js'; } from './deriveds.js';
import { aborted } from './effects.js'; import { aborted } from './effects.js';
import { queue_micro_task } from '../dom/task.js';
/** /**
* @param {Blocker[]} blockers * @param {Blocker[]} blockers
@ -160,6 +158,9 @@ export function capture() {
}; };
} }
/** `true` between a `save` thunk restoring a context and the end of that synchronous segment */
var restored = false;
/** /**
* Wraps an `await` expression in such a way that the effect context that was * Wraps an `await` expression in such a way that the effect context that was
* active before the expression evaluated can be reapplied afterwards * active before the expression evaluated can be reapplied afterwards
@ -170,15 +171,32 @@ export function capture() {
*/ */
export async function save(promise) { export async function save(promise) {
var restore = capture(); var restore = capture();
// the context restored by an earlier `save` in this expression must not
// outlive the synchronous segment that is about to end at this `await`
unsave();
var value = await promise; var value = await promise;
return () => { return () => {
restore(); restore();
queue_micro_task(unset_context); restored = true;
return value; return value;
}; };
} }
/**
* Unset the context if a `save` thunk restored it in the current synchronous segment,
* so that a foreign microtask can never run inside a restored reaction context.
* Called at every suspension point, and at the end of async expression bodies
* `async () => (await $.save(a))().b` becomes `async () => { try { return (await $.save(a))().b; } finally { $.unsave(); } }`
* @template T
* @param {T} [value]
* @returns {T}
*/
export function unsave(value) {
if (restored) unset_context();
return /** @type {T} */ (value);
}
/** /**
* Reset `current_async_effect` after the `promise` resolves, so * Reset `current_async_effect` after the `promise` resolves, so
* that we can emit `await_reactivity_loss` warnings * that we can emit `await_reactivity_loss` warnings
@ -187,6 +205,7 @@ export async function save(promise) {
* @returns {Promise<() => T>} * @returns {Promise<() => T>}
*/ */
export async function track_reactivity_loss(promise) { export async function track_reactivity_loss(promise) {
unsave();
var previous_reactivity_loss_tracker = reactivity_loss_tracker; var previous_reactivity_loss_tracker = reactivity_loss_tracker;
// Ensure that unrelated reads after an async operation is kicked off don't cause false positives // Ensure that unrelated reads after an async operation is kicked off don't cause false positives
queueMicrotask(() => { queueMicrotask(() => {
@ -273,6 +292,7 @@ export async function* for_await_track_reactivity_loss(iterable) {
} }
export function unset_context(deactivate_batch = true) { export function unset_context(deactivate_batch = true) {
restored = false;
set_active_effect(null); set_active_effect(null);
set_active_reaction(null); set_active_reaction(null);
set_component_context(null); set_component_context(null);

@ -259,37 +259,7 @@ export function update_reaction(reaction) {
var fn = /** @type {Function} */ (reaction.fn); var fn = /** @type {Function} */ (reaction.fn);
var result = fn(); var result = fn();
reaction.f |= REACTION_RAN; reaction.f |= REACTION_RAN;
var deps = reaction.deps; var deps = update_dependencies(reaction);
// Don't remove reactions during fork;
// they must remain for when fork is discarded
var is_fork = current_batch?.is_fork;
if (new_deps !== null) {
var i;
if (!is_fork) {
remove_reactions(reaction, skipped_deps);
}
if (deps !== null && skipped_deps > 0) {
deps.length = skipped_deps + new_deps.length;
for (i = 0; i < new_deps.length; i++) {
deps[skipped_deps + i] = new_deps[i];
}
} else {
reaction.deps = deps = new_deps;
}
if (effect_tracking() && (reaction.f & CONNECTED) !== 0) {
for (i = skipped_deps; i < deps.length; i++) {
(deps[i].reactions ??= []).push(reaction);
}
}
} else if (!is_fork && deps !== null && skipped_deps < deps.length) {
remove_reactions(reaction, skipped_deps);
deps.length = skipped_deps;
}
// If we're inside an effect and we have untracked writes, then we need to // If we're inside an effect and we have untracked writes, then we need to
// ensure that if any of those untracked writes result in re-invalidation // ensure that if any of those untracked writes result in re-invalidation
@ -301,7 +271,7 @@ export function update_reaction(reaction) {
deps !== null && deps !== null &&
(reaction.f & (DERIVED | MAYBE_DIRTY | DIRTY)) === 0 (reaction.f & (DERIVED | MAYBE_DIRTY | DIRTY)) === 0
) { ) {
for (i = 0; i < /** @type {Source[]} */ (untracked_writes).length; i++) { for (var i = 0; i < /** @type {Source[]} */ (untracked_writes).length; i++) {
schedule_possible_effect_self_invalidation( schedule_possible_effect_self_invalidation(
untracked_writes[i], untracked_writes[i],
/** @type {Effect} */ (reaction) /** @type {Effect} */ (reaction)
@ -345,6 +315,9 @@ export function update_reaction(reaction) {
return result; return result;
} catch (error) { } catch (error) {
// still commit the deps read before the throw, otherwise deriveds connected by this run keep no reader and the reaction never re-runs when they change
update_dependencies(reaction);
return handle_error(error); return handle_error(error);
} finally { } finally {
reaction.f ^= REACTION_IS_UPDATING; reaction.f ^= REACTION_IS_UPDATING;
@ -359,6 +332,45 @@ export function update_reaction(reaction) {
} }
} }
/**
* @param {Reaction} reaction
*/
function update_dependencies(reaction) {
var deps = reaction.deps;
// Don't remove reactions during fork;
// they must remain for when fork is discarded
var is_fork = current_batch?.is_fork;
if (new_deps !== null) {
var i;
if (!is_fork) {
remove_reactions(reaction, skipped_deps);
}
if (deps !== null && skipped_deps > 0) {
deps.length = skipped_deps + new_deps.length;
for (i = 0; i < new_deps.length; i++) {
deps[skipped_deps + i] = new_deps[i];
}
} else {
reaction.deps = deps = new_deps;
}
if (effect_tracking() && (reaction.f & CONNECTED) !== 0) {
for (i = skipped_deps; i < deps.length; i++) {
(deps[i].reactions ??= []).push(reaction);
}
}
} else if (!is_fork && deps !== null && skipped_deps < deps.length) {
remove_reactions(reaction, skipped_deps);
deps.length = skipped_deps;
}
return deps;
}
/** /**
* @template V * @template V
* @param {Reaction} signal * @param {Reaction} signal

@ -13,7 +13,7 @@ import { attributes } from './index.js';
import { get_render_context, with_render_context, init_render_context } from './render-context.js'; import { get_render_context, with_render_context, init_render_context } from './render-context.js';
import { sha256 } from './crypto.js'; import { sha256 } from './crypto.js';
import * as devalue from 'devalue'; import * as devalue from 'devalue';
import { has_own_property, noop } from '../shared/utils.js'; import { has_own_property, is_array, noop } from '../shared/utils.js';
import { escape_html } from '../../escaping.js'; import { escape_html } from '../../escaping.js';
/** @typedef {'head' | 'body'} RendererType */ /** @typedef {'head' | 'body'} RendererType */
@ -89,7 +89,7 @@ export class Renderer {
* State that is local to the branch it is declared in. * State that is local to the branch it is declared in.
* It will be shallow-copied to all children. * It will be shallow-copied to all children.
* *
* @type {{ select_value: string | undefined }} * @type {{ select_value: any, multiple: boolean }}
*/ */
local; local;
@ -101,7 +101,7 @@ export class Renderer {
this.#parent = parent; this.#parent = parent;
this.global = global; this.global = global;
this.local = parent ? { ...parent.local } : { select_value: undefined }; this.local = parent ? { ...parent.local } : { select_value: undefined, multiple: false };
this.type = parent ? parent.type : 'body'; this.type = parent ? parent.type : 'body';
} }
@ -338,11 +338,13 @@ export class Renderer {
* @returns {void} * @returns {void}
*/ */
select(attrs, fn, css_hash, classes, styles, flags, is_rich) { select(attrs, fn, css_hash, classes, styles, flags, is_rich) {
const { value, ...select_attrs } = attrs; const { value, defaultValue, ...select_attrs } = attrs;
if (select_attrs.multiple === '') select_attrs.multiple = true;
this.push(`<select${attributes(select_attrs, css_hash, classes, styles, flags)}>`); this.push(`<select${attributes(select_attrs, css_hash, classes, styles, flags)}>`);
this.child((renderer) => { this.child((renderer) => {
renderer.local.select_value = value; renderer.local.select_value = value === undefined ? defaultValue : value;
renderer.local.multiple = !!select_attrs.multiple;
fn(renderer); fn(renderer);
}); });
this.push(`${is_rich ? '<!>' : ''}</select>`); this.push(`${is_rich ? '<!>' : ''}</select>`);
@ -370,7 +372,15 @@ export class Renderer {
value = attrs.value; value = attrs.value;
} }
if (value === this.local.select_value) { var select_value = this.local.select_value;
if (
// Super edge-case, but theoretically someone could use arrays with non-multiple selects,
// so we gotta check for the multiple attribute presence, too.
this.local.multiple && is_array(select_value)
? select_value.includes(value)
: value === select_value
) {
renderer.#out.push(' selected=""'); renderer.#out.push(' selected=""');
} }

@ -27,7 +27,8 @@ export function attr(name, value, is_boolean = false) {
if (name === 'hidden' && value !== 'until-found') { if (name === 'hidden' && value !== 'until-found') {
is_boolean = true; is_boolean = true;
} }
if (value == null || (!value && is_boolean)) return ''; // `''` is a present boolean attribute, as it is in markup and on the client
if (value == null || (is_boolean && !value && value !== '')) return '';
const normalized = const normalized =
(has_own_property.call(replacements, name) && replacements[name].get(value)) || value; (has_own_property.call(replacements, name) && replacements[name].get(value)) || value;
const assignment = is_boolean ? `=""` : `="${escape_html(normalized, true)}"`; const assignment = is_boolean ? `=""` : `="${escape_html(normalized, true)}"`;

@ -163,6 +163,8 @@ export class SvelteURL extends URL {
set protocol(value) { set protocol(value) {
super.protocol = value; super.protocol = value;
set(this.#protocol, super.protocol); set(this.#protocol, super.protocol);
// changing the protocol can clear the port when it matches the new scheme's default
set(this.#port, super.port);
} }
get search() { get search() {

@ -240,3 +240,25 @@ test('url.searchParams.forEach re-runs when the search string changes via the UR
cleanup(); cleanup();
}); });
test('url.port is updated when the protocol change clears the port', () => {
const url = new SvelteURL('http://example.com:443/');
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(url.port);
});
});
flushSync(() => {
// 443 is the default port for https, so it gets stripped
url.protocol = 'https:';
});
assert.equal(url.port, '');
assert.equal(url.href, 'https://example.com/');
assert.deepEqual(log, ['443', '']);
cleanup();
});

@ -0,0 +1,9 @@
import { test } from '../../test';
export default test({
error: {
code: 'derived_invalid_export',
message:
'Cannot export derived state from a module. To expose the current derived value, export a function returning its value'
}
});

@ -0,0 +1,4 @@
<script>
let count = $state(0);
export let double = $derived(count * 2);
</script>

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

@ -0,0 +1,13 @@
<script module>
export { outer };
</script>
{#snippet outer()}
<p class="inner"></p>
{/snippet}
<style>
.inner {
color: red;
}
</style>

@ -0,0 +1,53 @@
{
"html": {
"type": "Fragment",
"start": 0,
"end": 32,
"children": [
{
"type": "Element",
"start": 0,
"end": 32,
"name": "p",
"attributes": [
{
"type": "Attribute",
"start": 3,
"end": 19,
"name": "title",
"name_loc": {
"start": {
"line": 1,
"column": 3,
"character": 3
},
"end": {
"line": 1,
"column": 8,
"character": 8
}
},
"value": [
{
"start": 10,
"end": 18,
"type": "Text",
"raw": "A&#x0A;B",
"data": "A\nB"
}
]
}
],
"children": [
{
"type": "Text",
"start": 20,
"end": 28,
"raw": "A&#x0A;B",
"data": "A B"
}
]
}
]
}
}

@ -14,8 +14,8 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
ce.shadowRoot.innerHTML, ce.shadowRoot.innerHTML,
` `
<slot></slot> <slot>fallback</slot>
<p>named fallback</p> <slot name="named"><p>named fallback</p></slot>
` `
); );
@ -23,8 +23,8 @@ export default test({
assert.htmlEqual( assert.htmlEqual(
ce.shadowRoot.innerHTML, ce.shadowRoot.innerHTML,
` `
<slot></slot> <slot>fallback</slot>
<p>named fallback</p> <slot name="named"><p>named fallback</p></slot>
` `
); );
} }

@ -3,10 +3,7 @@ const tick = () => Promise.resolve();
export default test({ export default test({
async test({ assert, target }) { async test({ assert, target }) {
target.innerHTML = ` target.innerHTML = '<custom-element></custom-element>';
<custom-element>
<strong>slotted</strong>
</custom-element>`;
await tick(); await tick();
await tick(); await tick();
@ -16,7 +13,26 @@ export default test({
const div = el.shadowRoot.children[0]; const div = el.shadowRoot.children[0];
const [slot0, slot1] = div.children; const [slot0, slot1] = div.children;
assert.equal(slot0.assignedNodes()[1], target.querySelector('strong')); assert.equal(slot0.localName, 'slot');
assert.equal(slot1.innerHTML, 'foo fallback content'); assert.equal(slot0.assignedNodes().length, 0);
assert.equal(slot0.innerHTML, '<p>default fallback content</p>');
assert.equal(slot1.localName, 'slot');
assert.equal(slot1.name, 'foo');
assert.equal(slot1.assignedNodes().length, 0);
assert.equal(slot1.innerHTML, '<p>foo fallback content</p>');
const default_content = document.createElement('strong');
default_content.textContent = 'default content';
el.append(default_content);
const named_content = document.createElement('strong');
named_content.slot = 'foo';
named_content.textContent = 'named content';
el.append(named_content);
assert.equal(slot0.assignedNodes().length, 1);
assert.equal(slot0.assignedNodes()[0], default_content);
assert.equal(slot1.assignedNodes().length, 1);
assert.equal(slot1.assignedNodes()[0], named_content);
} }
}); });

@ -3,7 +3,7 @@ const tick = () => Promise.resolve();
export default test({ export default test({
async test({ assert, target }) { async test({ assert, target }) {
target.innerHTML = '<custom-element name="world"></custom-element>'; target.innerHTML = '<custom-element name="world"><span>slotted</span></custom-element>';
await tick(); await tick();
await tick(); await tick();
@ -15,5 +15,6 @@ export default test({
assert.equal(el.shadowRoot, null); assert.equal(el.shadowRoot, null);
assert.equal(h1.innerHTML, 'Hello world!'); assert.equal(h1.innerHTML, 'Hello world!');
assert.equal(getComputedStyle(h1).color, 'rgb(255, 0, 0)'); assert.equal(getComputedStyle(h1).color, 'rgb(255, 0, 0)');
assert.equal(el.querySelector('slot').innerHTML, '');
} }
}); });

@ -5,6 +5,7 @@
</script> </script>
<h1>Hello {name}!</h1> <h1>Hello {name}!</h1>
<slot>fallback</slot>
<style> <style>
h1 { h1 {

@ -0,0 +1,14 @@
<script>
import Nested from './Nested.svelte';
import { slide } from 'svelte/transition';
let { depth } = $props();
</script>
<div class="level-{depth}" in:slide|global={{ duration: 100 }}>
{#if depth > 0}
<Nested depth={depth - 1} />
{:else}
<div style="height: 100px">leaf</div>
{/if}
</div>

@ -0,0 +1,36 @@
import { test } from '../../assert';
export default test({
async test({ assert, target }) {
const button = target.querySelector('button');
button?.click();
// wait for the transition's keyframes to be created
const animation = await new Promise((resolve, reject) => {
const start = performance.now();
function check() {
const outer = target.querySelector('.level-2');
const animation = outer
?.getAnimations()
.find((a) => a.effect?.getTiming().duration === 100);
if (animation) {
resolve(animation);
} else if (performance.now() - start > 2000) {
reject(new Error('timed out waiting for the transition to start'));
} else {
requestAnimationFrame(check);
}
}
check();
});
// the outermost `slide` must have measured the element with its
// descendants at their natural size, not collapsed to zero by their
// own starting styles (#18421)
const keyframes = animation.effect?.getKeyframes() ?? [];
assert.equal(keyframes[keyframes.length - 1].height, '100px');
}
});

@ -0,0 +1,11 @@
<script>
import Nested from './Nested.svelte';
let visible = $state(false);
</script>
<button onclick={() => (visible = !visible)}>toggle</button>
{#if visible}
<Nested depth={2} />
{/if}

@ -6,17 +6,30 @@ export default test({
return { selected: ['two', 'three'] }; return { selected: ['two', 'three'] };
}, },
html: ` ssrHtml: `
<select multiple> <select multiple>
<option>one</option> <option>one</option>
<option>two</option> <option selected>two</option>
<option>three</option> <option selected>three</option>
</select> </select>
<p>selected: two, three</p> <p>selected: two, three</p>
`, `,
test({ assert, component, target, window }) { test({ assert, component, target, window, variant }) {
const selected = variant === 'hydrate' ? ' selected' : '';
assert.htmlEqual(
target.innerHTML,
`
<select multiple>
<option>one</option>
<option${selected}>two</option>
<option${selected}>three</option>
</select>
<p>selected: two, three</p>
`
);
const select = target.querySelector('select'); const select = target.querySelector('select');
ok(select); ok(select);
const options = [...target.querySelectorAll('option')]; const options = [...target.querySelectorAll('option')];
@ -33,8 +46,8 @@ export default test({
` `
<select multiple> <select multiple>
<option>one</option> <option>one</option>
<option>two</option> <option${selected}>two</option>
<option>three</option> <option${selected}>three</option>
</select> </select>
<p>selected: three</p> <p>selected: three</p>
@ -51,8 +64,8 @@ export default test({
` `
<select multiple> <select multiple>
<option>one</option> <option>one</option>
<option>two</option> <option${selected}>two</option>
<option>three</option> <option${selected}>three</option>
</select> </select>
<p>selected: one, three</p> <p>selected: one, three</p>
@ -70,8 +83,8 @@ export default test({
` `
<select multiple> <select multiple>
<option>one</option> <option>one</option>
<option>two</option> <option${selected}>two</option>
<option>three</option> <option${selected}>three</option>
</select> </select>
<p>selected: one, two</p> <p>selected: one, two</p>

@ -7,6 +7,7 @@ export default test({
<span>*</span> <span>*</span>
<span>*</span> <span>*</span>
<span>*</span> <span>*</span>
<span>*</span>
<span></span> <span></span>
<span>A</span> <span>A</span>

@ -2,6 +2,7 @@
<span>&midast;</span> <span>&midast;</span>
<span>&#x0002A;</span> <span>&#x0002A;</span>
<span>&#x0002A</span> <span>&#x0002A</span>
<span>&#X0002A;</span>
<span>&#42;</span> <span>&#42;</span>
<span>&#10;</span> <span>&#10;</span>

@ -177,11 +177,11 @@ export function runtime_suite(runes: boolean) {
['dom', 'hydrate', 'ssr', 'async-ssr'], ['dom', 'hydrate', 'ssr', 'async-ssr'],
(variant, config, test_name) => { (variant, config, test_name) => {
if (!async_mode && (config.skip_no_async || test_name.startsWith('async-'))) { if (!async_mode && (config.skip_no_async || test_name.startsWith('async-'))) {
return true; return 'no-test';
} }
if (async_mode && config.skip_async) { if (async_mode && config.skip_async) {
return true; return 'no-test';
} }
if (variant === 'hydrate') { if (variant === 'hydrate') {
@ -195,9 +195,9 @@ export function runtime_suite(runes: boolean) {
) { ) {
return 'no-test'; return 'no-test';
} }
if (variant === 'ssr') { if (variant === 'ssr') {
if ( if (
(test_name.startsWith('async-') && !config.mode?.includes('server')) ||
(config.mode && !config.mode.includes('server')) || (config.mode && !config.mode.includes('server')) ||
(!config.test_ssr && (!config.test_ssr &&
config.html === undefined && config.html === undefined &&

@ -0,0 +1,10 @@
import { tick } from 'svelte';
import { test } from '../../test';
// #18469 — a @const in a nested snippet reading an async declaration through a closure must block on it
export default test({
async test({ assert, target }) {
await tick();
assert.htmlEqual(target.innerHTML, '<p>true</p> <p>false</p>');
}
});

@ -0,0 +1,12 @@
<script>
async function getValue() {
return new Set(['a', 'b', 'c']);
}
const value = await getValue();
</script>
{#each [['a', 'b'], ['a', 'x']] as keys}
{@const all_present = keys.every((k) => value.has(k))}
<p>{all_present}</p>
{/each}

@ -0,0 +1,11 @@
import { tick } from 'svelte';
import { test } from '../../test';
// #18469 — a sync $derived in a nested snippet reading an async declaration through a closure must block on it
export default test({
ssrHtml: '<p>true</p> <p>false</p>',
async test({ assert, target }) {
await tick();
assert.htmlEqual(target.innerHTML, '<p>true</p> <p>false</p>');
}
});

@ -0,0 +1,17 @@
<script>
async function getValue() {
return new Set(['a', 'b', 'c']);
}
</script>
{#snippet outer()}
{const value = $derived(await getValue())}
{#snippet inner(keys)}
{const all_present = $derived(keys.every((k) => value.has(k)))}
<p>{all_present}</p>
{/snippet}
{@render inner(['a', 'b'])}
{@render inner(['a', 'x'])}
{/snippet}
{@render outer()}

@ -0,0 +1,7 @@
<script>
const { environment } = $props();
if (environment === 'client') {
throw new Error('oops');
}
</script>

@ -0,0 +1,14 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['hydrate'],
server_props: { environment: 'server' },
props: { environment: 'client' },
ssrHtml: 'loading inner loading nested',
async test({ assert, target }) {
await tick();
assert.htmlEqual(target.innerHTML, 'inner failed: oops outer failed: oops');
}
});

@ -0,0 +1,24 @@
<script>
import Child from './Child.svelte';
const { environment } = $props();
</script>
<svelte:boundary>
<Child {environment} />
{await new Promise(() => {})}
{#snippet pending()}loading inner{/snippet}
{#snippet failed(error)}inner failed: {error.message}{/snippet}
</svelte:boundary>
<svelte:boundary>
<svelte:boundary>
<Child {environment} />
{await new Promise(() => {})}
{#snippet pending()}loading nested{/snippet}
</svelte:boundary>
{#snippet failed(error)}outer failed: {error.message}{/snippet}
</svelte:boundary>

@ -0,0 +1,20 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['client'],
compileOptions: {
dev: true
},
async test({ assert, target, errors }) {
await new Promise((resolve) => setTimeout(resolve, 20));
await tick();
assert.deepEqual(
errors.filter((error) => error.includes('state_unsafe_mutation')),
[]
);
assert.htmlEqual(target.innerHTML, '<p>pending</p><p>1</p>');
}
});

@ -0,0 +1,14 @@
<script>
let foreign = $state(0);
const input = Promise.resolve({ pending: new Promise(() => {}) });
setTimeout(() => {
foreign += 1;
});
</script>
{#await (await input).pending}
<p>pending</p>
{/await}
<p>{foreign}</p>

@ -0,0 +1,22 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['client'],
compileOptions: {
dev: false
},
async test({ assert, target, errors }) {
await tick();
await tick();
await tick();
assert.deepEqual(
errors.filter((error) => error.includes('state_unsafe_mutation')),
[]
);
assert.htmlEqual(target.innerHTML, '<p>4 1</p>');
}
});

@ -0,0 +1,20 @@
<script>
let foreign = $state(0);
const items = Promise.resolve([1, 2, 3]);
const one = Promise.resolve(1);
// lands the write while the derived is suspended on `await one`, after the
// context restored for `.length` — in production that await has no dev hook
items.then(() => {
queueMicrotask(() => {
queueMicrotask(() => {
foreign += 1;
});
});
});
const total = $derived((await items).length + (await one));
</script>
<p>{total} {foreign}</p>

@ -0,0 +1,21 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
mode: ['client'],
compileOptions: {
dev: true
},
async test({ assert, target, errors }) {
await tick();
await tick();
assert.deepEqual(
errors.filter((error) => error.includes('state_unsafe_mutation')),
[]
);
assert.htmlEqual(target.innerHTML, '<p>3 1</p>');
}
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save