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

1664 lines
51 KiB

import is_reference from 'is-reference';
import { walk } from 'zimmerframe';
import * as e from '../../errors.js';
import * as w from '../../warnings.js';
import {
extract_identifiers,
extract_all_identifiers_from_expression,
extract_paths,
is_event_attribute,
is_text_attribute,
object,
unwrap_optional,
get_attribute_expression,
get_attribute_chunks
} from '../../utils/ast.js';
import * as b from '../../utils/builders.js';
import { MathMLElements, ReservedKeywords, Runes, SVGElements } from '../constants.js';
import { Scope, ScopeRoot, create_scopes, get_rune, set_scope } from '../scope.js';
import { merge } from '../visitors.js';
import { validation_legacy, validation_runes, validation_runes_js } from './validation.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,
is_capture_event,
namespace_mathml,
namespace_svg
} from '../../../constants.js';
import { should_proxy_or_freeze } from '../3-transform/client/utils.js';
import { analyze_css } from './css/css-analyze.js';
import { prune } from './css/css-prune.js';
import { hash } from '../../../utils.js';
import { warn_unused } from './css/css-warn.js';
import { extract_svelte_ignore } from '../../utils/extract_svelte_ignore.js';
import { ignore_map, ignore_stack, pop_ignore, push_ignore } from '../../state.js';
import { equal } from '../../utils/assert.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);
}
/**
* Checks if given event attribute can be delegated/hoisted and returns the corresponding info if so
* @param {string} event_name
* @param {import('estree').Expression | null} handler
* @param {import('./types').Context} context
* @returns {null | import('#compiler').DelegatedEvent}
*/
function get_delegated_event(event_name, handler, context) {
// Handle delegated event handlers. Bail-out if not a delegated event.
if (!handler || !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;
}
/** @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 (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 (context.state.analysis.module.scope.references.has(handler.name)) {
// If a binding with the same name is referenced in the module scope (even if not declared there), bail-out
return non_hoistable;
}
if (binding != null) {
for (const { path } of binding.references) {
const parent = path.at(-1);
if (parent == null) return non_hoistable;
const grandparent = path.at(-2);
/** @type {import('#compiler').RegularElement | null} */
let element = null;
/** @type {string | null} */
let event_name = null;
if (parent.type === 'OnDirective') {
element = /** @type {import('#compiler').RegularElement} */ (grandparent);
event_name = parent.name;
} else if (
parent.type === 'ExpressionTag' &&
grandparent?.type === 'Attribute' &&
is_event_attribute(grandparent)
) {
element = /** @type {import('#compiler').RegularElement} */ (path.at(-3));
const attribute = /** @type {import('#compiler').Attribute} */ (grandparent);
event_name = get_attribute_event_name(attribute.name);
}
if (element && event_name) {
if (
element.type !== 'RegularElement' ||
determine_element_spread(element).metadata.has_spread ||
!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;
// Bail-out if references a store subscription
if (scope.get(`$${reference}`)?.kind === 'store_sub') return non_hoistable;
const binding = scope.get(reference);
const local_binding = context.state.scope.get(reference);
// If we are referencing a binding that is shadowed in another scope then bail out.
if (local_binding !== null && binding !== null && local_binding.node !== binding.node) {
return non_hoistable;
}
// 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 reference 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] === '$') {
e.global_reference_invalid(references[0].node, name);
}
}
walk(
/** @type {import('estree').Node} */ (ast),
{ scope, analysis: { runes: true } },
// @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',
accessors: false,
runes: true,
immutable: true
};
}
/**
* @param {import('#compiler').Root} root
* @param {string} source
* @param {import('#compiler').ValidatedCompileOptions} options
* @returns {import('../types.js').ComponentAnalysis}
*/
export function analyze_component(root, source, 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 };
// 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] === '$') {
e.global_reference_invalid(references[0].node, 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 ||
// rune-line names received as props are valid too (but we have to protect against $props as store)
(store_name !== 'props' && get_rune(declaration.initial, instance.scope) === '$props')) &&
// 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'
))
) {
let is_nested_store_subscription_node = undefined;
search: for (const reference of references) {
for (let i = reference.path.length - 1; i >= 0; i--) {
const scope =
scopes.get(reference.path[i]) ||
module.scopes.get(reference.path[i]) ||
instance.scopes.get(reference.path[i]);
if (scope) {
const owner = scope?.owner(store_name);
if (!!owner && owner !== module.scope && owner !== instance.scope) {
is_nested_store_subscription_node = reference.node;
break search;
}
break;
}
}
}
if (is_nested_store_subscription_node) {
e.store_invalid_scoped_subscription(is_nested_store_subscription_node);
}
if (options.runes !== false) {
if (declaration === null && /[a-z]/.test(store_name[0])) {
e.global_reference_invalid(references[0].node, name);
} else if (declaration !== null && Runes.includes(/** @type {any} */ (name))) {
for (const { node, path } of references) {
if (path.at(-1)?.type === 'CallExpression') {
w.store_rune_conflict(node, store_name);
}
}
}
}
if (module.ast) {
for (const { node, path } of references) {
// if the reference is inside context="module", error. this is a bit hacky but it works
if (
/** @type {number} */ (node.start) > /** @type {number} */ (module.ast.start) &&
/** @type {number} */ (node.end) < /** @type {number} */ (module.ast.end) &&
// const state = $state(0) is valid
get_rune(/** @type {import('estree').Node} */ (path.at(-1)), module.scope) === null
) {
e.store_invalid_subscription(node);
}
}
}
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: [],
runes,
immutable: runes || options.immutable,
exports: [],
uses_props: false,
uses_rest_props: false,
uses_slots: false,
uses_component_bindings: false,
uses_render_tags: false,
needs_context: false,
needs_props: false,
event_directive_node: null,
uses_event_attributes: false,
custom_element: options.customElementOptions ?? options.customElement,
inject_styles: options.css === 'injected' || options.customElement,
accessors: options.customElement
? true
: (runes ? false : !!options.accessors) ||
// because $set method needs accessors
options.compatibility?.componentApi === 4,
reactive_statements: new Map(),
binding_groups: new Map(),
slot_names: new Map(),
top_level_snippets: [],
css: {
ast: root.css,
hash: root.css
? options.cssHash({
css: root.css.content.styles,
filename: options.filename ?? '<unknown>',
name: component_name,
hash
})
: '',
keyframes: []
},
source
};
if (root.options) {
for (const attribute of root.options.attributes) {
if (attribute.name === 'accessors') {
w.options_deprecated_accessors(attribute);
}
if (attribute.name === 'customElement' && !options.customElement) {
w.options_missing_custom_element(attribute);
}
if (attribute.name === 'immutable') {
w.options_deprecated_immutable(attribute);
}
}
}
if (analysis.runes) {
const props_refs = module.scope.references.get('$$props');
if (props_refs) {
e.legacy_props_invalid(props_refs[0].node);
}
const rest_props_refs = module.scope.references.get('$$restProps');
if (rest_props_refs) {
e.legacy_rest_props_invalid(rest_props_refs[0].node);
}
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,
render_tag: 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)
);
}
// 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) {
inner: 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 inner;
}
// bind:this doesn't need to be a state reference if it will never change
if (
type === 'BindDirective' &&
/** @type {import('#compiler').BindDirective} */ (path[i]).name === 'this'
) {
for (let j = i - 1; j >= 0; j -= 1) {
const type = path[j].type;
if (
type === 'IfBlock' ||
type === 'EachBlock' ||
type === 'AwaitBlock' ||
type === 'KeyBlock'
) {
w.non_reactive_update(binding.node, name);
continue outer;
}
}
continue inner;
}
}
w.non_reactive_update(binding.node, name);
continue outer;
}
}
}
}
} else {
instance.scope.declare(b.id('$$props'), 'rest_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,
render_tag: 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)
);
}
for (const [name, binding] of instance.scope.declarations) {
if (
(binding.kind === 'prop' || binding.kind === 'bindable_prop') &&
binding.node.name !== '$$props'
) {
const references = binding.references.filter(
(r) => r.node !== binding.node && r.path.at(-1)?.type !== 'ExportSpecifier'
);
if (!references.length && !instance.scope.declarations.has(`$${name}`)) {
w.export_let_unused(binding.node, name);
}
}
}
analysis.reactive_statements = order_reactive_statements(analysis.reactive_statements);
}
if (analysis.event_directive_node && analysis.uses_event_attributes) {
e.mixed_event_handler_syntaxes(
analysis.event_directive_node,
analysis.event_directive_node.name
);
}
if (analysis.uses_render_tags && (analysis.uses_slots || analysis.slot_names.size > 0)) {
const pos = analysis.slot_names.values().next().value ?? analysis.source.indexOf('$$slot');
e.slot_snippet_conflict(pos);
}
if (analysis.css.ast) {
analyze_css(analysis.css.ast, analysis);
// mark nodes as scoped/unused/empty etc
for (const element of analysis.elements) {
prune(analysis.css.ast, element);
}
const { comment } = analysis.css.ast.content;
const should_ignore_unused =
comment &&
extract_svelte_ignore(comment.start, comment.data, analysis.runes).includes(
'css_unused_selector'
);
if (!should_ignore_unused) {
warn_unused(analysis.css.ast);
}
outer: for (const element of analysis.elements) {
if (element.metadata.scoped) {
// Dynamic elements in dom mode always use spread for attributes and therefore shouldn't have a class attribute added to them
// TODO this happens during the analysis phase, which shouldn't know anything about client vs server
if (element.type === 'SvelteElement' && options.generate === 'client') continue;
/** @type {import('#compiler').Attribute | undefined} */
let class_attribute = undefined;
for (const attribute of element.attributes) {
if (attribute.type === 'SpreadAttribute') {
// The spread method appends the hash to the end of the class attribute on its own
continue outer;
}
if (attribute.type !== 'Attribute') continue;
if (attribute.name.toLowerCase() !== 'class') continue;
class_attribute = attribute;
}
if (class_attribute && class_attribute.value !== true) {
if (is_text_attribute(class_attribute)) {
class_attribute.value[0].data += ` ${analysis.css.hash}`;
} else {
/** @type {import('#compiler').Text} */
const css_text = {
type: 'Text',
data: ` ${analysis.css.hash}`,
raw: ` ${analysis.css.hash}`,
start: -1,
end: -1,
parent: null
};
if (Array.isArray(class_attribute.value)) {
class_attribute.value.push(css_text);
} else {
class_attribute.value = [class_attribute.value, css_text];
}
}
} else {
element.attributes.push(
create_attribute('class', -1, -1, [
{
type: 'Text',
data: analysis.css.hash,
raw: analysis.css.hash,
parent: null,
start: -1,
end: -1
}
])
);
}
}
}
}
// 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: []
};
next({ ...state, reactive_statement, function_depth: state.scope.function_depth + 1 });
// Every referenced binding becomes a dependency, unless it's on
// the left-hand side of an `=` assignment
for (const [name, nodes] of state.scope.references) {
const binding = state.scope.get(name);
if (binding === null) continue;
for (const { node, path } of nodes) {
/** @type {import('estree').Expression} */
let left = node;
let i = path.length - 1;
let parent = /** @type {import('estree').Expression} */ (path.at(i));
while (parent.type === 'MemberExpression') {
left = parent;
parent = /** @type {import('estree').Expression} */ (path.at(--i));
}
if (
parent.type === 'AssignmentExpression' &&
parent.operator === '=' &&
parent.left === left
) {
continue;
}
reactive_statement.dependencies.push(binding);
break;
}
}
state.reactive_statements.set(node, reactive_statement);
// Ideally this would be in the validation file, but that isn't possible because this visitor
// calls "next" before setting the reactive statements.
if (
reactive_statement.dependencies.length &&
reactive_statement.dependencies.every(
(d) => d.scope === state.analysis.module.scope && d.declaration_kind !== 'const'
)
) {
w.reactive_declaration_module_script(node);
}
if (
node.body.type === 'ExpressionStatement' &&
node.body.expression.type === 'AssignmentExpression'
) {
let ids = extract_identifiers(node.body.expression.left);
if (node.body.expression.left.type === 'MemberExpression') {
const id = object(node.body.expression.left);
if (id !== null) {
ids = [id];
}
}
for (const id of ids) {
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) {
const id = node.left.type === 'MemberExpression' ? object(node.left) : node.left;
if (id !== null) {
for (const id of extract_identifiers(node.left)) {
const binding = state.scope.get(id.name);
if (binding) {
state.reactive_statement.assignments.add(binding);
}
}
}
}
next();
},
UpdateExpression(node, { state, next }) {
if (state.reactive_statement) {
const id = node.argument.type === 'MemberExpression' ? object(node.argument) : node.argument;
if (id?.type === 'Identifier') {
const binding = state.scope.get(id.name);
if (binding) {
state.reactive_statement.assignments.add(binding);
}
}
}
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?.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 if (binding?.kind === 'each' && binding.mutated) {
// Ensure that the array is marked as reactive even when only its entries are mutated
let i = path.length;
while (i--) {
const ancestor = path[i];
if (
ancestor.type === 'EachBlock' &&
state.analysis.template.scopes.get(ancestor)?.declarations.get(node.name) === binding
) {
for (const binding of ancestor.metadata.references) {
if (binding.kind === 'normal') {
binding.kind = 'state';
}
}
break;
}
}
}
}
},
ExportNamedDeclaration(node, { next, state }) {
if (state.ast_type !== 'instance') {
return next();
}
state.analysis.needs_props = true;
if (!node.declaration) {
for (const specifier of node.specifiers) {
const binding = /** @type {import('#compiler').Binding} */ (
state.scope.get(specifier.local.name)
);
if (
binding !== null &&
(binding.kind === 'state' ||
binding.kind === 'frozen_state' ||
(binding.kind === 'normal' &&
(binding.declaration_kind === 'let' || binding.declaration_kind === 'var')))
) {
binding.kind = 'bindable_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 = 'bindable_prop';
}
}
}
},
StyleDirective(node, { state }) {
// the case for node.value different from true is already covered by the Identifier visitor
if (node.value === true) {
// get the binding for node.name and change the binding to state
let binding = state.scope.get(node.name);
if (binding?.mutated && binding.kind === 'normal') {
binding.kind = 'state';
}
}
}
};
/** @type {import('zimmerframe').Visitors<import('#compiler').SvelteNode, { scope: Scope, analysis: { runes: true } }>} */
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.link' &&
rune !== '$state.frozen' &&
rune !== '$derived' &&
rune !== '$derived.by'
)
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.link'
? 'link_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 = 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 !== '$state.link' &&
rune !== '$derived' &&
rune !== '$derived.by' &&
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 === '$state.link'
? 'link_state'
: rune === '$derived' || rune === '$derived.by'
? 'derived'
: path.is_rest
? 'rest_prop'
: 'prop';
}
if (rune === '$props') {
state.analysis.needs_props = true;
if (node.id.type === 'Identifier') {
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(node.id.name));
binding.initial = null; // else would be $props()
binding.kind = 'rest_prop';
} else {
equal(node.id.type, 'ObjectPattern');
for (const property of 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
: String(/** @type {import('estree').Literal} */ (property.key).value);
let initial = property.value.type === 'AssignmentPattern' ? property.value.right : null;
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(name));
binding.prop_alias = alias;
// rewire initial from $props() to the actual initial value, stripping $bindable() if necessary
if (
initial?.type === 'CallExpression' &&
initial.callee.type === 'Identifier' &&
initial.callee.name === '$bindable'
) {
binding.initial = /** @type {import('estree').Expression | null} */ (
initial.arguments[0] ?? null
);
binding.kind = 'bindable_prop';
} else {
binding.initial = initial;
}
}
}
}
},
ExportSpecifier(node, { state }) {
if (state.ast_type !== 'instance') return;
state.analysis.exports.push({
name: node.local.name,
alias: node.exported.name
});
const binding = state.scope.get(node.local.name);
if (binding) binding.reassigned = true;
},
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;
// 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;
}
}
// TODO add more cases
return false;
}
/**
* @param {import('estree').ArrowFunctionExpression | import('estree').FunctionExpression | import('estree').FunctionDeclaration} node
* @param {import('./types').Context} context
*/
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
});
};
/**
* A 'safe' identifier means that the `foo` in `foo.bar` or `foo()` will not
* call functions that require component context to exist
* @param {import('estree').Expression | import('estree').Super} expression
* @param {Scope} scope
*/
function is_safe_identifier(expression, scope) {
let node = expression;
while (node.type === 'MemberExpression') node = node.object;
if (node.type !== 'Identifier') return false;
const binding = scope.get(node.name);
if (!binding) return true;
if (binding.kind === 'store_sub') {
return is_safe_identifier({ name: node.name.slice(1), type: 'Identifier' }, scope);
}
return (
binding.declaration_kind !== 'import' &&
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'rest_prop'
);
}
/** @type {import('./types').Visitors} */
const common_visitors = {
_(node, { state, next, path }) {
ignore_map.set(node, structuredClone(ignore_stack));
const parent = path.at(-1);
if (parent?.type === 'Fragment' && node.type !== 'Comment' && node.type !== 'Text') {
const idx = parent.nodes.indexOf(/** @type {any} */ (node));
/** @type {string[]} */
const ignores = [];
for (let i = idx - 1; i >= 0; i--) {
const prev = parent.nodes[i];
if (prev.type === 'Comment') {
ignores.push(
...extract_svelte_ignore(
prev.start + 4 /* '<!--'.length */,
prev.data,
state.analysis.runes
)
);
} else if (prev.type !== 'Text') {
break;
}
}
if (ignores.length > 0) {
push_ignore(ignores);
ignore_map.set(node, structuredClone(ignore_stack));
next();
pop_ignore();
}
} else {
const comments = /** @type {any} */ (node).leadingComments;
if (comments) {
/** @type {string[]} */
const ignores = [];
for (const comment of comments) {
ignores.push(
...extract_svelte_ignore(
comment.start + 2 /* '//'.length */,
comment.value,
state.analysis.runes
)
);
}
if (ignores.length > 0) {
push_ignore(ignores);
ignore_map.set(node, structuredClone(ignore_stack));
next();
pop_ignore();
}
}
}
},
Attribute(node, context) {
if (node.value === true) return;
context.next();
node.metadata.dynamic = get_attribute_chunks(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)) {
const parent = context.path.at(-1);
if (parent?.type === 'RegularElement' || parent?.type === 'SvelteElement') {
context.state.analysis.uses_event_attributes = true;
}
const expression = get_attribute_expression(node);
const delegated_event = get_delegated_event(node.name.slice(2), expression, 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.set(name, node);
},
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 = get_attribute_chunks(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;
}
// If we are using arguments outside of a function, then throw an error
if (
node.name === 'arguments' &&
context.path.every((n) => n.type !== 'FunctionDeclaration' && n.type !== 'FunctionExpression')
) {
e.invalid_arguments_usage(node);
}
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;
}
// TODO it would be better to just bail out when we hit the ExportSpecifier node but that's
// not currently possibly because of our visitor merging, which I desperately want to nuke
const is_export_specifier =
/** @type {import('#compiler').SvelteNode} */ (context.path.at(-1)).type ===
'ExportSpecifier';
if (
context.state.analysis.runes &&
node !== binding.node &&
context.state.function_depth === binding.scope.function_depth &&
// If we have $state that can be proxied or frozen and isn't re-assigned, then that means
// it's likely not using a primitive value and thus this warning isn't that helpful.
(((binding.kind === 'state' || binding.kind === 'link_state') &&
(binding.reassigned ||
(binding.initial?.type === 'CallExpression' &&
binding.initial.arguments.length === 1 &&
binding.initial.arguments[0].type !== 'SpreadElement' &&
!should_proxy_or_freeze(binding.initial.arguments[0], context.state.scope)))) ||
binding.kind === 'frozen_state' ||
binding.kind === 'derived') &&
!is_export_specifier &&
// We're only concerned with reads here
(parent.type !== 'AssignmentExpression' || parent.left !== node) &&
parent.type !== 'UpdateExpression'
) {
w.state_referenced_locally(node);
}
}
},
CallExpression(node, context) {
debugger;
const { expression, render_tag } = context.state;
if (
(expression?.type === 'ExpressionTag' || expression?.type === 'SpreadAttribute') &&
!is_known_safe_call(node, context)
) {
expression.metadata.contains_call_expression = true;
}
if (render_tag) {
// Find out which of the render tag arguments contains this call expression
const arg_idx = unwrap_optional(render_tag.expression).arguments.findIndex(
(arg) => arg === node || context.path.includes(arg)
);
// -1 if this is the call expression of the render tag itself
if (arg_idx !== -1) {
render_tag.metadata.args_with_call_expression.add(arg_idx);
}
}
const callee = node.callee;
const rune = get_rune(node, context.state.scope);
if (callee.type === 'Identifier') {
const binding = context.state.scope.get(callee.name);
if (binding !== null) {
binding.is_called = true;
}
if (rune === '$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;
}
}
if (rune === '$state.link') {
// Similar to $derived above, $state is treated as `$state(() => foo)`
context.next({
...context.state,
function_depth: context.state.function_depth + 1
});
return;
}
if (rune === '$effect' || rune === '$effect.pre') {
// `$effect` needs context because Svelte needs to know whether it should re-run
// effects that invalidate themselves, and that's determined by whether we're in runes mode
context.state.analysis.needs_context = true;
} else if (rune === null) {
if (!is_safe_identifier(callee, context.state.scope)) {
context.state.analysis.needs_context = true;
}
}
context.next();
},
MemberExpression(node, context) {
if (context.state.expression) {
context.state.expression.metadata.dynamic = true;
}
if (!is_safe_identifier(node, context.state.scope)) {
context.state.analysis.needs_context = true;
}
context.next();
},
OnDirective(node, { state, path, next }) {
const parent = path.at(-1);
if (parent?.type === 'SvelteElement' || parent?.type === 'RegularElement') {
state.analysis.event_directive_node ??= node;
}
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'
) {
if (node.name !== 'this') {
context.state.analysis.uses_component_bindings = true;
}
break;
} else if (is_element_node(parent)) {
break;
}
}
if (node.name !== 'group') return;
// Traverse the path upwards and find all EachBlocks who are (indirectly) contributing to bind:group,
// i.e. one of their declarations is referenced in the binding. This allows group bindings to work
// correctly when referencing a variable declared in an EachBlock by using the index of the each block
// entries as keys.
i = context.path.length;
const each_blocks = [];
const [keypath, expression_ids] = extract_all_identifiers_from_expression(node.expression);
let ids = expression_ids;
while (i--) {
const parent = context.path[i];
if (parent.type === 'EachBlock') {
const references = ids.filter((id) => parent.metadata.declarations.has(id.name));
if (references.length > 0) {
parent.metadata.contains_group_binding = true;
for (const binding of parent.metadata.references) {
binding.mutated = true;
}
each_blocks.push(parent);
ids = ids.filter((id) => !references.includes(id));
ids.push(...extract_all_identifiers_from_expression(parent.expression)[1]);
}
}
}
// The identifiers that make up the binding expression form they key for the binding group.
// If the same identifiers in the same order are used in another bind:group, they will be in the same group.
// (there's an edge case where `bind:group={a[i]}` will be in a different group than `bind:group={a[j]}` even when i == j,
// but this is a limitation of the current static analysis we do; it also never worked in Svelte 4)
const bindings = expression_ids.map((id) => context.state.scope.get(id.name));
let group_name;
outer: for (const [[key, b], group] of context.state.analysis.binding_groups) {
if (b.length !== bindings.length || key !== keypath) continue;
for (let i = 0; i < bindings.length; i++) {
if (bindings[i] !== b[i]) continue outer;
}
group_name = group;
}
if (!group_name) {
group_name = context.state.scope.root.unique('binding_group');
context.state.analysis.binding_groups.set([keypath, bindings], group_name);
}
node.metadata = {
binding_group_name: group_name,
parent_each_blocks: each_blocks
};
},
ArrowFunctionExpression: function_visitor,
FunctionExpression: function_visitor,
FunctionDeclaration: function_visitor,
RegularElement(node, context) {
if (context.state.options.namespace !== 'foreign') {
if (SVGElements.includes(node.name)) node.metadata.svg = true;
else if (MathMLElements.includes(node.name)) node.metadata.mathml = true;
}
determine_element_spread(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, context) {
context.state.analysis.elements.push(node);
for (const attribute of node.attributes) {
if (attribute.type === 'Attribute') {
if (attribute.name === 'xmlns' && is_text_attribute(attribute)) {
node.metadata.svg = attribute.value[0].data === namespace_svg;
node.metadata.mathml = attribute.value[0].data === namespace_mathml;
return;
}
}
}
for (let i = context.path.length - 1; i >= 0; i--) {
const ancestor = context.path[i];
if (
ancestor.type === 'Component' ||
ancestor.type === 'SvelteComponent' ||
ancestor.type === 'SvelteFragment' ||
ancestor.type === 'SnippetBlock'
) {
// Inside a slot or a snippet -> this resets the namespace, so assume the component namespace
node.metadata.svg = context.state.options.namespace === 'svg';
node.metadata.mathml = context.state.options.namespace === 'mathml';
return;
}
if (ancestor.type === 'SvelteElement' || ancestor.type === 'RegularElement') {
node.metadata.svg =
ancestor.type === 'RegularElement' && ancestor.name === 'foreignObject'
? false
: ancestor.metadata.svg;
node.metadata.mathml =
ancestor.type === 'RegularElement' && ancestor.name === 'foreignObject'
? false
: ancestor.metadata.mathml;
return;
}
}
},
Component(node, context) {
const binding = context.state.scope.get(
node.name.includes('.') ? node.name.slice(0, node.name.indexOf('.')) : node.name
);
node.metadata.dynamic = binding !== null && binding.kind !== 'normal';
},
RenderTag(node, context) {
context.next({ ...context.state, render_tag: node });
}
};
/**
* @param {import('#compiler').RegularElement} node
*/
function determine_element_spread(node) {
let has_spread = false;
for (const attribute of node.attributes) {
if (!has_spread && attribute.type === 'SpreadAttribute') {
has_spread = true;
}
}
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, 'include-on')) {
event_name = event_name.slice(0, -7);
}
event_name = event_name.slice(2);
return event_name;
}
/**
* @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) {
for (const binding of declaration.assignments) {
const statements = lookup.get(binding.node.name) ?? [];
statements.push([node, declaration]);
lookup.set(binding.node.name, statements);
}
}
/** @type {Array<[string, string]>} */
const edges = [];
for (const [, { assignments, dependencies }] of unsorted_reactive_declarations) {
for (const assignment of assignments) {
for (const dependency of dependencies) {
if (!assignments.has(dependency)) {
edges.push([assignment.node.name, dependency.node.name]);
}
}
}
}
const cycle = check_graph_for_cycles(edges);
if (cycle?.length) {
const declaration = /** @type {Tuple[]} */ (lookup.get(cycle[0]))[0];
e.reactive_declaration_cycle(declaration[0], cycle.join(' → '));
}
// 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;
for (const binding of declaration.dependencies) {
if (declaration.assignments.has(binding)) continue;
for (const [node, earlier] of lookup.get(binding.node.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;
}