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>
```
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>` 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).
## 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
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).
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
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;
size?: number | undefined | null;
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;
onchange?: ChangeEventHandler<HTMLSelectElement> | undefined | null;

@ -20,7 +20,7 @@ function reg_exp_entity(entity_name, is_attribute_value) {
/** @param {boolean} 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(
/** @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
if (entity[0] !== '#') {
code = entities[entity];
} else if (entity[1] === 'x') {
} else if (entity[1] === 'x' || entity[1] === 'X') {
code = parseInt(entity.substring(2), 16);
} else {
code = parseInt(entity.substring(1), 10);
@ -60,7 +60,7 @@ export function decode_character_references(html, is_attribute_value) {
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://html.spec.whatwg.org/multipage/parsing.html#preprocessing-the-input-stream
/** @param {number} code */
function validate_code(code) {
// line feed becomes generic whitespace
if (code === 10) {
/**
* @param {number} code
* @param {boolean} is_attribute_value
*/
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;
}

@ -285,7 +285,7 @@ export function analyze_module(source, options) {
runes: true,
immutable: true,
tracing: false,
async_deriveds: new Set(),
async_deriveds: new Map(),
comments,
classes: new Map(),
pickled_awaits: new Set()
@ -566,7 +566,7 @@ export function analyze_component(root, source, options) {
source,
snippet_renderers: new Map(),
snippets: new Set(),
async_deriveds: new Set(),
async_deriveds: new Map(),
pickled_awaits: new Set(),
instance_body: {
sync: [],
@ -841,6 +841,11 @@ export function analyze_component(root, source, options) {
} else {
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 { Context } from '../types' */
/** @import { AST } from '#compiler' */
/** @import { ExpressionMetadata } from '../../nodes.js' */
import * as e from '../../../errors.js';
/**
@ -10,15 +11,20 @@ import * as e from '../../../errors.js';
export function AwaitExpression(node, context) {
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 (
is_reactive_expression(
context.path,
context.state.derived_function_depth === context.state.function_depth
) &&
!is_last_evaluated_expression(context.path, 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;

@ -253,7 +253,7 @@ export function CallExpression(node, context) {
});
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

@ -43,7 +43,8 @@ export function DeclarationTag(node, context) {
*/
export function mark_async_declaration(context, metadata, declarations) {
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)
.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 * as e from '../../../errors.js';
import { extract_identifiers } from '../../../utils/ast.js';
@ -23,15 +23,6 @@ export function ExportNamedDeclaration(node, context) {
}
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 id of extract_identifiers(declarator.id)) {
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) {

@ -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
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 key =

@ -2,6 +2,7 @@
/** @import { Binding } from '#compiler' */
/** @import { ClientTransformState, ComponentClientTransformState } from './types.js' */
/** @import { Analysis } from '../../types.js' */
/** @import { ExpressionMetadata } from '../../nodes.js' */
/** @import { Scope } from '../../scope.js' */
import * as b from '#compiler/builders';
import { is_simple_expression, save } from '../../../utils/ast.js';
@ -164,20 +165,46 @@ export function should_proxy(node, scope) {
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
* @param {ComponentClientTransformState} state
* @param {Expression | BlockStatement} expression
* @param {boolean} [async]
* @param {ExpressionMetadata} [metadata]
*/
export function create_derived(state, expression, async = false) {
const thunk = b.thunk(expression, async);
if (async) {
return save(b.call('$.async_derived', thunk));
} else {
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', thunk);
export function create_derived(state, expression, metadata) {
if (metadata?.has_await) {
return save(b.call('$.async_derived', async_thunk(expression, metadata)));
}
return b.call(state.analysis.runes ? '$.derived' : '$.derived_safe_equal', b.thunk(expression));
}
/**

@ -1,9 +1,9 @@
/** @import { BlockStatement, Pattern, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @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 { create_derived } from '../utils.js';
import { async_thunk, create_derived } from '../utils.js';
import { get_value } from './shared/declarations.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();
// Visit {#await <expression>} first to ensure that scopes are in the correct order
const expression = b.thunk(
build_expression(context, node.expression, node.metadata.expression),
node.metadata.expression.has_await
);
const input = build_expression(context, node.expression, node.metadata.expression);
const expression = node.metadata.expression.has_await
? async_thunk(input, node.metadata.expression)
: b.thunk(input);
let then_block;
let catch_block;

@ -1,7 +1,7 @@
/** @import { CallExpression, Expression, Pattern } from 'estree' */
/** @import { AST } from '#compiler' */
/** @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 * as b from '#compiler/builders';
import { binding_properties } from '../../../bindings.js';
@ -40,9 +40,15 @@ export function BindDirective(node, context) {
validate_binding(context.state, node, expression);
}
const assignment = /** @type {Expression} */ (
context.visit(b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value')))
const raw_assignment = b.assignment(
'=',
/** @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) {
// in dev, create named functions, so that `$inspect(...)` delivers
@ -58,16 +64,7 @@ export function BindDirective(node, context) {
get = b.thunk(expression);
/** @type {Expression | undefined} */
set = b.unthunk(
b.arrow(
[b.id('$$value')],
/** @type {Expression} */ (
context.visit(
b.assignment('=', /** @type {Pattern} */ (node.expression), b.id('$$value'))
)
)
)
);
set = b.unthunk(b.arrow([b.id('$$value')], assignment));
if (get === set) {
set = undefined;

@ -19,7 +19,7 @@ export function ConstTag(node, context) {
if (declaration.id.type === 'Identifier') {
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) {
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))))
]);
let expression = create_derived(context.state, block, node.metadata.expression.has_await);
let expression = create_derived(context.state, block, node.metadata.expression);
if (dev) {
expression = b.call('$.tag', expression, b.literal('[@const]'));

@ -3,6 +3,7 @@
/** @import { ComponentContext } from '../types' */
import { extract_identifiers, has_await_expression } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.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));
}
const blockers = [...metadata.expression.dependencies]
const blockers = [...metadata.expression.references]
.map((dep) => dep.blocker)
.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 ||
assignments.some((assignment) => has_await_expression(assignment));
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 { extract_paths, object } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { get_value } from './shared/declarations.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 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 render_args = [b.id('$$anchor'), item];

@ -141,14 +141,9 @@ export function Fragment(node, context) {
flags |= TEMPLATE_USE_IMPORT_NODE;
}
if (state.template.nodes.length === 1 && state.template.nodes[0].type === 'comment') {
// 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)));
}
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
}

@ -2,6 +2,7 @@
/** @import { ComponentContext } from '../types' */
import { is_ignored } from '../../../../state.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { build_expression } from './shared/utils.js';
/**
@ -46,7 +47,7 @@ export function HtmlTag(node, context) {
'$.async',
context.state.node,
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(
has_await ? [context.state.node, b.id('$$html')] : [context.state.node],
b.block([statement])

@ -2,6 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';
/**
@ -117,7 +118,7 @@ export function IfBlock(node, context) {
'$.async',
context.state.node,
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(
has_await ? [context.state.node, b.id('$$condition')] : [context.state.node],
b.block(statements)

@ -2,6 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { build_expression, add_svelte_meta } from './shared/utils.js';
/**
@ -31,7 +32,7 @@ export function KeyBlock(node, context) {
'$.async',
context.state.node,
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(
has_await ? [context.state.node, b.id('$$key')] : [context.state.node],
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 { ComponentClientTransformState, ComponentContext } from '../types' */
/** @import { Scope } from '../../../scope' */
@ -232,6 +232,12 @@ export function RegularElement(node, context) {
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);
if (
@ -451,7 +457,7 @@ export function RegularElement(node, context) {
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)));
}
}
@ -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();
}
@ -720,6 +754,9 @@ function build_element_special_value_attribute(
);
const evaluated = context.state.scope.evaluate(value);
/** @param {Expression} value */
const build_update = (value) => {
const assignment = b.assignment('=', b.member(node_id, '__value'), value);
const set_value_assignment = b.assignment(
@ -728,7 +765,7 @@ function build_element_special_value_attribute(
evaluated.is_defined ? assignment : b.logical('??', assignment, b.literal(''))
);
const update = b.stmt(
return b.stmt(
is_select_with_value
? b.sequence([
set_value_assignment,
@ -742,6 +779,7 @@ function build_element_special_value_attribute(
? assignment
: set_value_assignment
);
};
if (has_state) {
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;
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 {
state.init.push(update);
state.init.push(build_update(value));
}
}
/**
* `<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 (is_select_with_value) {
state.init.push(b.stmt(b.call('$.init_select', node_id)));
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 { is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { async_thunk } from '../utils.js';
import { determine_namespace_for_children } from '../../utils.js';
import {
build_attribute_value,
@ -147,7 +148,7 @@ export function SvelteElement(node, context) {
'$.async',
context.state.node,
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(
has_await ? [context.state.node, b.id('$$tag')] : [context.state.node],
b.block(statements)

@ -6,7 +6,13 @@ import { extract_paths, save } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import * as assert from '../../../../utils/assert.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';
/**
@ -200,9 +206,10 @@ export function VariableDeclaration(node, context) {
}
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)
);
const is_async = metadata !== undefined;
if (declarator.id.type === 'Identifier') {
let expression = /** @type {Expression} */ (context.visit(value));
@ -213,7 +220,7 @@ export function VariableDeclaration(node, context) {
/** @type {Expression} */
let call = b.call(
'$.async_derived',
b.thunk(expression, true),
async_thunk(expression, metadata),
dev && b.literal(declarator.id.name),
location ? b.literal(location) : undefined
);
@ -246,7 +253,7 @@ export function VariableDeclaration(node, context) {
call = b.call(
'$.async_derived',
b.thunk(expression, true),
async_thunk(expression, metadata),
dev &&
b.literal(
`[$derived ${declarator.id.type === 'ArrayPattern' ? 'iterable' : 'object'}]`

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

@ -167,7 +167,7 @@ export function is_static_element(node) {
}
if (
['input', 'textarea'].includes(node.name) &&
['input', 'textarea', 'select'].includes(node.name) &&
['value', 'checked'].includes(attribute.name)
) {
return false;
@ -177,11 +177,6 @@ export function is_static_element(node) {
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)) {
return false;
}

@ -8,7 +8,7 @@ import { sanitize_template_string } from '../../../../../utils/sanitize_template
import { regex_is_valid_identifier } from '../../../../patterns.js';
import is_reference from 'is-reference';
import { dev, is_ignored, locator, 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';
/**
@ -16,10 +16,10 @@ import { ExpressionMetadata } from '../../../../nodes.js';
* from templates and replacing them with `$0`, `$1` etc
*/
export class Memoizer {
/** @type {Array<{ id: Identifier, expression: Expression }>} */
/** @type {Array<{ id: Identifier, expression: Expression, metadata: ExpressionMetadata }>} */
#sync = [];
/** @type {Array<{ id: Identifier, expression: Expression }>} */
/** @type {Array<{ id: Identifier, expression: Expression, metadata: ExpressionMetadata }>} */
#async = [];
/** @type {Set<Expression>} */
@ -43,7 +43,7 @@ export class Memoizer {
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;
}
@ -84,7 +84,7 @@ export class Memoizer {
if (this.#async.length === 0) return;
// use `b.arrow` rather than `b.thunk` so that deferred async/template effects
// 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() {

@ -110,7 +110,7 @@ function build_assignment(operator, left, right, context) {
context.visit(build_assignment_value(operator, left, right))
);
return b.call(binding.node, value);
return b.call(object, value);
}
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));
}
const blockers = [...metadata.expression.dependencies]
const blockers = [...metadata.expression.references]
.map((dep) => dep.blocker)
.filter((b) => b !== null && b.object !== context.state.async_consts?.id);

@ -2,7 +2,13 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
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
@ -51,7 +57,7 @@ export function EachBlock(node, context) {
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(
b.if(

@ -2,7 +2,7 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
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
@ -10,7 +10,7 @@ import { block_close, create_child_block } from './shared/utils.js';
*/
export function IfBlock(node, context) {
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} */
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
for (const elseif of node.metadata.flattened ?? []) {
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(
/** @type {Expression} */ (context.visit(elseif.test)),
@ -34,7 +34,7 @@ export function IfBlock(node, context) {
// Handle final else (or remaining async chain)
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;
context.state.template.push(

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

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

@ -180,6 +180,36 @@ export function build_template(template) {
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

@ -77,6 +77,9 @@ export class ExpressionMetadata {
/** True if the expression contains `await` */
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 */
has_member_expression = false;
@ -142,6 +145,7 @@ export class ExpressionMetadata {
this.has_state ||= source.has_state;
this.has_call ||= source.has_call;
this.has_await ||= source.has_await;
this.has_pickled_await ||= source.has_pickled_await;
this.has_member_expression ||= source.has_member_expression;
this.has_assignment ||= source.has_assignment;
this.#blockers = null; // so that blockers are recalculated

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

@ -1,14 +1,8 @@
/** @import { Effect, Source, TemplateNode, } from '#client' */
import {
BOUNDARY_EFFECT,
DIRTY,
EFFECT_PRESERVED,
EFFECT_TRANSPARENT,
MAYBE_DIRTY
} from '#client/constants';
import { BOUNDARY_EFFECT, EFFECT_PRESERVED, EFFECT_TRANSPARENT } from '#client/constants';
import { HYDRATION_START_ELSE, HYDRATION_START_FAILED } from '../../../../constants.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 {
block,
branch,
@ -285,13 +279,31 @@ export class Boundary {
var fragment = (this.#offscreen_fragment = create_fragment());
var anchor = create_text();
var handled = false;
append_child(fragment, anchor);
this.#main_effect = this.#run(() => {
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) {
insert_before(this.#anchor, fragment);
this.#offscreen_fragment = null;
@ -380,9 +392,6 @@ export class Boundary {
try {
Batch.ensure();
return fn();
} catch (e) {
handle_error(e);
return null;
} finally {
set_active_effect(previous_effect);
set_active_reaction(previous_reaction);

@ -1,4 +1,6 @@
import { hydrate_next, hydrating } from '../hydration.js';
import { create_element, create_text } from '../operations.js';
import { append } from '../template.js';
/**
* @param {Comment} anchor
@ -12,6 +14,23 @@ export function slot(anchor, $$props, name, slot_props, fallback_fn) {
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];
// Interop: Can use snippets to fill slots
var is_interop = false;

@ -26,7 +26,12 @@ import { set_class } from './class.js';
import { set_style } from './style.js';
import { ATTACHMENT_KEY, NAMESPACE_HTML, UNINITIALIZED } from '../../../../constants.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 {
has_attribute,
@ -135,25 +140,6 @@ export function set_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.
* @param {HTMLInputElement} element
@ -210,7 +196,7 @@ export function set_attribute(element, attribute, value, skip_warning) {
if (value == null) {
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
element[attribute] = value;
} else {
@ -263,7 +249,7 @@ export function set_custom_element_data(node, prop, value) {
// customElements may not be available in browser extension contexts
!customElements ||
customElements.get(get_attribute(node, 'is') || (node_name(node) ?? '').toLowerCase())
? get_setters(node).includes(prop)
? get_setters(node).has(prop)
: value && typeof value === 'object')
) {
// @ts-expect-error
@ -302,11 +288,8 @@ function set_attributes(
skip_warning = false
) {
if (hydrating && should_remove_defaults && node_name(element) === INPUT_TAG) {
var input = /** @type {HTMLInputElement} */ (element);
var attribute = input.type === 'checkbox' ? 'defaultChecked' : 'defaultValue';
if (!(attribute in next)) {
remove_input_defaults(input);
if (!('defaultValue' in next || 'defaultChecked' in next)) {
remove_input_defaults(/** @type {HTMLInputElement} */ (element));
}
}
@ -324,6 +307,7 @@ function set_attributes(
var current = prev || {};
var is_option_element = node_name(element) === OPTION_TAG;
var is_select_element = node_name(element) === SELECT_TAG;
for (var key in prev) {
// don't null our internal $$onX listeners
@ -477,6 +461,9 @@ function set_attributes(
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) {
attributes[key] = null;
if ((name === 'value' || name === 'checked') && current_renderer == null) {
@ -514,7 +501,7 @@ function set_attributes(
if (name in attributes) attributes[name] = UNINITIALIZED;
} else if (
is_default ||
(setters.includes(name) && (is_custom_element || typeof value !== 'string'))
((is_custom_element || typeof value !== 'string') && setters.has(name))
) {
// @ts-ignore
element[name] = value;
@ -575,8 +562,16 @@ export function attribute_effect(
skip_warning
);
if (inited && is_select && 'value' in next) {
select_option(/** @type {HTMLSelectElement} */ (element), next.value);
if (inited && is_select) {
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)) {
@ -601,7 +596,13 @@ export function attribute_effect(
var select = /** @type {HTMLSelectElement} */ (element);
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);
});
}
@ -623,7 +624,7 @@ function get_attributes(element) {
);
}
/** @type {Map<string, string[]>} */
/** @type {Map<string, Set<string>>} */
var setters_cache = new Map();
/** @param {Element} element */
@ -633,7 +634,7 @@ function get_setters(element) {
var cache_key = get_attribute(element, 'is') || (node_name(element) ?? '');
var setters = setters_cache.get(cache_key);
if (setters) return setters;
setters_cache.set(cache_key, (setters = []));
setters_cache.set(cache_key, (setters = new Set()));
var descriptors;
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 !== '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 { 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)
* @template V
@ -47,11 +112,10 @@ export function select_option(select, value, mounting = false) {
}
/**
* Selects the correct option(s) if `value` is given,
* and then sets up a mutation observer to sync the
* current selection to the dom when it changes. Such
* changes could for example occur when options are
* inside an `#each` block.
* Sets up a mutation observer to sync the current selection
* and default to the dom when the options change, for example
* when they are inside an `#each` block. Called once per `<select>`,
* by the compiled output or by `attribute_effect` for spreads.
* @param {HTMLSelectElement} 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
// 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`
if (entries.every(is_selectedcontent_mutation) || !('__value' in select)) return;
// @ts-ignore
if (entries.every(is_selectedcontent_mutation)) return;
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,
// the model should be preserved unless explicitly changed
});
@ -154,8 +224,6 @@ export function bind_select_value(select, get, set = get) {
select.__value = value;
mounting = false;
});
init_select(select);
}
/** @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) {
var is_intro = t2 === 1;
var aborted = false;
if (is_function(options)) {
// 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...
/** @type {Animation} */
var a;
var aborted = false;
queue_micro_task(() => {
if (aborted) return;
@ -381,6 +381,18 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) {
const { delay = 0, css, tick, easing = linear } = options;
/** @type {globalThis.Animation} */
var animation;
var get_t = () => 1 - t2;
// wait a microtask before applying the initial styles and creating the dummy animation,
// so that transitions created in the same batch (e.g. on nested elements) all measure
// 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 keyframes = [];
if (is_intro && counterpart === undefined) {
@ -394,15 +406,13 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) {
}
}
var get_t = () => 1 - t2;
// 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
var animation = element.animate(keyframes, { duration: delay, fill: 'forwards' });
animation = element.animate(keyframes, { duration: delay, fill: 'forwards' });
animation.onfinish = () => {
// remove dummy animation from the stack to prevent conflict with main animation
@ -471,9 +481,12 @@ function animate(element, options, counterpart, t2, on_begin, on_finish) {
on_finish();
};
};
});
return {
abort: () => {
aborted = true;
if (animation) {
animation.cancel();
// This prevents memory leaks in Chromium

@ -1,5 +1,5 @@
/** @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 { init_array_prototype_warnings } from '../dev/equality.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;
}
/**
* `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
* @param {TemplateNode} node

@ -38,7 +38,6 @@ export {
set_xlink_attribute,
set_value,
set_checked,
set_selected,
set_default_checked,
set_default_value,
CLASS,
@ -67,7 +66,13 @@ export {
} from './dom/elements/bindings/media.js';
export { bind_online } from './dom/elements/bindings/navigator.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_this } from './dom/elements/bindings/this.js';
export {
@ -108,6 +113,7 @@ export {
run,
save,
track_reactivity_loss,
unsave,
run_after_blockers,
wait
} from './reactivity/async.js';
@ -168,6 +174,7 @@ export { proxy } from './proxy.js';
export { create_custom_element } from './dom/elements/custom-element.js';
export {
child,
only_child,
first_child,
sibling,
$window as window,

@ -8,7 +8,6 @@ import {
set_component_context,
set_dev_stack
} from '../context.js';
import { Boundary } from '../dom/blocks/boundary.js';
import { current_renderer, set_renderer } from '../custom-renderer/state.js';
import { invoke_error_boundary } from '../error-handling.js';
import {
@ -26,7 +25,6 @@ import {
set_reactivity_loss_tracker
} from './deriveds.js';
import { aborted } from './effects.js';
import { queue_micro_task } from '../dom/task.js';
/**
* @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
* active before the expression evaluated can be reapplied afterwards
@ -170,15 +171,32 @@ export function capture() {
*/
export async function save(promise) {
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;
return () => {
restore();
queue_micro_task(unset_context);
restored = true;
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
* that we can emit `await_reactivity_loss` warnings
@ -187,6 +205,7 @@ export async function save(promise) {
* @returns {Promise<() => T>}
*/
export async function track_reactivity_loss(promise) {
unsave();
var previous_reactivity_loss_tracker = reactivity_loss_tracker;
// Ensure that unrelated reads after an async operation is kicked off don't cause false positives
queueMicrotask(() => {
@ -273,6 +292,7 @@ export async function* for_await_track_reactivity_loss(iterable) {
}
export function unset_context(deactivate_batch = true) {
restored = false;
set_active_effect(null);
set_active_reaction(null);
set_component_context(null);

@ -259,37 +259,7 @@ export function update_reaction(reaction) {
var fn = /** @type {Function} */ (reaction.fn);
var result = fn();
reaction.f |= REACTION_RAN;
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;
}
var deps = update_dependencies(reaction);
// 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
@ -301,7 +271,7 @@ export function update_reaction(reaction) {
deps !== null &&
(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(
untracked_writes[i],
/** @type {Effect} */ (reaction)
@ -345,6 +315,9 @@ export function update_reaction(reaction) {
return result;
} 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);
} finally {
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
* @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 { sha256 } from './crypto.js';
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';
/** @typedef {'head' | 'body'} RendererType */
@ -89,7 +89,7 @@ export class Renderer {
* State that is local to the branch it is declared in.
* It will be shallow-copied to all children.
*
* @type {{ select_value: string | undefined }}
* @type {{ select_value: any, multiple: boolean }}
*/
local;
@ -101,7 +101,7 @@ export class Renderer {
this.#parent = parent;
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';
}
@ -338,11 +338,13 @@ export class Renderer {
* @returns {void}
*/
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.child((renderer) => {
renderer.local.select_value = value;
renderer.local.select_value = value === undefined ? defaultValue : value;
renderer.local.multiple = !!select_attrs.multiple;
fn(renderer);
});
this.push(`${is_rich ? '<!>' : ''}</select>`);
@ -370,7 +372,15 @@ export class Renderer {
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=""');
}

@ -27,7 +27,8 @@ export function attr(name, value, is_boolean = false) {
if (name === 'hidden' && value !== 'until-found') {
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 =
(has_own_property.call(replacements, name) && replacements[name].get(value)) || value;
const assignment = is_boolean ? `=""` : `="${escape_html(normalized, true)}"`;

@ -163,6 +163,8 @@ export class SvelteURL extends URL {
set protocol(value) {
super.protocol = value;
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() {

@ -240,3 +240,25 @@ test('url.searchParams.forEach re-runs when the search string changes via the UR
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(
ce.shadowRoot.innerHTML,
`
<slot></slot>
<p>named fallback</p>
<slot>fallback</slot>
<slot name="named"><p>named fallback</p></slot>
`
);
@ -23,8 +23,8 @@ export default test({
assert.htmlEqual(
ce.shadowRoot.innerHTML,
`
<slot></slot>
<p>named fallback</p>
<slot>fallback</slot>
<slot name="named"><p>named fallback</p></slot>
`
);
}

@ -3,10 +3,7 @@ const tick = () => Promise.resolve();
export default test({
async test({ assert, target }) {
target.innerHTML = `
<custom-element>
<strong>slotted</strong>
</custom-element>`;
target.innerHTML = '<custom-element></custom-element>';
await tick();
await tick();
@ -16,7 +13,26 @@ export default test({
const div = el.shadowRoot.children[0];
const [slot0, slot1] = div.children;
assert.equal(slot0.assignedNodes()[1], target.querySelector('strong'));
assert.equal(slot1.innerHTML, 'foo fallback content');
assert.equal(slot0.localName, 'slot');
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({
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();
@ -15,5 +15,6 @@ export default test({
assert.equal(el.shadowRoot, null);
assert.equal(h1.innerHTML, 'Hello world!');
assert.equal(getComputedStyle(h1).color, 'rgb(255, 0, 0)');
assert.equal(el.querySelector('slot').innerHTML, '');
}
});

@ -5,6 +5,7 @@
</script>
<h1>Hello {name}!</h1>
<slot>fallback</slot>
<style>
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'] };
},
html: `
ssrHtml: `
<select multiple>
<option>one</option>
<option>two</option>
<option>three</option>
<option selected>two</option>
<option selected>three</option>
</select>
<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');
ok(select);
const options = [...target.querySelectorAll('option')];
@ -33,8 +46,8 @@ export default test({
`
<select multiple>
<option>one</option>
<option>two</option>
<option>three</option>
<option${selected}>two</option>
<option${selected}>three</option>
</select>
<p>selected: three</p>
@ -51,8 +64,8 @@ export default test({
`
<select multiple>
<option>one</option>
<option>two</option>
<option>three</option>
<option${selected}>two</option>
<option${selected}>three</option>
</select>
<p>selected: one, three</p>
@ -70,8 +83,8 @@ export default test({
`
<select multiple>
<option>one</option>
<option>two</option>
<option>three</option>
<option${selected}>two</option>
<option${selected}>three</option>
</select>
<p>selected: one, two</p>

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

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

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