Merge branch 'main' into blockless

blockless
Simon H 2 years ago committed by GitHub
commit f38006a1b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
perf: bail early when traversing non-state

@ -0,0 +1,5 @@
---
"svelte": patch
---
feat: improve ssr html mismatch validation

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: handle TypeScript's optional parameter syntax in snippets

@ -32,6 +32,7 @@
"brown-spoons-boil",
"chatty-beans-divide",
"chatty-cups-drop",
"chatty-sloths-allow",
"chatty-taxis-juggle",
"chilled-pumas-invite",
"chilly-dolphins-lick",
@ -116,6 +117,7 @@
"honest-icons-change",
"hungry-boxes-relate",
"hungry-dots-fry",
"hungry-singers-share",
"hungry-tips-unite",
"hungry-trees-travel",
"itchy-beans-melt",
@ -165,6 +167,7 @@
"nervous-spoons-relax",
"new-boats-wait",
"new-rabbits-flow",
"nice-avocados-move",
"ninety-dingos-walk",
"odd-buckets-lie",
"odd-needles-joke",
@ -211,6 +214,7 @@
"rotten-bags-type",
"rotten-buckets-develop",
"rotten-experts-relax",
"rotten-poems-applaud",
"rude-ghosts-tickle",
"selfish-dragons-knock",
"selfish-tools-hide",
@ -237,6 +241,7 @@
"slimy-walls-draw",
"slow-beds-shave",
"slow-chefs-dream",
"slow-kids-sparkle",
"slow-wombats-reply",
"small-papayas-laugh",
"smart-parents-swim",
@ -276,8 +281,10 @@
"tall-tigers-wait",
"tame-cycles-kneel",
"tame-spies-drum",
"tasty-cheetahs-appear",
"tasty-numbers-perform",
"ten-foxes-repeat",
"ten-jokes-divide",
"ten-peaches-sleep",
"ten-ties-repair",
"ten-worms-reflect",
@ -297,6 +304,7 @@
"tidy-buses-whisper",
"tidy-starfishes-allow",
"tiny-kings-whisper",
"tough-radios-punch",
"twelve-dragons-join",
"twelve-onions-juggle",
"twelve-worms-jog",

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: improve ssr code generation for class property $derived

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: warn when `$props` rune not called

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: improve derived rune destructuring support

@ -0,0 +1,5 @@
---
"svelte": patch
---
feat: allow arbitrary call expressions and optional chaining for snippets

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: add `$set` and `$on` methods in legacy compat mode

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: deduplicate generated props and action arg names

@ -1,5 +1,25 @@
# svelte
## 5.0.0-next.69
### Patch Changes
- perf: bail early when traversing non-state ([#10654](https://github.com/sveltejs/svelte/pull/10654))
- feat: improve ssr html mismatch validation ([#10658](https://github.com/sveltejs/svelte/pull/10658))
- fix: improve ssr output of dynamic textarea elements ([#10638](https://github.com/sveltejs/svelte/pull/10638))
- fix: improve ssr code generation for class property $derived ([#10661](https://github.com/sveltejs/svelte/pull/10661))
- fix: warn when `$props` rune not called ([#10655](https://github.com/sveltejs/svelte/pull/10655))
- fix: improve derived rune destructuring support ([#10665](https://github.com/sveltejs/svelte/pull/10665))
- feat: allow arbitrary call expressions and optional chaining for snippets ([#10656](https://github.com/sveltejs/svelte/pull/10656))
- fix: add `$set` and `$on` methods in legacy compat mode ([#10642](https://github.com/sveltejs/svelte/pull/10642))
## 5.0.0-next.68
### Patch Changes

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

@ -89,8 +89,10 @@ const parse = {
'duplicate-style-element': () => `A component can have a single top-level <style> element`,
'duplicate-script-element': () =>
`A component can have a single top-level <script> element and/or a single top-level <script context="module"> element`,
'invalid-render-expression': () => 'expected an identifier followed by (...)',
'invalid-render-expression': () => '{@render ...} tags can only contain call expressions',
'invalid-render-arguments': () => 'expected at most one argument',
'invalid-render-call': () =>
'Calling a snippet function using apply, bind or call is not allowed',
'invalid-render-spread-argument': () => 'cannot use spread arguments in {@render ...} tags',
'invalid-snippet-rest-parameter': () =>
'snippets do not support rest parameters; use an array instead'

@ -13,16 +13,17 @@ import { error } from '../../../errors.js';
/**
* @param {import('../index.js').Parser} parser
* @param {boolean} [optional_allowed]
* @returns {import('estree').Pattern}
*/
export default function read_pattern(parser) {
export default function read_pattern(parser, optional_allowed = false) {
const start = parser.index;
let i = parser.index;
const code = full_char_code_at(parser.template, i);
if (isIdentifierStart(code, true)) {
const name = /** @type {string} */ (parser.read_identifier());
const annotation = read_type_annotation(parser);
const annotation = read_type_annotation(parser, optional_allowed);
return {
type: 'Identifier',
@ -83,7 +84,7 @@ export default function read_pattern(parser) {
parse_expression_at(`${space_with_newline}(${pattern_string} = 1)`, parser.ts, start - 1)
).left;
expression.typeAnnotation = read_type_annotation(parser);
expression.typeAnnotation = read_type_annotation(parser, optional_allowed);
if (expression.typeAnnotation) {
expression.end = expression.typeAnnotation.end;
}
@ -96,12 +97,19 @@ export default function read_pattern(parser) {
/**
* @param {import('../index.js').Parser} parser
* @param {boolean} [optional_allowed]
* @returns {any}
*/
function read_type_annotation(parser) {
function read_type_annotation(parser, optional_allowed = false) {
const start = parser.index;
parser.allow_whitespace();
if (optional_allowed && parser.eat('?')) {
// Acorn-TS puts the optional info as a property on the surrounding node.
// We spare the work here because it doesn't matter for us anywhere else.
parser.allow_whitespace();
}
if (!parser.eat(':')) {
parser.index = start;
return undefined;

@ -276,7 +276,7 @@ function open(parser) {
const parameters = [];
while (!parser.match(')')) {
let pattern = read_pattern(parser);
let pattern = read_pattern(parser, true);
parser.allow_whitespace();
if (parser.eat('=')) {
@ -577,7 +577,12 @@ function special(parser) {
const expression = read_expression(parser);
if (expression.type !== 'CallExpression' || expression.callee.type !== 'Identifier') {
if (
expression.type !== 'CallExpression' &&
(expression.type !== 'ChainExpression' ||
expression.expression.type !== 'CallExpression' ||
!expression.expression.optional)
) {
error(expression, 'invalid-render-expression');
}
@ -589,8 +594,7 @@ function special(parser) {
type: 'RenderTag',
start,
end: parser.index,
expression: expression.callee,
arguments: expression.arguments
expression: expression
});
}
}

@ -1,3 +1,4 @@
import { interactive_elements } from '../../../../constants.js';
import entities from './entities.js';
const windows_1252 = [
@ -121,16 +122,6 @@ function validate_code(code) {
// based on http://developers.whatwg.org/syntax.html#syntax-tag-omission
// while `input` is also an interactive element, it is never moved by the browser, so we don't need to check for it
export const interactive_elements = new Set([
'a',
'button',
'iframe',
'embed',
'select',
'textarea'
]);
/** @type {Record<string, Set<string>>} */
const disallowed_contents = {
li: new Set(['li']),
@ -153,36 +144,6 @@ const disallowed_contents = {
th: new Set(['td', 'th', 'tr'])
};
export const disallowed_parapgraph_contents = [
'address',
'article',
'aside',
'blockquote',
'details',
'div',
'dl',
'fieldset',
'figcapture',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'menu',
'nav',
'ol',
'pre',
'section',
'table',
'ul'
];
for (const interactive_element of interactive_elements) {
disallowed_contents[interactive_element] = interactive_elements;
}

@ -357,7 +357,11 @@ export function analyze_component(root, options) {
uses_component_bindings: false,
custom_element: options.customElement,
inject_styles: options.css === 'injected' || !!options.customElement,
accessors: options.customElement ? true : !!options.accessors,
accessors: options.customElement
? true
: !!options.accessors ||
// because $set method needs accessors
!!options.legacy?.componentApi,
reactive_statements: new Map(),
binding_groups: new Map(),
slot_names: new Set(),

@ -1,14 +1,19 @@
import {
disallowed_parapgraph_contents,
interactive_elements,
is_tag_valid_with_parent
} from '../../../constants.js';
import { error } from '../../errors.js';
import {
extract_identifiers,
get_parent,
is_expression_attribute,
is_text_attribute,
object
object,
unwrap_optional
} from '../../utils/ast.js';
import { warn } from '../../warnings.js';
import fuzzymatch from '../1-parse/utils/fuzzymatch.js';
import { disallowed_parapgraph_contents, interactive_elements } from '../1-parse/utils/html.js';
import { binding_properties } from '../bindings.js';
import { ContentEditableBindings, EventModifiers, SVGElements } from '../constants.js';
import { is_custom_element_node } from '../nodes.js';
@ -226,127 +231,6 @@ function validate_slot_attribute(context, attribute) {
}
}
// https://html.spec.whatwg.org/multipage/syntax.html#generate-implied-end-tags
const implied_end_tags = ['dd', 'dt', 'li', 'option', 'optgroup', 'p', 'rp', 'rt'];
/**
* @param {string} tag
* @param {string} parent_tag
* @returns {boolean}
*/
function is_tag_valid_with_parent(tag, parent_tag) {
// First, let's check if we're in an unusual parsing mode...
switch (parent_tag) {
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect
case 'select':
return tag === 'option' || tag === 'optgroup' || tag === '#text';
case 'optgroup':
return tag === 'option' || tag === '#text';
// Strictly speaking, seeing an <option> doesn't mean we're in a <select>
// but
case 'option':
return tag === '#text';
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intd
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incaption
// No special behavior since these rules fall back to "in body" mode for
// all except special table nodes which cause bad parsing behavior anyway.
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intr
case 'tr':
return (
tag === 'th' || tag === 'td' || tag === 'style' || tag === 'script' || tag === 'template'
);
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intbody
case 'tbody':
case 'thead':
case 'tfoot':
return tag === 'tr' || tag === 'style' || tag === 'script' || tag === 'template';
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incolgroup
case 'colgroup':
return tag === 'col' || tag === 'template';
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intable
case 'table':
return (
tag === 'caption' ||
tag === 'colgroup' ||
tag === 'tbody' ||
tag === 'tfoot' ||
tag === 'thead' ||
tag === 'style' ||
tag === 'script' ||
tag === 'template'
);
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inhead
case 'head':
return (
tag === 'base' ||
tag === 'basefont' ||
tag === 'bgsound' ||
tag === 'link' ||
tag === 'meta' ||
tag === 'title' ||
tag === 'noscript' ||
tag === 'noframes' ||
tag === 'style' ||
tag === 'script' ||
tag === 'template'
);
// https://html.spec.whatwg.org/multipage/semantics.html#the-html-element
case 'html':
return tag === 'head' || tag === 'body' || tag === 'frameset';
case 'frameset':
return tag === 'frame';
case '#document':
return tag === 'html';
}
// Probably in the "in body" parsing mode, so we outlaw only tag combos
// where the parsing rules cause implicit opens or closes to be added.
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
switch (tag) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
return (
parent_tag !== 'h1' &&
parent_tag !== 'h2' &&
parent_tag !== 'h3' &&
parent_tag !== 'h4' &&
parent_tag !== 'h5' &&
parent_tag !== 'h6'
);
case 'rp':
case 'rt':
return implied_end_tags.indexOf(parent_tag) === -1;
case 'body':
case 'caption':
case 'col':
case 'colgroup':
case 'frameset':
case 'frame':
case 'head':
case 'html':
case 'tbody':
case 'td':
case 'tfoot':
case 'th':
case 'thead':
case 'tr':
// These tags are only valid with a few parents that have special child
// parsing rules -- if we're down here, then none of those matched and
// so we allow it only if we don't know what the parent is, as all other
// cases are invalid.
return parent_tag == null;
}
return true;
}
/**
* @type {import('zimmerframe').Visitors<import('#compiler').SvelteNode, import('./types.js').AnalysisState>}
*/
@ -604,11 +488,22 @@ const validation = {
});
},
RenderTag(node, context) {
for (const arg of node.arguments) {
const raw_args = unwrap_optional(node.expression).arguments;
for (const arg of raw_args) {
if (arg.type === 'SpreadElement') {
error(arg, 'invalid-render-spread-argument');
}
}
const callee = unwrap_optional(node.expression).callee;
if (
callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier' &&
['bind', 'apply', 'call'].includes(callee.property.name)
) {
error(node, 'invalid-render-call');
}
const is_inside_textarea = context.path.find((n) => {
return (
n.type === 'SvelteElement' &&
@ -622,7 +517,7 @@ const validation = {
node,
'invalid-tag-placement',
'inside <textarea> or <svelte:element this="textarea">',
node.expression.name
'render'
);
}
},
@ -1002,7 +897,12 @@ export const validation_runes = merge(validation, a11y_validators, {
const init = node.init;
const rune = get_rune(init, state.scope);
if (rune === null) return;
if (rune === null) {
if (init?.type === 'Identifier' && init.name === '$props' && !state.scope.get('props')) {
warn(state.analysis.warnings, node, path, 'invalid-props-declaration');
}
return;
}
const args = /** @type {import('estree').CallExpression} */ (init).arguments;

@ -258,6 +258,60 @@ export function client_component(source, analysis, options) {
}
}
if (options.legacy.componentApi) {
properties.push(
b.init('$set', b.id('$.update_legacy_props')),
b.init(
'$on',
b.arrow(
[b.id('$$event_name'), b.id('$$event_cb')],
b.call(
'$.add_legacy_event_listener',
b.id('$$props'),
b.id('$$event_name'),
b.id('$$event_cb')
)
)
)
);
} else if (options.dev) {
properties.push(
b.init(
'$set',
b.thunk(
b.block([
b.throw_error(
`The component shape you get when doing bind:this changed. Updating its properties via $set is no longer valid in Svelte 5. ` +
'See https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes for more information'
)
])
)
),
b.init(
'$on',
b.thunk(
b.block([
b.throw_error(
`The component shape you get when doing bind:this changed. Listening to events via $on is no longer valid in Svelte 5. ` +
'See https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes for more information'
)
])
)
),
b.init(
'$destroy',
b.thunk(
b.block([
b.throw_error(
`The component shape you get when doing bind:this changed. Destroying such a component via $destroy is no longer valid in Svelte 5. ` +
'See https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes for more information'
)
])
)
)
);
}
const push_args = [b.id('$$props'), b.literal(analysis.runes)];
if (options.dev) push_args.push(b.id(analysis.name));

@ -170,7 +170,7 @@ export const javascript_visitors_legacy = {
// If the binding is a prop, we need to deep read it because it could be fine-grained $state
// from a runes-component, where mutations don't trigger an update on the prop as a whole.
if (name === '$$props' || name === '$$restProps' || binding.kind === 'prop') {
serialized = b.call('$.deep_read', serialized);
serialized = b.call('$.deep_read_state', serialized);
}
sequence.push(serialized);

@ -299,15 +299,22 @@ export const javascript_visitors_runes = {
);
} else {
const bindings = state.scope.get_bindings(declarator);
const id = state.scope.generate('derived_value');
const object_id = state.scope.generate('derived_object');
const values_id = state.scope.generate('derived_values');
declarations.push(
b.declarator(
b.id(id),
b.id(object_id),
b.call('$.derived', b.thunk(rune === '$derived.by' ? b.call(value) : value))
)
);
declarations.push(
b.declarator(
b.id(values_id),
b.call(
'$.derived',
b.thunk(
b.block([
b.let(declarator.id, rune === '$derived.by' ? b.call(value) : value),
b.let(declarator.id, b.call('$.get', b.id(object_id))),
b.return(b.array(bindings.map((binding) => binding.node)))
])
)
@ -321,7 +328,7 @@ export const javascript_visitors_runes = {
binding.node,
b.call(
'$.derived',
b.thunk(b.member(b.call('$.get', b.id(id)), b.literal(i), true))
b.thunk(b.member(b.call('$.get', b.id(values_id)), b.literal(i), true))
)
)
);

@ -3,7 +3,8 @@ import {
extract_paths,
is_event_attribute,
is_text_attribute,
object
object,
unwrap_optional
} from '../../../../utils/ast.js';
import { binding_properties } from '../../../bindings.js';
import {
@ -1864,18 +1865,18 @@ export const template_visitors = {
},
RenderTag(node, context) {
context.state.template.push('<!>');
const binding = context.state.scope.get(node.expression.name);
const is_reactive = binding?.kind !== 'normal' || node.expression.type !== 'Identifier';
const callee = unwrap_optional(node.expression).callee;
const raw_args = unwrap_optional(node.expression).arguments;
const is_reactive =
callee.type !== 'Identifier' || context.state.scope.get(callee.name)?.kind !== 'normal';
/** @type {import('estree').Expression[]} */
const args = [context.state.node];
for (const arg of node.arguments) {
for (const arg of raw_args) {
args.push(b.thunk(/** @type {import('estree').Expression} */ (context.visit(arg))));
}
let snippet_function = /** @type {import('estree').Expression} */ (
context.visit(node.expression)
);
let snippet_function = /** @type {import('estree').Expression} */ (context.visit(callee));
if (context.state.options.dev) {
snippet_function = b.call('$.validate_snippet', snippet_function);
}
@ -1885,7 +1886,14 @@ export const template_visitors = {
b.stmt(b.call('$.snippet_effect', b.thunk(snippet_function), ...args))
);
} else {
context.state.after_update.push(b.stmt(b.call(snippet_function, ...args)));
context.state.after_update.push(
b.stmt(
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
snippet_function,
...args
)
)
);
}
},
AnimateDirective(node, { state, visit }) {
@ -2660,7 +2668,7 @@ export const template_visitors = {
const params = [b.id('$$node')];
if (node.expression) {
params.push(b.id('$$props'));
params.push(b.id('$$action_arg'));
}
/** @type {import('estree').Expression[]} */

@ -1,6 +1,11 @@
import { walk } from 'zimmerframe';
import { set_scope, get_rune } from '../../scope.js';
import { extract_identifiers, extract_paths, is_event_attribute } from '../../../utils/ast.js';
import {
extract_identifiers,
extract_paths,
is_event_attribute,
unwrap_optional
} from '../../../utils/ast.js';
import * as b from '../../../utils/builders.js';
import is_reference from 'is-reference';
import {
@ -541,6 +546,83 @@ const javascript_visitors = {
/** @type {import('./types').Visitors} */
const javascript_visitors_runes = {
ClassBody(node, { state, visit, next }) {
if (!state.analysis.runes) {
next();
}
/** @type {import('estree').PropertyDefinition[]} */
const deriveds = [];
/** @type {import('estree').MethodDefinition | null} */
let constructor = null;
// Get the constructor
for (const definition of node.body) {
if (definition.type === 'MethodDefinition' && definition.kind === 'constructor') {
constructor = /** @type {import('estree').MethodDefinition} */ (visit(definition));
}
}
// Move $derived() runes to the end of the body if there is a constructor
if (constructor !== null) {
const body = [];
for (const definition of node.body) {
if (
definition.type === 'PropertyDefinition' &&
(definition.key.type === 'Identifier' || definition.key.type === 'PrivateIdentifier')
) {
const is_private = definition.key.type === 'PrivateIdentifier';
if (definition.value?.type === 'CallExpression') {
const rune = get_rune(definition.value, state.scope);
if (rune === '$derived') {
deriveds.push(/** @type {import('estree').PropertyDefinition} */ (visit(definition)));
if (is_private) {
// Keep the private #name initializer if private, but remove initial value
body.push({
...definition,
value: null
});
}
continue;
}
}
}
if (definition.type !== 'MethodDefinition' || definition.kind !== 'constructor') {
body.push(
/** @type {import('estree').PropertyDefinition | import('estree').MethodDefinition | import('estree').StaticBlock} */ (
visit(definition)
)
);
}
}
if (deriveds.length > 0) {
body.push({
...constructor,
value: {
...constructor.value,
body: b.block([
...constructor.value.body.body,
...deriveds.map((d) => {
return b.stmt(
b.assignment(
'=',
b.member(b.this, d.key),
/** @type {import('estree').Expression} */ (d.value)
)
);
})
])
}
});
} else {
body.push(constructor);
}
return {
...node,
body
};
}
next();
},
PropertyDefinition(node, { state, next, visit }) {
if (node.value != null && node.value.type === 'CallExpression') {
const rune = get_rune(node.value, state.scope);
@ -1141,17 +1223,28 @@ const template_visitors = {
state.init.push(anchor);
state.template.push(t_expression(anchor_id));
const expression = /** @type {import('estree').Expression} */ (context.visit(node.expression));
const callee = unwrap_optional(node.expression).callee;
const raw_args = unwrap_optional(node.expression).arguments;
const expression = /** @type {import('estree').Expression} */ (context.visit(callee));
const snippet_function = state.options.dev
? b.call('$.validate_snippet', expression)
: expression;
const snippet_args = node.arguments.map((arg) => {
const snippet_args = raw_args.map((arg) => {
return /** @type {import('estree').Expression} */ (context.visit(arg));
});
state.template.push(
t_statement(b.stmt(b.call(snippet_function, b.id('$$payload'), ...snippet_args)))
t_statement(
b.stmt(
(node.expression.type === 'CallExpression' ? b.call : b.maybe_call)(
snippet_function,
b.id('$$payload'),
...snippet_args
)
)
)
);
state.template.push(t_expression(anchor_id));
@ -1207,6 +1300,12 @@ const template_visitors = {
inner_context.visit(node, state);
}
if (context.state.options.dev) {
context.state.template.push(
t_statement(b.stmt(b.call('$.push_element', b.literal(node.name), b.id('$$payload'))))
);
}
process_children(trimmed, node, inner_context);
if (body_expression !== null) {
@ -1239,6 +1338,9 @@ const template_visitors = {
if (!VoidElements.includes(node.name) && metadata.namespace !== 'foreign') {
context.state.template.push(t_string(`</${node.name}>`));
}
if (context.state.options.dev) {
context.state.template.push(t_statement(b.stmt(b.call('$.pop_element'))));
}
},
SvelteElement(node, context) {
let tag = /** @type {import('estree').Expression} */ (context.visit(node.tag));
@ -1281,6 +1383,12 @@ const template_visitors = {
serialize_element_attributes(node, inner_context);
if (context.state.options.dev) {
context.state.template.push(
t_statement(b.stmt(b.call('$.push_element', tag, b.id('$$payload'))))
);
}
context.state.template.push(
t_statement(
b.if(
@ -1304,6 +1412,9 @@ const template_visitors = {
),
t_expression(anchor_id)
);
if (context.state.options.dev) {
context.state.template.push(t_statement(b.stmt(b.call('$.pop_element'))));
}
},
EachBlock(node, context) {
const state = context.state;

@ -13,7 +13,10 @@ import type {
ObjectExpression,
Pattern,
Program,
SpreadElement
SpreadElement,
CallExpression,
ChainExpression,
SimpleCallExpression
} from 'estree';
import type { Atrule, Rule } from './css';
@ -151,8 +154,7 @@ export interface DebugTag extends BaseNode {
/** A `{@render foo(...)} tag */
export interface RenderTag extends BaseNode {
type: 'RenderTag';
expression: Identifier;
arguments: Array<Expression | SpreadElement>;
expression: SimpleCallExpression | (ChainExpression & { expression: SimpleCallExpression });
}
type Tag = ExpressionTag | HtmlTag | ConstTag | DebugTag | RenderTag;

@ -361,3 +361,12 @@ export function is_simple_expression(node) {
return false;
}
/**
* @template {import('estree').SimpleCallExpression | import('estree').MemberExpression} T
* @param {import('estree').ChainExpression & { expression : T } | T} node
* @returns {T}
*/
export function unwrap_optional(node) {
return node.type === 'ChainExpression' ? node.expression : node;
}

@ -27,7 +27,10 @@ const runes = {
/** @param {string} name */
'non-state-reference': (name) =>
`${name} is updated, but is not declared with $state(...). Changing its value will not correctly trigger updates.`,
'derived-iife': () => `Use \`$derived.by(() => {...})\` instead of \`$derived((() => {...})());\``
'derived-iife': () =>
`Use \`$derived.by(() => {...})\` instead of \`$derived((() => {...})());\``,
'invalid-props-declaration': () =>
`Component properties are declared using $props() in runes mode. Did you forget to call the function?`
};
/** @satisfies {Warnings} */

@ -88,3 +88,164 @@ export const DOMBooleanAttributes = [
export const namespace_svg = 'http://www.w3.org/2000/svg';
export const namespace_html = 'http://www.w3.org/1999/xhtml';
// while `input` is also an interactive element, it is never moved by the browser, so we don't need to check for it
export const interactive_elements = new Set([
'a',
'button',
'iframe',
'embed',
'select',
'textarea'
]);
export const disallowed_parapgraph_contents = [
'address',
'article',
'aside',
'blockquote',
'details',
'div',
'dl',
'fieldset',
'figcapture',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'menu',
'nav',
'ol',
'pre',
'section',
'table',
'ul'
];
// https://html.spec.whatwg.org/multipage/syntax.html#generate-implied-end-tags
const implied_end_tags = ['dd', 'dt', 'li', 'option', 'optgroup', 'p', 'rp', 'rt'];
/**
* @param {string} tag
* @param {string} parent_tag
* @returns {boolean}
*/
export function is_tag_valid_with_parent(tag, parent_tag) {
// First, let's check if we're in an unusual parsing mode...
switch (parent_tag) {
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect
case 'select':
return tag === 'option' || tag === 'optgroup' || tag === '#text';
case 'optgroup':
return tag === 'option' || tag === '#text';
// Strictly speaking, seeing an <option> doesn't mean we're in a <select>
// but
case 'option':
return tag === '#text';
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intd
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incaption
// No special behavior since these rules fall back to "in body" mode for
// all except special table nodes which cause bad parsing behavior anyway.
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intr
case 'tr':
return (
tag === 'th' || tag === 'td' || tag === 'style' || tag === 'script' || tag === 'template'
);
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intbody
case 'tbody':
case 'thead':
case 'tfoot':
return tag === 'tr' || tag === 'style' || tag === 'script' || tag === 'template';
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incolgroup
case 'colgroup':
return tag === 'col' || tag === 'template';
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intable
case 'table':
return (
tag === 'caption' ||
tag === 'colgroup' ||
tag === 'tbody' ||
tag === 'tfoot' ||
tag === 'thead' ||
tag === 'style' ||
tag === 'script' ||
tag === 'template'
);
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inhead
case 'head':
return (
tag === 'base' ||
tag === 'basefont' ||
tag === 'bgsound' ||
tag === 'link' ||
tag === 'meta' ||
tag === 'title' ||
tag === 'noscript' ||
tag === 'noframes' ||
tag === 'style' ||
tag === 'script' ||
tag === 'template'
);
// https://html.spec.whatwg.org/multipage/semantics.html#the-html-element
case 'html':
return tag === 'head' || tag === 'body' || tag === 'frameset';
case 'frameset':
return tag === 'frame';
case '#document':
return tag === 'html';
}
// Probably in the "in body" parsing mode, so we outlaw only tag combos
// where the parsing rules cause implicit opens or closes to be added.
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inbody
switch (tag) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
return (
parent_tag !== 'h1' &&
parent_tag !== 'h2' &&
parent_tag !== 'h3' &&
parent_tag !== 'h4' &&
parent_tag !== 'h5' &&
parent_tag !== 'h6'
);
case 'rp':
case 'rt':
return implied_end_tags.indexOf(parent_tag) === -1;
case 'body':
case 'caption':
case 'col':
case 'colgroup':
case 'frameset':
case 'frame':
case 'head':
case 'html':
case 'tbody':
case 'td':
case 'tfoot':
case 'th':
case 'thead':
case 'tr':
// These tags are only valid with a few parents that have special child
// parsing rules -- if we're down here, then none of those matched and
// so we allow it only if we don't know what the parent is, as all other
// cases are invalid.
return parent_tag == null;
}
return true;
}

@ -75,8 +75,8 @@ export function mark_module_end() {
const end = get_stack()?.[2];
if (end) {
// @ts-expect-error
boundaries[end.file].at(-1).end = end;
const boundaries_file = boundaries[end.file];
boundaries_file[boundaries_file.length - 1].end = end;
}
}

@ -123,7 +123,7 @@ export function each_keyed(anchor_node, collection, flags, key_fn, render_fn, fa
// Get the <!--ssr:..--> tag of the next item in the list
// The fragment array can be empty if each block has no content
hydrating_node = /** @type {import('#client').TemplateNode} */ (
/** @type {Node} */ ((fragment.at(-1) || hydrating_node).nextSibling).nextSibling
/** @type {Node} */ ((fragment[fragment.length - 1] || hydrating_node).nextSibling).nextSibling
);
}

@ -274,10 +274,10 @@ function capture_fragment_from_node(node) {
if (
node.nodeType === 8 &&
/** @type {Comment} */ (node).data.startsWith('ssr:') &&
current_hydration_fragment.at(-1) !== node
current_hydration_fragment[current_hydration_fragment.length - 1] !== node
) {
const fragment = /** @type {Array<Element | Text | Comment>} */ (get_hydration_fragment(node));
const last_child = fragment.at(-1) || node;
const last_child = fragment[fragment.length - 1] || node;
const target = /** @type {Node} */ (last_child.nextSibling);
// @ts-ignore
target.$$fragment = fragment;

@ -33,11 +33,11 @@ import {
push,
pop,
current_component_context,
deep_read,
get,
is_signals_recorded,
inspect_fn,
current_effect
current_effect,
deep_read_state
} from './runtime.js';
import {
render_effect,
@ -1617,7 +1617,7 @@ export function action(dom, action, value_fn) {
// This works in legacy mode because of mutable_source being updated as a whole, but when using $state
// together with actions and mutation, it wouldn't notice the change without a deep read.
if (needs_deep_read) {
deep_read(value);
deep_read_state(value);
}
} else {
untrack(() => (payload = action(dom)));
@ -2500,10 +2500,12 @@ export function init() {
if (!callbacks) return;
// beforeUpdate
pre_effect(() => {
observe_all(context);
callbacks.b.forEach(run);
});
if (callbacks.b.length) {
pre_effect(() => {
observe_all(context);
callbacks.b.forEach(run);
});
}
// onMount (must run before afterUpdate)
user_effect(() => {
@ -2518,10 +2520,12 @@ export function init() {
});
// afterUpdate
user_effect(() => {
observe_all(context);
callbacks.a.forEach(run);
});
if (callbacks.a.length) {
user_effect(() => {
observe_all(context);
callbacks.a.forEach(run);
});
}
}
/**
@ -2534,7 +2538,7 @@ function observe_all(context) {
for (const signal of context.d) get(signal);
}
deep_read(context.s);
deep_read_state(context.s);
}
/**
@ -2571,3 +2575,30 @@ export function bubble_event($$props, event) {
fn.call(this, event);
}
}
/**
* Used to simulate `$on` on a component instance when `legacy.componentApi` is `true`
* @param {Record<string, any>} $$props
* @param {string} event_name
* @param {Function} event_callback
*/
export function add_legacy_event_listener($$props, event_name, event_callback) {
$$props.$$events ||= {};
$$props.$$events[event_name] ||= [];
$$props.$$events[event_name].push(event_callback);
}
/**
* Used to simulate `$set` on a component instance when `legacy.componentApi` is `true`.
* Needs component accessors so that it can call the setter of the prop. Therefore doesn't
* work for updating props in `$$props` or `$$restProps`.
* @this {Record<string, any>}
* @param {Record<string, any>} $$new_props
*/
export function update_legacy_props($$new_props) {
for (const key in $$new_props) {
if (key in this) {
this[key] = $$new_props[key];
}
}
}

@ -1017,6 +1017,29 @@ export function pop(component) {
return component || /** @type {T} */ ({});
}
/**
* Possibly traverse an object and read all its properties so that they're all reactive in case this is `$state`.
* Does only check first level of an object for performance reasons (heuristic should be good for 99% of all cases).
* @param {any} value
* @returns {void}
*/
export function deep_read_state(value) {
if (typeof value !== 'object' || !value || value instanceof EventTarget) {
return;
}
if (STATE_SYMBOL in value) {
deep_read(value);
} else if (!Array.isArray(value)) {
for (let key in value) {
const prop = value[key];
if (typeof prop === 'object' && prop && STATE_SYMBOL in prop) {
deep_read(prop);
}
}
}
}
/**
* Deeply traverse an object and read all its properties
* so that they're all reactive in case this is `$state`

@ -118,7 +118,7 @@ export function add_snippet_symbol(fn) {
* @param {any} snippet_fn
*/
export function validate_snippet(snippet_fn) {
if (snippet_fn[snippet_symbol] !== true) {
if (snippet_fn && snippet_fn[snippet_symbol] !== true) {
throw new Error(
'The argument to `{@render ...}` must be a snippet function, not a component or some other kind of function. ' +
'If you want to dynamically render one snippet or another, use `$derived` and pass its result to `{@render ...}`.'

@ -11,7 +11,8 @@ export {
inspect,
unwrap,
freeze,
deep_read
deep_read,
deep_read_state
} from './client/runtime.js';
export * from './client/dev/ownership.js';
export { await_block as await } from './client/dom/blocks/await.js';

@ -1,11 +1,23 @@
import * as $ from '../client/runtime.js';
import { is_promise, noop } from '../common.js';
import { subscribe_to_store } from '../../store/utils.js';
import { DOMBooleanAttributes } from '../../constants.js';
import {
DOMBooleanAttributes,
disallowed_parapgraph_contents,
interactive_elements,
is_tag_valid_with_parent
} from '../../constants.js';
import { DEV } from 'esm-env';
export * from '../client/validate.js';
/**
* @typedef {{
* tag: string;
* parent: null | Element;
* }} Element
*/
/**
* @typedef {{
* head: string;
@ -51,6 +63,11 @@ export const VoidElements = new Set([
'wbr'
]);
/**
* @type {Element | null}
*/
let current_element = null;
/** @returns {Payload} */
function create_payload() {
return { out: '', head: { title: '', out: '', anchor: 0 }, anchor: 0 };
@ -79,6 +96,58 @@ export function assign_payload(p1, p2) {
p1.anchor = p2.anchor;
}
/**
* @param {Payload} payload
* @param {string} message
*/
function error_on_client(payload, message) {
message =
`Svelte SSR validation error:\n\n${message}\n\n` +
'Ensure your components render valid HTML as the browser will try to repair invalid HTML, ' +
'which may result in content being shifted around and will likely result in a hydration mismatch.';
// eslint-disable-next-line no-console
console.error(message);
payload.head.out += `<script>console.error(${message})</script>`;
}
/**
* @param {string} tag
* @param {Payload} payload
*/
export function push_element(tag, payload) {
if (current_element !== null && !is_tag_valid_with_parent(tag, current_element.tag)) {
error_on_client(payload, `<${tag}> is invalid inside <${current_element.tag}>`);
}
if (interactive_elements.has(tag)) {
let element = current_element;
while (element !== null) {
if (interactive_elements.has(element.tag)) {
error_on_client(payload, `<${tag}> is invalid inside <${element.tag}>`);
}
element = element.parent;
}
}
if (disallowed_parapgraph_contents.includes(tag)) {
let element = current_element;
while (element !== null) {
if (element.tag === 'p') {
error_on_client(payload, `<${tag}> is invalid inside <p>`);
}
element = element.parent;
}
}
current_element = {
tag,
parent: current_element
};
}
export function pop_element() {
if (current_element !== null) {
current_element = current_element.parent;
}
}
/**
* @param {Payload} payload
* @param {string} tag

@ -15,7 +15,7 @@ import * as $ from '../internal/index.js';
* component: import('../main/public.js').SvelteComponent<Props, Events, Slots>;
* immutable?: boolean;
* hydrate?: boolean;
* recover?: false;
* recover?: boolean;
* }} options
* @returns {import('../main/public.js').SvelteComponent<Props, Events, Slots> & Exports}
*/

@ -6,5 +6,5 @@
* https://svelte.dev/docs/svelte-compiler#svelte-version
* @type {string}
*/
export const VERSION = '5.0.0-next.68';
export const VERSION = '5.0.0-next.69';
export const PUBLIC_VERSION = '5';

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
error: {
code: 'invalid-render-call',
message: 'Calling a snippet function using apply, bind or call is not allowed'
}
});

@ -131,9 +131,9 @@
"start": 83,
"end": 101,
"expression": {
"type": "Identifier",
"type": "CallExpression",
"start": 92,
"end": 95,
"end": 100,
"loc": {
"start": {
"line": 7,
@ -141,29 +141,45 @@
},
"end": {
"line": 7,
"column": 12
"column": 17
}
},
"name": "foo"
},
"arguments": [
{
"callee": {
"type": "Identifier",
"start": 96,
"end": 99,
"start": 92,
"end": 95,
"loc": {
"start": {
"line": 7,
"column": 13
"column": 9
},
"end": {
"line": 7,
"column": 16
"column": 12
}
},
"name": "msg"
}
]
"name": "foo"
},
"arguments": [
{
"type": "Identifier",
"start": 96,
"end": 99,
"loc": {
"start": {
"line": 7,
"column": 13
},
"end": {
"line": 7,
"column": 16
}
},
"name": "msg"
}
],
"optional": false
}
}
],
"transparent": false

@ -0,0 +1,14 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
accessors: false,
async test({ assert, target }) {
assert.htmlEqual(target.innerHTML, '<button>foo / foo</button><div></div>');
const button = target.querySelector('button');
button?.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>bar / bar</button><div></div>');
}
});

@ -0,0 +1,17 @@
<script>
import Component from "./sub.svelte"
let state = 'foo';
let param = '';
function action(node, _param) {
param = _param
return {
update(_param) {
param = _param;
}
};
}
</script>
<button on:click={() => state = 'bar'}>{state} / {param}</button>
<Component {action} {state}></Component>

@ -0,0 +1,6 @@
<script>
export let action;
export let state;
</script>
<div use:action={state}></div>

@ -0,0 +1,17 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
compileOptions: {
legacy: {
componentApi: true
}
},
html: '<button>0</button>',
async test({ assert, target }) {
const button = target.querySelector('button');
await button?.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>1</button>');
}
});

@ -0,0 +1,16 @@
<script>
import Sub from './sub.svelte';
import { onMount } from 'svelte';
let count = 0;
let component;
onMount(() => {
component.$on('increment', (e) => {
count += e.detail;
component.$set({ count });
});
});
</script>
<Sub bind:this={component} />

@ -0,0 +1,8 @@
<script>
import { createEventDispatcher } from 'svelte';
export let count = 0;
const dispatch = createEventDispatcher();
</script>
<button on:click={() => dispatch('increment', 1)}>{count}</button>

@ -67,6 +67,7 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
warnings?: string[];
expect_unhandled_rejections?: boolean;
withoutNormalizeHtml?: boolean;
recover?: boolean;
}
let unhandled_rejection: Error | null = null;
@ -258,7 +259,7 @@ async function run_test_variant(
target,
immutable: config.immutable,
intro: config.intro,
recover: false,
recover: config.recover === undefined ? false : config.recover,
hydrate: variant === 'hydrate'
});

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

@ -0,0 +1,15 @@
<script>
export class Counter {
count = $state(0);
doubled = $derived(this.count * 2);
constructor(initialCount = 0) {
this.count = initialCount;
}
}
const counter = new Counter(1);
</script>
{counter.doubled}

@ -0,0 +1,14 @@
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>1 1 1</button>`);
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>2 2 2</button>`);
}
});

@ -0,0 +1,23 @@
<script>
function get_values() {
let a = $state(0);
let b = $state(0);
let c = $state(0);
return {
get a() { return a },
get b() { return b },
get c() { return c },
increment() {
a++;
b++;
c++;
}
};
}
const { a, b, c, increment } = $derived(get_values());
</script>
<button onclick={increment}>{a} {b} {c}</button>

@ -0,0 +1,41 @@
import { test } from '../../test';
let console_error = console.error;
/**
* @type {any[]}
*/
const log = [];
export default test({
compileOptions: {
dev: true
},
html: `<p></p><h1>foo</h1><p></p>`,
recover: true,
before_test() {
console.error = (x) => {
log.push(x);
};
},
after_test() {
console.error = console_error;
log.length = 0;
},
async test({ assert, target, variant }) {
await assert.htmlEqual(target.innerHTML, `<p></p><h1>foo</h1><p></p>`);
if (variant === 'hydrate') {
assert.equal(
log[0],
`Svelte SSR validation error:\n\n<h1> is invalid inside <p>\n\n` +
'Ensure your components render valid HTML as the browser will try to repair invalid HTML, ' +
'which may result in content being shifted around and will likely result in a hydration mismatch.'
);
}
}
});

@ -0,0 +1,7 @@
<script>
import Component from "./Component.svelte";
</script>
<p>
<Component />
</p>

@ -0,0 +1,42 @@
import { test } from '../../test';
export default test({
html: `
<p>foo</p>
<hr>
<p>foo</p>
<hr>
<p>foo</p>
<hr>
<p>foo</p>
<hr>
<p>bar</p>
<hr>
<hr>
<button>toggle</button>
`,
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
assert.htmlEqual(
target.innerHTML,
`
<p>bar</p>
<hr>
<p>bar</p>
<hr>
<p>foo</p>
<hr>
<p>foo</p>
<hr>
<p>foo</p>
<hr>
<p>foo</p>
<hr>
<p>foo</p>
<button>toggle</button>
`
);
}
});

@ -0,0 +1,21 @@
<script>
let { snippets, snippet, optional } = $props();
function getOptional() {
return optional;
}
</script>
{@render snippets[snippet]()}
<hr>
{@render snippets?.[snippet]?.()}
<hr>
{@render snippets.foo()}
<hr>
{@render snippets.foo?.()}
<hr>
{@render (optional ?? snippets.bar)()}
<hr>
{@render optional?.()}
<hr>
{@render getOptional()?.()}

@ -0,0 +1,17 @@
<script>
import Child from './child.svelte';
let snippet = $state('foo');
let show = $state(false);
</script>
{#snippet foo()}
<p>foo</p>
{/snippet}
{#snippet bar()}
<p>bar</p>
{/snippet}
<Child snippets={{foo, bar}} {snippet} optional={show ? foo : undefined} />
<button on:click={() => { snippet = 'bar'; show = true; }}>toggle</button>

@ -17,7 +17,7 @@
{#snippet counter(c)}
{#if c}
<button on:click={() => (c.value += 1)}>{c.value}</button>
<button on:click={() => (c.value += 1)}>{c.value}</button>
{:else}
<p>fallback</p>
{/if}
@ -25,4 +25,3 @@
{@render counter()}
{@render counter(count)}

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
html: '1 2 3 4 5'
});

@ -0,0 +1,24 @@
<script lang="ts">
</script>
{#snippet counter1(c: number)}
{c}
{/snippet}
{#snippet counter2({ c }: {c: number})}
{c}
{/snippet}
{#snippet counter3(c?: number)}
{c}
{/snippet}
{#snippet counter4(c: number = 4)}
{c}
{/snippet}
{#snippet counter5(c?: number = 5)}
{c}
{/snippet}
{@render counter1(1)}
{@render counter2({ c: 2 })}
{@render counter3(3)}
{@render counter4()}
{@render counter5()}

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

@ -0,0 +1,14 @@
[
{
"code": "invalid-props-declaration",
"message": "Component properties are declared using $props() in runes mode. Did you forget to call the function?",
"start": {
"column": 5,
"line": 2
},
"end": {
"column": 19,
"line": 2
}
}
]

@ -475,7 +475,7 @@ declare module 'svelte/animate' {
}
declare module 'svelte/compiler' {
import type { AssignmentExpression, ClassDeclaration, Expression, FunctionDeclaration, Identifier, ImportDeclaration, ArrayExpression, MemberExpression, ObjectExpression, Pattern, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, FunctionExpression, Node, Program, SpreadElement } from 'estree';
import type { AssignmentExpression, ClassDeclaration, Expression, FunctionDeclaration, Identifier, ImportDeclaration, ArrayExpression, MemberExpression, ObjectExpression, Pattern, ArrowFunctionExpression, VariableDeclaration, VariableDeclarator, FunctionExpression, Node, Program, ChainExpression, SimpleCallExpression } from 'estree';
import type { Location } from 'locate-character';
import type { SourceMap } from 'magic-string';
import type { Context } from 'zimmerframe';
@ -1197,8 +1197,7 @@ declare module 'svelte/compiler' {
/** A `{@render foo(...)} tag */
interface RenderTag extends BaseNode {
type: 'RenderTag';
expression: Identifier;
arguments: Array<Expression | SpreadElement>;
expression: SimpleCallExpression | (ChainExpression & { expression: SimpleCallExpression });
}
type Tag = ExpressionTag | HtmlTag | ConstTag | DebugTag | RenderTag;
@ -1724,7 +1723,7 @@ declare module 'svelte/legacy' {
component: SvelteComponent<Props, Events, Slots>;
immutable?: boolean | undefined;
hydrate?: boolean | undefined;
recover?: false | undefined;
recover?: boolean | undefined;
}): SvelteComponent<Props, Events, Slots> & Exports;
/**
* Takes the component function and returns a Svelte 4 compatible component constructor.

@ -70,7 +70,7 @@ import App from './App.svelte'
export default app;
```
If this component is not under your control, you can use the `legacy.componentApi` compiler option for auto-applied backwards compatibility (note that this adds a bit of overhead to each component).
If this component is not under your control, you can use the `legacy.componentApi` compiler option for auto-applied backwards compatibility (note that this adds a bit of overhead to each component). This will also add `$set` and `$on` methods for all component instances you get through `bind:this`.
### Server API changes

Loading…
Cancel
Save