Merge branch 'main' into gh-9556

pull/9571/head
Rich Harris 3 years ago
commit 01428b3589

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: apply keyed validation only for keyed each

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: add inline new class warning

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: add type of `$effect.active`

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: correct bind this multiple bindings

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: prevent false positives when detecting runes mode

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: reuse common templates

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: handle undefined bubble events

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: warn on references to mutated non-state in template

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: adjust mount and createRoot types

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: deconflict generated names against globals

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: prevent reactive snippet from reinitializing unnecessarily

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: dont error on stores looking like runes when runes explicitly turned off

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: omit this bind this arg if we know it's not a signal

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: improve member expression mutation logic

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: bump esrap

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: untrack keyed validation logic

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: take event attributes into account when checking a11y

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: add $effect.active rune

@ -12,17 +12,26 @@
"afraid-moose-matter", "afraid-moose-matter",
"brave-walls-destroy", "brave-walls-destroy",
"brown-spoons-boil", "brown-spoons-boil",
"chilled-pumas-invite",
"chilly-dolphins-lick", "chilly-dolphins-lick",
"clean-eels-beg", "clean-eels-beg",
"cold-birds-own",
"cuddly-pianos-drop", "cuddly-pianos-drop",
"curly-lizards-dream", "curly-lizards-dream",
"dirty-garlics-design",
"dirty-tips-add",
"early-ads-tie", "early-ads-tie",
"eight-steaks-shout", "eight-steaks-shout",
"eighty-bikes-camp", "eighty-bikes-camp",
"fair-crabs-check",
"few-mugs-fail",
"fifty-steaks-float", "fifty-steaks-float",
"forty-comics-invent",
"fresh-weeks-trade", "fresh-weeks-trade",
"funny-wombats-argue", "funny-wombats-argue",
"good-pianos-jump", "good-pianos-jump",
"green-eggs-approve",
"green-hounds-play",
"honest-icons-change", "honest-icons-change",
"hungry-dots-fry", "hungry-dots-fry",
"itchy-lions-wash", "itchy-lions-wash",
@ -30,23 +39,38 @@
"kind-deers-lay", "kind-deers-lay",
"lazy-spiders-think", "lazy-spiders-think",
"long-crews-return", "long-crews-return",
"lovely-items-turn",
"lovely-rules-eat",
"lucky-schools-hang", "lucky-schools-hang",
"moody-frogs-exist",
"moody-owls-cry", "moody-owls-cry",
"new-boats-wait",
"odd-needles-joke", "odd-needles-joke",
"odd-schools-wait",
"poor-eggs-enjoy", "poor-eggs-enjoy",
"popular-mangos-rest",
"quiet-camels-mate", "quiet-camels-mate",
"rich-sheep-burn", "rich-sheep-burn",
"rotten-buckets-develop", "rotten-buckets-develop",
"serious-zebras-scream",
"seven-deers-jam",
"sharp-gorillas-impress", "sharp-gorillas-impress",
"shiny-baboons-play",
"shiny-shrimps-march", "shiny-shrimps-march",
"small-papayas-laugh", "small-papayas-laugh",
"soft-geese-learn", "soft-geese-learn",
"sour-rules-march", "sour-rules-march",
"strong-lemons-provide", "strong-lemons-provide",
"tall-shrimps-worry", "tall-shrimps-worry",
"ten-worms-reflect",
"thirty-flowers-sit", "thirty-flowers-sit",
"thirty-ghosts-fix", "thirty-ghosts-fix",
"thirty-impalas-repair",
"thirty-wombats-relax",
"tiny-kings-whisper", "tiny-kings-whisper",
"two-falcons-buy",
"wet-games-fly",
"wicked-clouds-exercise",
"wicked-doors-train" "wicked-doors-train"
] ]
} }

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: ensure snippets have correct scope

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: better attribute casing logic

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: support type definition in {@const}

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: add Snippet type

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: ignore href attributes when hydrating

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: bump esrap

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: remove constructor overload

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: adjust event delegation heuristics

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: improve each block index handling

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: remove unused code

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: type-level back-compat for default slot and children prop

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: adjust regex

@ -25,6 +25,9 @@ jobs:
os: ubuntu-latest os: ubuntu-latest
- node-version: 20 - node-version: 20
os: ubuntu-latest os: ubuntu-latest
- node-version: 21
os: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.4 - uses: pnpm/action-setup@v2.2.4

@ -37,3 +37,7 @@ sites/svelte.dev/src/lib/generated
.changeset .changeset
pnpm-lock.yaml pnpm-lock.yaml
pnpm-workspace.yaml pnpm-workspace.yaml
# Temporarily ignore this file to avoid merge conflicts.
# see: https://github.com/sveltejs/svelte/pull/9609
documentation/docs/05-misc/03-typescript.md

@ -1,5 +1,69 @@
# svelte # svelte
## 5.0.0-next.13
### Patch Changes
- fix: apply keyed validation only for keyed each ([#9641](https://github.com/sveltejs/svelte/pull/9641))
- fix: omit this bind this arg if we know it's not a signal ([#9635](https://github.com/sveltejs/svelte/pull/9635))
- fix: improve each block index handling ([#9644](https://github.com/sveltejs/svelte/pull/9644))
## 5.0.0-next.12
### Patch Changes
- fix: adjust mount and createRoot types ([`63e583184`](https://github.com/sveltejs/svelte/commit/63e58318460dbb3485df93d15beb2779a86d2c9a))
- fix: remove constructor overload ([`cb4b1f0a1`](https://github.com/sveltejs/svelte/commit/cb4b1f0a189803bed04adcb90fbd4334782e8469))
- fix: type-level back-compat for default slot and children prop ([`a3bc7d569`](https://github.com/sveltejs/svelte/commit/a3bc7d5698425ec9dde86eb302f2fd56d9da8f96))
## 5.0.0-next.11
### Patch Changes
- feat: add type of `$effect.active` ([#9624](https://github.com/sveltejs/svelte/pull/9624))
- fix: correct bind this multiple bindings ([#9617](https://github.com/sveltejs/svelte/pull/9617))
- chore: reuse common templates ([#9601](https://github.com/sveltejs/svelte/pull/9601))
- fix: handle undefined bubble events ([#9614](https://github.com/sveltejs/svelte/pull/9614))
- fix: dont error on stores looking like runes when runes explicitly turned off ([#9615](https://github.com/sveltejs/svelte/pull/9615))
- fix: improve member expression mutation logic ([#9625](https://github.com/sveltejs/svelte/pull/9625))
- chore: untrack keyed validation logic ([#9618](https://github.com/sveltejs/svelte/pull/9618))
- fix: ensure snippets have correct scope ([#9623](https://github.com/sveltejs/svelte/pull/9623))
- fix: better attribute casing logic ([#9626](https://github.com/sveltejs/svelte/pull/9626))
## 5.0.0-next.10
### Patch Changes
- chore: add inline new class warning ([#9583](https://github.com/sveltejs/svelte/pull/9583))
- fix: prevent false positives when detecting runes mode ([#9599](https://github.com/sveltejs/svelte/pull/9599))
- fix: deconflict generated names against globals ([#9570](https://github.com/sveltejs/svelte/pull/9570))
- chore: bump esrap ([#9590](https://github.com/sveltejs/svelte/pull/9590))
- feat: add $effect.active rune ([#9591](https://github.com/sveltejs/svelte/pull/9591))
- feat: add Snippet type ([#9584](https://github.com/sveltejs/svelte/pull/9584))
- fix: adjust event delegation heuristics ([#9581](https://github.com/sveltejs/svelte/pull/9581))
- chore: remove unused code ([#9593](https://github.com/sveltejs/svelte/pull/9593))
- fix: adjust regex ([#9572](https://github.com/sveltejs/svelte/pull/9572))
## 5.0.0-next.9 ## 5.0.0-next.9
### Patch Changes ### Patch Changes

@ -2,7 +2,7 @@
"name": "svelte", "name": "svelte",
"description": "Cybernetically enhanced web apps", "description": "Cybernetically enhanced web apps",
"license": "MIT", "license": "MIT",
"version": "5.0.0-next.9", "version": "5.0.0-next.13",
"type": "module", "type": "module",
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
"engines": { "engines": {
@ -120,7 +120,7 @@
"aria-query": "^5.3.0", "aria-query": "^5.3.0",
"axobject-query": "^4.0.0", "axobject-query": "^4.0.0",
"esm-env": "^1.0.0", "esm-env": "^1.0.0",
"esrap": "^1.1.1", "esrap": "^1.2.1",
"is-reference": "^3.0.1", "is-reference": "^3.0.1",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.4", "magic-string": "^0.30.4",

@ -209,6 +209,33 @@ export function convert(source, ast) {
}; };
}, },
// @ts-ignore // @ts-ignore
ConstTag(node) {
if (
/** @type {import('./types/legacy-nodes.js').LegacyConstTag} */ (node).expression !==
undefined
) {
return node;
}
const modern_node = /** @type {import('#compiler').ConstTag} */ (node);
const { id: left } = { ...modern_node.declaration.declarations[0] };
// @ts-ignore
delete left.typeAnnotation;
return {
type: 'ConstTag',
start: modern_node.start,
end: node.end,
expression: {
type: 'AssignmentExpression',
start: (modern_node.declaration.start ?? 0) + 'const '.length,
end: modern_node.declaration.end ?? 0,
operator: '=',
left,
right: modern_node.declaration.declarations[0].init
}
};
},
// @ts-ignore
KeyBlock(node, { visit }) { KeyBlock(node, { visit }) {
remove_surrounding_whitespace_nodes(node.fragment.nodes); remove_surrounding_whitespace_nodes(node.fragment.nodes);
return { return {

@ -2,7 +2,6 @@ import * as acorn from 'acorn';
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
import { tsPlugin } from 'acorn-typescript'; import { tsPlugin } from 'acorn-typescript';
// @ts-expect-error
const ParserWithTS = acorn.Parser.extend(tsPlugin()); const ParserWithTS = acorn.Parser.extend(tsPlugin());
/** /**
@ -59,7 +58,6 @@ export function get_comment_handlers(source) {
* @typedef {import('estree').Comment & { * @typedef {import('estree').Comment & {
* start: number; * start: number;
* end: number; * end: number;
* has_trailing_newline?: boolean
* }} CommentWithLocation * }} CommentWithLocation
*/ */
@ -92,20 +90,12 @@ export function get_comment_handlers(source) {
add_comments(ast) { add_comments(ast) {
if (comments.length === 0) return; if (comments.length === 0) return;
walk( walk(ast, null, {
ast,
{},
{
_(node, { next }) { _(node, { next }) {
let comment; let comment;
while (comments[0] && comments[0].start < node.start) { while (comments[0] && comments[0].start < node.start) {
comment = /** @type {CommentWithLocation} */ (comments.shift()); comment = /** @type {CommentWithLocation} */ (comments.shift());
const next = comments[0] || node;
comment.has_trailing_newline =
comment.type === 'Line' || /\n/.test(source.slice(comment.end, next.start));
(node.leadingComments ||= []).push(comment); (node.leadingComments ||= []).push(comment);
} }
@ -119,8 +109,7 @@ export function get_comment_handlers(source) {
} }
} }
} }
} });
);
} }
}; };
} }

@ -0,0 +1,3 @@
// Silence the acorn typescript errors through this ambient type definition + tsconfig.json path alias
// That way we can omit `"skipLibCheck": true` and catch other errors in our d.ts files
declare module 'acorn-typescript';

@ -11,7 +11,7 @@ import read_options from './read/options.js';
const regex_position_indicator = / \(\d+:\d+\)$/; const regex_position_indicator = / \(\d+:\d+\)$/;
const regex_lang_attribute = const regex_lang_attribute =
/<!--[^]*?-->|<script\s+(?:[^>]*|(?:[^=>'"/]+=(?:"[^"]*"|'[^']*'|[^>\s])\s+)*)lang=(["'])?([^"' >]+)\1[^>]*>/; /<!--[^]*?-->|<script\s+(?:[^>]*|(?:[^=>'"/]+=(?:"[^"]*"|'[^']*'|[^>\s]+)\s+)*)lang=(["'])?([^"' >]+)\1[^>]*>/;
export class Parser { export class Parser {
/** /**

@ -2,8 +2,8 @@ import read_context from '../read/context.js';
import read_expression from '../read/expression.js'; import read_expression from '../read/expression.js';
import { error } from '../../../errors.js'; import { error } from '../../../errors.js';
import { create_fragment } from '../utils/create.js'; import { create_fragment } from '../utils/create.js';
import { parse_expression_at } from '../acorn.js';
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
import { parse } from '../acorn.js';
const regex_whitespace_with_closing_curly_brace = /^\s*}/; const regex_whitespace_with_closing_curly_brace = /^\s*}/;
@ -532,21 +532,54 @@ function special(parser) {
// {@const a = b} // {@const a = b}
parser.require_whitespace(); parser.require_whitespace();
const expression = read_expression(parser); const CONST_LENGTH = 'const '.length;
parser.index = parser.index - CONST_LENGTH;
let end_index = parser.index;
/** @type {import('estree').VariableDeclaration | undefined} */
let declaration = undefined;
const dummy_spaces = parser.template.substring(0, parser.index).replace(/[^\n]/g, ' ');
while (true) {
end_index = parser.template.indexOf('}', end_index + 1);
if (end_index === -1) break;
try {
const node = parse(
dummy_spaces + parser.template.substring(parser.index, end_index),
parser.ts
).body[0];
if (node?.type === 'VariableDeclaration') {
declaration = node;
break;
}
} catch (e) {
continue;
}
}
if (!(expression.type === 'AssignmentExpression' && expression.operator === '=')) { if (
declaration === undefined ||
declaration.declarations.length !== 1 ||
declaration.declarations[0].init === undefined
) {
error(start, 'invalid-const'); error(start, 'invalid-const');
} }
parser.allow_whitespace(); parser.index = end_index;
parser.eat('}', true); parser.eat('}', true);
const id = declaration.declarations[0].id;
if (id.type === 'Identifier') {
// Tidy up some stuff left behind by acorn-typescript
id.end = (id.start ?? 0) + id.name.length;
}
parser.append( parser.append(
/** @type {import('#compiler').ConstTag} */ ({ /** @type {import('#compiler').ConstTag} */ ({
type: 'ConstTag', type: 'ConstTag',
start, start,
end: parser.index, end: parser.index,
expression declaration
}) })
); );
} }

@ -9,7 +9,7 @@ import {
} from '../patterns.js'; } from '../patterns.js';
import { warn } from '../../warnings.js'; import { warn } from '../../warnings.js';
import fuzzymatch from '../1-parse/utils/fuzzymatch.js'; import fuzzymatch from '../1-parse/utils/fuzzymatch.js';
import { is_text_attribute } from '../../utils/ast.js'; import { is_event_attribute, is_text_attribute } from '../../utils/ast.js';
import { ContentEditableBindings } from '../constants.js'; import { ContentEditableBindings } from '../constants.js';
import { walk } from 'zimmerframe'; import { walk } from 'zimmerframe';
@ -704,11 +704,15 @@ function check_element(node, state, path) {
} else if (attribute.type === 'OnDirective') { } else if (attribute.type === 'OnDirective') {
handlers.add(attribute.name); handlers.add(attribute.name);
} else if (attribute.type === 'Attribute') { } else if (attribute.type === 'Attribute') {
if (is_event_attribute(attribute)) {
handlers.add(attribute.name.slice(2));
} else {
attributes.push(attribute); attributes.push(attribute);
attribute_map.set(attribute.name, attribute); attribute_map.set(attribute.name, attribute);
if (attribute.name === 'contenteditable') { if (attribute.name === 'contenteditable') {
has_contenteditable_attr = true; has_contenteditable_attr = true;
} }
}
} else if ( } else if (
attribute.type === 'BindDirective' && attribute.type === 'BindDirective' &&
ContentEditableBindings.includes(attribute.name) ContentEditableBindings.includes(attribute.name)

@ -5,7 +5,6 @@ import * as assert from '../../utils/assert.js';
import { import {
extract_identifiers, extract_identifiers,
extract_paths, extract_paths,
get_callee_name,
is_event_attribute, is_event_attribute,
is_text_attribute, is_text_attribute,
object object
@ -175,7 +174,7 @@ function get_delegated_event(node, context) {
// Bail-out if we reference anything from the EachBlock (for now) that mutates in non-runes mode, // Bail-out if we reference anything from the EachBlock (for now) that mutates in non-runes mode,
((!context.state.analysis.runes && binding.kind === 'each') || ((!context.state.analysis.runes && binding.kind === 'each') ||
// or any normal not reactive bindings that are mutated. // or any normal not reactive bindings that are mutated.
(binding.kind === 'normal' && context.state.analysis.runes) || binding.kind === 'normal' ||
// or any reactive imports (those are rewritten) (can only happen in legacy mode) // or any reactive imports (those are rewritten) (can only happen in legacy mode)
(binding.kind === 'state' && binding.declaration_kind === 'import')) && (binding.kind === 'state' && binding.declaration_kind === 'import')) &&
binding.mutated binding.mutated
@ -201,21 +200,25 @@ export function analyze_module(ast, options) {
} }
} }
/** @type {import('../types').RawWarning[]} */
const warnings = [];
const analysis = {
warnings
};
walk( walk(
/** @type {import('estree').Node} */ (ast), /** @type {import('estree').Node} */ (ast),
{ scope }, { scope, analysis },
// @ts-expect-error TODO clean this mess up // @ts-expect-error TODO clean this mess up
merge(set_scope(scopes), validation_runes_js, runes_scope_js_tweaker) merge(set_scope(scopes), validation_runes_js, runes_scope_js_tweaker)
); );
/** @type {import('../types').RawWarning[]} */
const warnings = [];
// If we are in runes mode, then check for possible misuses of state runes // If we are in runes mode, then check for possible misuses of state runes
for (const [, scope] of scopes) { for (const [, scope] of scopes) {
for (const [name, binding] of scope.declarations) { for (const [name, binding] of scope.declarations) {
if (binding.kind === 'state' && !binding.mutated) { if (binding.kind === 'state' && !binding.mutated) {
warn(warnings, binding.node, [], 'state-rune-not-mutated', name); warn(warnings, binding.node, [], 'state-not-mutated', name);
} }
} }
} }
@ -264,11 +267,7 @@ export function analyze_component(root, options) {
!Runes.includes(name) || !Runes.includes(name) ||
(declaration !== null && (declaration !== null &&
// const state = $state(0) is valid // const state = $state(0) is valid
!Runes.includes( get_rune(declaration.initial, instance.scope) === null &&
/** @type {string} */ (
get_callee_name(/** @type {import('estree').Expression} */ (declaration.initial))
)
) &&
// allow `import { derived } from 'svelte/store'` in the same file as `const x = $derived(..)` because one is not a subscription to the other // allow `import { derived } from 'svelte/store'` in the same file as `const x = $derived(..)` because one is not a subscription to the other
!( !(
name === '$derived' && name === '$derived' &&
@ -280,7 +279,11 @@ export function analyze_component(root, options) {
if (declaration === null && /[a-z]/.test(store_name[0])) { if (declaration === null && /[a-z]/.test(store_name[0])) {
error(references[0].node, 'illegal-global', name); error(references[0].node, 'illegal-global', name);
} else if (declaration !== null && Runes.includes(name)) { } else if (declaration !== null && Runes.includes(name)) {
warn(warnings, declaration.node, [], 'store-with-rune-name', store_name); for (const { node, path } of references) {
if (path.at(-1)?.type === 'CallExpression') {
warn(warnings, node, [], 'store-with-rune-name', store_name);
}
}
} }
} }
@ -298,6 +301,8 @@ export function analyze_component(root, options) {
const binding = instance.scope.declare(b.id(name), 'store_sub', 'synthetic'); const binding = instance.scope.declare(b.id(name), 'store_sub', 'synthetic');
binding.references = references; binding.references = references;
instance.scope.references.set(name, references);
module.scope.references.delete(name);
} }
} }
@ -372,7 +377,7 @@ export function analyze_component(root, options) {
for (const [, scope] of instance.scopes) { for (const [, scope] of instance.scopes) {
for (const [name, binding] of scope.declarations) { for (const [name, binding] of scope.declarations) {
if (binding.kind === 'state' && !binding.mutated) { if (binding.kind === 'state' && !binding.mutated) {
warn(warnings, binding.node, [], 'state-rune-not-mutated', name); warn(warnings, binding.node, [], 'state-not-mutated', name);
} }
} }
} }
@ -409,6 +414,30 @@ export function analyze_component(root, options) {
analysis.reactive_statements = order_reactive_statements(analysis.reactive_statements); analysis.reactive_statements = order_reactive_statements(analysis.reactive_statements);
} }
// warn on any nonstate declarations that are a) mutated and b) referenced in the template
for (const scope of [module.scope, instance.scope]) {
outer: for (const [name, binding] of scope.declarations) {
if (binding.kind === 'normal' && binding.mutated) {
for (const { path } of binding.references) {
if (path[0].type !== 'Fragment') continue;
for (let i = 1; i < path.length; i += 1) {
const type = path[i].type;
if (
type === 'FunctionDeclaration' ||
type === 'FunctionExpression' ||
type === 'ArrowFunctionExpression'
) {
continue;
}
}
warn(warnings, binding.node, [], 'non-state-reference', name);
continue outer;
}
}
}
}
analysis.stylesheet.validate(analysis); analysis.stylesheet.validate(analysis);
for (const element of analysis.elements) { for (const element of analysis.elements) {
@ -608,7 +637,7 @@ const legacy_scope_tweaker = {
} }
}; };
/** @type {import('zimmerframe').Visitors<import('#compiler').SvelteNode, { scope: Scope }>} */ /** @type {import('zimmerframe').Visitors<import('#compiler').SvelteNode, { scope: Scope, analysis: { warnings: import('../types').RawWarning[] } }>} */
const runes_scope_js_tweaker = { const runes_scope_js_tweaker = {
VariableDeclarator(node, { state }) { VariableDeclarator(node, { state }) {
if (node.init?.type !== 'CallExpression') return; if (node.init?.type !== 'CallExpression') return;

@ -443,7 +443,7 @@ export const validation = {
}; };
export const validation_legacy = merge(validation, a11y_validators, { export const validation_legacy = merge(validation, a11y_validators, {
VariableDeclarator(node) { VariableDeclarator(node, { state }) {
if (node.init?.type !== 'CallExpression') return; if (node.init?.type !== 'CallExpression') return;
const callee = node.init.callee; const callee = node.init.callee;
@ -454,8 +454,9 @@ export const validation_legacy = merge(validation, a11y_validators, {
return; return;
} }
// TODO check if it's a store subscription that's called? How likely is it that someone uses a store that contains a function? if (state.scope.get(callee.name)?.kind !== 'store_sub') {
error(node.init, 'invalid-rune-usage', callee.name); error(node.init, 'invalid-rune-usage', callee.name);
}
}, },
AssignmentExpression(node, { state, path }) { AssignmentExpression(node, { state, path }) {
const parent = path.at(-1); const parent = path.at(-1);
@ -490,19 +491,32 @@ function validate_call_expression(node, scope, path) {
const rune = get_rune(node, scope); const rune = get_rune(node, scope);
if (rune === null) return; if (rune === null) return;
if (rune === '$props' && path.at(-1)?.type !== 'VariableDeclarator') { const parent = /** @type {import('#compiler').SvelteNode} */ (path.at(-1));
if (rune === '$props') {
if (parent.type === 'VariableDeclarator') return;
error(node, 'invalid-props-location'); error(node, 'invalid-props-location');
} else if ( }
(rune === '$state' || rune === '$derived') &&
path.at(-1)?.type !== 'VariableDeclarator' && if (rune === '$state' || rune === '$derived') {
path.at(-1)?.type !== 'PropertyDefinition' if (parent.type === 'VariableDeclarator') return;
) { if (parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) return;
error(node, rune === '$derived' ? 'invalid-derived-location' : 'invalid-state-location'); error(node, rune === '$derived' ? 'invalid-derived-location' : 'invalid-state-location');
} else if (rune === '$effect') { }
if (path.at(-1)?.type !== 'ExpressionStatement') {
if (rune === '$effect' || rune === '$effect.pre') {
if (parent.type !== 'ExpressionStatement') {
error(node, 'invalid-effect-location'); error(node, 'invalid-effect-location');
} else if (node.arguments.length !== 1) { }
error(node, 'invalid-rune-args-length', '$effect', [1]);
if (node.arguments.length !== 1) {
error(node, 'invalid-rune-args-length', rune, [1]);
}
}
if (rune === '$effect.active') {
if (node.arguments.length !== 0) {
error(node, 'invalid-rune-args-length', '$effect.active', [0]);
} }
} }
} }
@ -572,6 +586,21 @@ export const validation_runes_js = {
...context.state, ...context.state,
private_derived_state private_derived_state
}); });
},
ClassDeclaration(node, context) {
// In modules, we allow top-level module scope only, in components, we allow the component scope,
// which is function_depth of 1. With the exception of `new class` which is also not allowed at
// component scope level either.
const allowed_depth = context.state.ast_type === 'module' ? 0 : 1;
if (context.state.scope.function_depth > allowed_depth) {
warn(context.state.analysis.warnings, node, context.path, 'avoid-nested-class');
}
},
NewExpression(node, context) {
if (node.callee.type === 'ClassExpression' && context.state.scope.function_depth > 0) {
warn(context.state.analysis.warnings, node, context.path, 'avoid-inline-class');
}
} }
}; };
@ -714,5 +743,8 @@ export const validation_runes = merge(validation, a11y_validators, {
} }
} }
}, },
ClassBody: validation_runes_js.ClassBody // TODO this is a code smell. need to refactor this stuff
ClassBody: validation_runes_js.ClassBody,
ClassDeclaration: validation_runes_js.ClassDeclaration,
NewExpression: validation_runes_js.NewExpression
}); });

@ -135,7 +135,7 @@ export const javascript_visitors_runes = {
for (const declarator of node.declarations) { for (const declarator of node.declarations) {
const init = declarator.init; const init = declarator.init;
const rune = get_rune(init, state.scope); const rune = get_rune(init, state.scope);
if (!rune) { if (!rune || rune === '$effect.active') {
if (init != null && is_hoistable_function(init)) { if (init != null && is_hoistable_function(init)) {
const hoistable_function = visit(init); const hoistable_function = visit(init);
state.hoisted.push( state.hoisted.push(
@ -291,5 +291,14 @@ export const javascript_visitors_runes = {
} }
context.next(); context.next();
},
CallExpression(node, { state, next }) {
const rune = get_rune(node, state.scope);
if (rune === '$effect.active') {
return b.call('$.effect_active');
}
next();
} }
}; };

@ -12,13 +12,7 @@ import {
escape_html, escape_html,
infer_namespace infer_namespace
} from '../../utils.js'; } from '../../utils.js';
import { import { DOMProperties, PassiveEvents, VoidElements } from '../../../constants.js';
AttributeAliases,
DOMBooleanAttributes,
DOMProperties,
PassiveEvents,
VoidElements
} from '../../../constants.js';
import { is_custom_element_node, is_element_node } from '../../../nodes.js'; import { is_custom_element_node, is_element_node } from '../../../nodes.js';
import * as b from '../../../../utils/builders.js'; import * as b from '../../../../utils/builders.js';
import { error } from '../../../../errors.js'; import { error } from '../../../../errors.js';
@ -29,12 +23,35 @@ import {
serialize_set_binding serialize_set_binding
} from '../utils.js'; } from '../utils.js';
import { import {
AttributeAliases,
DOMBooleanAttributes,
EACH_INDEX_REACTIVE, EACH_INDEX_REACTIVE,
EACH_IS_CONTROLLED, EACH_IS_CONTROLLED,
EACH_ITEM_REACTIVE, EACH_ITEM_REACTIVE,
EACH_KEYED EACH_KEYED
} from '../../../../../constants.js'; } from '../../../../../constants.js';
import { regex_is_valid_identifier } from '../../../patterns.js'; import { regex_is_valid_identifier } from '../../../patterns.js';
import { javascript_visitors_runes } from './javascript-runes.js';
/**
* @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} element
* @param {import('#compiler').Attribute} attribute
* @param {{ state: { metadata: { namespace: import('#compiler').Namespace }}}} context
*/
function get_attribute_name(element, attribute, context) {
let name = attribute.name;
if (
element.type === 'RegularElement' &&
!element.metadata.svg &&
context.state.metadata.namespace !== 'foreign'
) {
name = name.toLowerCase();
if (name in AttributeAliases) {
name = AttributeAliases[name];
}
}
return name;
}
/** /**
* Serializes each style directive into something like `$.style(element, style_property, value)` * Serializes each style directive into something like `$.style(element, style_property, value)`
@ -255,13 +272,14 @@ function setup_select_synchronization(value_binding, context) {
* value = $.spread_attributes(element, value, [...]) * value = $.spread_attributes(element, value, [...])
* }); * });
* ``` * ```
* Returns the id of the spread_attribute varialbe if spread is deemed reactive, `null` otherwise. * Returns the id of the spread_attribute variable if spread is deemed reactive, `null` otherwise.
* @param {Array<import('#compiler').Attribute | import('#compiler').SpreadAttribute>} attributes * @param {Array<import('#compiler').Attribute | import('#compiler').SpreadAttribute>} attributes
* @param {import('../types.js').ComponentContext} context * @param {import('../types.js').ComponentContext} context
* @param {import('#compiler').RegularElement} element
* @param {import('estree').Identifier} element_id * @param {import('estree').Identifier} element_id
* @returns {string | null} * @returns {string | null}
*/ */
function serialize_element_spread_attributes(attributes, context, element_id) { function serialize_element_spread_attributes(attributes, context, element, element_id) {
let is_reactive = false; let is_reactive = false;
/** @type {import('estree').Expression[]} */ /** @type {import('estree').Expression[]} */
@ -269,7 +287,7 @@ function serialize_element_spread_attributes(attributes, context, element_id) {
for (const attribute of attributes) { for (const attribute of attributes) {
if (attribute.type === 'Attribute') { if (attribute.type === 'Attribute') {
const name = get_attribute_name(attribute, context.state); const name = get_attribute_name(element, attribute, context);
// TODO: handle contains_call_expression // TODO: handle contains_call_expression
const [, value] = serialize_attribute_value(attribute.value, context); const [, value] = serialize_attribute_value(attribute.value, context);
values.push(b.object([b.init(name, value)])); values.push(b.object([b.init(name, value)]));
@ -280,6 +298,9 @@ function serialize_element_spread_attributes(attributes, context, element_id) {
is_reactive ||= attribute.metadata.dynamic; is_reactive ||= attribute.metadata.dynamic;
} }
const lowercase_attributes =
element.metadata.svg || is_custom_element_node(element) ? b.false : b.true;
if (is_reactive) { if (is_reactive) {
const id = context.state.scope.generate('spread_attributes'); const id = context.state.scope.generate('spread_attributes');
context.state.init.push(b.let(id, undefined)); context.state.init.push(b.let(id, undefined));
@ -293,6 +314,7 @@ function serialize_element_spread_attributes(attributes, context, element_id) {
element_id, element_id,
b.id(id), b.id(id),
b.array(values), b.array(values),
lowercase_attributes,
b.literal(context.state.analysis.stylesheet.id) b.literal(context.state.analysis.stylesheet.id)
) )
) )
@ -307,6 +329,7 @@ function serialize_element_spread_attributes(attributes, context, element_id) {
element_id, element_id,
b.literal(null), b.literal(null),
b.array(values), b.array(values),
lowercase_attributes,
b.literal(context.state.analysis.stylesheet.id) b.literal(context.state.analysis.stylesheet.id)
) )
) )
@ -376,7 +399,7 @@ function serialize_dynamic_element_spread_attributes(attributes, context, elemen
} }
/** /**
* Serializes an assigment to an element property by adding relevant statements to either only * Serializes an assignment to an element property by adding relevant statements to either only
* the init or the the init and update arrays, depending on whether or not the value is dynamic. * the init or the the init and update arrays, depending on whether or not the value is dynamic.
* Resulting code for static looks something like this: * Resulting code for static looks something like this:
* ```js * ```js
@ -397,14 +420,15 @@ function serialize_dynamic_element_spread_attributes(attributes, context, elemen
* }); * });
* ``` * ```
* Returns true if attribute is deemed reactive, false otherwise. * Returns true if attribute is deemed reactive, false otherwise.
* @param {import('#compiler').RegularElement} element
* @param {import('estree').Identifier} node_id * @param {import('estree').Identifier} node_id
* @param {import('#compiler').Attribute} attribute * @param {import('#compiler').Attribute} attribute
* @param {import('../types.js').ComponentContext} context * @param {import('../types.js').ComponentContext} context
* @returns {boolean} * @returns {boolean}
*/ */
function serialize_element_attribute_update_assignment(node_id, attribute, context) { function serialize_element_attribute_update_assignment(element, node_id, attribute, context) {
const state = context.state; const state = context.state;
const name = get_attribute_name(attribute, state); const name = get_attribute_name(element, attribute, context);
let [contains_call_expression, value] = serialize_attribute_value(attribute.value, context); let [contains_call_expression, value] = serialize_attribute_value(attribute.value, context);
// The foreign namespace doesn't have any special handling, everything goes through the attr function // The foreign namespace doesn't have any special handling, everything goes through the attr function
@ -551,7 +575,7 @@ function serialize_custom_element_attribute_update_assignment(node_id, attribute
} }
/** /**
* Serializes an assigment to the value property of a `<select>`, `<option>` or `<input>` element * Serializes an assignment to the value property of a `<select>`, `<option>` or `<input>` element
* that needs the hidden `__value` property. * that needs the hidden `__value` property.
* Returns true if attribute is deemed reactive, false otherwise. * Returns true if attribute is deemed reactive, false otherwise.
* @param {string} element * @param {string} element
@ -567,13 +591,17 @@ function serialize_element_special_value_attribute(element, node_id, attribute,
const inner_assignment = b.assignment( const inner_assignment = b.assignment(
'=', '=',
b.member(node_id, b.id('value')), b.member(node_id, b.id('value')),
b.assignment('=', b.member(node_id, b.id('__value')), value) b.conditional(
b.binary('==', b.literal(null), b.assignment('=', b.member(node_id, b.id('__value')), value)),
b.literal(''), // render null/undefined values as empty string to support placeholder options
value
)
); );
const is_reactive = attribute.metadata.dynamic; const is_reactive = attribute.metadata.dynamic;
const needs_selected_call = const needs_selected_call =
element === 'option' && (is_reactive || collect_parent_each_blocks(context).length > 0); element === 'option' && (is_reactive || collect_parent_each_blocks(context).length > 0);
const needs_option_call = element === 'select' && is_reactive; const needs_option_call = element === 'select' && is_reactive;
const assigment = b.stmt( const assignment = b.stmt(
needs_selected_call needs_selected_call
? b.sequence([ ? b.sequence([
inner_assignment, inner_assignment,
@ -582,9 +610,12 @@ function serialize_element_special_value_attribute(element, node_id, attribute,
b.call('$.selected', node_id) b.call('$.selected', node_id)
]) ])
: needs_option_call : needs_option_call
? // This ensures a one-way street to the DOM in case it's <select {value}> ? b.sequence([
inner_assignment,
// This ensures a one-way street to the DOM in case it's <select {value}>
// and not <select bind:value> // and not <select bind:value>
b.call('$.select_option', node_id, inner_assignment) b.call('$.select_option', node_id, value)
])
: inner_assignment : inner_assignment
); );
@ -595,12 +626,12 @@ function serialize_element_special_value_attribute(element, node_id, attribute,
id, id,
undefined, undefined,
value, value,
{ grouped: assigment }, { grouped: assignment },
contains_call_expression contains_call_expression
); );
return true; return true;
} else { } else {
state.init.push(assigment); state.init.push(assignment);
return false; return false;
} }
} }
@ -664,21 +695,6 @@ function collect_parent_each_blocks(context) {
); );
} }
/**
* @param {import('#compiler').Attribute} attribute
* @param {import('../types.js').ComponentClientTransformState} state
*/
function get_attribute_name(attribute, state) {
let name = attribute.name;
if (state.metadata.namespace !== 'foreign') {
name = name.toLowerCase();
if (name !== 'class' && name in AttributeAliases) {
name = AttributeAliases[name];
}
}
return name;
}
/** /**
* @param {import('#compiler').Component | import('#compiler').SvelteComponent | import('#compiler').SvelteSelf} node * @param {import('#compiler').Component | import('#compiler').SvelteComponent | import('#compiler').SvelteSelf} node
* @param {string} component_name * @param {string} component_name
@ -883,6 +899,10 @@ function serialize_inline_component(node, component_name, context) {
if (bind_this !== null) { if (bind_this !== null) {
const prev = fn; const prev = fn;
const assignment = b.assignment('=', bind_this, b.id('$$value')); const assignment = b.assignment('=', bind_this, b.id('$$value'));
const bind_this_id = /** @type {import('estree').Expression} */ (
// if expression is not an identifier, we know it can't be a signal
bind_this.type === 'Identifier' ? bind_this : undefined
);
fn = (node_id) => fn = (node_id) =>
b.call( b.call(
'$.bind_this', '$.bind_this',
@ -890,7 +910,8 @@ function serialize_inline_component(node, component_name, context) {
b.arrow( b.arrow(
[b.id('$$value')], [b.id('$$value')],
serialize_set_binding(assignment, context, () => context.visit(assignment)) serialize_set_binding(assignment, context, () => context.visit(assignment))
) ),
bind_this_id
); );
} }
@ -966,9 +987,6 @@ function create_block(parent, name, nodes, context) {
/** @type {import('estree').Statement | undefined} */ /** @type {import('estree').Statement | undefined} */
let close = undefined; let close = undefined;
/** @type {import('estree').Identifier | undefined} */
let id = undefined;
/** @type {import('../types').ComponentClientTransformState} */ /** @type {import('../types').ComponentClientTransformState} */
const state = { const state = {
...context.state, ...context.state,
@ -991,7 +1009,7 @@ function create_block(parent, name, nodes, context) {
if (is_single_element) { if (is_single_element) {
const element = /** @type {import('#compiler').RegularElement} */ (trimmed[0]); const element = /** @type {import('#compiler').RegularElement} */ (trimmed[0]);
id = b.id(context.state.scope.generate(element.name)); const id = b.id(context.state.scope.generate(element.name));
context.visit(element, { context.visit(element, {
...state, ...state,
@ -1006,7 +1024,7 @@ function create_block(parent, name, nodes, context) {
body.push( body.push(
b.var( b.var(
id.name, id,
b.call( b.call(
'$.open', '$.open',
b.id('$$anchor'), b.id('$$anchor'),
@ -1020,15 +1038,30 @@ function create_block(parent, name, nodes, context) {
} else if (is_single_child_not_needing_template) { } else if (is_single_child_not_needing_template) {
context.visit(trimmed[0], state); context.visit(trimmed[0], state);
body.push(...state.init); body.push(...state.init);
} else { } else if (trimmed.length > 0) {
id = b.id(context.state.scope.generate('fragment')); const id = b.id(context.state.scope.generate('fragment'));
const node_id = b.id(context.state.scope.generate('node'));
process_children(trimmed, b.call('$.child_frag', id), { process_children(trimmed, node_id, {
...context, ...context,
state state
}); });
if (state.template.length > 0) { const template = state.template[0];
if (state.template.length === 1 && (template === ' ' || template === '<!>')) {
if (template === ' ') {
body.push(b.var(node_id, b.call('$.space', b.id('$$anchor'))), ...state.init);
close = b.stmt(b.call('$.close', b.id('$$anchor'), node_id));
} else {
body.push(
b.var(id, b.call('$.comment', b.id('$$anchor'))),
b.var(node_id, b.call('$.child_frag', id)),
...state.init
);
close = b.stmt(b.call('$.close_frag', b.id('$$anchor'), id));
}
} else {
const callee = namespace === 'svg' ? '$.svg_template' : '$.template'; const callee = namespace === 'svg' ? '$.svg_template' : '$.template';
state.hoisted.push( state.hoisted.push(
@ -1040,7 +1073,7 @@ function create_block(parent, name, nodes, context) {
body.push( body.push(
b.var( b.var(
id.name, id,
b.call( b.call(
'$.open_frag', '$.open_frag',
b.id('$$anchor'), b.id('$$anchor'),
@ -1048,13 +1081,15 @@ function create_block(parent, name, nodes, context) {
template_name template_name
) )
), ),
b.var(node_id, b.call('$.child_frag', id)),
...state.init ...state.init
); );
close = b.stmt(b.call('$.close_frag', b.id('$$anchor'), id)); close = b.stmt(b.call('$.close_frag', b.id('$$anchor'), id));
}
} else { } else {
body.push(...state.init); body.push(...state.init);
} }
}
if (state.update.length > 0 || state.update_effects.length > 0) { if (state.update.length > 0 || state.update_effects.length > 0) {
/** @type {import('estree').Statement | undefined} */ /** @type {import('estree').Statement | undefined} */
@ -1069,26 +1104,7 @@ function create_block(parent, name, nodes, context) {
} }
} }
if (state.update.length > 0) { if (state.update.length > 0) {
let render; const render = serialize_render_stmt(state, body);
if (state.update.length === 1 && state.update[0].singular) {
render = state.update[0].singular;
} else {
render = b.stmt(
b.call(
'$.render_effect',
b.thunk(
b.block(
state.update.map((n) => {
if (n.init) {
body.push(n.init);
}
return n.grouped;
})
)
)
)
);
}
if (!update) { if (!update) {
update = render; update = render;
} }
@ -1098,9 +1114,7 @@ function create_block(parent, name, nodes, context) {
/** @type {import('estree').Statement} */ (update).leadingComments = [ /** @type {import('estree').Statement} */ (update).leadingComments = [
{ {
type: 'Block', type: 'Block',
value: ` Update `, value: ` Update `
// @ts-expect-error
has_trailing_newline: true
} }
]; ];
} }
@ -1118,9 +1132,7 @@ function create_block(parent, name, nodes, context) {
body[0].leadingComments = [ body[0].leadingComments = [
{ {
type: 'Block', type: 'Block',
value: ` Init `, value: ` Init `
// @ts-expect-error
has_trailing_newline: true
} }
]; ];
} }
@ -1128,6 +1140,35 @@ function create_block(parent, name, nodes, context) {
return body; return body;
} }
/**
*
* @param {import('../types.js').ComponentClientTransformState} state
* @param {import('estree').Statement[]} body
*/
function serialize_render_stmt(state, body) {
let render;
if (state.update.length === 1 && state.update[0].singular) {
render = state.update[0].singular;
} else {
render = b.stmt(
b.call(
'$.render_effect',
b.thunk(
b.block(
state.update.map((n) => {
if (n.init) {
body.push(n.init);
}
return n.grouped;
})
)
)
)
);
}
return render;
}
/** /**
* Serializes the event handler function of the `on:` directive * Serializes the event handler function of the `on:` directive
* @param {Pick<import('#compiler').OnDirective, 'name' | 'modifiers' | 'expression'>} node * @param {Pick<import('#compiler').OnDirective, 'name' | 'modifiers' | 'expression'>} node
@ -1345,13 +1386,11 @@ function process_children(nodes, parent, { visit, state }) {
state.template.push(' '); state.template.push(' ');
const name = state.scope.generate('text'); const text_id = get_node_id(expression, state, 'text');
state.init.push(b.var(name, expression));
const singular = b.stmt( const singular = b.stmt(
b.call( b.call(
'$.text_effect', '$.text_effect',
b.id(name), text_id,
b.thunk(/** @type {import('estree').Expression} */ (visit(node.expression))) b.thunk(/** @type {import('estree').Expression} */ (visit(node.expression)))
) )
); );
@ -1364,7 +1403,7 @@ function process_children(nodes, parent, { visit, state }) {
grouped: b.stmt( grouped: b.stmt(
b.call( b.call(
'$.text', '$.text',
b.id(name), text_id,
/** @type {import('estree').Expression} */ (visit(node.expression)) /** @type {import('estree').Expression} */ (visit(node.expression))
) )
) )
@ -1374,7 +1413,7 @@ function process_children(nodes, parent, { visit, state }) {
b.stmt( b.stmt(
b.assignment( b.assignment(
'=', '=',
b.id(`${name}.nodeValue`), b.member(text_id, b.id('nodeValue')),
b.call( b.call(
'$.stringify', '$.stringify',
/** @type {import('estree').Expression} */ (visit(node.expression)) /** @type {import('estree').Expression} */ (visit(node.expression))
@ -1389,17 +1428,16 @@ function process_children(nodes, parent, { visit, state }) {
state.template.push(' '); state.template.push(' ');
const name = state.scope.generate('text'); const text_id = get_node_id(expression, state, 'text');
const contains_call_expression = sequence.some( const contains_call_expression = sequence.some(
(n) => n.type === 'ExpressionTag' && n.metadata.contains_call_expression (n) => n.type === 'ExpressionTag' && n.metadata.contains_call_expression
); );
state.init.push(b.var(name, expression));
const assignment = serialize_template_literal(sequence, visit, state)[1]; const assignment = serialize_template_literal(sequence, visit, state)[1];
const init = b.stmt(b.assignment('=', b.id(`${name}.nodeValue`), assignment)); const init = b.stmt(b.assignment('=', b.member(text_id, b.id('nodeValue')), assignment));
const singular = b.stmt( const singular = b.stmt(
b.call( b.call(
'$.text_effect', '$.text_effect',
b.id(name), text_id,
b.thunk(serialize_template_literal(sequence, visit, state)[1]) b.thunk(serialize_template_literal(sequence, visit, state)[1])
) )
); );
@ -1412,13 +1450,13 @@ function process_children(nodes, parent, { visit, state }) {
) { ) {
state.update.push({ state.update.push({
singular, singular,
grouped: b.stmt(b.call('$.text', b.id(name), assignment)) grouped: b.stmt(b.call('$.text', text_id, assignment))
}); });
} else { } else {
state.init.push(init); state.init.push(init);
} }
expression = b.call('$.sibling', b.id(name)); expression = b.call('$.sibling', text_id);
} }
for (let i = 0; i < nodes.length; i += 1) { for (let i = 0; i < nodes.length; i += 1) {
@ -1442,9 +1480,6 @@ function process_children(nodes, parent, { visit, state }) {
// get hoisted inside clean_nodes? // get hoisted inside clean_nodes?
visit(node, state); visit(node, state);
} else { } else {
const name = state.scope.generate(node.type === 'RegularElement' ? node.name : 'node');
const id = b.id(name);
// Optimization path for each blocks. If the parent isn't a fragment and it only has // Optimization path for each blocks. If the parent isn't a fragment and it only has
// a single child, then we can classify the block as being "controlled". // a single child, then we can classify the block as being "controlled".
if ( if (
@ -1457,7 +1492,12 @@ function process_children(nodes, parent, { visit, state }) {
node.metadata.is_controlled = true; node.metadata.is_controlled = true;
visit(node, state); visit(node, state);
} else { } else {
state.init.push(b.var(name, expression)); const id = get_node_id(
expression,
state,
node.type === 'RegularElement' ? node.name : 'node'
);
expression = b.call('$.sibling', id); expression = b.call('$.sibling', id);
visit(node, { visit(node, {
@ -1474,6 +1514,22 @@ function process_children(nodes, parent, { visit, state }) {
} }
} }
/**
* @param {import('estree').Expression} expression
* @param {import('../types.js').ComponentClientTransformState} state
* @param {string} name
*/
function get_node_id(expression, state, name) {
let id = expression;
if (id.type !== 'Identifier') {
id = b.id(state.scope.generate(name));
state.init.push(b.var(id, expression));
}
return id;
}
/** /**
* @param {true | Array<import('#compiler').Text | import('#compiler').ExpressionTag>} attribute_value * @param {true | Array<import('#compiler').Text | import('#compiler').ExpressionTag>} attribute_value
* @param {import('../types').ComponentContext} context * @param {import('../types').ComponentContext} context
@ -1568,19 +1624,20 @@ export const template_visitors = {
); );
}, },
ConstTag(node, { state, visit }) { ConstTag(node, { state, visit }) {
const declaration = node.declaration.declarations[0];
// TODO we can almost certainly share some code with $derived(...) // TODO we can almost certainly share some code with $derived(...)
if (node.expression.left.type === 'Identifier') { if (declaration.id.type === 'Identifier') {
state.init.push( state.init.push(
b.const( b.const(
node.expression.left, declaration.id,
b.call( b.call(
'$.derived', '$.derived',
b.thunk(/** @type {import('estree').Expression} */ (visit(node.expression.right))) b.thunk(/** @type {import('estree').Expression} */ (visit(declaration.init)))
) )
) )
); );
} else { } else {
const identifiers = extract_identifiers(node.expression.left); const identifiers = extract_identifiers(declaration.id);
const tmp = b.id(state.scope.generate('computed_const')); const tmp = b.id(state.scope.generate('computed_const'));
// Make all identifiers that are declared within the following computed regular // Make all identifiers that are declared within the following computed regular
@ -1596,8 +1653,8 @@ export const template_visitors = {
[], [],
b.block([ b.block([
b.const( b.const(
/** @type {import('estree').Pattern} */ (visit(node.expression.left)), /** @type {import('estree').Pattern} */ (visit(declaration.id)),
/** @type {import('estree').Expression} */ (visit(node.expression.right)) /** @type {import('estree').Expression} */ (visit(declaration.init))
), ),
b.return(b.object(identifiers.map((node) => b.prop('init', node, node)))) b.return(b.object(identifiers.map((node) => b.prop('init', node, node))))
]) ])
@ -1649,18 +1706,20 @@ export const template_visitors = {
if (node.argument) { if (node.argument) {
args.push(b.thunk(/** @type {import('estree').Expression} */ (context.visit(node.argument)))); args.push(b.thunk(/** @type {import('estree').Expression} */ (context.visit(node.argument))));
} }
const snippet_function = /** @type {import('estree').Expression} */ (
let snippet_function = /** @type {import('estree').Expression} */ (
context.visit(node.expression) context.visit(node.expression)
); );
const init = b.call( if (context.state.options.dev) {
context.state.options.dev ? b.call('$.validate_snippet', snippet_function) : snippet_function, snippet_function = b.call('$.validate_snippet', snippet_function);
...args }
);
if (is_reactive) { if (is_reactive) {
context.state.init.push(b.stmt(b.call('$.snippet_effect', b.thunk(init)))); context.state.init.push(
b.stmt(b.call('$.snippet_effect', b.thunk(snippet_function), ...args))
);
} else { } else {
context.state.init.push(b.stmt(init)); context.state.init.push(b.stmt(b.call(snippet_function, ...args)));
} }
}, },
AnimateDirective(node, { state, visit }) { AnimateDirective(node, { state, visit }) {
@ -1825,7 +1884,7 @@ export const template_visitors = {
// Then do attributes // Then do attributes
let is_attributes_reactive = false; let is_attributes_reactive = false;
if (node.metadata.has_spread) { if (node.metadata.has_spread) {
const spread_id = serialize_element_spread_attributes(attributes, context, node_id); const spread_id = serialize_element_spread_attributes(attributes, context, node, node_id);
if (child_metadata.namespace !== 'foreign') { if (child_metadata.namespace !== 'foreign') {
add_select_to_spread_update(spread_id, node, context, node_id); add_select_to_spread_update(spread_id, node, context, node_id);
} }
@ -1846,7 +1905,7 @@ export const template_visitors = {
attribute.name !== 'autofocus' && attribute.name !== 'autofocus' &&
(attribute.value === true || is_text_attribute(attribute)) (attribute.value === true || is_text_attribute(attribute))
) { ) {
const name = get_attribute_name(attribute, context.state); const name = get_attribute_name(node, attribute, context);
const literal_value = /** @type {import('estree').Literal} */ ( const literal_value = /** @type {import('estree').Literal} */ (
serialize_attribute_value(attribute.value, context)[1] serialize_attribute_value(attribute.value, context)[1]
).value; ).value;
@ -1867,7 +1926,7 @@ export const template_visitors = {
const is = const is =
is_custom_element && child_metadata.namespace !== 'foreign' is_custom_element && child_metadata.namespace !== 'foreign'
? serialize_custom_element_attribute_update_assignment(node_id, attribute, context) ? serialize_custom_element_attribute_update_assignment(node_id, attribute, context)
: serialize_element_attribute_update_assignment(node_id, attribute, context); : serialize_element_attribute_update_assignment(node, node_id, attribute, context);
if (is) is_attributes_reactive = true; if (is) is_attributes_reactive = true;
} }
} }
@ -2005,27 +2064,7 @@ export const template_visitors = {
} }
} }
if (inner_context.state.update.length > 0) { if (inner_context.state.update.length > 0) {
let render; inner.push(serialize_render_stmt(inner_context.state, inner));
if (inner_context.state.update.length === 1 && inner_context.state.update[0].singular) {
render = inner_context.state.update[0].singular;
} else {
render = b.stmt(
b.call(
'$.render_effect',
b.thunk(
b.block(
inner_context.state.update.map((n) => {
if (n.init) {
inner.push(n.init);
}
return n.grouped;
})
)
)
)
);
}
inner.push(render);
} }
} }
inner.push(...inner_context.state.after_update); inner.push(...inner_context.state.after_update);
@ -2116,17 +2155,15 @@ export const template_visitors = {
/** /**
* @param {import('estree').Pattern} expression_for_id * @param {import('estree').Pattern} expression_for_id
* @param {import('estree').Expression} expression_for_other
* @returns {import('#compiler').Binding['mutation']} * @returns {import('#compiler').Binding['mutation']}
*/ */
const create_mutation = (expression_for_id, expression_for_other) => { const create_mutation = (expression_for_id) => {
return (assignment, context) => { return (assignment, context) => {
if (assignment.left.type !== 'Identifier' && assignment.left.type !== 'MemberExpression') { if (assignment.left.type !== 'Identifier' && assignment.left.type !== 'MemberExpression') {
// serialize_set_binding turns other patterns into IIFEs and separates the assignments // serialize_set_binding turns other patterns into IIFEs and separates the assignments
// into separate expressions, at which point this is called again with an identifier or member expression // into separate expressions, at which point this is called again with an identifier or member expression
return serialize_set_binding(assignment, context, () => assignment); return serialize_set_binding(assignment, context, () => assignment);
} }
const left = object(assignment.left); const left = object(assignment.left);
const value = get_assignment_value(assignment, context); const value = get_assignment_value(assignment, context);
const invalidate = b.call( const invalidate = b.call(
@ -2139,11 +2176,7 @@ export const template_visitors = {
return context.state.analysis.runes ? assign : b.sequence([assign, invalidate]); return context.state.analysis.runes ? assign : b.sequence([assign, invalidate]);
} else { } else {
const original_left = /** @type {import('estree').MemberExpression} */ (assignment.left); const original_left = /** @type {import('estree').MemberExpression} */ (assignment.left);
const left = b.member( const left = context.visit(original_left);
expression_for_other,
context.visit(original_left).property,
original_left.computed
);
const assign = b.assignment(assignment.operator, left, value); const assign = b.assignment(assignment.operator, left, value);
return context.state.analysis.runes ? assign : b.sequence([assign, invalidate]); return context.state.analysis.runes ? assign : b.sequence([assign, invalidate]);
} }
@ -2169,8 +2202,7 @@ export const template_visitors = {
each_node_meta.array_name ? b.call(each_node_meta.array_name) : collection, each_node_meta.array_name ? b.call(each_node_meta.array_name) : collection,
index, index,
true true
), )
binding.expression
); );
} else { } else {
const unwrapped = binding.expression; const unwrapped = binding.expression;
@ -2198,8 +2230,7 @@ export const template_visitors = {
binding.expression = b.call(name); binding.expression = b.call(name);
binding.mutation = create_mutation( binding.mutation = create_mutation(
/** @type {import('estree').Pattern} */ (path.update_expression(unwrapped)), /** @type {import('estree').Pattern} */ (path.update_expression(unwrapped))
binding.expression
); );
} }
} }
@ -2225,12 +2256,6 @@ export const template_visitors = {
) )
: b.literal(null); : b.literal(null);
if (context.state.options.dev && key_function.type !== 'Literal') {
context.state.init.push(
b.stmt(b.call('$.validate_each_keys', b.thunk(collection), key_function))
);
}
if (node.index && each_node_meta.contains_group_binding) { if (node.index && each_node_meta.contains_group_binding) {
// We needed to create a unique identifier for the index above, but we want to use the // We needed to create a unique identifier for the index above, but we want to use the
// original index name in the template, therefore create another binding // original index name in the template, therefore create another binding
@ -2238,6 +2263,12 @@ export const template_visitors = {
} }
if ((each_type & EACH_KEYED) !== 0) { if ((each_type & EACH_KEYED) !== 0) {
if (context.state.options.dev && key_function.type !== 'Literal') {
context.state.init.push(
b.stmt(b.call('$.validate_each_keys', b.thunk(collection), key_function))
);
}
context.state.after_update.push( context.state.after_update.push(
b.stmt( b.stmt(
b.call( b.call(
@ -2437,7 +2468,7 @@ export const template_visitors = {
next(); next();
}, },
BindDirective(node, context) { BindDirective(node, context) {
const { state, path } = context; const { state, path, visit } = context;
/** @type {import('estree').Expression[]} */ /** @type {import('estree').Expression[]} */
const properties = []; const properties = [];
@ -2568,9 +2599,16 @@ export const template_visitors = {
} }
case 'this': case 'this':
call_expr = b.call(`$.bind_this`, state.node, setter); call_expr = b.call(
`$.bind_this`,
state.node,
setter,
/** @type {import('estree').Expression} */ (
// if expression is not an identifier, we know it can't be a signal
node.expression.type === 'Identifier' ? node.expression : undefined
)
);
break; break;
case 'textContent': case 'textContent':
case 'innerHTML': case 'innerHTML':
case 'innerText': case 'innerText':
@ -2895,5 +2933,6 @@ export const template_visitors = {
...context.state, ...context.state,
node: b.id('$.document') node: b.id('$.document')
}); });
} },
CallExpression: javascript_visitors_runes.CallExpression
}; };

@ -32,15 +32,11 @@ export function transform_component(analysis, source, options) {
program.body[0].leadingComments = [ program.body[0].leadingComments = [
{ {
type: 'Line', type: 'Line',
value: ` ${basename} (Svelte v${VERSION})`, value: ` ${basename} (Svelte v${VERSION})`
// @ts-ignore
has_trailing_newline: true
}, },
{ {
type: 'Line', type: 'Line',
value: ' Note: compiler output will change before 5.0 is released!', value: ' Note: compiler output will change before 5.0 is released!'
// @ts-ignore
has_trailing_newline: true
} }
]; ];
} }
@ -86,9 +82,7 @@ export function transform_module(analysis, source, options) {
program.body[0].leadingComments = [ program.body[0].leadingComments = [
{ {
type: 'Block', type: 'Block',
value: ` ${basename} generated by Svelte v${VERSION} `, value: ` ${basename} generated by Svelte v${VERSION} `
// @ts-ignore
has_trailing_newline: true
} }
]; ];
} }

@ -5,7 +5,6 @@ import * as b from '../../../utils/builders.js';
import is_reference from 'is-reference'; import is_reference from 'is-reference';
import { import {
ContentEditableBindings, ContentEditableBindings,
DOMBooleanAttributes,
VoidElements, VoidElements,
WhitespaceInsensitiveAttributes WhitespaceInsensitiveAttributes
} from '../../constants.js'; } from '../../constants.js';
@ -15,11 +14,12 @@ import {
escape_html, escape_html,
infer_namespace infer_namespace
} from '../utils.js'; } from '../utils.js';
import { create_attribute, is_element_node } from '../../nodes.js'; import { create_attribute, is_custom_element_node, is_element_node } from '../../nodes.js';
import { error } from '../../../errors.js'; import { error } from '../../../errors.js';
import { binding_properties } from '../../bindings.js'; import { binding_properties } from '../../bindings.js';
import { regex_starts_with_newline, regex_whitespaces_strict } from '../../patterns.js'; import { regex_starts_with_newline, regex_whitespaces_strict } from '../../patterns.js';
import { remove_types } from '../typescript.js'; import { remove_types } from '../typescript.js';
import { DOMBooleanAttributes } from '../../../../constants.js';
/** /**
* @param {string} value * @param {string} value
@ -471,6 +471,25 @@ function serialize_set_binding(node, context, fallback) {
return fallback(); return fallback();
} }
/**
* @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} element
* @param {import('#compiler').Attribute} attribute
* @param {{ state: { metadata: { namespace: import('#compiler').Namespace }}}} context
*/
function get_attribute_name(element, attribute, context) {
let name = attribute.name;
if (
element.type === 'RegularElement' &&
!element.metadata.svg &&
context.state.metadata.namespace !== 'foreign'
) {
name = name.toLowerCase();
// don't lookup boolean aliases here, the server runtime function does only
// check for the lowercase variants of boolean attributes
}
return name;
}
/** @type {import('./types').Visitors} */ /** @type {import('./types').Visitors} */
const global_visitors = { const global_visitors = {
Identifier(node, { path, state }) { Identifier(node, { path, state }) {
@ -550,7 +569,7 @@ const javascript_visitors_runes = {
for (const declarator of node.declarations) { for (const declarator of node.declarations) {
const rune = get_rune(declarator.init, state.scope); const rune = get_rune(declarator.init, state.scope);
if (!rune) { if (!rune || rune === '$effect.active') {
declarations.push(/** @type {import('estree').VariableDeclarator} */ (visit(declarator))); declarations.push(/** @type {import('estree').VariableDeclarator} */ (visit(declarator)));
continue; continue;
} }
@ -604,6 +623,15 @@ const javascript_visitors_runes = {
} }
} }
context.next(); context.next();
},
CallExpression(node, { state, next }) {
const rune = get_rune(node, state.scope);
if (rune === '$effect.active') {
return b.literal(false);
}
next();
} }
}; };
@ -681,12 +709,14 @@ function serialize_attribute_value(
/** /**
* *
* @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} element
* @param {Array<import('#compiler').Attribute | import('#compiler').SpreadAttribute>} attributes * @param {Array<import('#compiler').Attribute | import('#compiler').SpreadAttribute>} attributes
* @param {import('#compiler').StyleDirective[]} style_directives * @param {import('#compiler').StyleDirective[]} style_directives
* @param {import('#compiler').ClassDirective[]} class_directives * @param {import('#compiler').ClassDirective[]} class_directives
* @param {import('./types').ComponentContext} context * @param {import('./types').ComponentContext} context
*/ */
function serialize_element_spread_attributes( function serialize_element_spread_attributes(
element,
attributes, attributes,
style_directives, style_directives,
class_directives, class_directives,
@ -697,7 +727,7 @@ function serialize_element_spread_attributes(
for (const attribute of attributes) { for (const attribute of attributes) {
if (attribute.type === 'Attribute') { if (attribute.type === 'Attribute') {
const name = attribute.name.toLowerCase(); const name = get_attribute_name(element, attribute, context);
const value = serialize_attribute_value( const value = serialize_attribute_value(
attribute.value, attribute.value,
context, context,
@ -709,8 +739,18 @@ function serialize_element_spread_attributes(
} }
} }
const lowercase_attributes =
element.type !== 'RegularElement' || element.metadata.svg || is_custom_element_node(element)
? b.false
: b.true;
const is_svg = element.type === 'RegularElement' && element.metadata.svg ? b.true : b.false;
/** @type {import('estree').Expression[]} */ /** @type {import('estree').Expression[]} */
const args = [b.array(values), b.literal(context.state.analysis.stylesheet.id)]; const args = [
b.array(values),
lowercase_attributes,
is_svg,
b.literal(context.state.analysis.stylesheet.id)
];
if (style_directives.length > 0 || class_directives.length > 0) { if (style_directives.length > 0 || class_directives.length > 0) {
const styles = style_directives.map((directive) => const styles = style_directives.map((directive) =>
@ -1040,8 +1080,9 @@ const template_visitors = {
state.template.push(t_expression(id)); state.template.push(t_expression(id));
}, },
ConstTag(node, { state, visit }) { ConstTag(node, { state, visit }) {
const pattern = /** @type {import('estree').Pattern} */ (visit(node.expression.left)); const declaration = node.declaration.declarations[0];
const init = /** @type {import('estree').Expression} */ (visit(node.expression.right)); const pattern = /** @type {import('estree').Pattern} */ (visit(declaration.id));
const init = /** @type {import('estree').Expression} */ (visit(declaration.init));
state.init.push(b.declaration('const', pattern, init)); state.init.push(b.declaration('const', pattern, init));
}, },
DebugTag(node, { state, visit }) { DebugTag(node, { state, visit }) {
@ -1579,7 +1620,9 @@ const template_visitors = {
b.stmt(b.call('$.head', b.id('$$payload'), b.arrow([b.id('$$payload')], b.block(body)))) b.stmt(b.call('$.head', b.id('$$payload'), b.arrow([b.id('$$payload')], b.block(body))))
) )
); );
} },
// @ts-ignore: need to extract this out somehow
CallExpression: javascript_visitors_runes.CallExpression
}; };
/** /**
@ -1749,11 +1792,17 @@ function serialize_element_attributes(node, context) {
context.state.init.push(...lets); context.state.init.push(...lets);
if (has_spread) { if (has_spread) {
serialize_element_spread_attributes(attributes, style_directives, class_directives, context); serialize_element_spread_attributes(
node,
attributes,
style_directives,
class_directives,
context
);
} else { } else {
for (const attribute of /** @type {import('#compiler').Attribute[]} */ (attributes)) { for (const attribute of /** @type {import('#compiler').Attribute[]} */ (attributes)) {
if (attribute.value === true || is_text_attribute(attribute)) { if (attribute.value === true || is_text_attribute(attribute)) {
const name = attribute.name.toLowerCase(); const name = get_attribute_name(node, attribute, context);
const literal_value = /** @type {import('estree').Literal} */ ( const literal_value = /** @type {import('estree').Literal} */ (
serialize_attribute_value( serialize_attribute_value(
attribute.value, attribute.value,
@ -1775,7 +1824,7 @@ function serialize_element_attributes(node, context) {
continue; continue;
} }
const name = attribute.name.toLowerCase(); const name = get_attribute_name(node, attribute, context);
const is_boolean = DOMBooleanAttributes.includes(name); const is_boolean = DOMBooleanAttributes.includes(name);
const value = serialize_attribute_value( const value = serialize_attribute_value(
attribute.value, attribute.value,

@ -1,52 +1,12 @@
export const DOMBooleanAttributes = [ import { AttributeAliases, DOMBooleanAttributes } from '../../constants.js';
'allowfullscreen',
'async',
'autofocus',
'autoplay',
'checked',
'controls',
'default',
'disabled',
'formnovalidate',
'hidden',
'indeterminate',
'ismap',
'loop',
'multiple',
'muted',
'nomodule',
'novalidate',
'open',
'playsinline',
'readonly',
'required',
'reversed',
'seamless',
'selected'
];
export const DOMProperties = [ export const DOMProperties = [
'className', ...Object.values(AttributeAliases),
'value', 'value',
'readOnly',
'formNoValidate',
'isMap',
'noModule',
'playsInline',
'inert', 'inert',
...DOMBooleanAttributes ...DOMBooleanAttributes
]; ];
/** @type {Record<string, string>} */
export const AttributeAliases = {
class: 'className',
formnovalidate: 'formNoValidate',
ismap: 'isMap',
nomodule: 'noModule',
playsinline: 'playsInline',
readonly: 'readOnly'
};
export const VoidElements = [ export const VoidElements = [
'area', 'area',
'base', 'base',
@ -110,7 +70,7 @@ export const ElementBindings = [
'indeterminate' 'indeterminate'
]; ];
export const Runes = ['$state', '$props', '$derived', '$effect']; export const Runes = ['$state', '$props', '$derived', '$effect', '$effect.pre', '$effect.active'];
/** /**
* Whitespace inside one of these elements will not result in * Whitespace inside one of these elements will not result in

@ -3,11 +3,7 @@ import { walk } from 'zimmerframe';
import { is_element_node } from './nodes.js'; import { is_element_node } from './nodes.js';
import * as b from '../utils/builders.js'; import * as b from '../utils/builders.js';
import { error } from '../errors.js'; import { error } from '../errors.js';
import { import { extract_identifiers, extract_identifiers_from_expression } from '../utils/ast.js';
extract_identifiers,
extract_identifiers_from_expression,
get_callee_name
} from '../utils/ast.js';
import { Runes } from './constants.js'; import { Runes } from './constants.js';
export class Scope { export class Scope {
@ -179,11 +175,15 @@ export class Scope {
references.push({ node, path }); references.push({ node, path });
const declaration = this.declarations.get(node.name); const binding = this.declarations.get(node.name);
if (declaration) { if (binding) {
declaration.references.push({ node, path }); binding.references.push({ node, path });
} else if (this.#parent) { } else if (this.#parent) {
this.#parent.reference(node, path); this.#parent.reference(node, path);
} else {
// no binding was found, and this is the top level scope,
// which means this is a global
this.root.conflicts.add(node.name);
} }
} }
} }
@ -433,11 +433,12 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
SwitchStatement: create_block_scope, SwitchStatement: create_block_scope,
ClassDeclaration(node, { state, next }) { ClassDeclaration(node, { state, next }) {
if (node.id) state.scope.declare(node.id, 'normal', 'const'); if (node.id) state.scope.declare(node.id, 'normal', 'const', node);
next(); next();
}, },
VariableDeclaration(node, { state, next }) { VariableDeclaration(node, { state, path, next }) {
const is_parent_const_tag = path.at(-1)?.type === 'ConstTag';
for (const declarator of node.declarations) { for (const declarator of node.declarations) {
/** @type {import('#compiler').Binding[]} */ /** @type {import('#compiler').Binding[]} */
const bindings = []; const bindings = [];
@ -445,7 +446,12 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
state.scope.declarators.set(declarator, bindings); state.scope.declarators.set(declarator, bindings);
for (const id of extract_identifiers(declarator.id)) { for (const id of extract_identifiers(declarator.id)) {
const binding = state.scope.declare(id, 'normal', node.kind, declarator.init); const binding = state.scope.declare(
id,
is_parent_const_tag ? 'derived' : 'normal',
node.kind,
declarator.init
);
bindings.push(binding); bindings.push(binding);
} }
} }
@ -495,12 +501,10 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
} }
if (node.index) { if (node.index) {
scope.declare( const is_keyed =
b.id(node.index), node.key &&
// TODO see logic in EachBlock in dom.ts (node.key.type !== 'Identifier' || !node.index || node.key.name !== node.index);
node.key ? 'derived' : 'normal', scope.declare(b.id(node.index), is_keyed ? 'derived' : 'normal', 'const');
'const'
);
} }
if (node.key) visit(node.key, { scope }); if (node.key) visit(node.key, { scope });
@ -557,6 +561,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
}, },
SnippetBlock(node, context) { SnippetBlock(node, context) {
const state = context.state;
// Special-case for root-level snippets: they become part of the instance scope // Special-case for root-level snippets: they become part of the instance scope
const is_top_level = !context.path.at(-2); const is_top_level = !context.path.at(-2);
let scope = state.scope; let scope = state.scope;
@ -594,7 +599,8 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) {
}, },
ConstTag(node, { state, next }) { ConstTag(node, { state, next }) {
for (const identifier of extract_identifiers(node.expression.left)) { const declaration = node.declaration.declarations[0];
for (const identifier of extract_identifiers(declaration.id)) {
state.scope.declare( state.scope.declare(
/** @type {import('estree').Identifier} */ (identifier), /** @type {import('estree').Identifier} */ (identifier),
'derived', 'derived',
@ -664,15 +670,31 @@ export function set_scope(scopes) {
/** /**
* Returns the name of the rune if the given expression is a `CallExpression` using a rune. * Returns the name of the rune if the given expression is a `CallExpression` using a rune.
* @param {import('estree').Expression | null | undefined} node * @param {import('estree').Node | null | undefined} node
* @param {Scope} scope * @param {Scope} scope
*/ */
export function get_rune(node, scope) { export function get_rune(node, scope) {
const callee = get_callee_name(node); if (!node) return null;
if (callee === null || !Runes.includes(callee)) return null; if (node.type !== 'CallExpression') return null;
let n = node.callee;
let joined = '';
while (n.type === 'MemberExpression') {
if (n.computed) return null;
if (n.property.type !== 'Identifier') return null;
joined = '.' + n.property.name + joined;
n = n.object;
}
if (n.type !== 'Identifier') return null;
joined = n.name + joined;
if (!Runes.includes(joined)) return null;
const binding = scope.get(callee); const binding = scope.get(n.name);
if (binding !== null) return null; // rune name, but references a variable or store if (binding !== null) return null; // rune name, but references a variable or store
return callee; return joined;
} }

@ -1,6 +1,7 @@
import type { StyleDirective as LegacyStyleDirective, Text } from '#compiler'; import type { StyleDirective as LegacyStyleDirective, Text } from '#compiler';
import type { import type {
ArrayExpression, ArrayExpression,
AssignmentExpression,
Expression, Expression,
Identifier, Identifier,
MemberExpression, MemberExpression,
@ -168,6 +169,11 @@ export interface LegacyTitle extends BaseElement {
name: 'title'; name: 'title';
} }
export interface LegacyConstTag extends BaseNode {
type: 'ConstTag';
expression: AssignmentExpression;
}
export interface LegacyTransition extends BaseNode { export interface LegacyTransition extends BaseNode {
type: 'Transition'; type: 'Transition';
/** The 'x' in `transition:x` */ /** The 'x' in `transition:x` */
@ -215,6 +221,7 @@ export type LegacyElementLike =
| LegacyWindow; | LegacyWindow;
export type LegacySvelteNode = export type LegacySvelteNode =
| LegacyConstTag
| LegacyElementLike | LegacyElementLike
| LegacyAttributeLike | LegacyAttributeLike
| LegacyAttributeShorthand | LegacyAttributeShorthand

@ -2,7 +2,8 @@ import type { Binding } from '#compiler';
import type { import type {
ArrayExpression, ArrayExpression,
ArrowFunctionExpression, ArrowFunctionExpression,
AssignmentExpression, VariableDeclaration,
VariableDeclarator,
Expression, Expression,
FunctionDeclaration, FunctionDeclaration,
FunctionExpression, FunctionExpression,
@ -130,7 +131,9 @@ export interface Comment extends BaseNode {
/** A `{@const ...}` tag */ /** A `{@const ...}` tag */
export interface ConstTag extends BaseNode { export interface ConstTag extends BaseNode {
type: 'ConstTag'; type: 'ConstTag';
expression: AssignmentExpression; declaration: VariableDeclaration & {
declarations: [VariableDeclarator & { id: Identifier; init: Expression }];
};
} }
/** A `{@debug ...}` tag */ /** A `{@debug ...}` tag */

@ -20,19 +20,6 @@ export function object(expression) {
return expression; return expression;
} }
/**
* Returns the name of callee if the given expression is a call expression.
* @param {import('estree').Expression | null | undefined} node
*/
export function get_callee_name(node) {
if (!node) return null;
if (node.type !== 'CallExpression') return null;
if (node.callee.type !== 'Identifier' && node.callee.type !== 'MemberExpression') return null;
const id = object(node.callee);
return id === null ? null : id.name;
}
/** /**
* Returns true if the attribute contains a single static text node. * Returns true if the attribute contains a single static text node.
* @param {import('#compiler').Attribute} attribute * @param {import('#compiler').Attribute} attribute

@ -22,8 +22,11 @@ const runes = {
`It looks like you're using the $${name} rune, but there is a local binding called ${name}. ` + `It looks like you're using the $${name} rune, but there is a local binding called ${name}. ` +
`Referencing a local variable with a $ prefix will create a store subscription. Please rename ${name} to avoid the ambiguity.`, `Referencing a local variable with a $ prefix will create a store subscription. Please rename ${name} to avoid the ambiguity.`,
/** @param {string} name */ /** @param {string} name */
'state-rune-not-mutated': (name) => 'state-not-mutated': (name) =>
`${name} is declared with $state(...) but is never updated. Did you mean to create a function that changes its value?` `${name} is declared with $state(...) but is never updated. Did you mean to create a function that changes its value?`,
/** @param {string} name */
'non-state-reference': (name) =>
`${name} is updated, but is not declared with $state(...). Changing its value will not correctly trigger updates.`
}; };
/** @satisfies {Warnings} */ /** @satisfies {Warnings} */
@ -115,7 +118,7 @@ const a11y = {
'a11y-misplaced-scope': () => 'A11y: The scope attribute should only be used with <th> elements', 'a11y-misplaced-scope': () => 'A11y: The scope attribute should only be used with <th> elements',
'a11y-positive-tabindex': () => 'A11y: avoid tabindex values above zero', 'a11y-positive-tabindex': () => 'A11y: avoid tabindex values above zero',
'a11y-click-events-have-key-events': () => 'a11y-click-events-have-key-events': () =>
'A11y: visible, non-interactive elements with an on:click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as <button type="button"> or <a> might be more appropriate. See https://svelte.dev/docs/accessibility-warnings#a11y-click-events-have-key-events for more details.', 'A11y: visible, non-interactive elements with a click event must be accompanied by a keyboard event handler. Consider whether an interactive element such as <button type="button"> or <a> might be more appropriate. See https://svelte.dev/docs/accessibility-warnings#a11y-click-events-have-key-events for more details.',
'a11y-no-noninteractive-tabindex': () => 'a11y-no-noninteractive-tabindex': () =>
'A11y: noninteractive element cannot have nonnegative tabIndex value', 'A11y: noninteractive element cannot have nonnegative tabIndex value',
/** /**
@ -180,7 +183,7 @@ const a11y = {
* @param {string} accompanied_by * @param {string} accompanied_by
*/ */
'a11y-mouse-events-have-key-events': (event, accompanied_by) => 'a11y-mouse-events-have-key-events': (event, accompanied_by) =>
`A11y: on:${event} must be accompanied by on:${accompanied_by}`, `A11y: '${event}' event must be accompanied by '${accompanied_by}' event`,
/** @param {string} name */ /** @param {string} name */
'a11y-missing-content': (name) => `A11y: <${name}> element should have child content` 'a11y-missing-content': (name) => `A11y: <${name}> element should have child content`
}; };
@ -191,12 +194,20 @@ const state = {
`State referenced in its own scope will never update. Did you mean to reference it inside a closure?` `State referenced in its own scope will never update. Did you mean to reference it inside a closure?`
}; };
/** @satisfies {Warnings} */
const performance = {
'avoid-inline-class': () =>
`Avoid 'new class' — instead, declare the class at the top level scope`,
'avoid-nested-class': () => `Avoid declaring classes below the top level scope`
};
/** @satisfies {Warnings} */ /** @satisfies {Warnings} */
const warnings = { const warnings = {
...css, ...css,
...attributes, ...attributes,
...runes, ...runes,
...a11y, ...a11y,
...performance,
...state ...state
}; };

@ -32,3 +32,48 @@ export const DelegatedEvents = [
/** List of Element events that will be delegated and are passive */ /** List of Element events that will be delegated and are passive */
export const PassiveDelegatedEvents = ['touchstart', 'touchmove', 'touchend']; export const PassiveDelegatedEvents = ['touchstart', 'touchmove', 'touchend'];
/**
* @type {Record<string, string>}
* List of attribute names that should be aliased to their property names
* because they behave differently between setting them as an attribute and
* setting them as a property.
*/
export const AttributeAliases = {
// no `class: 'className'` because we handle that separately
formnovalidate: 'formNoValidate',
ismap: 'isMap',
nomodule: 'noModule',
playsinline: 'playsInline',
readonly: 'readOnly'
};
/**
* Attributes that are boolean, i.e. they are present or not present.
*/
export const DOMBooleanAttributes = [
'allowfullscreen',
'async',
'autofocus',
'autoplay',
'checked',
'controls',
'default',
'disabled',
'formnovalidate',
'hidden',
'indeterminate',
'ismap',
'loop',
'multiple',
'muted',
'nomodule',
'novalidate',
'open',
'playsinline',
'readonly',
'required',
'reversed',
'seamless',
'selected'
];

@ -27,7 +27,8 @@ import {
EACH_INDEX_REACTIVE, EACH_INDEX_REACTIVE,
EACH_ITEM_REACTIVE, EACH_ITEM_REACTIVE,
PassiveDelegatedEvents, PassiveDelegatedEvents,
DelegatedEvents DelegatedEvents,
AttributeAliases
} from '../../constants.js'; } from '../../constants.js';
import { import {
create_fragment_from_html, create_fragment_from_html,
@ -60,7 +61,8 @@ import {
push, push,
current_component_context, current_component_context,
pop, pop,
schedule_task schedule_task,
managed_render_effect
} from './runtime.js'; } from './runtime.js';
import { import {
current_hydration_fragment, current_hydration_fragment,
@ -133,7 +135,7 @@ export function svg_replace(node) {
* @param {boolean} is_fragment * @param {boolean} is_fragment
* @param {boolean} use_clone_node * @param {boolean} use_clone_node
* @param {null | Text | Comment | Element} anchor * @param {null | Text | Comment | Element} anchor
* @param {() => Element} [template_element_fn] * @param {() => Node} [template_element_fn]
* @returns {Element | DocumentFragment | Node[]} * @returns {Element | DocumentFragment | Node[]}
*/ */
function open_template(is_fragment, use_clone_node, anchor, template_element_fn) { function open_template(is_fragment, use_clone_node, anchor, template_element_fn) {
@ -156,7 +158,7 @@ function open_template(is_fragment, use_clone_node, anchor, template_element_fn)
/** /**
* @param {null | Text | Comment | Element} anchor * @param {null | Text | Comment | Element} anchor
* @param {boolean} use_clone_node * @param {boolean} use_clone_node
* @param {() => Element} [template_element_fn] * @param {() => Node} [template_element_fn]
* @returns {Element | DocumentFragment | Node[]} * @returns {Element | DocumentFragment | Node[]}
*/ */
/*#__NO_SIDE_EFFECTS__*/ /*#__NO_SIDE_EFFECTS__*/
@ -167,7 +169,7 @@ export function open(anchor, use_clone_node, template_element_fn) {
/** /**
* @param {null | Text | Comment | Element} anchor * @param {null | Text | Comment | Element} anchor
* @param {boolean} use_clone_node * @param {boolean} use_clone_node
* @param {() => Element} [template_element_fn] * @param {() => Node} [template_element_fn]
* @returns {Element | DocumentFragment | Node[]} * @returns {Element | DocumentFragment | Node[]}
*/ */
/*#__NO_SIDE_EFFECTS__*/ /*#__NO_SIDE_EFFECTS__*/
@ -175,6 +177,25 @@ export function open_frag(anchor, use_clone_node, template_element_fn) {
return open_template(true, use_clone_node, anchor, template_element_fn); return open_template(true, use_clone_node, anchor, template_element_fn);
} }
const space_template = template(' ', false);
const comment_template = template('<!>', true);
/**
* @param {null | Text | Comment | Element} anchor
*/
/*#__NO_SIDE_EFFECTS__*/
export function space(anchor) {
return open(anchor, true, space_template);
}
/**
* @param {null | Text | Comment | Element} anchor
*/
/*#__NO_SIDE_EFFECTS__*/
export function comment(anchor) {
return open_frag(anchor, true, comment_template);
}
/** /**
* @param {Element | Text} dom * @param {Element | Text} dom
* @param {boolean} is_fragment * @param {boolean} is_fragment
@ -444,20 +465,22 @@ export function class_toggle(dom, class_name, value) {
* @template V * @template V
* @param {HTMLSelectElement} select * @param {HTMLSelectElement} select
* @param {V} value * @param {V} value
* @param {boolean} [mounting]
*/ */
export function select_option(select, value) { export function select_option(select, value, mounting) {
if (select.multiple) { if (select.multiple) {
return select_options(select, value); return select_options(select, value);
} }
for (let i = 0; i < select.options.length; i += 1) { for (const option of select.options) {
const option = select.options[i];
const option_value = get_option_value(option); const option_value = get_option_value(option);
if (option_value === value) { if (option_value === value) {
option.selected = true; option.selected = true;
return; return;
} }
} }
select.value = ''; if (!mounting || value !== undefined) {
select.selectedIndex = -1; // no option should be selected
}
} }
/** /**
@ -466,8 +489,7 @@ export function select_option(select, value) {
* @param {V} value * @param {V} value
*/ */
function select_options(select, value) { function select_options(select, value) {
for (let i = 0; i < select.options.length; i += 1) { for (const option of select.options) {
const option = select.options[i];
// @ts-ignore // @ts-ignore
option.selected = ~value.indexOf(get_option_value(option)); option.selected = ~value.indexOf(get_option_value(option));
} }
@ -897,20 +919,10 @@ export function selected(dom) {
} }
select = select.parentNode; select = select.parentNode;
} }
if (select != null) {
// @ts-ignore
const select_value = select.__value;
// @ts-ignore // @ts-ignore
const option_value = dom.__value; if (select != null && dom.__value === select.__value) {
const selected = select_value === option_value; // never set to false, since this causes browser to select default option
dom.selected = selected; dom.selected = true;
dom.value = option_value;
// Handle the edge case of new options being added to a select when its state is "nothing selected"
// and keeping the selection state in sync (the DOM auto-selects the first option on insert)
// @ts-ignore
if (select.__value === null) {
/** @type {HTMLSelectElement} */ (select).value = '';
}
} }
}); });
} }
@ -949,7 +961,7 @@ export function bind_value(dom, get_value, update) {
* @returns {void} * @returns {void}
*/ */
export function bind_select_value(dom, get_value, update) { export function bind_select_value(dom, get_value, update) {
let mounted = false; let mounting = true;
dom.addEventListener('change', () => { dom.addEventListener('change', () => {
/** @type {unknown} */ /** @type {unknown} */
let value; let value;
@ -964,40 +976,19 @@ export function bind_select_value(dom, get_value, update) {
}); });
// Needs to be an effect, not a render_effect, so that in case of each loops the logic runs after the each block has updated // Needs to be an effect, not a render_effect, so that in case of each loops the logic runs after the each block has updated
effect(() => { effect(() => {
const value = get_value(); let value = get_value();
if (value == null && !mounted) { select_option(dom, value, mounting);
if (mounting && value === undefined) {
/** @type {HTMLOptionElement | null} */ /** @type {HTMLOptionElement | null} */
let selected_option = value === undefined ? dom.querySelector(':checked') : null; let selected_option = dom.querySelector(':checked');
if (selected_option === null) { if (selected_option !== null) {
dom.value = ''; value = get_option_value(selected_option);
// @ts-ignore update(value);
dom.__value = null;
}
const options = dom.querySelectorAll('option');
for (const option of options) {
if (get_option_value(option) === value || option.hasAttribute('selected')) {
if (option.disabled) {
option.value = '';
}
option.selected = true;
selected_option = option;
break;
}
}
if (selected_option != null) {
const non_null_value = get_option_value(selected_option);
update(non_null_value);
if (selected_option.hasAttribute('selected')) {
selected_option.removeAttribute('selected');
selected_option.selected = true;
} }
} }
} else {
select_option(dom, value);
// @ts-ignore // @ts-ignore
dom.__value = value; dom.__value = value;
} mounting = false;
mounted = true;
}); });
} }
@ -1235,14 +1226,21 @@ export function bind_prop(props, prop, value) {
/** /**
* @param {Element} element_or_component * @param {Element} element_or_component
* @param {(value: unknown) => void} update * @param {(value: unknown) => void} update
* @param {import('./types.js').MaybeSignal} binding
* @returns {void} * @returns {void}
*/ */
export function bind_this(element_or_component, update) { export function bind_this(element_or_component, update, binding) {
untrack(() => { untrack(() => {
update(element_or_component); update(element_or_component);
render_effect(() => () => { render_effect(() => () => {
// Defer to the next tick so that all updates can be reconciled first.
// This solves the case where one variable is shared across multiple this-bindings.
render_effect(() => {
untrack(() => { untrack(() => {
if (!is_signal(binding) || binding.v === element_or_component) {
update(null); update(null);
}
});
}); });
}); });
}); });
@ -2534,6 +2532,7 @@ export function attr(dom, attribute, value) {
// (we can't just compare the strings as they can be different between client and server but result in the // (we can't just compare the strings as they can be different between client and server but result in the
// same url, so we would need to create hidden anchor elements to compare them) // same url, so we would need to create hidden anchor elements to compare them)
attribute !== 'src' && attribute !== 'src' &&
attribute !== 'href' &&
attribute !== 'srcset') attribute !== 'srcset')
) { ) {
if (value === null) { if (value === null) {
@ -2552,7 +2551,7 @@ let src_url_equal_anchor;
* @param {string} url * @param {string} url
* @returns {boolean} * @returns {boolean}
*/ */
export function src_url_equal(element_src, url) { function src_url_equal(element_src, url) {
if (element_src === url) return true; if (element_src === url) return true;
if (!src_url_equal_anchor) { if (!src_url_equal_anchor) {
src_url_equal_anchor = document.createElement('a'); src_url_equal_anchor = document.createElement('a');
@ -2568,13 +2567,13 @@ function split_srcset(srcset) {
} }
/** /**
* @param {HTMLSourceElement | HTMLImageElement} element_srcset * @param {HTMLSourceElement | HTMLImageElement} element
* @param {string | undefined | null} srcset * @param {string | undefined | null} srcset
* @returns {boolean} * @returns {boolean}
*/ */
export function srcset_url_equal(element_srcset, srcset) { export function srcset_url_equal(element, srcset) {
const element_urls = split_srcset(element_srcset.srcset); const element_urls = split_srcset(element.srcset);
const urls = split_srcset(srcset || ''); const urls = split_srcset(srcset ?? '');
return ( return (
urls.length === element_urls.length && urls.length === element_urls.length &&
@ -2597,22 +2596,20 @@ export function srcset_url_equal(element_srcset, srcset) {
* @param {string | null} value * @param {string | null} value
*/ */
function check_src_in_dev_hydration(dom, attribute, value) { function check_src_in_dev_hydration(dom, attribute, value) {
if (current_hydration_fragment !== null && (attribute === 'src' || attribute === 'srcset')) { if (!current_hydration_fragment) return;
if ( if (attribute !== 'src' && attribute !== 'href' && attribute !== 'srcset') return;
(attribute === 'src' && !src_url_equal(dom.getAttribute('src') || '', value || '')) ||
(attribute === 'srcset' && if (attribute === 'srcset' && srcset_url_equal(dom, value)) return;
!srcset_url_equal(/** @type {HTMLImageElement | HTMLSourceElement} */ (dom), value || '')) if (src_url_equal(dom.getAttribute(attribute) ?? '', value ?? '')) return;
) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error( console.error(
'Detected a src/srcset attribute value change during hydration. This will not be repaired during hydration, ' + `Detected a ${attribute} attribute value change during hydration. This will not be repaired during hydration, ` +
'the src/srcset value that came from the server will be used. Related element:', `the ${attribute} value that came from the server will be used. Related element:`,
dom, dom,
' Differing value:', ' Differing value:',
value value
); );
}
}
} }
/** /**
@ -2705,10 +2702,11 @@ function get_setters(element) {
* @param {Element & ElementCSSInlineStyle} dom * @param {Element & ElementCSSInlineStyle} dom
* @param {Record<string, unknown> | null} prev * @param {Record<string, unknown> | null} prev
* @param {Record<string, unknown>[]} attrs * @param {Record<string, unknown>[]} attrs
* @param {boolean} lowercase_attributes
* @param {string} css_hash * @param {string} css_hash
* @returns {Record<string, unknown>} * @returns {Record<string, unknown>}
*/ */
export function spread_attributes(dom, prev, attrs, css_hash) { export function spread_attributes(dom, prev, attrs, lowercase_attributes, css_hash) {
const next = Object.assign({}, ...attrs); const next = Object.assign({}, ...attrs);
const has_hash = css_hash.length !== 0; const has_hash = css_hash.length !== 0;
for (const key in prev) { for (const key in prev) {
@ -2727,13 +2725,13 @@ export function spread_attributes(dom, prev, attrs, css_hash) {
let value = next[key]; let value = next[key];
if (value === prev?.[key]) continue; if (value === prev?.[key]) continue;
const prefix = key.slice(0, 2); const prefix = key[0] + key[1]; // this is faster than key.slice(0, 2)
if (prefix === '$$') continue; if (prefix === '$$') continue;
if (prefix === 'on') { if (prefix === 'on') {
/** @type {{ capture?: true }} */ /** @type {{ capture?: true }} */
const opts = {}; const opts = {};
let event_name = key.slice(2).toLowerCase(); let event_name = key.slice(2);
const delegated = DelegatedEvents.includes(event_name); const delegated = DelegatedEvents.includes(event_name);
if ( if (
@ -2765,25 +2763,33 @@ export function spread_attributes(dom, prev, attrs, css_hash) {
} else if (key === '__value' || key === 'value') { } else if (key === '__value' || key === 'value') {
// @ts-ignore // @ts-ignore
dom.value = dom[key] = dom.__value = value; dom.value = dom[key] = dom.__value = value;
} else if (setters.includes(key)) { } else {
let name = key;
if (lowercase_attributes) {
name = name.toLowerCase();
name = AttributeAliases[name] || name;
}
if (setters.includes(name)) {
if (DEV) { if (DEV) {
check_src_in_dev_hydration(dom, key, value); check_src_in_dev_hydration(dom, name, value);
} }
if ( if (
current_hydration_fragment === null || current_hydration_fragment === null ||
// @ts-ignore see attr method for an explanation of src/srcset // @ts-ignore see attr method for an explanation of src/srcset
(dom[key] !== value && key !== 'src' && key !== 'srcset') (dom[name] !== value && name !== 'src' && name !== 'href' && name !== 'srcset')
) { ) {
// @ts-ignore // @ts-ignore
dom[key] = value; dom[name] = value;
} }
} else if (typeof value !== 'function') { } else if (typeof value !== 'function') {
if (has_hash && key === 'class') { if (has_hash && name === 'class') {
if (value) value += ' '; if (value) value += ' ';
value += css_hash; value += css_hash;
} }
attr(dom, key, value); attr(dom, name, value);
}
} }
} }
return next; return next;
@ -2814,6 +2820,7 @@ export function spread_dynamic_element_attributes(node, prev, attrs, css_hash) {
/** @type {Element & ElementCSSInlineStyle} */ (node), /** @type {Element & ElementCSSInlineStyle} */ (node),
prev, prev,
attrs, attrs,
node.namespaceURI !== 'http://www.w3.org/2000/svg',
css_hash css_hash
); );
} }
@ -2907,7 +2914,7 @@ export function unwrap(value) {
* @template {Record<string, any>} Props * @template {Record<string, any>} Props
* @template {Record<string, any> | undefined} Exports * @template {Record<string, any> | undefined} Exports
* @template {Record<string, any>} Events * @template {Record<string, any>} Events
* @param {import('../../main/public.js').SvelteComponent<Props, Events>} component * @param {typeof import('../../main/public.js').SvelteComponent<Props, Events>} component
* @param {{ * @param {{
* target: Node; * target: Node;
* props?: Props; * props?: Props;
@ -3026,7 +3033,7 @@ export function createRoot(component, options) {
* @template {Record<string, any>} Props * @template {Record<string, any>} Props
* @template {Record<string, any> | undefined} Exports * @template {Record<string, any> | undefined} Exports
* @template {Record<string, any>} Events * @template {Record<string, any>} Events
* @param {import('../../main/public.js').SvelteComponent<Props, Events>} component * @param {typeof import('../../main/public.js').SvelteComponent<Props, Events>} component
* @param {{ * @param {{
* target: Node; * target: Node;
* props?: Props; * props?: Props;
@ -3162,13 +3169,18 @@ export function sanitize_slots(props) {
} }
/** /**
* @param {() => void} create_snippet * @param {() => Function} get_snippet
* @param {Node} node
* @param {() => any} args
* @returns {void} * @returns {void}
*/ */
export function snippet_effect(create_snippet) { export function snippet_effect(get_snippet, node, args) {
const block = create_snippet_block(); const block = create_snippet_block();
render_effect(() => { render_effect(() => {
create_snippet(); // Only rerender when the snippet function itself changes,
// not when an eagerly-read prop inside the snippet function changes
const snippet = get_snippet();
untrack(() => snippet(node, args));
return () => { return () => {
if (block.d !== null) { if (block.d !== null) {
remove(block.d); remove(block.d);

@ -1143,6 +1143,13 @@ function internal_create_effect(type, init, sync, block, schedule) {
return signal; return signal;
} }
/**
* @returns {boolean}
*/
export function effect_active() {
return current_effect ? (current_effect.f & MANAGED) === 0 : false;
}
/** /**
* @param {() => void | (() => void)} init * @param {() => void | (() => void)} init
* @returns {import('./types.js').EffectSignal} * @returns {import('./types.js').EffectSignal}
@ -1523,7 +1530,7 @@ export function bubble_event($$props, event) {
const events = /** @type {Record<string, Function[] | Function>} */ (unwrap($$props).$$events)?.[ const events = /** @type {Record<string, Function[] | Function>} */ (unwrap($$props).$$events)?.[
event.type event.type
]; ];
const callbacks = is_array(events) ? events.slice() : [events]; const callbacks = is_array(events) ? events.slice() : events == null ? [] : [events];
let fn; let fn;
for (fn of callbacks) { for (fn of callbacks) {
// Preserve "this" context // Preserve "this" context

@ -1,3 +1,4 @@
import { untrack } from './runtime.js';
import { is_array } from './utils.js'; import { is_array } from './utils.js';
/** regex of all html void element names */ /** regex of all html void element names */
@ -69,7 +70,7 @@ export function validate_dynamic_element_tag(tag_fn) {
*/ */
export function validate_each_keys(collection, key_fn) { export function validate_each_keys(collection, key_fn) {
const keys = new Map(); const keys = new Map();
const maybe_array = collection(); const maybe_array = untrack(() => collection());
const array = is_array(maybe_array) const array = is_array(maybe_array)
? maybe_array ? maybe_array
: maybe_array == null : maybe_array == null

@ -35,7 +35,8 @@ export {
onDestroy, onDestroy,
pop, pop,
push, push,
reactive_import reactive_import,
effect_active
} from './client/runtime.js'; } from './client/runtime.js';
export * from './client/validate.js'; export * from './client/validate.js';

@ -2,6 +2,7 @@ import * as $ from '../client/runtime.js';
import { set_is_ssr } from '../client/runtime.js'; import { set_is_ssr } from '../client/runtime.js';
import { is_promise } from '../common.js'; import { is_promise } from '../common.js';
import { subscribe_to_store } from '../../store/utils.js'; import { subscribe_to_store } from '../../store/utils.js';
import { DOMBooleanAttributes } from '../../constants.js';
export * from '../client/validate.js'; export * from '../client/validate.js';
@ -31,34 +32,6 @@ const CONTENT_REGEX = /[&<]/g;
const INVALID_ATTR_NAME_CHAR_REGEX = const INVALID_ATTR_NAME_CHAR_REGEX =
/[\s'">/=\u{FDD0}-\u{FDEF}\u{FFFE}\u{FFFF}\u{1FFFE}\u{1FFFF}\u{2FFFE}\u{2FFFF}\u{3FFFE}\u{3FFFF}\u{4FFFE}\u{4FFFF}\u{5FFFE}\u{5FFFF}\u{6FFFE}\u{6FFFF}\u{7FFFE}\u{7FFFF}\u{8FFFE}\u{8FFFF}\u{9FFFE}\u{9FFFF}\u{AFFFE}\u{AFFFF}\u{BFFFE}\u{BFFFF}\u{CFFFE}\u{CFFFF}\u{DFFFE}\u{DFFFF}\u{EFFFE}\u{EFFFF}\u{FFFFE}\u{FFFFF}\u{10FFFE}\u{10FFFF}]/u; /[\s'">/=\u{FDD0}-\u{FDEF}\u{FFFE}\u{FFFF}\u{1FFFE}\u{1FFFF}\u{2FFFE}\u{2FFFF}\u{3FFFE}\u{3FFFF}\u{4FFFE}\u{4FFFF}\u{5FFFE}\u{5FFFF}\u{6FFFE}\u{6FFFF}\u{7FFFE}\u{7FFFF}\u{8FFFE}\u{8FFFF}\u{9FFFE}\u{9FFFF}\u{AFFFE}\u{AFFFF}\u{BFFFE}\u{BFFFF}\u{CFFFE}\u{CFFFF}\u{DFFFE}\u{DFFFF}\u{EFFFE}\u{EFFFF}\u{FFFFE}\u{FFFFF}\u{10FFFE}\u{10FFFF}]/u;
// This is duplicated from the compiler, but we need it at runtime too.
export const DOMBooleanAttributes = [
'allowfullscreen',
'async',
'autofocus',
'autoplay',
'checked',
'controls',
'default',
'disabled',
'formnovalidate',
'hidden',
'indeterminate',
'ismap',
'loop',
'multiple',
'muted',
'nomodule',
'novalidate',
'open',
'playsinline',
'readonly',
'required',
'reversed',
'seamless',
'selected'
];
export const VoidElements = new Set([ export const VoidElements = new Set([
'area', 'area',
'base', 'base',
@ -225,11 +198,13 @@ export function css_props(payload, is_html, props, component) {
/** /**
* @param {Record<string, unknown>[]} attrs * @param {Record<string, unknown>[]} attrs
* @param {boolean} lowercase_attributes
* @param {boolean} is_svg
* @param {string} class_hash * @param {string} class_hash
* @param {{ styles: Record<string, string> | null; classes: string }} [additional] * @param {{ styles: Record<string, string> | null; classes: string }} [additional]
* @returns {string} * @returns {string}
*/ */
export function spread_attributes(attrs, class_hash, additional) { export function spread_attributes(attrs, lowercase_attributes, is_svg, class_hash, additional) {
/** @type {Record<string, unknown>} */ /** @type {Record<string, unknown>} */
const merged_attrs = {}; const merged_attrs = {};
let key; let key;
@ -276,7 +251,10 @@ export function spread_attributes(attrs, class_hash, additional) {
for (name in merged_attrs) { for (name in merged_attrs) {
if (INVALID_ATTR_NAME_CHAR_REGEX.test(name)) continue; if (INVALID_ATTR_NAME_CHAR_REGEX.test(name)) continue;
const is_boolean = DOMBooleanAttributes.includes(name); if (lowercase_attributes) {
name = name.toLowerCase();
}
const is_boolean = !is_svg && DOMBooleanAttributes.includes(name);
attr_str += attr(name, merged_attrs[name], is_boolean); attr_str += attr(name, merged_attrs[name], is_boolean);
} }

@ -68,6 +68,28 @@ declare namespace $effect {
* @param fn The function to execute * @param fn The function to execute
*/ */
export function pre(fn: () => void | (() => void)): void; export function pre(fn: () => void | (() => void)): void;
/**
* The `$effect.active` rune is an advanced feature that tells you whether or not the code is running inside an effect or inside your template.
*
* Example:
* ```svelte
* <script>
* console.log('in component setup:', $effect.active()); // false
*
* $effect(() => {
* console.log('in effect:', $effect.active()); // true
* });
* </script>
*
* <p>in template: {$effect.active()}</p> <!-- true -->
* ```
*
* This allows you to (for example) add things like subscriptions without causing memory leaks, by putting them in child effects.
*
* https://svelte-5-preview.vercel.app/docs/runes#$effect-active
*/
export function active(): boolean;
} }
/** /**

@ -18,6 +18,14 @@ export interface ComponentConstructorOptions<
$$inline?: boolean; $$inline?: boolean;
} }
// Utility type for ensuring backwards compatibility on a type level: If there's a default slot, add 'children' to the props if it doesn't exist there already
type PropsWithChildren<Props, Slots> = Props &
(Props extends { children?: any }
? {}
: Slots extends { default: any }
? { children?: Snippet }
: {});
/** /**
* Can be used to create strongly typed Svelte components. * Can be used to create strongly typed Svelte components.
* *
@ -52,25 +60,18 @@ export class SvelteComponent<
Slots extends Record<string, any> = any Slots extends Record<string, any> = any
> { > {
[prop: string]: any; [prop: string]: any;
/**
* For type checking capabilities only.
* Does not exist at runtime.
* ### DO NOT USE!
*/
constructor(props: Props);
/** /**
* @deprecated This constructor only exists when using the `asClassComponent` compatibility helper, which * @deprecated This constructor only exists when using the `asClassComponent` compatibility helper, which
* is a stop-gap solution. Migrate towards using `mount` or `createRoot` instead. See * is a stop-gap solution. Migrate towards using `mount` or `createRoot` instead. See
* https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes for more info. * https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes for more info.
*/ */
constructor(options: ComponentConstructorOptions<Props>); constructor(options: ComponentConstructorOptions<PropsWithChildren<Props, Slots>>);
/** /**
* For type checking capabilities only. * For type checking capabilities only.
* Does not exist at runtime. * Does not exist at runtime.
* ### DO NOT USE! * ### DO NOT USE!
* */ * */
$$prop_def: Props; $$prop_def: PropsWithChildren<Props, Slots>;
/** /**
* For type checking capabilities only. * For type checking capabilities only.
* Does not exist at runtime. * Does not exist at runtime.
@ -185,6 +186,21 @@ export type ComponentType<Comp extends SvelteComponent> = (new (
element?: typeof HTMLElement; element?: typeof HTMLElement;
}; };
declare const SnippetReturn: unique symbol;
/**
* The type of a `#snippet` block. You can use it to (for example) express that your component expects a snippet of a certain type:
* ```ts
* let { banner } = $props<{ banner: Snippet<{ text: string }> }>();
* ```
* You can only call a snippet through the `{@render ...}` tag.
*/
export interface Snippet<T = void> {
(arg: T): typeof SnippetReturn & {
_: 'functions passed to {@render ...} tags must use the `Snippet` type imported from "svelte"';
};
}
interface DispatchOptions { interface DispatchOptions {
cancelable?: boolean; cancelable?: boolean;
} }

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

@ -0,0 +1,9 @@
import { test } from '../../test';
export default test({
error: {
code: 'invalid-state-location',
message: '$state() can only be used as a variable declaration initializer or a class field',
position: process.platform === 'win32' ? [35, 43] : [33, 41]
}
});

@ -0,0 +1,5 @@
<script>
class X {
static x = $state();
}
</script>

@ -0,0 +1 @@
<!--ssr:0--><a href="/bar">foo</a><!--ssr:0-->

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
test(assert, target) {
assert.equal(target.querySelector('a')?.getAttribute('href'), '/bar');
}
});

@ -0,0 +1,5 @@
<script>
let browser = typeof window !== 'undefined';
</script>
<a href={browser ? '/foo': '/bar'}>foo</a>

@ -109,8 +109,7 @@
"type": "Block", "type": "Block",
"value": " another comment ", "value": " another comment ",
"start": 163, "start": 163,
"end": 184, "end": 184
"has_trailing_newline": true
} }
] ]
} }
@ -121,8 +120,7 @@
"type": "Line", "type": "Line",
"value": " comment", "value": " comment",
"start": 141, "start": 141,
"end": 151, "end": 151
"has_trailing_newline": true
} }
] ]
} }
@ -222,8 +220,7 @@
"type": "Line", "type": "Line",
"value": " a leading comment", "value": " a leading comment",
"start": 10, "start": 10,
"end": 30, "end": 30
"has_trailing_newline": true
} }
], ],
"trailingComments": [ "trailingComments": [
@ -290,8 +287,7 @@
"type": "Block", "type": "Block",
"value": "* a comment ", "value": "* a comment ",
"start": 72, "start": 72,
"end": 88, "end": 88
"has_trailing_newline": true
} }
] ]
} }

@ -8,7 +8,7 @@ export default test({
<select> <select>
<option>a</option> <option>a</option>
<option>b</option> <option selected="">b</option>
<option>c</option> <option>c</option>
</select> </select>

@ -0,0 +1,34 @@
import { ok, test } from '../../test';
const items = [{ id: 'a' }, { id: 'b' }];
export default test({
get props() {
return {
/** @type {{ id: string } | null} */
foo: null,
items
};
},
test({ assert, component, target }) {
const select = target.querySelector('select');
ok(select);
const options = target.querySelectorAll('option');
assert.equal(options[0].selected, true);
assert.equal(options[1].selected, false);
assert.equal(options[0].value, '');
component.foo = items[0];
assert.equal(options[0].selected, false);
assert.equal(options[1].selected, true);
component.foo = { id: 'c' }; // doesn't match an option
assert.equal(select.value, '');
assert.equal(select.selectedIndex, -1);
assert.equal(options[0].selected, false);
assert.equal(options[1].selected, false);
}
});

@ -0,0 +1,11 @@
<script>
export let foo;
export let items;
</script>
<select bind:value={foo}>
<option value={null}></option>
{#each items as item}
<option value={item}>{item.id}</option>
{/each}
</select>

@ -0,0 +1,25 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
test({ assert, component, target }) {
const [b1, b2, b3] = target.querySelectorAll('button');
const first_h1 = target.querySelector('h1');
assert.deepEqual(component.log, [undefined, first_h1]);
flushSync(() => {
b3.click();
});
const third_h1 = target.querySelector('h1');
assert.deepEqual(component.log, [undefined, first_h1, third_h1]);
flushSync(() => {
b1.click();
});
assert.deepEqual(component.log, [undefined, first_h1, third_h1, target.querySelector('h1')]);
}
});

@ -0,0 +1,27 @@
<script>
let activeTab = 0;
let activeHeading;
export let log = [];
$: log.push(activeHeading);
</script>
<div class="tabs">
<div class="tab-toggles">
<button class:active={activeTab === 0} on:click={() => activeTab = 0}>Tab 1</button>
<button class:active={activeTab === 1} on:click={() => activeTab = 1}>Tab 2</button>
<button class:active={activeTab === 2} on:click={() => activeTab = 2}>Tab 3</button>
</div>
<div class="tab-content">
{#if activeTab === 0}
<div><h1 bind:this={activeHeading}>Tab 1</h1></div>
{/if}
{#if activeTab === 1}
<div><h1 bind:this={activeHeading}>Tab 2</h1></div>
{/if}
{#if activeTab === 2}
<div><h1 bind:this={activeHeading}>Tab 3</h1></div>
{/if}
</div>
<duiv>
</div>

@ -0,0 +1,21 @@
import { flushSync } from 'svelte';
import { ok, test } from '../../test';
export default test({
test({ assert, component, target, window }) {
const button = target.querySelector('button');
ok(button);
flushSync(() => {
button.click();
});
assert.deepEqual(component.log, ['1 - 1']);
flushSync(() => {
button.click();
});
assert.deepEqual(component.log, ['1 - 1', '2 - 2']);
}
});

@ -0,0 +1,16 @@
<script>
export let log = [];
let referenced_directly = 0;
let not_referenced_directly = 0;
let css_based_on_not_referenced = '';
function click() {
referenced_directly += 1;
not_referenced_directly += 1;
css_based_on_not_referenced = not_referenced_directly % 2 == 1 ? 'background-color: red' : '';
log.push(referenced_directly + ' - ' + not_referenced_directly); //only referenced_directly is increasing
}
</script>
<button on:click={click} style={css_based_on_not_referenced}> increase both </button>
{referenced_directly}

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

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

@ -0,0 +1,3 @@
{#each ["a", "b"] as result, i (i)}
<div>{i}</div>
{/each}

@ -0,0 +1,29 @@
import { test } from '../../test';
export default test({
// There's a slight difference in the output between modes, because the server doesn't know
// whether or not the custom element has the readonly boolean, so it plays it save and
// assumes it does.
html: `
<button>click me</button>
<input>
<input>
<custom-element readonly="false"></custom-element>
<custom-element readonly="false"></custom-element>
<svg readonly="false"></svg>
<svg readonly="false"></svg>
`,
ssrHtml: `
<button>click me</button>
<input>
<input>
<custom-element></custom-element>
<custom-element readonly="false"></custom-element>
<svg readonly="false"></svg>
<svg readonly="false"></svg>
`
});

@ -0,0 +1,18 @@
<script>
const disabled = { dIsAbLeD: false };
const readonly = { readonly: false }
const readOnly = { readOnly: false }
</script>
<!-- lowercase, then compare -->
<button {...disabled}>click me</button>
<input {...readonly}>
<input {...readOnly}>
<!-- keep casing -->
<custom-element {...readonly}></custom-element>
<custom-element {...readOnly}></custom-element>
<!-- keep casing -->
<svg {...readonly}></svg>
<svg {...readOnly}></svg>

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
async test({ assert, target, component }) {
assert.equal(target.querySelector('img'), component.items[0].img);
}
});

@ -0,0 +1,11 @@
<script>
let { items = [{ src: 'https://ds' }] } = $props();
</script>
{#each items as item, i}
<img
src={item.src}
bind:this={items[i].img}
alt="slider{i}"
/>
{/each}

@ -0,0 +1,15 @@
import { test } from '../../test';
export default test({
ssrHtml: `
<p>false</p>
<p>false</p>
<p>false</p>
`,
html: `
<p>false</p>
<p>true</p>
<p>true</p>
`
});

@ -0,0 +1,11 @@
<script>
const foo = $effect.active();
let bar = $state(false);
$effect.pre(() => {
bar = $effect.active();
});
</script>
<p>{foo}</p>
<p>{bar}</p>
<p>{$effect.active()}</p>

@ -0,0 +1,15 @@
import { test } from '../../test';
export default test({
html: `<p>hello</p>`,
before_test: () => {
// @ts-expect-error
globalThis.frag = 'hello';
},
after_test: () => {
// @ts-expect-error
delete globalThis.frag;
}
});

@ -0,0 +1,19 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>person.name.first = "dave"</button><h3>JSON output</h3><div>[{"name":{"first":"rob"}}]</div>`,
async test({ assert, target }) {
const button = target.querySelector('button');
flushSync(() => {
button?.click();
});
assert.htmlEqual(
target.innerHTML,
`<button>person.name.first = "dave"</button><h3>JSON output</h3><div>[{"name":{"first":"dave"}}]</div>`
);
}
});

@ -0,0 +1,15 @@
<script>
let people = $state([{name:{first:'rob'}}]);
</script>
{#each people as person}
<button on:click={()=>{
person.name.first = "dave";
people = people;
}}>person.name.first = "dave"</button>
{/each}
<h3>JSON output</h3>
{#each people as person}
<div>{JSON.stringify(people)}</div>
{/each}

@ -0,0 +1,62 @@
import { test } from '../../test';
export default test({
html: `
<p>snippet: 0</p>
<button>toggle</button>
<button>increase count</button>
`,
props: {
get log() {
return [];
}
},
async test({ assert, target, component }) {
const [toggle, increment] = target.querySelectorAll('button');
await increment?.click();
assert.htmlEqual(
target.innerHTML,
`
<p>snippet: 1</p>
<button>toggle</button>
<button>increase count</button>
`
);
assert.deepEqual(component.log, []);
await toggle?.click();
assert.htmlEqual(
target.innerHTML,
`
<p>component: 1</p>
<button>toggle</button>
<button>increase count</button>
`
);
assert.deepEqual(component.log, [1]);
await increment?.click();
assert.htmlEqual(
target.innerHTML,
`
<p>component: 2</p>
<button>toggle</button>
<button>increase count</button>
`
);
assert.deepEqual(component.log, [1]);
await toggle?.click();
assert.htmlEqual(
target.innerHTML,
`
<p>snippet: 2</p>
<button>toggle</button>
<button>increase count</button>
`
);
assert.deepEqual(component.log, [1]);
}
});

@ -0,0 +1,6 @@
<script>
let { count, log } = $props();
log.push(count);
</script>
<p>component: {count}</p>

@ -0,0 +1,22 @@
<script>
import Inner from "./inner.svelte";
let { log } = $props();
let count = $state(0);
let show_foo = $state(true);
let snippet = $derived(show_foo ? foo : bar);
</script>
{#snippet foo({count})}
<p>snippet: {count}</p>
{/snippet}
{#snippet bar(props)}
<Inner {...props}></Inner>
{/snippet}
{@render snippet({ count, log })}
<button onclick={() => show_foo = !show_foo}>toggle</button>
<button onclick={() => count++}>increase count</button>

@ -0,0 +1,7 @@
<script>
let { inner } = $props();
</script>
{#if inner}
{@render inner()}
{/if}

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
html: `<p>S</p><a href="#">S</a><p>v</p><a href="#">v</a><p>e</p><a href="#">e</a><p>l</p><a href="#">l</a><p>t</p><a href="#">t</a><p>e</p><a href="#">e</a>`
});

@ -0,0 +1,13 @@
<script>
import Component from './Component.svelte';
let name = "Svelte";
</script>
{#each name.split('') as character}
<p>{character}</p>
<Component>
{#snippet inner()}
<a href={'#'}>{character}</a>
{/snippet}
</Component>
{/each}

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
html: '<p>10 * 10 = 100</p><p>20 * 20 = 400</p>'
});

@ -0,0 +1,8 @@
<script lang="ts">
const boxes = [ { width: 10, height: 10 }, { width: 20, height: 20 } ];
</script>
{#each boxes as box}
{@const area: number = box.width * box.height}
<p>{box.width} * {box.height} = {area}</p>
{/each}

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

@ -0,0 +1,5 @@
<script lang="ts">
</script>
{@const name: string = "{}"}
<p>{name}</p>

@ -0,0 +1,48 @@
// main.svelte (Svelte VERSION)
// Note: compiler output will change before 5.0 is released!
import "svelte/internal/disclose-version";
import * as $ from "svelte/internal";
var frag = $.template(`<div></div> <svg></svg> <custom-element></custom-element> <div></div> <svg></svg> <custom-element></custom-element>`, true);
export default function Main($$anchor, $$props) {
$.push($$props, true);
// needs to be a snapshot test because jsdom does auto-correct the attribute casing
let x = $.source('test');
let y = $.source(() => 'test');
/* Init */
var fragment = $.open_frag($$anchor, false, frag);
var node = $.child_frag(fragment);
var svg = $.sibling($.sibling(node));
var custom_element = $.sibling($.sibling(svg));
var div = $.sibling($.sibling(custom_element));
var svg_1 = $.sibling($.sibling(div));
var custom_element_1 = $.sibling($.sibling(svg_1));
/* Update */
$.attr_effect(div, "foobar", () => $.get(y)());
$.attr_effect(svg_1, "viewBox", () => $.get(y)());
$.set_custom_element_data_effect(custom_element_1, "fooBar", () => $.get(y)());
var node_foobar;
var svg_viewBox;
var custom_element_fooBar;
$.render_effect(() => {
if (node_foobar !== (node_foobar = $.get(x))) {
$.attr(node, "foobar", node_foobar);
}
if (svg_viewBox !== (svg_viewBox = $.get(x))) {
$.attr(svg, "viewBox", svg_viewBox);
}
if (custom_element_fooBar !== (custom_element_fooBar = $.get(x))) {
$.set_custom_element_data(custom_element, "fooBar", custom_element_fooBar);
}
});
$.close_frag($$anchor, fragment);
$.pop();
}

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

Loading…
Cancel
Save