You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte/packages/svelte/src/compiler/phases/2-analyze/index.js

1231 lines
38 KiB

import is_reference from 'is-reference';
import { walk } from 'zimmerframe';
import { error } from '../../errors.js';
import * as assert from '../../utils/assert.js';
import {
extract_identifiers,
extract_paths,
is_event_attribute,
is_text_attribute,
object,
unwrap_ts_expression
} from '../../utils/ast.js';
import * as b from '../../utils/builders.js';
import { ReservedKeywords, Runes, SVGElements } from '../constants.js';
import { Scope, ScopeRoot, create_scopes, get_rune, set_scope } from '../scope.js';
import { merge } from '../visitors.js';
import Stylesheet from './css/Stylesheet.js';
import { validation_legacy, validation_runes, validation_runes_js } from './validation.js';
import { warn } from '../../warnings.js';
import check_graph_for_cycles from './utils/check_graph_for_cycles.js';
import { regex_starts_with_newline } from '../patterns.js';
import { create_attribute, is_element_node } from '../nodes.js';
import { DelegatedEvents } from '../../../constants.js';
/**
* @param {import('#compiler').Script | null} script
* @param {ScopeRoot} root
* @param {boolean} allow_reactive_declarations
* @param {Scope | null} parent
* @returns {import('../types.js').Js}
*/
function js(script, root, allow_reactive_declarations, parent) {
/** @type {import('estree').Program} */
const ast = script?.content ?? {
type: 'Program',
sourceType: 'module',
start: -1,
end: -1,
body: []
};
const { scope, scopes } = create_scopes(ast, root, allow_reactive_declarations, parent);
return { ast, scope, scopes };
}
/**
* @param {string} filename
*/
function get_component_name(filename) {
const parts = filename.split(/[/\\]/);
const basename = /** @type {string} */ (parts.pop());
const last_dir = /** @type {string} */ (parts.at(-1));
let name = basename.replace('.svelte', '');
if (name === 'index' && last_dir && last_dir !== 'src') {
name = last_dir;
}
return name[0].toUpperCase() + name.slice(1);
}
/**
* @param {Pick<import('#compiler').OnDirective, 'expression'| 'name' | 'modifiers'> & { type: string }} node
* @param {import('./types').Context} context
* @returns {null | import('#compiler').DelegatedEvent}
*/
function get_delegated_event(node, context) {
const handler = node.expression;
const event_name = node.name;
// Handle delegated event handlers. Bail-out if not a delegated event.
if (!handler || node.modifiers.includes('capture') || !DelegatedEvents.includes(event_name)) {
return null;
}
// If we are not working with a RegularElement, then bail-out.
const element = context.path.at(-1);
if (element?.type !== 'RegularElement') {
return null;
}
// If element says we can't delegate because we have multiple OnDirectives of the same type, bail-out.
if (!element.metadata.can_delegate_events) {
return null;
}
/** @type {import('#compiler').DelegatedEvent} */
const non_hoistable = { type: 'non-hoistable' };
/** @type {import('estree').FunctionExpression | import('estree').FunctionDeclaration | import('estree').ArrowFunctionExpression | null} */
let target_function = null;
let binding = null;
if (node.type === 'Attribute' && element.metadata.has_spread) {
// event attribute becomes part of the dynamic spread array
return non_hoistable;
}
if (handler.type === 'ArrowFunctionExpression' || handler.type === 'FunctionExpression') {
target_function = handler;
} else if (handler.type === 'Identifier') {
binding = context.state.scope.get(handler.name);
if (binding != null) {
for (const { path } of binding.references) {
const parent = path.at(-1);
if (parent == null) {
return non_hoistable;
}
/** @type {import('#compiler').RegularElement | null} */
let element = null;
/** @type {string | null} */
let event_name = null;
if (parent.type === 'OnDirective') {
element = /** @type {import('#compiler').RegularElement} */ (path.at(-2));
event_name = parent.name;
} else if (
parent.type === 'ExpressionTag' &&
is_event_attribute(/** @type {import('#compiler').Attribute} */ (path.at(-2)))
) {
element = /** @type {import('#compiler').RegularElement} */ (path.at(-3));
const attribute = /** @type {import('#compiler').Attribute} */ (path.at(-2));
event_name = get_attribute_event_name(attribute.name);
}
if (element && event_name) {
if (
element.type !== 'RegularElement' ||
!determine_element_spread_and_delegatable(element).metadata.can_delegate_events ||
(element.metadata.has_spread && node.type === 'Attribute') ||
!DelegatedEvents.includes(event_name)
) {
return non_hoistable;
}
} else if (parent.type !== 'FunctionDeclaration' && parent.type !== 'VariableDeclarator') {
return non_hoistable;
}
}
}
// If the binding is exported, bail-out
if (context.state.analysis.exports.find((node) => node.name === handler.name)) {
return non_hoistable;
}
if (binding !== null && binding.initial !== null && !binding.mutated && !binding.is_called) {
const binding_type = binding.initial.type;
if (
binding_type === 'ArrowFunctionExpression' ||
binding_type === 'FunctionDeclaration' ||
binding_type === 'FunctionExpression'
) {
target_function = binding.initial;
}
}
}
// If we can't find a function, bail-out
if (target_function == null) {
return non_hoistable;
}
// If the function is marked as non-hoistable, bail-out
if (target_function.metadata.hoistable === 'impossible') {
return non_hoistable;
}
// If the function has more than one arg, then bail-out
if (target_function.params.length > 1) {
return non_hoistable;
}
const visited_references = new Set();
const scope = target_function.metadata.scope;
for (const [reference] of scope.references) {
// Bail-out if the arguments keyword is used
if (reference === 'arguments') {
return non_hoistable;
}
const binding = scope.get(reference);
// If we have multiple references to the same store using $ prefix, bail out.
if (
binding !== null &&
binding.kind === 'store_sub' &&
visited_references.has(reference.slice(1))
) {
return non_hoistable;
}
// If we referebnce the index within an each block, then bail-out.
if (binding !== null && binding.initial?.type === 'EachBlock') {
return non_hoistable;
}
if (
binding !== null &&
// Bail-out if the the binding is a rest param
(binding.declaration_kind === 'rest_param' ||
// Bail-out if we reference anything from the EachBlock (for now) that mutates in non-runes mode,
(((!context.state.analysis.runes && binding.kind === 'each') ||
// or any normal not reactive bindings that are mutated.
binding.kind === 'normal' ||
// or any reactive imports (those are rewritten) (can only happen in legacy mode)
binding.kind === 'legacy_reactive_import') &&
binding.mutated))
) {
return non_hoistable;
}
visited_references.add(reference);
}
return { type: 'hoistable', function: target_function };
}
/**
* @param {import('estree').Program} ast
* @param {import('#compiler').ValidatedModuleCompileOptions} options
* @returns {import('../types.js').Analysis}
*/
export function analyze_module(ast, options) {
const { scope, scopes } = create_scopes(ast, new ScopeRoot(), false, null);
for (const [name, references] of scope.references) {
if (name[0] !== '$' || ReservedKeywords.includes(name)) continue;
if (name === '$' || name[1] === '$') {
error(references[0].node, 'illegal-global', name);
}
}
/** @type {import('../types').RawWarning[]} */
const warnings = [];
const analysis = {
warnings
};
walk(
/** @type {import('estree').Node} */ (ast),
{ scope, analysis },
// @ts-expect-error TODO clean this mess up
merge(set_scope(scopes), validation_runes_js, runes_scope_js_tweaker)
);
return {
module: { ast, scope, scopes },
name: options.filename || 'module',
warnings,
accessors: false,
runes: true,
immutable: true
};
}
/**
* @param {import('#compiler').Root} root
* @param {import('#compiler').ValidatedCompileOptions} options
* @returns {import('../types.js').ComponentAnalysis}
*/
export function analyze_component(root, options) {
const scope_root = new ScopeRoot();
const module = js(root.module, scope_root, false, null);
const instance = js(root.instance, scope_root, true, module.scope);
const { scope, scopes } = create_scopes(root.fragment, scope_root, false, instance.scope);
/** @type {import('../types.js').Template} */
const template = { ast: root.fragment, scope, scopes };
/** @type {import('../types').RawWarning[]} */
const warnings = [];
// create synthetic bindings for store subscriptions
for (const [name, references] of module.scope.references) {
if (name[0] !== '$' || ReservedKeywords.includes(name)) continue;
if (name === '$' || name[1] === '$') {
error(references[0].node, 'illegal-global', name);
}
const store_name = name.slice(1);
const declaration = instance.scope.get(store_name);
// If we're not in legacy mode through the compiler option, assume the user
// is referencing a rune and not a global store.
if (
options.runes === false ||
!Runes.includes(/** @type {any} */ (name)) ||
(declaration !== null &&
// const state = $state(0) is valid
get_rune(declaration.initial, instance.scope) === null &&
// 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' &&
declaration.initial?.type === 'ImportDeclaration' &&
declaration.initial.source.value === 'svelte/store'
))
) {
if (options.runes !== false) {
if (declaration === null && /[a-z]/.test(store_name[0])) {
error(references[0].node, 'illegal-global', name);
} else if (declaration !== null && Runes.includes(/** @type {any} */ (name))) {
for (const { node, path } of references) {
if (path.at(-1)?.type === 'CallExpression') {
warn(warnings, node, [], 'store-with-rune-name', store_name);
}
}
}
}
if (module.ast) {
// if the reference is inside context="module", error. this is a bit hacky but it works
for (const { node } of references) {
if (
/** @type {number} */ (node.start) > /** @type {number} */ (module.ast.start) &&
/** @type {number} */ (node.end) < /** @type {number} */ (module.ast.end)
) {
error(node, 'illegal-subscription');
}
}
}
const binding = instance.scope.declare(b.id(name), 'store_sub', 'synthetic');
binding.references = references;
instance.scope.references.set(name, references);
module.scope.references.delete(name);
}
}
const component_name = get_component_name(options.filename ?? 'Component');
const runes =
options.runes ??
Array.from(module.scope.references).some(([name]) => Runes.includes(/** @type {any} */ (name)));
// TODO remove all the ?? stuff, we don't need it now that we're validating the config
/** @type {import('../types.js').ComponentAnalysis} */
const analysis = {
name: module.scope.generate(options.name ?? component_name),
root: scope_root,
module,
instance,
template,
elements: [],
stylesheet: new Stylesheet({
ast: root.css,
// TODO are any of these necessary or can we just pass in the whole `analysis` object later?
filename: options.filename ?? '<unknown>',
component_name,
get_css_hash: options.cssHash
}),
runes,
immutable: runes || options.immutable,
exports: [],
uses_props: false,
uses_rest_props: false,
uses_slots: false,
uses_component_bindings: false,
custom_element: options.customElement,
inject_styles: options.css === 'injected' || !!options.customElement,
accessors: options.customElement ? true : !!options.accessors,
reactive_statements: new Map(),
binding_groups: new Map(),
slot_names: new Set(),
warnings
};
if (analysis.runes) {
const props_refs = module.scope.references.get('$$props');
if (props_refs) {
error(props_refs[0].node, 'invalid-legacy-props');
}
const rest_props_refs = module.scope.references.get('$$restProps');
if (rest_props_refs) {
error(rest_props_refs[0].node, 'invalid-legacy-rest-props');
}
for (const { ast, scope, scopes } of [module, instance, template]) {
/** @type {import('./types').AnalysisState} */
const state = {
scope,
analysis,
options,
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',
parent_element: null,
has_props_rune: false,
component_slots: new Set(),
expression: null,
private_derived_state: [],
function_depth: scope.function_depth
};
walk(
/** @type {import('#compiler').SvelteNode} */ (ast),
state,
merge(set_scope(scopes), validation_runes, runes_scope_tweaker, common_visitors)
);
}
} else {
instance.scope.declare(b.id('$$props'), 'prop', 'synthetic');
instance.scope.declare(b.id('$$restProps'), 'rest_prop', 'synthetic');
for (const { ast, scope, scopes } of [module, instance, template]) {
/** @type {import('./types').LegacyAnalysisState} */
const state = {
scope,
analysis,
options,
parent_element: null,
has_props_rune: false,
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',
instance_scope: instance.scope,
reactive_statement: null,
reactive_statements: analysis.reactive_statements,
component_slots: new Set(),
expression: null,
private_derived_state: [],
function_depth: scope.function_depth
};
walk(
/** @type {import('#compiler').SvelteNode} */ (ast),
state,
// @ts-expect-error TODO
merge(set_scope(scopes), validation_legacy, legacy_scope_tweaker, common_visitors)
);
}
analysis.reactive_statements = order_reactive_statements(analysis.reactive_statements);
}
// warn on any nonstate declarations that are a) reassigned 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.reassigned) {
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);
for (const element of analysis.elements) {
analysis.stylesheet.apply(element);
}
analysis.stylesheet.reify(options.generate === 'client');
// TODO
// analysis.stylesheet.warn_on_unused_selectors(analysis);
return analysis;
}
/** @type {import('./types').Visitors<import('./types').LegacyAnalysisState>} */
const legacy_scope_tweaker = {
LabeledStatement(node, { next, path, state }) {
if (
state.ast_type !== 'instance' ||
node.label.name !== '$' ||
/** @type {import('#compiler').SvelteNode} */ (path.at(-1)).type !== 'Program'
) {
return next();
}
// Find all dependencies of this `$: {...}` statement
/** @type {import('../types.js').ReactiveStatement} */
const reactive_statement = {
assignments: new Set(),
dependencies: new Set()
};
next({ ...state, reactive_statement, function_depth: state.scope.function_depth + 1 });
for (const [name, nodes] of state.scope.references) {
const binding = state.scope.get(name);
if (binding === null) continue;
// Include bindings that have references other than assignments and their own declarations
if (
nodes.some((n) => n.node !== binding.node && !reactive_statement.assignments.has(n.node))
) {
reactive_statement.dependencies.add(binding);
}
}
state.reactive_statements.set(node, reactive_statement);
if (
node.body.type === 'ExpressionStatement' &&
node.body.expression.type === 'AssignmentExpression'
) {
for (const id of extract_identifiers(node.body.expression.left)) {
const binding = state.scope.get(id.name);
if (binding?.kind === 'legacy_reactive') {
// TODO does this include `let double; $: double = x * 2`?
binding.legacy_dependencies = Array.from(reactive_statement.dependencies);
}
}
}
},
AssignmentExpression(node, { state, next }) {
if (state.reactive_statement && node.operator === '=') {
for (const id of extract_identifiers(node.left)) {
state.reactive_statement.assignments.add(id);
}
}
next();
},
Identifier(node, { state, path }) {
const parent = /** @type {import('estree').Node} */ (path.at(-1));
if (is_reference(node, parent)) {
if (node.name === '$$props') {
state.analysis.uses_props = true;
}
if (node.name === '$$restProps') {
state.analysis.uses_rest_props = true;
}
if (node.name === '$$slots') {
state.analysis.uses_slots = true;
}
let binding = state.scope.get(node.name);
if (binding?.kind === 'store_sub') {
// get the underlying store to mark it as reactive in case it's mutated
binding = state.scope.get(node.name.slice(1));
}
if (
binding !== null &&
binding.kind === 'normal' &&
((binding.scope === state.instance_scope && binding.declaration_kind !== 'function') ||
binding.declaration_kind === 'import')
) {
if (binding.declaration_kind === 'import') {
if (
binding.mutated &&
// TODO could be more fine-grained - not every mention in the template implies a state binding
(state.reactive_statement || state.ast_type === 'template') &&
parent.type === 'MemberExpression'
) {
binding.kind = 'legacy_reactive_import';
}
} else if (
binding.mutated &&
// TODO could be more fine-grained - not every mention in the template implies a state binding
(state.reactive_statement || state.ast_type === 'template')
) {
binding.kind = 'state';
} else if (
state.reactive_statement &&
parent.type === 'AssignmentExpression' &&
parent.left === binding.node
) {
binding.kind = 'derived';
} else {
let idx = -1;
let ancestor = path.at(idx);
while (ancestor) {
if (ancestor.type === 'EachBlock') {
// Ensures that the array is reactive when only its entries are mutated
// TODO: this doesn't seem correct. We should be checking at the points where
// the identifier (the each expression) is used in a way that makes it reactive.
// This just forces the collection identifier to always be reactive even if it's
// not.
if (ancestor.expression === (idx === -1 ? node : path.at(idx + 1))) {
binding.kind = 'state';
break;
}
}
ancestor = path.at(--idx);
}
}
}
}
},
ExportNamedDeclaration(node, { next, state }) {
if (state.ast_type !== 'instance') {
return next();
}
if (!node.declaration) {
for (const specifier of node.specifiers) {
const binding = /** @type {import('#compiler').Binding} */ (
state.scope.get(specifier.local.name)
);
if (
binding.kind === 'state' ||
binding.kind === 'frozen_state' ||
(binding.kind === 'normal' && binding.declaration_kind === 'let')
) {
binding.kind = 'prop';
if (specifier.exported.name !== specifier.local.name) {
binding.prop_alias = specifier.exported.name;
}
} else {
state.analysis.exports.push({
name: specifier.local.name,
alias: specifier.exported.name
});
}
}
return next();
}
if (
node.declaration.type === 'FunctionDeclaration' ||
node.declaration.type === 'ClassDeclaration'
) {
state.analysis.exports.push({
name: /** @type {import('estree').Identifier} */ (node.declaration.id).name,
alias: null
});
return next();
}
if (node.declaration.type === 'VariableDeclaration') {
if (node.declaration.kind === 'const') {
for (const declarator of node.declaration.declarations) {
for (const node of extract_identifiers(declarator.id)) {
state.analysis.exports.push({ name: node.name, alias: null });
}
}
return next();
}
for (const declarator of node.declaration.declarations) {
for (const id of extract_identifiers(declarator.id)) {
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(id.name));
binding.kind = 'prop';
}
}
}
}
};
/** @type {import('zimmerframe').Visitors<import('#compiler').SvelteNode, { scope: Scope, analysis: { warnings: import('../types').RawWarning[] } }>} */
const runes_scope_js_tweaker = {
VariableDeclarator(node, { state }) {
if (node.init?.type !== 'CallExpression') return;
const rune = get_rune(node.init, state.scope);
if (rune === null) return;
const callee = node.init.callee;
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return;
if (rune !== '$state' && rune !== '$state.frozen' && rune !== '$derived') return;
for (const path of extract_paths(node.id)) {
// @ts-ignore this fails in CI for some insane reason
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(path.node.name));
binding.kind =
rune === '$state' ? 'state' : rune === '$state.frozen' ? 'frozen_state' : 'derived';
}
}
};
/** @type {import('./types').Visitors} */
const runes_scope_tweaker = {
CallExpression(node, { state, next }) {
const rune = get_rune(node, state.scope);
// `$inspect(foo)` should not trigger the `static-state-reference` warning
if (rune === '$inspect') {
next({ ...state, function_depth: state.function_depth + 1 });
}
},
VariableDeclarator(node, { state }) {
const init = unwrap_ts_expression(node.init);
if (!init || init.type !== 'CallExpression') return;
const rune = get_rune(init, state.scope);
if (rune === null) return;
const callee = init.callee;
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return;
if (rune !== '$state' && rune !== '$state.frozen' && rune !== '$derived' && rune !== '$props')
return;
for (const path of extract_paths(node.id)) {
// @ts-ignore this fails in CI for some insane reason
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(path.node.name));
binding.kind =
rune === '$state'
? 'state'
: rune === '$state.frozen'
? 'frozen_state'
: rune === '$derived'
? 'derived'
: path.is_rest
? 'rest_prop'
: 'prop';
}
if (rune === '$props') {
for (const property of /** @type {import('estree').ObjectPattern} */ (node.id).properties) {
if (property.type !== 'Property') continue;
const name =
property.value.type === 'AssignmentPattern'
? /** @type {import('estree').Identifier} */ (property.value.left).name
: /** @type {import('estree').Identifier} */ (property.value).name;
const alias =
property.key.type === 'Identifier'
? property.key.name
: /** @type {string} */ (/** @type {import('estree').Literal} */ (property.key).value);
const initial = property.value.type === 'AssignmentPattern' ? property.value.right : null;
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(name));
binding.prop_alias = alias;
binding.initial = initial; // rewire initial from $props() to the actual initial value
}
}
},
ExportSpecifier(node, { state }) {
if (state.ast_type !== 'instance') return;
state.analysis.exports.push({
name: node.local.name,
alias: node.exported.name
});
},
ExportNamedDeclaration(node, { next, state }) {
if (!node.declaration || state.ast_type !== 'instance') {
return next();
}
if (
node.declaration.type === 'FunctionDeclaration' ||
node.declaration.type === 'ClassDeclaration'
) {
state.analysis.exports.push({
name: /** @type {import('estree').Identifier} */ (node.declaration.id).name,
alias: null
});
return next();
}
if (node.declaration.type === 'VariableDeclaration' && node.declaration.kind === 'const') {
for (const declarator of node.declaration.declarations) {
for (const node of extract_identifiers(declarator.id)) {
state.analysis.exports.push({ name: node.name, alias: null });
}
}
}
}
};
/**
* @param {import('estree').CallExpression} node
* @param {import('./types').Context} context
* @returns {boolean}
*/
function is_known_safe_call(node, context) {
const callee = node.callee;
// Check for selector() API calls
if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier') {
const binding = context.state.scope.get(callee.object.name);
const selector_binding = context.state.scope.get('selector');
if (
selector_binding !== null &&
selector_binding.declaration_kind === 'import' &&
selector_binding.initial !== null &&
selector_binding.initial.type === 'ImportDeclaration' &&
selector_binding.initial.source.value === 'svelte' &&
binding !== null &&
binding.initial !== null &&
binding.initial.type === 'CallExpression' &&
binding.initial.callee.type === 'Identifier' &&
binding.initial.callee.name === 'selector'
) {
return true;
}
}
// String / Number / BigInt / Boolean casting calls
if (callee.type === 'Identifier') {
const name = callee.name;
const binding = context.state.scope.get(name);
if (
binding === null &&
(name === 'BigInt' || name === 'String' || name === 'Number' || name === 'Boolean')
) {
return true;
}
}
return false;
}
/**
* @param {import('estree').ArrowFunctionExpression | import('estree').FunctionExpression | import('estree').FunctionDeclaration} node
* @param {import('./types').Context} context
*/
export const function_visitor = (node, context) => {
// TODO retire this in favour of a more general solution based on bindings
node.metadata = {
// module context -> already hoisted
hoistable: context.state.ast_type === 'module' ? 'impossible' : false,
hoistable_params: [],
scope: context.state.scope
};
context.next({
...context.state,
function_depth: context.state.function_depth + 1
});
};
/** @type {import('./types').Visitors} */
const common_visitors = {
Attribute(node, context) {
if (node.value === true) return;
context.next();
node.metadata.dynamic = node.value.some((chunk) => {
if (chunk.type !== 'ExpressionTag') {
return false;
}
if (
chunk.expression.type === 'FunctionExpression' ||
chunk.expression.type === 'ArrowFunctionExpression'
) {
return false;
}
return chunk.metadata.dynamic || chunk.metadata.contains_call_expression;
});
if (is_event_attribute(node)) {
/** @type {string[]} */
const modifiers = [];
const expression = node.value[0].expression;
let name = node.name.slice(2);
if (is_capture_event(name)) {
name = name.slice(0, -7);
modifiers.push('capture');
}
const delegated_event = get_delegated_event(
{ type: node.type, name, expression, modifiers },
context
);
if (delegated_event !== null) {
if (delegated_event.type === 'hoistable') {
delegated_event.function.metadata.hoistable = true;
}
node.metadata.delegated = delegated_event;
}
}
},
ClassDirective(node, context) {
context.next({ ...context.state, expression: node });
},
SpreadAttribute(node, context) {
context.next({ ...context.state, expression: node });
},
SlotElement(node, context) {
let name = 'default';
for (const attr of node.attributes) {
if (attr.type === 'Attribute' && attr.name === 'name' && is_text_attribute(attr)) {
name = attr.value[0].data;
break;
}
}
context.state.analysis.slot_names.add(name);
},
StyleDirective(node, context) {
if (node.value === true) {
const binding = context.state.scope.get(node.name);
if (binding?.kind !== 'normal') {
node.metadata.dynamic = true;
}
} else {
context.next();
node.metadata.dynamic = node.value.some(
(node) => node.type === 'ExpressionTag' && node.metadata.dynamic
);
}
},
ExpressionTag(node, context) {
context.next({ ...context.state, expression: node });
},
Identifier(node, context) {
const parent = /** @type {import('estree').Node} */ (context.path.at(-1));
if (!is_reference(node, parent)) return;
if (node.name === '$$slots') {
context.state.analysis.uses_slots = true;
return;
}
const binding = context.state.scope.get(node.name);
// if no binding, means some global variable
if (binding && binding.kind !== 'normal') {
if (context.state.expression) {
context.state.expression.metadata.dynamic = true;
}
if (
node !== binding.node &&
(binding.kind === 'state' ||
binding.kind === 'frozen_state' ||
binding.kind === 'derived') &&
context.state.function_depth === binding.scope.function_depth
) {
warn(context.state.analysis.warnings, node, context.path, 'static-state-reference');
}
}
},
CallExpression(node, context) {
if (
context.state.expression?.type === 'ExpressionTag' ||
(context.state.expression?.type === 'SpreadAttribute' && !is_known_safe_call(node, context))
) {
context.state.expression.metadata.contains_call_expression = true;
}
const callee = node.callee;
if (callee.type === 'Identifier') {
const binding = context.state.scope.get(callee.name);
if (binding !== null) {
binding.is_called = true;
}
if (get_rune(node, context.state.scope) === '$derived') {
// special case — `$derived(foo)` is treated as `$derived(() => foo)`
// for the purposes of identifying static state references
context.next({
...context.state,
function_depth: context.state.function_depth + 1
});
return;
}
}
context.next();
},
MemberExpression(node, context) {
if (context.state.expression) {
context.state.expression.metadata.dynamic = true;
}
context.next();
},
BindDirective(node, context) {
let i = context.path.length;
while (i--) {
const parent = context.path[i];
if (
parent.type === 'Component' ||
parent.type === 'SvelteComponent' ||
parent.type === 'SvelteSelf'
) {
context.state.analysis.uses_component_bindings = true;
break;
} else if (is_element_node(parent)) {
break;
}
}
if (node.name !== 'group') return;
i = context.path.length;
while (i--) {
const parent = context.path[i];
if (parent.type === 'EachBlock') {
parent.metadata.contains_group_binding = true;
for (const binding of parent.metadata.references) {
binding.mutated = true;
}
}
}
const id = object(node.expression);
const binding = id === null ? null : context.state.scope.get(id.name);
assert.ok(binding);
let group = context.state.analysis.binding_groups.get(binding);
if (!group) {
group = {
name: context.state.scope.root.unique('binding_group'),
directives: []
};
context.state.analysis.binding_groups.set(binding, group);
}
group.directives.push(node);
node.metadata = {
binding_group_name: group.name,
parent_each_blocks: /** @type {import('#compiler').EachBlock[]} */ (
context.path.filter((p) => p.type === 'EachBlock')
)
};
},
OnDirective(node, context) {
node.metadata = { delegated: null };
context.next();
const delegated_event = get_delegated_event(node, context);
if (delegated_event !== null) {
if (delegated_event.type === 'hoistable') {
delegated_event.function.metadata.hoistable = true;
}
node.metadata.delegated = delegated_event;
}
},
ArrowFunctionExpression: function_visitor,
FunctionExpression: function_visitor,
FunctionDeclaration: function_visitor,
RegularElement(node, context) {
if (context.state.options.namespace !== 'foreign' && SVGElements.includes(node.name)) {
node.metadata.svg = true;
}
determine_element_spread_and_delegatable(node);
// Special case: Move the children of <textarea> into a value attribute if they are dynamic
if (
context.state.options.namespace !== 'foreign' &&
node.name === 'textarea' &&
node.fragment.nodes.length > 0
) {
if (node.fragment.nodes.length > 1 || node.fragment.nodes[0].type !== 'Text') {
const first = node.fragment.nodes[0];
if (first.type === 'Text') {
// The leading newline character needs to be stripped because of a qirk:
// It is ignored by browsers if the tag and its contents are set through
// innerHTML, but we're now setting it through the value property at which
// point it is _not_ ignored, so we need to strip it ourselves.
// see https://html.spec.whatwg.org/multipage/syntax.html#element-restrictions
// see https://html.spec.whatwg.org/multipage/grouping-content.html#the-pre-element
first.data = first.data.replace(regex_starts_with_newline, '');
first.raw = first.raw.replace(regex_starts_with_newline, '');
}
node.attributes.push(
create_attribute(
'value',
/** @type {import('#compiler').Text} */ (node.fragment.nodes.at(0)).start,
/** @type {import('#compiler').Text} */ (node.fragment.nodes.at(-1)).end,
// @ts-ignore
node.fragment.nodes
)
);
node.fragment.nodes = [];
}
}
// Special case: single expression tag child of option element -> add "fake" attribute
// to ensure that value types are the same (else for example numbers would be strings)
if (
context.state.options.namespace !== 'foreign' &&
node.name === 'option' &&
node.fragment.nodes?.length === 1 &&
node.fragment.nodes[0].type === 'ExpressionTag' &&
!node.attributes.some(
(attribute) => attribute.type === 'Attribute' && attribute.name === 'value'
)
) {
const child = node.fragment.nodes[0];
node.attributes.push(create_attribute('value', child.start, child.end, [child]));
}
context.state.analysis.elements.push(node);
},
SvelteElement(node, { state }) {
state.analysis.elements.push(node);
}
};
/**
* Check if events on this element can theoretically be delegated. They can if there's no
* possibility of an OnDirective and an event attribute on the same element, and if there's
* no OnDirectives of the same type (the latter is a bit too strict because `on:click on:click on:keyup`
* means that `on:keyup` can be delegated but we gloss over this edge case).
* @param {import('#compiler').RegularElement} node
*/
function determine_element_spread_and_delegatable(node) {
if (typeof node.metadata.can_delegate_events === 'boolean') {
return node; // did this already
}
let events = new Map();
let has_spread = false;
let has_on = false;
let has_action_or_bind = false;
for (const attribute of node.attributes) {
if (
attribute.type === 'OnDirective' ||
(attribute.type === 'Attribute' && is_event_attribute(attribute))
) {
let event_name = attribute.name;
if (attribute.type === 'Attribute') {
event_name = get_attribute_event_name(event_name);
}
events.set(event_name, (events.get(event_name) || 0) + 1);
if (!has_on && attribute.type === 'OnDirective') {
has_on = true;
}
} else if (!has_spread && attribute.type === 'SpreadAttribute') {
has_spread = true;
} else if (
!has_action_or_bind &&
((attribute.type === 'BindDirective' && attribute.name !== 'this') ||
attribute.type === 'UseDirective')
) {
has_action_or_bind = true;
}
}
node.metadata.can_delegate_events =
// Actions/bindings need the old on:-events to fire in order
!has_action_or_bind &&
// spreading events means we don't know if there's an event attribute with the same name as an on:-event
!(has_spread && has_on) &&
// multiple on:-events/event attributes with the same name
![...events.values()].some((count) => count > 1);
node.metadata.has_spread = has_spread;
return node;
}
/**
* @param {string} event_name
*/
function get_attribute_event_name(event_name) {
if (is_capture_event(event_name)) {
event_name = event_name.slice(0, -7);
}
event_name = event_name.slice(2);
return event_name;
}
/**
* @param {string} name
* @returns boolean
*/
function is_capture_event(name) {
return (
name.endsWith('capture') && name !== 'ongotpointercapture' && name !== 'onlostpointercapture'
);
}
/**
* @param {Map<import('estree').LabeledStatement, import('../types.js').ReactiveStatement>} unsorted_reactive_declarations
*/
function order_reactive_statements(unsorted_reactive_declarations) {
/** @typedef {[import('estree').LabeledStatement, import('../types.js').ReactiveStatement]} Tuple */
/** @type {Map<string, Array<Tuple>>} */
const lookup = new Map();
for (const [node, declaration] of unsorted_reactive_declarations) {
declaration.assignments.forEach(({ name }) => {
const statements = lookup.get(name) ?? [];
statements.push([node, declaration]);
lookup.set(name, statements);
});
}
/** @type {Array<[string, string]>} */
const edges = [];
for (const [, { assignments, dependencies }] of unsorted_reactive_declarations) {
for (const { name } of assignments) {
for (const { node } of dependencies) {
if (![...assignments].find(({ name }) => node.name === name)) {
edges.push([name, node.name]);
}
}
}
}
const cycle = check_graph_for_cycles(edges);
if (cycle?.length) {
const declaration = /** @type {Tuple[]} */ (lookup.get(cycle[0]))[0];
error(declaration[0], 'cyclical-reactive-declaration', cycle);
}
// We use a map and take advantage of the fact that the spec says insertion order is preserved when iterating
/** @type {Map<import('estree').LabeledStatement, import('../types.js').ReactiveStatement>} */
const reactive_declarations = new Map();
/**
*
* @param {import('estree').LabeledStatement} node
* @param {import('../types.js').ReactiveStatement} declaration
* @returns
*/
const add_declaration = (node, declaration) => {
if ([...reactive_declarations.values()].includes(declaration)) return;
declaration.dependencies.forEach(({ node: { name } }) => {
if ([...declaration.assignments].some((a) => a.name === name)) return;
for (const [node, earlier] of lookup.get(name) ?? []) {
add_declaration(node, earlier);
}
});
reactive_declarations.set(node, declaration);
};
for (const [node, declaration] of unsorted_reactive_declarations) {
add_declaration(node, declaration);
}
return reactive_declarations;
}