Merge branch 'main' into each-block-pending

pull/17902/merge
Rich Harris 2 weeks ago
commit 1b68706e6e

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: `SvelteMap` incorrectly handles keys with `undefined` values

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: SvelteURL `search` setter now returns the normalized value, matching native URL behavior

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: visit synthetic value node during ssr

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: always case insensitive event handlers during ssr

@ -0,0 +1,5 @@
---
"svelte": patch
---
perf: optimize compiler analysis phase

@ -0,0 +1,2 @@
https://svelte.dev/funding.json

@ -0,0 +1,184 @@
---
title: Best practices
name: svelte-core-bestpractices
description: Guidance on writing fast, robust, modern Svelte code. Load this skill whenever in a Svelte project and asked to write/edit or analyze a Svelte component or module. Covers reactivity, event handling, styling, integration with libraries and more.
---
<!-- llm-ignore-start -->
This document outlines some best practices that will help you write fast, robust Svelte apps. It is also available as a `svelte-core-bestpractices` skill for your agents.
<!-- llm-ignore-end -->
## `$state`
Only use the `$state` rune for variables that should be _reactive_ — in other words, variables that cause an `$effect`, `$derived` or template expression to update. Everything else can be a normal variable.
Objects and arrays (`$state({...})` or `$state([...])`) are made deeply reactive, meaning mutation will trigger updates. This has a trade-off: in exchange for fine-grained reactivity, the objects must be proxied, which has performance overhead. In cases where you're dealing with large objects that are only ever reassigned (rather than mutated), use `$state.raw` instead. This is often the case with API responses, for example.
## `$derived`
To compute something from state, use `$derived` rather than `$effect`:
```js
// @errors: 2451
let num = 0;
// ---cut---
// do this
let square = $derived(num * num);
// don't do this
let square;
$effect(() => {
square = num * num;
});
```
> [!NOTE] `$derived` is given an expression, _not_ a function. If you need to use a function (because the expression is complex, for example) use `$derived.by`.
Deriveds are writable — you can assign to them, just like `$state`, except that they will re-evaluate when their expression changes.
If the derived expression is an object or array, it will be returned as-is — it is _not_ made deeply reactive. You can, however, use `$state` inside `$derived.by` in the rare cases that you need this.
## `$effect`
Effects are an escape hatch and should mostly be avoided. In particular, avoid updating state inside effects.
- If you need to sync state to an external library such as D3, it is often neater to use [`{@attach ...}`](@attach)
- If you need to run some code in response to user interaction, put the code directly in an event handler or use a [function binding](bind#Function-bindings) as appropriate
- If you need to log values for debugging purposes, use [`$inspect`]($inspect)
- If you need to observe something external to Svelte, use [`createSubscriber`](svelte-reactivity#createSubscriber)
Never wrap the contents of an effect in `if (browser) {...}` or similar — effects do not run on the server.
## `$props`
Treat props as though they will change. For example, values that depend on props should usually use `$derived`:
```js
// @errors: 2451
let { type } = $props();
// do this
let color = $derived(type === 'danger' ? 'red' : 'green');
// don't do this — `color` will not update if `type` changes
let color = type === 'danger' ? 'red' : 'green';
```
## `$inspect.trace`
`$inspect.trace` is a debugging tool for reactivity. If something is not updating properly or running more than it should you can add `$inspect.trace(label)` as the first line of an `$effect` or `$derived.by` (or any function they call) to trace their dependencies and discover which one triggered an update.
## Events
Any element attribute starting with `on` is treated as an event listener:
```svelte
<button onclick={() => {...}}>click me</button>
<!-- attribute shorthand also works -->
<button {onclick}>...</button>
<!-- so do spread attributes -->
<button {...props}>...</button>
```
If you need to attach listeners to `window` or `document` you can use `<svelte:window>` and `<svelte:document>`:
```svelte
<svelte:window onkeydown={...} />
<svelte:document onvisibilitychange={...} />
```
Avoid using `onMount` or `$effect` for this.
## Snippets
[Snippets](snippet) are a way to define reusable chunks of markup that can be instantiated with the [`{@render ...}`](@render) tag, or passed to components as props. They must be declared within the template.
```svelte
{#snippet greeting(name)}
<p>hello {name}!</p>
{/snippet}
{@render greeting('world')}
```
> [!NOTE] Snippets declared at the top level of a component (i.e. not inside elements or blocks) can be referenced inside `<script>`. A snippet that doesn't reference component state is also available in a `<script module>`, in which case it can be exported for use by other components.
## Each blocks
Prefer to use [keyed each blocks](each#Keyed-each-blocks) — this improves performance by allowing Svelte to surgically insert or remove items rather than updating the DOM belonging to existing items.
> [!NOTE] The key _must_ uniquely identify the object. Do not use the index as a key.
Avoid destructuring if you need to mutate the item (with something like `bind:value={item.count}`, for example).
## Using JavaScript variables in CSS
If you have a JS variable that you want to use inside CSS you can set a CSS custom property with the `style:` directive.
```svelte
<div style:--columns={columns}>...</div>
```
You can then reference `var(--columns)` inside the component's `<style>`.
## Styling child components
The CSS in a component's `<style>` is scoped to that component. If a parent component needs to control the child's styles, the preferred way is to use CSS custom properties:
```svelte
<!-- Parent.svelte -->
<Child --color="red" />
<!-- Child.svelte -->
<h1>Hello</h1>
<style>
h1 {
color: var(--color);
}
</style>
```
If this impossible (for example, the child component comes from a library) you can use `:global` to override styles:
```svelte
<div>
<Child />
</div>
<style>
div :global {
h1 {
color: red;
}
}
</style>
```
## Context
Consider using context instead of declaring state in a shared module. This will scope the state to the part of the app that needs it, and eliminate the possibility of it leaking between users when server-side rendering.
Use `createContext` rather than `setContext` and `getContext`, as it provides type safety.
## Async Svelte
If using version 5.36 or higher, you can use [await expressions](await-expressions) and [hydratable](hydratable) to use promises directly inside components. Note that these require the `experimental.async` option to be enabled in `svelte.config.js` as they are not yet considered fully stable.
## Avoid legacy features
Always use runes mode for new code, and avoid features that have more modern replacements:
- use `$state` instead of implicit reactivity (e.g. `let count = 0; count += 1`)
- use `$derived` and `$effect` instead of `$:` assignments and statements (but only use effects when there is no better solution)
- use `$props` instead of `export let`, `$$props` and `$$restProps`
- use `onclick={...}` instead of `on:click={...}`
- use `{#snippet ...}` and `{@render ...}` instead of `<slot>` and `$$slots` and `<svelte:fragment>`
- use `<DynamicComponent>` instead of `<svelte:component this={DynamicComponent}>`
- use `import Self from './ThisComponent.svelte'` and `<Self>` instead of `<svelte:self>`
- use classes with `$state` fields to share reactivity between components, instead of using stores
- use `{@attach ...}` instead of `use:action`
- use clsx-style arrays and objects in `class` attributes, instead of the `class:` directive

@ -125,9 +125,9 @@ const seen = new Set();
/**
*
* @param {Compiler.AST.CSS.StyleSheet} stylesheet
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
* @param {Iterable<Compiler.AST.RegularElement | Compiler.AST.SvelteElement>} elements
*/
export function prune(stylesheet, element) {
export function prune(stylesheet, elements) {
walk(/** @type {Compiler.AST.CSS.Node} */ (stylesheet), null, {
Rule(node, context) {
if (node.metadata.is_global_block) {
@ -139,17 +139,19 @@ export function prune(stylesheet, element) {
ComplexSelector(node) {
const selectors = get_relative_selectors(node);
seen.clear();
for (const element of elements) {
seen.clear();
if (
apply_selector(
selectors,
/** @type {Compiler.AST.CSS.Rule} */ (node.metadata.rule),
element,
BACKWARD
)
) {
node.metadata.used = true;
if (
apply_selector(
selectors,
/** @type {Compiler.AST.CSS.Rule} */ (node.metadata.rule),
element,
BACKWARD
)
) {
node.metadata.used = true;
}
}
// note: we don't call context.next() here, we only recurse into

@ -21,7 +21,7 @@ import { prune } from './css/css-prune.js';
import { hash, is_rune } from '../../../utils.js';
import { warn_unused } from './css/css-warn.js';
import { extract_svelte_ignore } from '../../utils/extract_svelte_ignore.js';
import { ignore_map, ignore_stack, pop_ignore, push_ignore } from '../../state.js';
import { ignore_map, get_ignore_snapshot, pop_ignore, push_ignore } from '../../state.js';
import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js';
import { AssignmentExpression } from './visitors/AssignmentExpression.js';
import { AnimateDirective } from './visitors/AnimateDirective.js';
@ -134,7 +134,7 @@ const visitors = {
push_ignore(ignores);
}
ignore_map.set(node, structuredClone(ignore_stack));
ignore_map.set(node, get_ignore_snapshot());
const scope = state.scopes.get(node);
next(scope !== undefined && scope !== state.scope ? { ...state, scope } : state);
@ -856,9 +856,7 @@ export function analyze_component(root, source, options) {
analyze_css(analysis.css.ast, analysis);
// mark nodes as scoped/unused/empty etc
for (const node of analysis.elements) {
prune(analysis.css.ast, node);
}
prune(analysis.css.ast, analysis.elements);
const { comment } = analysis.css.ast.content;
const should_ignore_unused =

@ -126,7 +126,7 @@ export function RegularElement(node, context) {
if (node.metadata.synthetic_value_node) {
body = optimiser.transform(
node.metadata.synthetic_value_node.expression,
/** @type {Expression} */ (context.visit(node.metadata.synthetic_value_node.expression)),
node.metadata.synthetic_value_node.metadata.expression
);
} else {

@ -82,16 +82,38 @@ export let ignore_stack = [];
*/
export let ignore_map = new Map();
/**
* Cached snapshot of the ignore_stack. Only re-created when the stack changes
* (i.e. when push_ignore or pop_ignore is called), avoiding a structuredClone
* on every node visit during analysis.
* @type {Set<string>[] | null}
*/
let cached_ignore_snapshot = null;
/**
* Returns a snapshot of the current ignore_stack, reusing a cached copy
* when the stack hasn't changed since the last call.
* @returns {Set<string>[]}
*/
export function get_ignore_snapshot() {
if (cached_ignore_snapshot === null) {
cached_ignore_snapshot = ignore_stack.map((s) => new Set(s));
}
return cached_ignore_snapshot;
}
/**
* @param {string[]} ignores
*/
export function push_ignore(ignores) {
const next = new Set([...(ignore_stack.at(-1) || []), ...ignores]);
ignore_stack.push(next);
cached_ignore_snapshot = null;
}
export function pop_ignore() {
ignore_stack.pop();
cached_ignore_snapshot = null;
}
/**
@ -141,4 +163,5 @@ export function adjust(state) {
ignore_stack = [];
ignore_map.clear();
cached_ignore_snapshot = null;
}

@ -66,7 +66,6 @@ let queued_root_effects = [];
/** @type {Effect | null} */
let last_scheduled_effect = null;
let is_flushing = false;
export let is_flushing_sync = false;
/**
@ -608,8 +607,6 @@ export function flushSync(fn) {
}
function flush_effects() {
is_flushing = true;
var source_stacks = DEV ? new Set() : null;
try {
@ -658,7 +655,6 @@ function flush_effects() {
} finally {
queued_root_effects = [];
is_flushing = false;
last_scheduled_effect = null;
collected_effects = null;

@ -154,13 +154,12 @@ export function attributes(attrs, css_hash, classes, styles, flags = 0) {
if (INVALID_ATTR_NAME_CHAR_REGEX.test(name)) continue;
var value = attrs[name];
var lower = name.toLowerCase();
if (lowercase) {
name = name.toLowerCase();
}
if (lowercase) name = lower;
// omit event handler attributes
if (name.length > 2 && name.startsWith('on')) continue;
if (lower.length > 2 && lower.startsWith('on')) continue;
if (is_input) {
if (name === 'defaultvalue' || name === 'defaultchecked') {

@ -98,8 +98,7 @@ export class SvelteMap extends Map {
var s = sources.get(key);
if (s === undefined) {
var ret = super.get(key);
if (ret !== undefined) {
if (super.has(key)) {
s = this.#source(0);
if (DEV) {
@ -134,8 +133,7 @@ export class SvelteMap extends Map {
var s = sources.get(key);
if (s === undefined) {
var ret = super.get(key);
if (ret !== undefined) {
if (super.has(key)) {
s = this.#source(0);
if (DEV) {
@ -202,8 +200,11 @@ export class SvelteMap extends Map {
if (s !== undefined) {
sources.delete(key);
set(this.#size, super.size);
set(s, -1);
}
if (res) {
set(this.#size, super.size);
increment(this.#version);
}

@ -207,6 +207,75 @@ test('map handling of undefined values', () => {
cleanup();
});
test('map.has() and map.get() with undefined values', () => {
const map = new SvelteMap<string, undefined | string>([['foo', undefined]]);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push('has', map.has('foo'));
});
render_effect(() => {
log.push('get', map.get('foo'));
});
flushSync(() => {
map.delete('foo');
});
flushSync(() => {
map.set('bar', undefined);
});
});
assert.deepEqual(log, [
'has',
true,
'get',
undefined,
'has',
false,
'get',
undefined,
// set('bar') bumps version, causing has('foo')/get('foo') effects to re-run
'has',
false,
'get',
undefined
]);
assert.equal(map.has('bar'), true);
assert.equal(map.get('bar'), undefined);
cleanup();
});
test('map.delete() triggers size reactivity for keys without per-key sources', () => {
const map = new SvelteMap([
[1, 'a'],
[2, 'b']
]);
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(map.size);
});
// delete key 2 which was never individually read (no per-key source)
flushSync(() => {
map.delete(2);
});
});
assert.deepEqual(log, [2, 1]);
cleanup();
});
test('not invoking reactivity when value is not in the map after changes', () => {
const map = new SvelteMap([[1, 1]]);

@ -171,7 +171,7 @@ export class SvelteURL extends URL {
set search(value) {
super.search = value;
set(this.#search, value);
set(this.#search, super.search);
this.#searchParams[REPLACE](super.searchParams);
}

@ -115,6 +115,35 @@ test('url.searchParams', () => {
cleanup();
});
test('url.search normalizes value', () => {
const url = new SvelteURL('https://svelte.dev');
const log: any = [];
const cleanup = effect_root(() => {
render_effect(() => {
log.push(url.search);
});
});
flushSync(() => {
// setting without ? prefix — URL normalizes to "?foo=bar"
url.search = 'foo=bar';
});
flushSync(() => {
url.search = '?baz=qux';
});
flushSync(() => {
// lone "?" is normalized to ""
url.search = '?';
});
assert.deepEqual(log, ['', '?foo=bar', '?baz=qux', '']);
cleanup();
});
test('SvelteURL instanceof URL', () => {
assert.ok(new SvelteURL('https://svelte.dev') instanceof URL);
});

@ -0,0 +1 @@
<!--[--><svg><circle cx="12" cy="12" r="10"></circle></svg> <math><mi>x</mi></math> <custom-element></custom-element><!--]-->

After

Width:  |  Height:  |  Size: 125 B

@ -0,0 +1,16 @@
<script>
const userdata = {
ONCLICK: 'alert(document.cookie)',
ONMOUSEOVER: 'alert("XSS")'
};
</script>
<svg {...userdata}>
<circle cx="12" cy="12" r="10" />
</svg>
<math {...userdata}>
<mi>x</mi>
</math>
<custom-element {...userdata}></custom-element>

@ -0,0 +1 @@
<!--[--><select><option>Dog</option><option>cat</option></select><!--]-->

@ -0,0 +1,11 @@
<script>
import { writable } from 'svelte/store';
const value = writable('dog');
const label = writable('Dog');
</script>
<select bind:value={$value}>
<option>{$label}</option>
<option>cat</option>
</select>

@ -0,0 +1 @@
<!--[--><select><option disabled="" value="" selected="">placeholder</option><option value="a">A</option><option value="b">B</option></select><!--]-->

@ -0,0 +1,13 @@
<script>
import { readable } from 'svelte/store';
const t = readable((/** @type {string} */ key) => key);
let value = $state('');
</script>
<select bind:value>
<option disabled value="">{$t('placeholder')}</option>
<option value="a">A</option>
<option value="b">B</option>
</select>
Loading…
Cancel
Save