Merge branch 'main' into lazy-derived-server

pull/15964/head
Simon Holthausen 4 months ago
commit 846d780415

@ -1,5 +0,0 @@
---
'svelte': minor
---
feat: attachments `fromAction` utility

@ -200,6 +200,19 @@ Consider the following code:
To fix it, either create callback props to communicate changes, or mark `person` as [`$bindable`]($bindable).
### select_multiple_invalid_value
```
The `value` property of a `<select multiple>` element should be an array, but it received a non-array value. The selection will be kept as is.
```
When using `<select multiple value={...}>`, Svelte will mark all selected `<option>` elements as selected by iterating over the array passed to `value`. If `value` is not an array, Svelte will emit this warning and keep the selected options as they are.
To silence the warning, ensure that `value`:
- is an array for an explicit selection
- is `null` or `undefined` to keep the selection as is
### state_proxy_equality_mismatch
```

@ -632,6 +632,25 @@ In some situations a selector may target an element that is not 'visible' to the
</style>
```
### element_implicitly_closed
```
This element is implicitly closed by the following `%tag%`, which can cause an unexpected DOM structure. Add an explicit `%closing%` to avoid surprises.
```
In HTML, some elements are implicitly closed by another element. For example, you cannot nest a `<p>` inside another `<p>`:
```html
<!-- this HTML... -->
<p><p>hello</p>
<!-- results in this DOM structure -->
<p></p>
<p>hello</p>
```
Similarly, a parent element's closing tag will implicitly close all child elements, even if the `</` was a typo and you meant to create a _new_ element. To avoid ambiguity, it's always a good idea to have an explicit closing tag.
### element_invalid_self_closing_tag
```

@ -1,5 +1,41 @@
# svelte
## 5.33.0
### Minor Changes
- feat: XHTML compliance ([#15538](https://github.com/sveltejs/svelte/pull/15538))
- feat: add `fragments: 'html' | 'tree'` option for wider CSP compliance ([#15538](https://github.com/sveltejs/svelte/pull/15538))
## 5.32.2
### Patch Changes
- chore: simplify `<pre>` cleaning ([#15980](https://github.com/sveltejs/svelte/pull/15980))
- fix: attach `__svelte_meta` correctly to elements following a CSS wrapper ([#15982](https://github.com/sveltejs/svelte/pull/15982))
- fix: import untrack directly from client in `svelte/attachments` ([#15974](https://github.com/sveltejs/svelte/pull/15974))
## 5.32.1
### Patch Changes
- Warn when an invalid `<select multiple>` value is given ([#14816](https://github.com/sveltejs/svelte/pull/14816))
## 5.32.0
### Minor Changes
- feat: warn on implicitly closed tags ([#15932](https://github.com/sveltejs/svelte/pull/15932))
- feat: attachments `fromAction` utility ([#15933](https://github.com/sveltejs/svelte/pull/15933))
### Patch Changes
- fix: only re-run directly applied attachment if it changed ([#15962](https://github.com/sveltejs/svelte/pull/15962))
## 5.31.1
### Patch Changes

@ -168,6 +168,17 @@ Consider the following code:
To fix it, either create callback props to communicate changes, or mark `person` as [`$bindable`]($bindable).
## select_multiple_invalid_value
> The `value` property of a `<select multiple>` element should be an array, but it received a non-array value. The selection will be kept as is.
When using `<select multiple value={...}>`, Svelte will mark all selected `<option>` elements as selected by iterating over the array passed to `value`. If `value` is not an array, Svelte will emit this warning and keep the selected options as they are.
To silence the warning, ensure that `value`:
- is an array for an explicit selection
- is `null` or `undefined` to keep the selection as is
## state_proxy_equality_mismatch
> Reactive `$state(...)` proxies and the values they proxy have different identities. Because of this, comparisons with `%operator%` will produce unexpected results

@ -30,6 +30,23 @@
> `<%name%>` will be treated as an HTML element unless it begins with a capital letter
## element_implicitly_closed
> This element is implicitly closed by the following `%tag%`, which can cause an unexpected DOM structure. Add an explicit `%closing%` to avoid surprises.
In HTML, some elements are implicitly closed by another element. For example, you cannot nest a `<p>` inside another `<p>`:
```html
<!-- this HTML... -->
<p><p>hello</p>
<!-- results in this DOM structure -->
<p></p>
<p>hello</p>
```
Similarly, a parent element's closing tag will implicitly close all child elements, even if the `</` was a typo and you meant to create a _new_ element. To avoid ambiguity, it's always a good idea to have an explicit closing tag.
## element_invalid_self_closing_tag
> Self-closing HTML tags for non-void elements are ambiguous — use `<%name% ...></%name%>` rather than `<%name% ... />`

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

@ -2,7 +2,7 @@
/** @import { Attachment } from './public' */
import { noop, render_effect } from 'svelte/internal/client';
import { ATTACHMENT_KEY } from '../constants.js';
import { untrack } from 'svelte';
import { untrack } from '../index-client.js';
import { teardown } from '../internal/client/reactivity/effects.js';
/**

@ -93,7 +93,16 @@ export default function element(parser) {
}
}
if (parent.type !== 'RegularElement' && !parser.loose) {
if (parent.type === 'RegularElement') {
if (!parser.last_auto_closed_tag || parser.last_auto_closed_tag.tag !== name) {
const end = parent.fragment.nodes[0]?.start ?? start;
w.element_implicitly_closed(
{ start: parent.start, end },
`</${name}>`,
`</${parent.name}>`
);
}
} else if (!parser.loose) {
if (parser.last_auto_closed_tag && parser.last_auto_closed_tag.tag === name) {
e.element_invalid_closing_tag_autoclosed(start, name, parser.last_auto_closed_tag.reason);
} else {
@ -186,6 +195,8 @@ export default function element(parser) {
parser.allow_whitespace();
if (parent.type === 'RegularElement' && closing_tag_omitted(parent.name, name)) {
const end = parent.fragment.nodes[0]?.start ?? start;
w.element_implicitly_closed({ start: parent.start, end }, `<${name}>`, `</${parent.name}>`);
parent.end = start;
parser.pop();
parser.last_auto_closed_tag = {

@ -154,10 +154,6 @@ export function client_component(analysis, options) {
legacy_reactive_imports: [],
legacy_reactive_statements: new Map(),
metadata: {
context: {
template_needs_import_node: false,
template_contains_script_tag: false
},
namespace: options.namespace,
bound_contenteditable: false
},
@ -174,8 +170,7 @@ export function client_component(analysis, options) {
update: /** @type {any} */ (null),
expressions: /** @type {any} */ (null),
after_update: /** @type {any} */ (null),
template: /** @type {any} */ (null),
locations: /** @type {any} */ (null)
template: /** @type {any} */ (null)
};
const module = /** @type {ESTree.Program} */ (

@ -0,0 +1,18 @@
const svg_attributes =
'accent-height accumulate additive alignment-baseline allowReorder alphabetic amplitude arabic-form ascent attributeName attributeType autoReverse azimuth baseFrequency baseline-shift baseProfile bbox begin bias by calcMode cap-height class clip clipPathUnits clip-path clip-rule color color-interpolation color-interpolation-filters color-profile color-rendering contentScriptType contentStyleType cursor cx cy d decelerate descent diffuseConstant direction display divisor dominant-baseline dur dx dy edgeMode elevation enable-background end exponent externalResourcesRequired fill fill-opacity fill-rule filter filterRes filterUnits flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight format from fr fx fy g1 g2 glyph-name glyph-orientation-horizontal glyph-orientation-vertical glyphRef gradientTransform gradientUnits hanging height href horiz-adv-x horiz-origin-x id ideographic image-rendering in in2 intercept k k1 k2 k3 k4 kernelMatrix kernelUnitLength kerning keyPoints keySplines keyTimes lang lengthAdjust letter-spacing lighting-color limitingConeAngle local marker-end marker-mid marker-start markerHeight markerUnits markerWidth mask maskContentUnits maskUnits mathematical max media method min mode name numOctaves offset onabort onactivate onbegin onclick onend onerror onfocusin onfocusout onload onmousedown onmousemove onmouseout onmouseover onmouseup onrepeat onresize onscroll onunload opacity operator order orient orientation origin overflow overline-position overline-thickness panose-1 paint-order pathLength patternContentUnits patternTransform patternUnits pointer-events points pointsAtX pointsAtY pointsAtZ preserveAlpha preserveAspectRatio primitiveUnits r radius refX refY rendering-intent repeatCount repeatDur requiredExtensions requiredFeatures restart result rotate rx ry scale seed shape-rendering slope spacing specularConstant specularExponent speed spreadMethod startOffset stdDeviation stemh stemv stitchTiles stop-color stop-opacity strikethrough-position strikethrough-thickness string stroke stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width style surfaceScale systemLanguage tabindex tableValues target targetX targetY text-anchor text-decoration text-rendering textLength to transform type u1 u2 underline-position underline-thickness unicode unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical values version vert-adv-y vert-origin-x vert-origin-y viewBox viewTarget visibility width widths word-spacing writing-mode x x-height x1 x2 xChannelSelector xlink:actuate xlink:arcrole xlink:href xlink:role xlink:show xlink:title xlink:type xml:base xml:lang xml:space y y1 y2 yChannelSelector z zoomAndPan'.split(
' '
);
const svg_attribute_lookup = new Map();
svg_attributes.forEach((name) => {
svg_attribute_lookup.set(name.toLowerCase(), name);
});
/**
* @param {string} name
*/
export default function fix_attribute_casing(name) {
name = name.toLowerCase();
return svg_attribute_lookup.get(name) || name;
}

@ -0,0 +1,68 @@
/** @import { Location } from 'locate-character' */
/** @import { Namespace } from '#compiler' */
/** @import { ComponentClientTransformState } from '../types.js' */
/** @import { Node } from './types.js' */
import { TEMPLATE_USE_MATHML, TEMPLATE_USE_SVG } from '../../../../../constants.js';
import { dev, locator } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';
/**
* @param {Node[]} nodes
*/
function build_locations(nodes) {
const array = b.array([]);
for (const node of nodes) {
if (node.type !== 'element') continue;
const { line, column } = /** @type {Location} */ (locator(node.start));
const expression = b.array([b.literal(line), b.literal(column)]);
const children = build_locations(node.children);
if (children.elements.length > 0) {
expression.elements.push(children);
}
array.elements.push(expression);
}
return array;
}
/**
* @param {ComponentClientTransformState} state
* @param {Namespace} namespace
* @param {number} [flags]
*/
export function transform_template(state, namespace, flags = 0) {
const tree = state.options.fragments === 'tree';
const expression = tree ? state.template.as_tree() : state.template.as_html();
if (tree) {
if (namespace === 'svg') flags |= TEMPLATE_USE_SVG;
if (namespace === 'mathml') flags |= TEMPLATE_USE_MATHML;
}
let call = b.call(
tree ? `$.from_tree` : `$.from_${namespace}`,
expression,
flags ? b.literal(flags) : undefined
);
if (state.template.contains_script_tag) {
call = b.call(`$.with_script`, call);
}
if (dev) {
call = b.call(
'$.add_locations',
call,
b.member(b.id(state.analysis.name), '$.FILENAME', true),
build_locations(state.template.nodes)
);
}
return call;
}

@ -0,0 +1,162 @@
/** @import { AST } from '#compiler' */
/** @import { Node, Element } from './types'; */
import { escape_html } from '../../../../../escaping.js';
import { is_void } from '../../../../../utils.js';
import * as b from '#compiler/builders';
import fix_attribute_casing from './fix-attribute-casing.js';
import { regex_starts_with_newline } from '../../../patterns.js';
export class Template {
/**
* `true` if HTML template contains a `<script>` tag. In this case we need to invoke a special
* template instantiation function (see `create_fragment_with_script_from_html` for more info)
*/
contains_script_tag = false;
/** `true` if the HTML template needs to be instantiated with `importNode` */
needs_import_node = false;
/** @type {Node[]} */
nodes = [];
/** @type {Node[][]} */
#stack = [this.nodes];
/** @type {Element | undefined} */
#element;
#fragment = this.nodes;
/**
* @param {string} name
* @param {number} start
*/
push_element(name, start) {
this.#element = {
type: 'element',
name,
attributes: {},
children: [],
start
};
this.#fragment.push(this.#element);
this.#fragment = /** @type {Element} */ (this.#element).children;
this.#stack.push(this.#fragment);
}
/** @param {string} [data] */
push_comment(data) {
this.#fragment.push({ type: 'comment', data });
}
/** @param {AST.Text[]} nodes */
push_text(nodes) {
this.#fragment.push({ type: 'text', nodes });
}
pop_element() {
this.#stack.pop();
this.#fragment = /** @type {Node[]} */ (this.#stack.at(-1));
}
/**
* @param {string} key
* @param {string | undefined} value
*/
set_prop(key, value) {
/** @type {Element} */ (this.#element).attributes[key] = value;
}
as_html() {
return b.template([b.quasi(this.nodes.map(stringify).join(''), true)], []);
}
as_tree() {
// if the first item is a comment we need to add another comment for effect.start
if (this.nodes[0].type === 'comment') {
this.nodes.unshift({ type: 'comment', data: undefined });
}
return b.array(this.nodes.map(objectify));
}
}
/**
* @param {Node} item
*/
function stringify(item) {
if (item.type === 'text') {
return item.nodes.map((node) => node.raw).join('');
}
if (item.type === 'comment') {
return item.data ? `<!--${item.data}-->` : '<!>';
}
let str = `<${item.name}`;
for (const key in item.attributes) {
const value = item.attributes[key];
str += ` ${key}`;
if (value !== undefined) str += `="${escape_html(value, true)}"`;
}
if (is_void(item.name)) {
str += '/>'; // XHTML compliance
} else {
str += `>`;
str += item.children.map(stringify).join('');
str += `</${item.name}>`;
}
return str;
}
/** @param {Node} item */
function objectify(item) {
if (item.type === 'text') {
return b.literal(item.nodes.map((node) => node.data).join(''));
}
if (item.type === 'comment') {
return item.data ? b.array([b.literal(`// ${item.data}`)]) : null;
}
const element = b.array([b.literal(item.name)]);
const attributes = b.object([]);
for (const key in item.attributes) {
const value = item.attributes[key];
attributes.properties.push(
b.prop(
'init',
b.key(fix_attribute_casing(key)),
value === undefined ? b.void0 : b.literal(value)
)
);
}
if (attributes.properties.length > 0 || item.children.length > 0) {
element.elements.push(attributes.properties.length > 0 ? attributes : b.null);
}
if (item.children.length > 0) {
const children = item.children.map(objectify);
element.elements.push(...children);
// special case — strip leading newline from `<pre>` and `<textarea>`
if (item.name === 'pre' || item.name === 'textarea') {
const first = children[0];
if (first?.type === 'Literal') {
first.value = /** @type {string} */ (first.value).replace(regex_starts_with_newline, '');
}
}
}
return element;
}

@ -0,0 +1,22 @@
import type { AST } from '#compiler';
export interface Element {
type: 'element';
name: string;
attributes: Record<string, string | undefined>;
children: Node[];
/** used for populating __svelte_meta */
start: number;
}
export interface Text {
type: 'text';
nodes: AST.Text[];
}
export interface Comment {
type: 'comment';
data: string | undefined;
}
export type Node = Element | Text | Comment;

@ -3,16 +3,15 @@ import type {
Statement,
LabeledStatement,
Identifier,
PrivateIdentifier,
Expression,
AssignmentExpression,
UpdateExpression,
VariableDeclaration
} from 'estree';
import type { AST, Namespace, StateField, ValidatedCompileOptions } from '#compiler';
import type { AST, Namespace, ValidatedCompileOptions } from '#compiler';
import type { TransformState } from '../types.js';
import type { ComponentAnalysis } from '../../types.js';
import type { SourceLocation } from '#shared';
import type { Template } from './transform-template/template.js';
export interface ClientTransformState extends TransformState {
/**
@ -53,26 +52,10 @@ export interface ComponentClientTransformState extends ClientTransformState {
/** Expressions used inside the render effect */
readonly expressions: Expression[];
/** The HTML template string */
readonly template: Array<string | Expression>;
readonly locations: SourceLocation[];
readonly template: Template;
readonly metadata: {
namespace: Namespace;
bound_contenteditable: boolean;
/**
* Stuff that is set within the children of one `Fragment` visitor that is relevant
* to said fragment. Shouldn't be destructured or otherwise spread unless inside the
* `Fragment` visitor to keep the object reference intact (it's also nested
* within `metadata` for this reason).
*/
context: {
/** `true` if the HTML template needs to be instantiated with `importNode` */
template_needs_import_node: boolean;
/**
* `true` if HTML template contains a `<script>` tag. In this case we need to invoke a special
* template instantiation function (see `create_fragment_with_script_from_html` for more info)
*/
template_contains_script_tag: boolean;
};
};
readonly preserve_whitespace: boolean;

@ -11,7 +11,7 @@ import { get_value } from './shared/declarations.js';
* @param {ComponentContext} context
*/
export function AwaitBlock(node, context) {
context.state.template.push('<!>');
context.state.template.push_comment();
// Visit {#await <expression>} first to ensure that scopes are in the correct order
const expression = b.thunk(/** @type {Expression} */ (context.visit(node.expression)));

@ -7,5 +7,5 @@
*/
export function Comment(node, context) {
// We'll only get here if comments are not filtered out, which they are unless preserveComments is true
context.state.template.push(`<!--${node.data}-->`);
context.state.template.push_comment(node.data);
}

@ -32,7 +32,7 @@ export function EachBlock(node, context) {
);
if (!each_node_meta.is_controlled) {
context.state.template.push('<!>');
context.state.template.push_comment();
}
let flags = 0;

@ -1,14 +1,13 @@
/** @import { Expression, Identifier, Statement, TemplateElement } from 'estree' */
/** @import { AST, Namespace } from '#compiler' */
/** @import { SourceLocation } from '#shared' */
/** @import { Expression, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */
import { TEMPLATE_FRAGMENT, TEMPLATE_USE_IMPORT_NODE } from '../../../../../constants.js';
import { dev } from '../../../../state.js';
import * as b from '#compiler/builders';
import { sanitize_template_string } from '../../../../utils/sanitize_template_string.js';
import { clean_nodes, infer_namespace } from '../../utils.js';
import { transform_template } from '../transform-template/index.js';
import { process_children } from './shared/fragment.js';
import { build_render_statement } from './shared/utils.js';
import { Template } from '../transform-template/template.js';
/**
* @param {AST.Fragment} node
@ -18,7 +17,7 @@ export function Fragment(node, context) {
// Creates a new block which looks roughly like this:
// ```js
// // hoisted:
// const block_name = $.template(`...`);
// const block_name = $.from_html(`...`);
//
// // for the main block:
// const id = block_name();
@ -67,14 +66,9 @@ export function Fragment(node, context) {
update: [],
expressions: [],
after_update: [],
template: [],
locations: [],
template: new Template(),
transform: { ...context.state.transform },
metadata: {
context: {
template_needs_import_node: false,
template_contains_script_tag: false
},
namespace,
bound_contenteditable: context.state.metadata.bound_contenteditable
}
@ -89,24 +83,6 @@ export function Fragment(node, context) {
body.push(b.stmt(b.call('$.next')));
}
/**
* @param {Identifier} template_name
* @param {Expression[]} args
*/
const add_template = (template_name, args) => {
let call = b.call(get_template_function(namespace, state), ...args);
if (dev) {
call = b.call(
'$.add_locations',
call,
b.member(b.id(context.state.analysis.name), '$.FILENAME', true),
build_locations(state.locations)
);
}
context.state.hoisted.push(b.var(template_name, call));
};
if (is_single_element) {
const element = /** @type {AST.RegularElement} */ (trimmed[0]);
@ -117,14 +93,10 @@ export function Fragment(node, context) {
node: id
});
/** @type {Expression[]} */
const args = [join_template(state.template)];
if (state.metadata.context.template_needs_import_node) {
args.push(b.literal(TEMPLATE_USE_IMPORT_NODE));
}
let flags = state.template.needs_import_node ? TEMPLATE_USE_IMPORT_NODE : undefined;
add_template(template_name, args);
const template = transform_template(state, namespace, flags);
state.hoisted.push(b.var(template_name, template));
body.push(b.var(id, b.call(template_name)));
close = b.stmt(b.call('$.append', b.id('$$anchor'), id));
@ -164,15 +136,16 @@ export function Fragment(node, context) {
let flags = TEMPLATE_FRAGMENT;
if (state.metadata.context.template_needs_import_node) {
if (state.template.needs_import_node) {
flags |= TEMPLATE_USE_IMPORT_NODE;
}
if (state.template.length === 1 && state.template[0] === '<!>') {
if (state.template.nodes.length === 1 && state.template.nodes[0].type === 'comment') {
// special case — we can use `$.comment` instead of creating a unique template
body.push(b.var(id, b.call('$.comment')));
} else {
add_template(template_name, [join_template(state.template), b.literal(flags)]);
const template = transform_template(state, namespace, flags);
state.hoisted.push(b.var(template_name, template));
body.push(b.var(id, b.call(template_name)));
}
@ -199,86 +172,3 @@ export function Fragment(node, context) {
return b.block(body);
}
/**
* @param {Array<string | Expression>} items
*/
function join_template(items) {
let quasi = b.quasi('');
const template = b.template([quasi], []);
/**
* @param {Expression} expression
*/
function push(expression) {
if (expression.type === 'TemplateLiteral') {
for (let i = 0; i < expression.expressions.length; i += 1) {
const q = expression.quasis[i];
const e = expression.expressions[i];
quasi.value.cooked += /** @type {string} */ (q.value.cooked);
push(e);
}
const last = /** @type {TemplateElement} */ (expression.quasis.at(-1));
quasi.value.cooked += /** @type {string} */ (last.value.cooked);
} else if (expression.type === 'Literal') {
/** @type {string} */ (quasi.value.cooked) += expression.value;
} else {
template.expressions.push(expression);
template.quasis.push((quasi = b.quasi('')));
}
}
for (const item of items) {
if (typeof item === 'string') {
quasi.value.cooked += item;
} else {
push(item);
}
}
for (const quasi of template.quasis) {
quasi.value.raw = sanitize_template_string(/** @type {string} */ (quasi.value.cooked));
}
quasi.tail = true;
return template;
}
/**
*
* @param {Namespace} namespace
* @param {ComponentClientTransformState} state
* @returns
*/
function get_template_function(namespace, state) {
const contains_script_tag = state.metadata.context.template_contains_script_tag;
return namespace === 'svg'
? contains_script_tag
? '$.svg_template_with_script'
: '$.ns_template'
: namespace === 'mathml'
? '$.mathml_template'
: contains_script_tag
? '$.template_with_script'
: '$.template';
}
/**
* @param {SourceLocation[]} locations
*/
function build_locations(locations) {
return b.array(
locations.map((loc) => {
const expression = b.array([b.literal(loc[0]), b.literal(loc[1])]);
if (loc.length === 3) {
expression.elements.push(build_locations(loc[2]));
}
return expression;
})
);
}

@ -9,7 +9,7 @@ import * as b from '#compiler/builders';
* @param {ComponentContext} context
*/
export function HtmlTag(node, context) {
context.state.template.push('<!>');
context.state.template.push_comment();
const expression = /** @type {Expression} */ (context.visit(node.expression));

@ -8,7 +8,7 @@ import * as b from '#compiler/builders';
* @param {ComponentContext} context
*/
export function IfBlock(node, context) {
context.state.template.push('<!>');
context.state.template.push_comment();
const statements = [];
const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent));

@ -8,7 +8,7 @@ import * as b from '#compiler/builders';
* @param {ComponentContext} context
*/
export function KeyBlock(node, context) {
context.state.template.push('<!>');
context.state.template.push_comment();
const key = /** @type {Expression} */ (context.visit(node.expression));
const body = /** @type {Expression} */ (context.visit(node.fragment));

@ -1,17 +1,14 @@
/** @import { ArrayExpression, Expression, ExpressionStatement, Identifier, MemberExpression, ObjectExpression } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { SourceLocation } from '#shared' */
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */
/** @import { Scope } from '../../../scope' */
import {
cannot_be_set_statically,
is_boolean_attribute,
is_dom_property,
is_load_error_element,
is_void
is_load_error_element
} from '../../../../../utils.js';
import { escape_html } from '../../../../../escaping.js';
import { dev, is_ignored, locator } from '../../../../state.js';
import { is_ignored } from '../../../../state.js';
import { is_event_attribute, is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { is_custom_element_node } from '../../../nodes.js';
@ -39,40 +36,24 @@ import { visit_event_attribute } from './shared/events.js';
* @param {ComponentContext} context
*/
export function RegularElement(node, context) {
/** @type {SourceLocation} */
let location = [-1, -1];
if (dev) {
const loc = locator(node.start);
if (loc) {
location[0] = loc.line;
location[1] = loc.column;
context.state.locations.push(location);
}
}
context.state.template.push_element(node.name, node.start);
if (node.name === 'noscript') {
context.state.template.push('<noscript></noscript>');
context.state.template.pop_element();
return;
}
const is_custom_element = is_custom_element_node(node);
if (node.name === 'video' || is_custom_element) {
// cloneNode is faster, but it does not instantiate the underlying class of the
// custom element until the template is connected to the dom, which would
// cause problems when setting properties on the custom element.
// Therefore we need to use importNode instead, which doesn't have this caveat.
// Additionally, Webkit browsers need importNode for video elements for autoplay
// to work correctly.
context.state.metadata.context.template_needs_import_node = true;
}
if (node.name === 'script') {
context.state.metadata.context.template_contains_script_tag = true;
}
context.state.template.needs_import_node ||= node.name === 'video' || is_custom_element;
context.state.template.push(`<${node.name}`);
context.state.template.contains_script_tag ||= node.name === 'script';
/** @type {Array<AST.Attribute | AST.SpreadAttribute>} */
const attributes = [];
@ -110,7 +91,7 @@ export function RegularElement(node, context) {
const { value } = build_attribute_value(attribute.value, context);
if (value.type === 'Literal' && typeof value.value === 'string') {
context.state.template.push(` is="${escape_html(value.value, true)}"`);
context.state.template.set_prop('is', value.value);
continue;
}
}
@ -290,12 +271,9 @@ export function RegularElement(node, context) {
}
if (name !== 'class' || value) {
context.state.template.push(
` ${attribute.name}${
is_boolean_attribute(name) && value === true
? ''
: `="${value === true ? '' : escape_html(value, true)}"`
}`
context.state.template.set_prop(
attribute.name,
is_boolean_attribute(name) && value === true ? undefined : value === true ? '' : value
);
}
} else if (name === 'autofocus') {
@ -329,8 +307,6 @@ export function RegularElement(node, context) {
context.state.after_update.push(b.stmt(b.call('$.replay_events', node_id)));
}
context.state.template.push('>');
const metadata = {
...context.state.metadata,
namespace: determine_namespace_for_children(node, context.state.metadata.namespace)
@ -352,7 +328,6 @@ export function RegularElement(node, context) {
const state = {
...context.state,
metadata,
locations: [],
scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)),
preserve_whitespace:
context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea'
@ -446,14 +421,7 @@ export function RegularElement(node, context) {
context.state.update.push(b.stmt(b.assignment('=', dir, dir)));
}
if (state.locations.length > 0) {
// @ts-expect-error
location.push(state.locations);
}
if (!is_void(node.name)) {
context.state.template.push(`</${node.name}>`);
}
context.state.template.pop_element();
}
/**

@ -9,7 +9,7 @@ import * as b from '#compiler/builders';
* @param {ComponentContext} context
*/
export function RenderTag(node, context) {
context.state.template.push('<!>');
context.state.template.push_comment();
const expression = unwrap_optional(node.expression);

@ -11,7 +11,7 @@ import { memoize_expression } from './shared/utils.js';
*/
export function SlotElement(node, context) {
// <slot {a}>fallback</slot> --> $.slot($$slots.default, { get a() { .. } }, () => ...fallback);
context.state.template.push('<!>');
context.state.template.push_comment();
/** @type {Property[]} */
const props = [];

@ -88,7 +88,7 @@ export function SvelteBoundary(node, context) {
b.call('$.boundary', context.state.node, props, b.arrow([b.id('$$anchor')], block))
);
context.state.template.push('<!>');
context.state.template.push_comment();
context.state.init.push(
external_statements.length > 0 ? b.block([...external_statements, boundary]) : boundary
);

@ -13,7 +13,7 @@ import { build_render_statement } from './shared/utils.js';
* @param {ComponentContext} context
*/
export function SvelteElement(node, context) {
context.state.template.push(`<!>`);
context.state.template.push_comment();
/** @type {Array<AST.Attribute | AST.SpreadAttribute>} */
const attributes = [];

@ -440,11 +440,17 @@ export function build_component(node, component_name, context, anchor = context.
}
if (Object.keys(custom_css_props).length > 0) {
context.state.template.push(
context.state.metadata.namespace === 'svg'
? '<g><!></g>'
: '<svelte-css-wrapper style="display: contents"><!></svelte-css-wrapper>'
);
if (context.state.metadata.namespace === 'svg') {
// this boils down to <g><!></g>
context.state.template.push_element('g', node.start);
} else {
// this boils down to <svelte-css-wrapper style='display: contents'><!></svelte-css-wrapper>
context.state.template.push_element('svelte-css-wrapper', node.start);
context.state.template.set_prop('style', 'display: contents');
}
context.state.template.push_comment();
context.state.template.pop_element();
statements.push(
b.stmt(b.call('$.css_props', anchor, b.thunk(b.object(custom_css_props)))),
@ -452,7 +458,7 @@ export function build_component(node, component_name, context, anchor = context.
b.stmt(b.call('$.reset', anchor))
);
} else {
context.state.template.push('<!>');
context.state.template.push_comment();
statements.push(b.stmt(fn(anchor)));
}

@ -64,11 +64,11 @@ export function process_children(nodes, initial, is_element, { visit, state }) {
function flush_sequence(sequence) {
if (sequence.every((node) => node.type === 'Text')) {
skipped += 1;
state.template.push(sequence.map((node) => node.raw).join(''));
state.template.push_text(sequence);
return;
}
state.template.push(' ');
state.template.push_text([{ type: 'Text', data: ' ', raw: ' ', start: -1, end: -1 }]);
const { has_state, value } = build_template_chunk(sequence, visit, state);

@ -24,9 +24,11 @@ export function RegularElement(node, context) {
context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea'
};
const node_is_void = is_void(node.name);
context.state.template.push(b.literal(`<${node.name}`));
const body = build_element_attributes(node, { ...context, state });
context.state.template.push(b.literal('>'));
context.state.template.push(b.literal(node_is_void ? '/>' : '>')); // add `/>` for XHTML compliance
if ((node.name === 'script' || node.name === 'style') && node.fragment.nodes.length === 1) {
context.state.template.push(
@ -94,7 +96,7 @@ export function RegularElement(node, context) {
);
}
if (!is_void(node.name)) {
if (!node_is_void) {
state.template.push(b.literal(`</${node.name}>`));
}

@ -271,21 +271,14 @@ export function clean_nodes(
var first = trimmed[0];
// initial newline inside a `<pre>` is disregarded, if not followed by another newline
// if first text node inside a <pre> is a single newline, discard it, because otherwise
// the browser will do it for us which could break hydration
if (parent.type === 'RegularElement' && parent.name === 'pre' && first?.type === 'Text') {
const text = first.data.replace(regex_starts_with_newline, '');
if (text !== first.data) {
const tmp = text.replace(regex_starts_with_newline, '');
if (text === tmp) {
first.data = text;
first.raw = first.raw.replace(regex_starts_with_newline, '');
if (first.data === '') {
if (first.data === '\n' || first.data === '\r\n') {
trimmed.shift();
first = trimmed[0];
}
}
}
}
// Special case: Add a comment if this is a lone script tag. This ensures that our run_scripts logic in template.js
// will always be able to call node.replaceWith() on the script tag in order to make it run. If we don't add this
@ -331,7 +324,7 @@ export function clean_nodes(
}
/**
* Infers the namespace for the children of a node that should be used when creating the `$.template(...)`.
* Infers the namespace for the children of a node that should be used when creating the fragment
* @param {Namespace} namespace
* @param {AST.SvelteNode} parent
* @param {AST.SvelteNode[]} nodes

@ -122,6 +122,15 @@ export interface CompileOptions extends ModuleCompileOptions {
* @default false
*/
preserveWhitespace?: boolean;
/**
* Which strategy to use when cloning DOM fragments:
*
* - `html` populates a `<template>` with `innerHTML` and clones it. This is faster, but cannot be used if your app's [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) includes [`require-trusted-types-for 'script'`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/require-trusted-types-for)
* - `tree` creates the fragment one element at a time and _then_ clones it. This is slower, but works everywhere
*
* @default 'html'
*/
fragments?: 'html' | 'tree';
/**
* Set to `true` to force the compiler into runes mode, even if there are no indications of runes usage.
* Set to `false` to force the compiler into ignoring runes, even if there are indications of runes usage.

@ -110,6 +110,8 @@ export const validate_component_options =
preserveComments: boolean(false),
fragments: list(['html', 'tree']),
preserveWhitespace: boolean(false),
runes: boolean(undefined),

@ -114,6 +114,7 @@ export const codes = [
'bind_invalid_each_rest',
'block_empty',
'component_name_lowercase',
'element_implicitly_closed',
'element_invalid_self_closing_tag',
'event_directive_deprecated',
'node_invalid_placement_ssr',
@ -746,6 +747,16 @@ export function component_name_lowercase(node, name) {
w(node, 'component_name_lowercase', `\`<${name}>\` will be treated as an HTML element unless it begins with a capital letter\nhttps://svelte.dev/e/component_name_lowercase`);
}
/**
* This element is implicitly closed by the following `%tag%`, which can cause an unexpected DOM structure. Add an explicit `%closing%` to avoid surprises.
* @param {null | NodeLike} node
* @param {string} tag
* @param {string} closing
*/
export function element_implicitly_closed(node, tag, closing) {
w(node, 'element_implicitly_closed', `This element is implicitly closed by the following \`${tag}\`, which can cause an unexpected DOM structure. Add an explicit \`${closing}\` to avoid surprises.\nhttps://svelte.dev/e/element_implicitly_closed`);
}
/**
* Self-closing HTML tags for non-void elements are ambiguous use `<%name% ...></%name%>` rather than `<%name% ... />`
* @param {null | NodeLike} node

@ -17,6 +17,8 @@ export const TRANSITION_GLOBAL = 1 << 2;
export const TEMPLATE_FRAGMENT = 1;
export const TEMPLATE_USE_IMPORT_NODE = 1 << 1;
export const TEMPLATE_USE_SVG = 1 << 2;
export const TEMPLATE_USE_MATHML = 1 << 3;
export const HYDRATION_START = '[';
/** used to indicate that an `{:else}...` block was rendered */

@ -1,4 +1,4 @@
/** @import { SourceLocation } from '#shared' */
/** @import { SourceLocation } from '#client' */
import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../constants.js';
import { hydrating } from '../dom/hydration.js';

@ -1,15 +1,33 @@
import { effect } from '../../reactivity/effects.js';
/** @import { Effect } from '#client' */
import { block, branch, effect, destroy_effect } from '../../reactivity/effects.js';
// TODO in 6.0 or 7.0, when we remove legacy mode, we can simplify this by
// getting rid of the block/branch stuff and just letting the effect rip.
// see https://github.com/sveltejs/svelte/pull/15962
/**
* @param {Element} node
* @param {() => (node: Element) => void} get_fn
*/
export function attach(node, get_fn) {
effect(() => {
const fn = get_fn();
/** @type {false | undefined | ((node: Element) => void)} */
var fn = undefined;
/** @type {Effect | null} */
var e;
block(() => {
if (fn !== (fn = get_fn())) {
if (e) {
destroy_effect(e);
e = null;
}
// we use `&&` rather than `?.` so that things like
// `{@attach DEV && something_dev_only()}` work
return fn && fn(node);
if (fn) {
e = branch(() => {
effect(() => /** @type {(node: Element) => void} */ (fn)(node));
});
}
}
});
}

@ -2,6 +2,8 @@ import { effect } from '../../../reactivity/effects.js';
import { listen_to_event_and_reset_event } from './shared.js';
import { untrack } from '../../../runtime.js';
import { is } from '../../../proxy.js';
import { is_array } from '../../../../shared/utils.js';
import * as w from '../../../warnings.js';
/**
* Selects the correct option(s) (depending on whether this is a multiple select)
@ -12,6 +14,17 @@ import { is } from '../../../proxy.js';
*/
export function select_option(select, value, mounting) {
if (select.multiple) {
// If value is null or undefined, keep the selection as is
if (value == undefined) {
return;
}
// If not an array, warn and keep the selection as is
if (!is_array(value)) {
return w.select_multiple_invalid_value();
}
// Otherwise, update the selection
return select_options(select, value);
}
@ -124,14 +137,12 @@ export function bind_select_value(select, get, set = get) {
}
/**
* @template V
* @param {HTMLSelectElement} select
* @param {V} value
* @param {unknown[]} value
*/
function select_options(select, value) {
for (var option of select.options) {
// @ts-ignore
option.selected = ~value.indexOf(get_option_value(option));
option.selected = value.includes(get_option_value(option));
}
}

@ -204,3 +204,44 @@ export function sibling(node, count = 1, is_text = false) {
export function clear_text_content(node) {
node.textContent = '';
}
/**
*
* @param {string} tag
* @param {string} [namespace]
* @param {string} [is]
* @returns
*/
export function create_element(tag, namespace, is) {
let options = is ? { is } : undefined;
if (namespace) {
return document.createElementNS(namespace, tag, options);
}
return document.createElement(tag, options);
}
export function create_fragment() {
return document.createDocumentFragment();
}
/**
* @param {string} data
* @returns
*/
export function create_comment(data = '') {
return document.createComment(data);
}
/**
* @param {Element} element
* @param {string} key
* @param {string} value
* @returns
*/
export function set_attribute(element, key, value = '') {
if (key.startsWith('xlink:')) {
element.setAttributeNS('http://www.w3.org/1999/xlink', key, value);
return;
}
return element.setAttribute(key, value);
}

@ -1,6 +1,6 @@
/** @param {string} html */
export function create_fragment_from_html(html) {
var elem = document.createElement('template');
elem.innerHTML = html;
elem.innerHTML = html.replaceAll('<!>', '<!---->'); // XHTML compliance
return elem.content;
}

@ -1,9 +1,25 @@
/** @import { Effect, TemplateNode } from '#client' */
/** @import { TemplateStructure } from './types' */
import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js';
import { create_text, get_first_child, is_firefox } from './operations.js';
import {
create_text,
get_first_child,
is_firefox,
create_element,
create_fragment,
create_comment,
set_attribute
} from './operations.js';
import { create_fragment_from_html } from './reconciler.js';
import { active_effect } from '../runtime.js';
import { TEMPLATE_FRAGMENT, TEMPLATE_USE_IMPORT_NODE } from '../../../constants.js';
import {
NAMESPACE_MATHML,
NAMESPACE_SVG,
TEMPLATE_FRAGMENT,
TEMPLATE_USE_IMPORT_NODE,
TEMPLATE_USE_MATHML,
TEMPLATE_USE_SVG
} from '../../../constants.js';
/**
* @param {TemplateNode} start
@ -23,7 +39,7 @@ export function assign_nodes(start, end) {
* @returns {() => Node | Node[]}
*/
/*#__NO_SIDE_EFFECTS__*/
export function template(content, flags) {
export function from_html(content, flags) {
var is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;
var use_import_node = (flags & TEMPLATE_USE_IMPORT_NODE) !== 0;
@ -64,17 +80,6 @@ export function template(content, flags) {
};
}
/**
* @param {string} content
* @param {number} flags
* @returns {() => Node | Node[]}
*/
/*#__NO_SIDE_EFFECTS__*/
export function template_with_script(content, flags) {
var fn = template(content, flags);
return () => run_scripts(/** @type {Element | DocumentFragment} */ (fn()));
}
/**
* @param {string} content
* @param {number} flags
@ -82,7 +87,7 @@ export function template_with_script(content, flags) {
* @returns {() => Node | Node[]}
*/
/*#__NO_SIDE_EFFECTS__*/
export function ns_template(content, flags, ns = 'svg') {
function from_namespace(content, flags, ns = 'svg') {
/**
* Whether or not the first item is a text/element node. If not, we need to
* create an additional comment node to act as `effect.nodes.start`
@ -133,22 +138,120 @@ export function ns_template(content, flags, ns = 'svg') {
/**
* @param {string} content
* @param {number} flags
* @returns {() => Node | Node[]}
*/
/*#__NO_SIDE_EFFECTS__*/
export function svg_template_with_script(content, flags) {
var fn = ns_template(content, flags);
return () => run_scripts(/** @type {Element | DocumentFragment} */ (fn()));
export function from_svg(content, flags) {
return from_namespace(content, flags, 'svg');
}
/**
* @param {string} content
* @param {number} flags
*/
/*#__NO_SIDE_EFFECTS__*/
export function from_mathml(content, flags) {
return from_namespace(content, flags, 'math');
}
/**
* @param {TemplateStructure[]} structure
* @param {NAMESPACE_SVG | NAMESPACE_MATHML | undefined} [ns]
*/
function fragment_from_tree(structure, ns) {
var fragment = create_fragment();
for (var item of structure) {
if (typeof item === 'string') {
fragment.append(create_text(item));
continue;
}
// if `preserveComments === true`, comments are represented as `['// <data>']`
if (item === undefined || item[0][0] === '/') {
fragment.append(create_comment(item ? item[0].slice(3) : ''));
continue;
}
const [name, attributes, ...children] = item;
const namespace = name === 'svg' ? NAMESPACE_SVG : name === 'math' ? NAMESPACE_MATHML : ns;
var element = create_element(name, namespace, attributes?.is);
for (var key in attributes) {
set_attribute(element, key, attributes[key]);
}
if (children.length > 0) {
var target =
element.tagName === 'TEMPLATE'
? /** @type {HTMLTemplateElement} */ (element).content
: element;
target.append(
fragment_from_tree(children, element.tagName === 'foreignObject' ? undefined : namespace)
);
}
fragment.append(element);
}
return fragment;
}
/**
* @param {TemplateStructure[]} structure
* @param {number} flags
* @returns {() => Node | Node[]}
*/
/*#__NO_SIDE_EFFECTS__*/
export function mathml_template(content, flags) {
return ns_template(content, flags, 'math');
export function from_tree(structure, flags) {
var is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;
var use_import_node = (flags & TEMPLATE_USE_IMPORT_NODE) !== 0;
/** @type {Node} */
var node;
return () => {
if (hydrating) {
assign_nodes(hydrate_node, null);
return hydrate_node;
}
if (node === undefined) {
const ns =
(flags & TEMPLATE_USE_SVG) !== 0
? NAMESPACE_SVG
: (flags & TEMPLATE_USE_MATHML) !== 0
? NAMESPACE_MATHML
: undefined;
node = fragment_from_tree(structure, ns);
if (!is_fragment) node = /** @type {Node} */ (get_first_child(node));
}
var clone = /** @type {TemplateNode} */ (
use_import_node || is_firefox ? document.importNode(node, true) : node.cloneNode(true)
);
if (is_fragment) {
var start = /** @type {TemplateNode} */ (get_first_child(clone));
var end = /** @type {TemplateNode} */ (clone.lastChild);
assign_nodes(start, end);
} else {
assign_nodes(clone, clone);
}
return clone;
};
}
/**
* @param {() => Element | DocumentFragment} fn
*/
export function with_script(fn) {
return () => run_scripts(fn());
}
/**

@ -0,0 +1,4 @@
export type TemplateStructure =
| string
| undefined
| [string, Record<string, string> | undefined, ...TemplateStructure[]];

@ -88,13 +88,13 @@ export {
export {
append,
comment,
ns_template,
svg_template_with_script,
mathml_template,
template,
template_with_script,
from_html,
from_mathml,
from_svg,
from_tree,
text,
props_id
props_id,
with_script
} from './dom/template.js';
export { user_derived as derived, derived_safe_equal } from './reactivity/deriveds.js';
export {

@ -183,4 +183,8 @@ export type ProxyStateObject<T = Record<string | symbol, any>> = T & {
[STATE_SYMBOL]: T;
};
export type SourceLocation =
| [line: number, column: number]
| [line: number, column: number, SourceLocation[]];
export * from './reactivity/types';

@ -158,6 +158,17 @@ export function ownership_invalid_mutation(name, location, prop, parent) {
}
}
/**
* The `value` property of a `<select multiple>` element should be an array, but it received a non-array value. The selection will be kept as is.
*/
export function select_multiple_invalid_value() {
if (DEV) {
console.warn(`%c[svelte] select_multiple_invalid_value\n%cThe \`value\` property of a \`<select multiple>\` element should be an array, but it received a non-array value. The selection will be kept as is.\nhttps://svelte.dev/e/select_multiple_invalid_value`, bold, normal);
} else {
console.warn(`https://svelte.dev/e/select_multiple_invalid_value`);
}
}
/**
* Reactive `$state(...)` proxies and the values they proxy have different identities. Because of this, comparisons with `%operator%` will produce unexpected results
* @param {string} operator

@ -3,10 +3,6 @@ export type Store<V> = {
set(value: V): void;
};
export type SourceLocation =
| [line: number, column: number]
| [line: number, column: number, SourceLocation[]];
export type Getters<T> = {
[K in keyof T]: () => T[K];
};

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

@ -190,3 +190,5 @@ if (typeof window !== 'undefined') {
};
});
}
export const fragments = /** @type {'html' | 'tree'} */ (process.env.FRAGMENTS) ?? 'html';

@ -4,6 +4,7 @@ import * as fs from 'node:fs';
import { assert } from 'vitest';
import { compile_directory } from '../helpers.js';
import { assert_html_equal } from '../html_equal.js';
import { fragments } from '../helpers.js';
import { assert_ok, suite, type BaseTest } from '../suite.js';
import { createClassComponent } from 'svelte/legacy';
import { render } from 'svelte/server';
@ -43,7 +44,12 @@ function read(path: string): string | void {
const { test, run } = suite<HydrationTest>(async (config, cwd) => {
if (!config.load_compiled) {
await compile_directory(cwd, 'client', { accessors: true, ...config.compileOptions });
await compile_directory(cwd, 'client', {
accessors: true,
fragments,
...config.compileOptions
});
await compile_directory(cwd, 'server', config.compileOptions);
}
@ -125,7 +131,8 @@ const { test, run } = suite<HydrationTest>(async (config, cwd) => {
flushSync();
const normalize = (string: string) => string.trim().replace(/\r\n/g, '\n');
const normalize = (string: string) =>
string.trim().replaceAll('\r\n', '\n').replaceAll('/>', '>');
const expected = read(`${cwd}/_expected.html`) ?? rendered.html;
assert.equal(normalize(target.innerHTML), normalize(expected));

@ -5,7 +5,7 @@ import * as path from 'node:path';
import { compile } from 'svelte/compiler';
import { afterAll, assert, beforeAll, describe } from 'vitest';
import { suite, suite_with_variants } from '../suite';
import { write } from '../helpers';
import { write, fragments } from '../helpers';
import type { Warning } from '#compiler';
const assert_file = path.resolve(__dirname, 'assert.js');
@ -87,6 +87,7 @@ async function run_test(
build.onLoad({ filter: /\.svelte$/ }, (args) => {
const compiled = compile(fs.readFileSync(args.path, 'utf-8').replace(/\r/g, ''), {
generate: 'client',
fragments,
...config.compileOptions,
immutable: config.immutable,
customElement: test_dir.includes('custom-elements-samples'),

@ -0,0 +1,16 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, target, logs }) {
assert.deepEqual(logs, ['up']);
const button = target.querySelector('button');
flushSync(() => button?.click());
assert.deepEqual(logs, ['up']);
flushSync(() => button?.click());
assert.deepEqual(logs, ['up', 'down']);
}
});

@ -0,0 +1,15 @@
<script>
let state = {
count: 0,
attachment(){
console.log('up');
return () => console.log('down');
}
};
</script>
<button onclick={() => state.count++}>{state.count}</button>
{#if state.count < 2}
<div {@attach state.attachment}></div>
{/if}

@ -6,7 +6,7 @@ import { proxy } from 'svelte/internal/client';
import { flushSync, hydrate, mount, unmount } from 'svelte';
import { render } from 'svelte/server';
import { afterAll, assert, beforeAll } from 'vitest';
import { compile_directory } from '../helpers.js';
import { compile_directory, fragments } from '../helpers.js';
import { setup_html_equal } from '../html_equal.js';
import { raf } from '../animation-helpers.js';
import type { CompileOptions } from '#compiler';
@ -158,6 +158,7 @@ async function common_setup(cwd: string, runes: boolean | undefined, config: Run
rootDir: cwd,
dev: force_hmr ? true : undefined,
hmr: force_hmr ? true : undefined,
fragments,
...config.compileOptions,
immutable: config.immutable,
accessors: 'accessors' in config ? config.accessors : true,

@ -1,4 +1,5 @@
<script module>
if(!customElements.get('value-element')) {
customElements.define('value-element', class extends HTMLElement {
constructor() {
@ -13,6 +14,7 @@
}
}
});
}
</script>
<my-element string="test" object={{ test: true }}></my-element>

@ -0,0 +1,9 @@
import { test } from '../../test';
export default test({
compileOptions: {
fragments: 'tree'
},
html: `<p>hello</p>`
});

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
warnings: [
'The `value` property of a `<select multiple>` element should be an array, but it received a non-array value. The selection will be kept as is.'
]
});

@ -0,0 +1,9 @@
<select multiple value={null}>
<option>option</option>
</select>
<select multiple value={undefined}>
<option>option</option>
</select>
<select multiple value={123}>
<option>option</option>
</select>

@ -0,0 +1,7 @@
<h2>hello from component</h2>
<style>
h2 {
color: var(--color);
}
</style>

@ -0,0 +1,42 @@
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
html: `
<h1>hello</h1>
<svelte-css-wrapper style="display: contents; --color: red;">
<h2 class="svelte-13kae5a">hello from component</h2>
</svelte-css-wrapper>
<p>goodbye</p>
`,
async test({ target, assert }) {
const h1 = target.querySelector('h1');
const h2 = target.querySelector('h2');
const p = target.querySelector('p');
// @ts-expect-error
assert.deepEqual(h1.__svelte_meta.loc, {
file: 'main.svelte',
line: 5,
column: 0
});
// @ts-expect-error
assert.deepEqual(h2.__svelte_meta.loc, {
file: 'Component.svelte',
line: 1,
column: 0
});
// @ts-expect-error
assert.deepEqual(p.__svelte_meta.loc, {
file: 'main.svelte',
line: 7,
column: 0
});
}
});

@ -0,0 +1,7 @@
<script>
import Component from './Component.svelte';
</script>
<h1>hello</h1>
<Component --color="red" />
<p>goodbye</p>

@ -5,7 +5,7 @@ function increment(_, counter) {
counter.count += 1;
}
var root = $.template(`<button> </button> <!> `, 1);
var root = $.from_html(`<button> </button> <!> `, 1);
export default function Await_block_scope($$anchor) {
let counter = $.proxy({ count: 0 });

@ -10,7 +10,7 @@ const snippet = ($$anchor) => {
$.append($$anchor, text);
};
var root = $.template(`<!> `, 1);
var root = $.from_html(`<!> `, 1);
export default function Bind_component_snippet($$anchor) {
let value = $.state('');

@ -1,7 +1,7 @@
import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client';
var root = $.template(`<div></div> <svg></svg> <custom-element></custom-element> <div></div> <svg></svg> <custom-element></custom-element>`, 3);
var root = $.from_html(`<div></div> <svg></svg> <custom-element></custom-element> <div></div> <svg></svg> <custom-element></custom-element>`, 3);
export default function Main($$anchor) {
// needs to be a snapshot test because jsdom does auto-correct the attribute casing

@ -2,7 +2,7 @@ import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
var root_1 = $.template(`<p></p>`);
var root_1 = $.from_html(`<p></p>`);
export default function Each_index_non_null($$anchor) {
var fragment = $.comment();

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
compileOptions: {
fragments: 'tree'
}
});

@ -0,0 +1,25 @@
import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
var root = $.from_tree(
[
['h1', null, 'hello'],
' ',
[
'div',
{ class: 'potato' },
['p', null, 'child element'],
' ',
['p', null, 'another child element']
]
],
1
);
export default function Functional_templating($$anchor) {
var fragment = root();
$.next(2);
$.append($$anchor, fragment);
}

@ -0,0 +1,5 @@
import * as $ from 'svelte/internal/server';
export default function Functional_templating($$payload) {
$$payload.out += `<h1>hello</h1> <div class="potato"><p>child element</p> <p>another child element</p></div>`;
}

@ -0,0 +1,6 @@
<h1>hello</h1>
<div class="potato">
<p>child element</p>
<p>another child element</p>
</div>

@ -2,7 +2,7 @@ import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
var root = $.template(`<h1>hello world</h1>`);
var root = $.from_html(`<h1>hello world</h1>`);
export default function Hello_world($$anchor) {
var h1 = root();

@ -2,7 +2,7 @@ import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
var root = $.template(`<h1>hello world</h1>`);
var root = $.from_html(`<h1>hello world</h1>`);
function Hmr($$anchor) {
var h1 = root();

@ -2,7 +2,7 @@ import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client';
var on_click = (_, count) => $.update(count);
var root = $.template(`<h1></h1> <b></b> <button> </button> <h1></h1>`, 1);
var root = $.from_html(`<h1></h1> <b></b> <button> </button> <h1></h1>`, 1);
export default function Nullish_coallescence_omittance($$anchor) {
let name = 'world';

@ -2,7 +2,7 @@ import 'svelte/internal/disclose-version';
import 'svelte/internal/flags/legacy';
import * as $ from 'svelte/internal/client';
var root = $.template(`<p></p> <p></p> <!>`, 1);
var root = $.from_html(`<p></p> <p></p> <!>`, 1);
export default function Purity($$anchor) {
var fragment = root();

@ -1,7 +1,7 @@
import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client';
var root = $.template(`<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1> </h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> <!> <p>these</p> <p>trailing</p> <p>nodes</p> <p>can</p> <p>be</p> <p>completely</p> <p>ignored</p></main> <cant-skip><custom-elements></custom-elements></cant-skip> <div><input></div> <div><source></div> <select><option>a</option></select> <img src="..." alt="" loading="lazy"> <div><img src="..." alt="" loading="lazy"></div>`, 3);
var root = $.from_html(`<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1> </h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> <!> <p>these</p> <p>trailing</p> <p>nodes</p> <p>can</p> <p>be</p> <p>completely</p> <p>ignored</p></main> <cant-skip><custom-elements></custom-elements></cant-skip> <div><input/></div> <div><source/></div> <select><option>a</option></select> <img src="..." alt="" loading="lazy"/> <div><img src="..." alt="" loading="lazy"/></div>`, 3);
export default function Skip_static_subtree($$anchor, $$props) {
var fragment = root();

@ -3,5 +3,5 @@ import * as $ from 'svelte/internal/server';
export default function Skip_static_subtree($$payload, $$props) {
let { title, content } = $$props;
$$payload.out += `<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1>${$.escape(title)}</h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> ${$.html(content)} <p>these</p> <p>trailing</p> <p>nodes</p> <p>can</p> <p>be</p> <p>completely</p> <p>ignored</p></main> <cant-skip><custom-elements with="attributes"></custom-elements></cant-skip> <div><input autofocus></div> <div><source muted></div> <select><option value="a">a</option></select> <img src="..." alt="" loading="lazy"> <div><img src="..." alt="" loading="lazy"></div>`;
$$payload.out += `<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1>${$.escape(title)}</h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> ${$.html(content)} <p>these</p> <p>trailing</p> <p>nodes</p> <p>can</p> <p>be</p> <p>completely</p> <p>ignored</p></main> <cant-skip><custom-elements with="attributes"></custom-elements></cant-skip> <div><input autofocus/></div> <div><source muted/></div> <select><option value="a">a</option></select> <img src="..." alt="" loading="lazy"/> <div><img src="..." alt="" loading="lazy"/></div>`;
}

@ -8,7 +8,7 @@ function reset(_, str, tpl) {
$.set(tpl, ``);
}
var root = $.template(`<input> <input> <button>reset</button>`, 1);
var root = $.from_html(`<input/> <input/> <button>reset</button>`, 1);
export default function State_proxy_literal($$anchor) {
let str = $.state('');

@ -11,5 +11,5 @@ export default function State_proxy_literal($$payload) {
tpl = ``;
}
$$payload.out += `<input${$.attr('value', str)}> <input${$.attr('value', tpl)}> <button>reset</button>`;
$$payload.out += `<input${$.attr('value', str)}/> <input${$.attr('value', tpl)}/> <button>reset</button>`;
}

@ -1,7 +1,7 @@
import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client';
var root = $.template(`<p> </p>`);
var root = $.from_html(`<p> </p>`);
export default function Text_nodes_deriveds($$anchor) {
let count1 = 0;

@ -0,0 +1,6 @@
<main><div class="hello"></main>
<main>
<div class="hello">
<p>hello</p>
</main>

@ -0,0 +1,26 @@
[
{
"code": "element_implicitly_closed",
"message": "This element is implicitly closed by the following `</main>`, which can cause an unexpected DOM structure. Add an explicit `</div>` to avoid surprises.",
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 25
}
},
{
"code": "element_implicitly_closed",
"message": "This element is implicitly closed by the following `</main>`, which can cause an unexpected DOM structure. Add an explicit `</div>` to avoid surprises.",
"start": {
"line": 4,
"column": 1
},
"end": {
"line": 4,
"column": 20
}
}
]

@ -0,0 +1,9 @@
<div>
<p class="hello">
<span></span>
<p></p>
</div>
<div>
<p class="hello"><p></p>
</div>

@ -0,0 +1,26 @@
[
{
"code": "element_implicitly_closed",
"message": "This element is implicitly closed by the following `<p>`, which can cause an unexpected DOM structure. Add an explicit `</p>` to avoid surprises.",
"start": {
"line": 2,
"column": 1
},
"end": {
"line": 2,
"column": 18
}
},
{
"code": "element_implicitly_closed",
"message": "This element is implicitly closed by the following `<p>`, which can cause an unexpected DOM structure. Add an explicit `</p>` to avoid surprises.",
"start": {
"line": 8,
"column": 1
},
"end": {
"line": 8,
"column": 18
}
}
]

@ -985,6 +985,15 @@ declare module 'svelte/compiler' {
* @default false
*/
preserveWhitespace?: boolean;
/**
* Which strategy to use when cloning DOM fragments:
*
* - `html` populates a `<template>` with `innerHTML` and clones it. This is faster, but cannot be used if your app's [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) includes [`require-trusted-types-for 'script'`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/require-trusted-types-for)
* - `tree` creates the fragment one element at a time and _then_ clones it. This is slower, but works everywhere
*
* @default 'html'
*/
fragments?: 'html' | 'tree';
/**
* Set to `true` to force the compiler into runes mode, even if there are no indications of runes usage.
* Set to `false` to force the compiler into ignoring runes, even if there are indications of runes usage.
@ -2872,6 +2881,15 @@ declare module 'svelte/types/compiler/interfaces' {
* @default false
*/
preserveWhitespace?: boolean;
/**
* Which strategy to use when cloning DOM fragments:
*
* - `html` populates a `<template>` with `innerHTML` and clones it. This is faster, but cannot be used if your app's [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) includes [`require-trusted-types-for 'script'`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/require-trusted-types-for)
* - `tree` creates the fragment one element at a time and _then_ clones it. This is slower, but works everywhere
*
* @default 'html'
*/
fragments?: 'html' | 'tree';
/**
* Set to `true` to force the compiler into runes mode, even if there are no indications of runes usage.
* Set to `false` to force the compiler into ignoring runes, even if there are indications of runes usage.

@ -85,8 +85,24 @@ for (const generate of /** @type {const} */ (['client', 'server'])) {
}
write(output_js, compiled.js.code + '\n//# sourceMappingURL=' + path.basename(output_map));
write(output_map, compiled.js.map.toString());
// generate with fragments: 'tree'
if (generate === 'client') {
const compiled = compile(source, {
dev: true,
filename: input,
generate,
runes: argv.values.runes,
fragments: 'tree'
});
const output_js = `${cwd}/output/${generate}/${file}.tree.js`;
const output_map = `${cwd}/output/${generate}/${file}.tree.js.map`;
write(output_js, compiled.js.code + '\n//# sourceMappingURL=' + path.basename(output_map));
write(output_map, compiled.js.map.toString());
}
if (compiled.css) {
write(output_css, compiled.css.code);

Loading…
Cancel
Save