diff --git a/.changeset/twelve-peaches-jam.md b/.changeset/twelve-peaches-jam.md new file mode 100644 index 000000000000..a903cc5d3e23 --- /dev/null +++ b/.changeset/twelve-peaches-jam.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: use unchanged primitives directly in template attributes diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index 27069478c896..34b891c5ab08 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -77,10 +77,18 @@ export function client_component(source, analysis, options) { return a; }, get template() { - /** @type {any[]} */ - const a = []; - a.push = () => - error(null, 'INTERNAL', 'template.push should not be called outside create_block'); + const a = { + quasi: [], + expressions: [] + }; + a.quasi.push = () => + error(null, 'INTERNAL', 'template.quasi.push should not be called outside create_block'); + a.expressions.push = () => + error( + null, + 'INTERNAL', + 'template.expressions.push should not be called outside create_block' + ); return a; }, legacy_reactive_statements: new Map(), @@ -336,8 +344,8 @@ export function client_component(source, analysis, options) { } const body = [ - ...state.hoisted, ...module.body, + ...state.hoisted, b.export_default( b.function_declaration( b.id(analysis.name), diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index d94e1f9fe6bc..2de307c4d637 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -3,7 +3,8 @@ import type { Statement, LabeledStatement, Identifier, - PrivateIdentifier + PrivateIdentifier, + Expression } from 'estree'; import type { Namespace, SvelteNode, ValidatedCompileOptions } from '#compiler'; import type { TransformState } from '../types.js'; @@ -45,7 +46,10 @@ export interface ComponentClientTransformState extends ClientTransformState { /** Stuff that happens after the render effect (bindings, actions) */ readonly after_update: Statement[]; /** The HTML template string */ - readonly template: string[]; + readonly template: { + quasi: string[]; + expressions: Expression[]; + }; readonly metadata: { namespace: Namespace; /** `true` if the HTML template needs to be instantiated with `importNode` */ diff --git a/packages/svelte/src/compiler/phases/3-transform/client/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/utils.js index ca652801a75f..72be213a6730 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -7,6 +7,7 @@ import { PROPS_IS_RUNES, PROPS_IS_UPDATED } from '../../../../constants.js'; +import { GlobalBindings } from '../../constants.js'; /** * @template {import('./types').ClientTransformState} State @@ -518,3 +519,39 @@ export function should_proxy_or_freeze(node) { return true; } + +/** + * Whether a variable can be referenced directly from template string. + * @param {import('#compiler').Binding | undefined} binding + * @param {string} name + * @returns {boolean} + */ +export function can_inline_variable(binding, name) { + return can_hoist_declaration(binding, name) || (!!binding && !binding.scope.has_parent()); +} + +/** + * @param {import('#compiler').Binding | undefined} binding + * @param {string} name + * @returns {boolean} + */ +export function can_hoist_declaration(binding, name) { + // We cannot hoist functions or constructors because they could be non-deterministic + // (i.e. `Math.random()`) or have side-effects (e.g. `increment()`). We also cannot hoist + // anything that references a non-hoistable variable. + return ( + !!binding && + binding.kind === 'normal' && + binding.scope.is_top_level && + binding.scope.has_parent() && // i.e. not when context="module" + // For now we just allow primitives for simplicity + binding.initial?.type === 'Literal' && + // For now, we just check that it's not mutated or reassigned for simplicity + // E.g. if you have `let x = 0; x++` you could hoist both statements + !binding.mutated && + !binding.reassigned && + // Avoid conflicts. It would be nice to rename the variable, but keeping it simple for now + !binding.scope.declared_in_outer_scope(name) && + !GlobalBindings.has(name) + ); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js index ba09d5f04e74..64189ce8b080 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-legacy.js @@ -1,7 +1,7 @@ import { is_hoistable_function } from '../../utils.js'; import * as b from '../../../../utils/builders.js'; import { extract_paths } from '../../../../utils/ast.js'; -import { get_prop_source, serialize_get_binding } from '../utils.js'; +import { can_hoist_declaration, get_prop_source, serialize_get_binding } from '../utils.js'; /** * Creates the output for a state declaration. @@ -44,6 +44,18 @@ export const javascript_visitors_legacy = { if (!has_state && !has_props) { const init = declarator.init; + + if (init != null && declarator.id.type === 'Identifier') { + const binding = state.scope + .owner(declarator.id.name) + ?.declarations.get(declarator.id.name); + + if (can_hoist_declaration(binding, declarator.id.name)) { + state.hoisted.push(b.declaration('const', declarator.id, init)); + continue; + } + } + if (init != null && is_hoistable_function(init)) { const hoistable_function = visit(init); state.hoisted.push( diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js index 63c46d8c6464..edaac3a17d3c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js @@ -2,7 +2,12 @@ import { get_rune } from '../../../scope.js'; import { is_hoistable_function, transform_inspect_rune } from '../../utils.js'; import * as b from '../../../../utils/builders.js'; import * as assert from '../../../../utils/assert.js'; -import { get_prop_source, is_state_source, should_proxy_or_freeze } from '../utils.js'; +import { + can_hoist_declaration, + get_prop_source, + is_state_source, + should_proxy_or_freeze +} from '../utils.js'; import { extract_paths, unwrap_ts_expression } from '../../../../utils/ast.js'; /** @type {import('../types.js').ComponentVisitors} */ @@ -156,6 +161,15 @@ export const javascript_visitors_runes = { for (const declarator of node.declarations) { const init = unwrap_ts_expression(declarator.init); + + if (init != null && declarator.id.type === 'Identifier') { + const binding = state.scope.owner(declarator.id.name)?.declarations.get(declarator.id.name); + if (can_hoist_declaration(binding, declarator.id.name)) { + state.hoisted.push(b.declaration('const', declarator.id, init)); + continue; + } + } + const rune = get_rune(init, state.scope); if (!rune || rune === '$effect.active' || rune === '$effect.root' || rune === '$inspect') { if (init != null && is_hoistable_function(init)) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js index 8c3cb748c4fa..3a9eccdb74aa 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js @@ -17,6 +17,7 @@ import { is_custom_element_node, is_element_node } from '../../../nodes.js'; import * as b from '../../../../utils/builders.js'; import { error } from '../../../../errors.js'; import { + can_inline_variable, function_visitor, get_assignment_value, serialize_get_binding, @@ -35,6 +36,32 @@ import { regex_is_valid_identifier } from '../../../patterns.js'; import { javascript_visitors_runes } from './javascript-runes.js'; import { sanitize_template_string } from '../../../../utils/sanitize_template_string.js'; +/** + * @param {import('../types.js').ComponentClientTransformState} state + * @param {string} quasi_to_add + */ +function push_template_quasi(state, quasi_to_add) { + const { quasi } = state.template; + if (quasi.length === 0) { + quasi.push(quasi_to_add); + return; + } + quasi[quasi.length - 1] = quasi[quasi.length - 1].concat(quasi_to_add); +} + +/** + * @param {import('../types.js').ComponentClientTransformState} state + * @param {import('estree').Expression} expression_to_add + */ +function push_template_expression(state, expression_to_add) { + const { expressions, quasi } = state.template; + if (quasi.length === 0) { + quasi.push(''); + } + expressions.push(expression_to_add); + quasi.push(''); +} + /** * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} element * @param {import('#compiler').Attribute} attribute @@ -555,7 +582,10 @@ function serialize_element_attribute_update_assignment(element, node_id, attribu } }; - if (attribute.metadata.dynamic) { + const { has_expression_tag, can_inline } = Array.isArray(attribute.value) + ? can_inline_all_nodes(attribute.value, context.state) + : { has_expression_tag: false, can_inline: true }; + if (attribute.metadata.dynamic && !can_inline) { const id = state.scope.generate(`${node_id.name}_${name}`); serialize_update_assignment( state, @@ -567,11 +597,40 @@ function serialize_element_attribute_update_assignment(element, node_id, attribu ); return true; } else { - state.init.push(assign(grouped_value).grouped); + if (has_expression_tag && can_inline) { + push_template_quasi(context.state, ` ${name}="`); + push_template_expression(context.state, grouped_value); + push_template_quasi(context.state, `"`); + } else { + state.init.push(assign(grouped_value).grouped); + } return false; } } +/** + * @param {(import('#compiler').Text | import('#compiler').ExpressionTag)[]} nodes + * @param {import('../types.js').ComponentClientTransformState} state + */ +function can_inline_all_nodes(nodes, state) { + let can_inline = true; + let has_expression_tag = false; + for (let value of nodes) { + if (value.type === 'ExpressionTag') { + if (value.expression.type === 'Identifier') { + const binding = state.scope + .owner(value.expression.name) + ?.declarations.get(value.expression.name); + can_inline &&= can_inline_variable(binding, value.expression.name); + } else { + can_inline = false; + } + has_expression_tag = true; + } + } + return { can_inline, has_expression_tag }; +} + /** * Like `serialize_element_attribute_update_assignment` but without any special attribute treatment. * @param {import('estree').Identifier} node_id @@ -603,7 +662,10 @@ function serialize_custom_element_attribute_update_assignment(node_id, attribute }; }; - if (attribute.metadata.dynamic) { + const { has_expression_tag, can_inline } = Array.isArray(attribute.value) + ? can_inline_all_nodes(attribute.value, context.state) + : { has_expression_tag: false, can_inline: true }; + if (attribute.metadata.dynamic && !can_inline) { const id = state.scope.generate(`${node_id.name}_${name}`); // TODO should this use the if condition? what if someone mutates the value passed to the ce? serialize_update_assignment( @@ -616,7 +678,13 @@ function serialize_custom_element_attribute_update_assignment(node_id, attribute ); return true; } else { - state.init.push(assign(grouped_value).grouped); + if (has_expression_tag && can_inline) { + push_template_quasi(context.state, ` ${name}="`); + push_template_expression(context.state, grouped_value); + push_template_quasi(context.state, `"`); + } else { + state.init.push(assign(grouped_value).grouped); + } return false; } } @@ -1061,7 +1129,10 @@ function create_block(parent, name, nodes, context) { update: [], update_effects: [], after_update: [], - template: [], + template: { + quasi: [], + expressions: [] + }, metadata: { template_needs_import_node: false, namespace, @@ -1086,7 +1157,16 @@ function create_block(parent, name, nodes, context) { const callee = namespace === 'svg' ? '$.svg_template' : '$.template'; context.state.hoisted.push( - b.var(template_name, b.call(callee, b.template([b.quasi(state.template.join(''), true)], []))) + b.var( + template_name, + b.call( + callee, + b.template( + state.template.quasi.map((quasi) => b.quasi(quasi, true)), + state.template.expressions + ) + ) + ) ); body.push( @@ -1114,27 +1194,29 @@ function create_block(parent, name, nodes, context) { state }); - 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)); - } + const quasis = state.template.quasi; + if (quasis.length === 1 && quasis[0] === ' ') { + 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 if (quasis.length === 1 && quasis[0] === '') { + 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'; - state.hoisted.push( b.var( template_name, - b.call(callee, b.template([b.quasi(state.template.join(''), true)], []), b.true) + b.call( + namespace === 'svg' ? '$.svg_template' : '$.template', + b.template( + quasis.map((quasi) => b.quasi(quasi, true)), + state.template.expressions + ), + b.true + ) ) ); @@ -1452,11 +1534,11 @@ function process_children(nodes, parent, { visit, state }) { } if (node.type === 'Text') { - state.template.push(node.raw); + push_template_quasi(state, node.raw); return; } - state.template.push(' '); + push_template_quasi(state, ' '); const text_id = get_node_id(expression, state, 'text'); const singular = b.stmt( @@ -1498,8 +1580,6 @@ function process_children(nodes, parent, { visit, state }) { return; } - state.template.push(' '); - const text_id = get_node_id(expression, state, 'text'); const contains_call_expression = sequence.some( (n) => n.type === 'ExpressionTag' && n.metadata.contains_call_expression @@ -1515,17 +1595,30 @@ function process_children(nodes, parent, { visit, state }) { ); if (contains_call_expression && !within_bound_contenteditable) { + push_template_quasi(state, ' '); state.update_effects.push(singular); } else if ( sequence.some((node) => node.type === 'ExpressionTag' && node.metadata.dynamic) && !within_bound_contenteditable ) { + push_template_quasi(state, ' '); state.update.push({ singular, grouped: b.stmt(b.call('$.text', text_id, assignment)) }); } else { - state.init.push(init); + const { can_inline } = can_inline_all_nodes(sequence, state); + if (can_inline) { + for (let i = 0; i < assignment.quasis.length; i++) { + push_template_quasi(state, assignment.quasis[i].value.raw); + if (i <= assignment.expressions.length - 1) { + push_template_expression(state, assignment.expressions[i]); + } + } + } else { + push_template_quasi(state, ' '); + state.init.push(init); + } } expression = b.call('$.sibling', text_id); @@ -1654,10 +1747,10 @@ function serialize_template_literal(values, visit, state) { /** @type {import('estree').Expression[]} */ const expressions = []; - const scope = state.scope; let contains_call_expression = false; quasis.push(b.quasi('')); + let can_inline = true; for (let i = 0; i < values.length; i++) { const node = values[i]; if (node.type === 'Text') { @@ -1684,10 +1777,10 @@ export const template_visitors = { }, Comment(node, context) { // We'll only get here if comments are not filtered out, which they are unless preserveComments is true - context.state.template.push(``); + push_template_quasi(context.state, ``); }, HtmlTag(node, context) { - context.state.template.push(''); + push_template_quasi(context.state, ''); // push into init, so that bindings run afterwards, which might trigger another run and override hydration context.state.init.push( @@ -1775,7 +1868,7 @@ export const template_visitors = { ); }, RenderTag(node, context) { - context.state.template.push(''); + push_template_quasi(context.state, ''); const binding = context.state.scope.get(node.expression.name); const is_reactive = binding?.kind !== 'normal' || node.expression.type !== 'Identifier'; @@ -1848,7 +1941,7 @@ export const template_visitors = { }, RegularElement(node, context) { if (node.name === 'noscript') { - context.state.template.push(''); + push_template_quasi(context.state, ''); return; } @@ -1858,7 +1951,7 @@ export const template_visitors = { namespace: determine_element_namespace(node, context.state.metadata.namespace, context.path) }; - context.state.template.push(`<${node.name}`); + push_template_quasi(context.state, `<${node.name}`); /** @type {Array} */ const attributes = []; @@ -1999,7 +2092,8 @@ export const template_visitors = { if (name !== 'class' || literal_value) { // TODO namespace=foreign probably doesn't want to do template stuff at all and instead use programmatic methods // to create the elements it needs. - context.state.template.push( + push_template_quasi( + context.state, ` ${attribute.name}${ DOMBooleanAttributes.includes(name) && literal_value === true ? '' @@ -2022,7 +2116,7 @@ export const template_visitors = { serialize_class_directives(class_directives, node_id, context, is_attributes_reactive); serialize_style_directives(style_directives, node_id, context, is_attributes_reactive); - context.state.template.push('>'); + push_template_quasi(context.state, '>'); /** @type {import('../types').ComponentClientTransformState} */ const state = { @@ -2062,11 +2156,11 @@ export const template_visitors = { ); if (!VoidElements.includes(node.name)) { - context.state.template.push(``); + push_template_quasi(context.state, ``); } }, SvelteElement(node, context) { - context.state.template.push(``); + push_template_quasi(context.state, ``); /** @type {Array} */ const attributes = []; @@ -2177,7 +2271,7 @@ export const template_visitors = { let each_item_is_reactive = true; if (!each_node_meta.is_controlled) { - context.state.template.push(''); + push_template_quasi(context.state, ''); } if (each_node_meta.array_name !== null) { @@ -2396,7 +2490,7 @@ export const template_visitors = { } }, IfBlock(node, context) { - context.state.template.push(''); + push_template_quasi(context.state, ''); const consequent = /** @type {import('estree').BlockStatement} */ ( context.visit(node.consequent) @@ -2420,7 +2514,7 @@ export const template_visitors = { ); }, AwaitBlock(node, context) { - context.state.template.push(''); + push_template_quasi(context.state, ''); context.state.after_update.push( b.stmt( @@ -2461,7 +2555,7 @@ export const template_visitors = { ); }, KeyBlock(node, context) { - context.state.template.push(''); + push_template_quasi(context.state, ''); const key = /** @type {import('estree').Expression} */ (context.visit(node.expression)); const body = /** @type {import('estree').Expression} */ (context.visit(node.fragment)); context.state.after_update.push( @@ -2792,7 +2886,7 @@ export const template_visitors = { } }, Component(node, context) { - context.state.template.push(''); + push_template_quasi(context.state, ''); const binding = context.state.scope.get( node.name.includes('.') ? node.name.slice(0, node.name.indexOf('.')) : node.name @@ -2820,12 +2914,12 @@ export const template_visitors = { context.state.after_update.push(component); }, SvelteSelf(node, context) { - context.state.template.push(''); + push_template_quasi(context.state, ''); const component = serialize_inline_component(node, context.state.analysis.name, context); context.state.after_update.push(component); }, SvelteComponent(node, context) { - context.state.template.push(''); + push_template_quasi(context.state, ''); let component = serialize_inline_component(node, '$$component', context); if (context.state.options.dev) { @@ -2920,7 +3014,7 @@ export const template_visitors = { }, SlotElement(node, context) { // fallback --> $.slot($$slots.default, { get a() { .. } }, () => ...fallback); - context.state.template.push(''); + push_template_quasi(context.state, ''); /** @type {import('estree').Property[]} */ const props = []; diff --git a/packages/svelte/src/compiler/phases/constants.js b/packages/svelte/src/compiler/phases/constants.js index b8663e97f013..d31c38af4ec2 100644 --- a/packages/svelte/src/compiler/phases/constants.js +++ b/packages/svelte/src/compiler/phases/constants.js @@ -70,6 +70,76 @@ export const ElementBindings = [ 'indeterminate' ]; +export const GlobalBindings = new Set([ + 'Map', + 'Set', + 'Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array', + 'BigInt64Array', + 'BigUint64Array', + 'Float32Array', + 'Float64Array', + 'Int8Array', + 'Function', + 'Error', + 'AggregateError', + 'EvalError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'TypeError', + 'URIError', + 'InternalError', + 'Number', + 'Math', + 'BigInt', + 'String', + 'RegEx', + 'Date', + 'Boolean', + 'Symbol', + 'Object', + 'ArrayBuffer', + 'SharedArrayBuffer', + 'DataView', + 'Atomics', + 'JSON', + 'WeakRef', + 'FinalizationRegistry', + 'Iterator', + 'AsyncIterator', + 'Promise', + 'GeneratorFunction', + 'AsyncGeneratorFunction', + 'Generator', + 'AsyncGenerator', + 'AsyncFunction', + 'Reflect', + 'Proxy', + 'Intl', + 'eval', + 'isFinite', + 'isNaN', + 'parseFloat', + 'parseInt', + 'decodeURI', + 'decodeURIComponent', + 'encodeURI', + 'encodeURIComponent', + 'console', + 'undefined', + 'NaN', + 'Infinity', + 'globalThis', + 'window', + 'document' +]); + export const Runes = /** @type {const} */ ([ '$state', '$state.frozen', diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index 0530535087f3..b69bae8aaed6 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -10,6 +10,13 @@ export class Scope { /** @type {ScopeRoot} */ root; + /** + * Whether this is a top-level script scope. + * I.e. ` + + + +

boolean is {boolean} and autocapitalize is w{o}r{d}s

+ +example diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 7baeab418975..1284ab247ab1 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1001,9 +1001,14 @@ declare module 'svelte/compiler' { export const VERSION: string; class Scope { - constructor(root: ScopeRoot, parent: Scope | null, porous: boolean); + constructor(root: ScopeRoot, parent: Scope | null, porous: boolean, is_top_level: boolean); root: ScopeRoot; + /** + * Whether this is a top-level script scope. + * I.e. `