diff --git a/.changeset/dedupe-hoisted-templates.md b/.changeset/dedupe-hoisted-templates.md new file mode 100644 index 0000000000..a91882f525 --- /dev/null +++ b/.changeset/dedupe-hoisted-templates.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: deduplicate identical hoisted templates within a component 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 6a71a49601..552fe89960 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 @@ -141,6 +141,7 @@ export function client_component(analysis, options) { scopes: analysis.module.scopes, is_instance: false, hoisted: [b.import_all('$', 'svelte/internal/client'), ...analysis.instance_body.hoisted], + templates: new Map(), node: /** @type {any} */ (null), // populated by the root node legacy_reactive_imports: [], legacy_reactive_statements: new Map(), diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js index 40c0907e38..5fdc88844b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js @@ -1,3 +1,4 @@ +/** @import { TemplateLiteral } from 'estree' */ /** @import { Namespace } from '#compiler' */ /** @import { ComponentClientTransformState } from '../types.js' */ /** @import { Node } from './types.js' */ @@ -31,14 +32,29 @@ function build_locations(nodes) { /** * @param {ComponentClientTransformState} state - * @param {Namespace} namespace + * @param {string} name * @param {number} [flags] */ -export function transform_template(state, namespace, flags = 0) { +export function transform_template(state, name, flags = 0) { + const namespace = state.metadata.namespace; const tree = state.options.fragments === 'tree'; const expression = tree ? state.template.as_tree() : state.template.as_html(); + const key = + tree || dev + ? null + : get_template_key( + /** @type {TemplateLiteral} */ (expression), + state.metadata.namespace, + flags + ); + + if (key !== null) { + const existing = state.templates.get(key); + if (existing !== undefined) return existing; + } + if (tree) { if (namespace === 'svg') flags |= TEMPLATE_USE_SVG; if (namespace === 'mathml') flags |= TEMPLATE_USE_MATHML; @@ -63,5 +79,26 @@ export function transform_template(state, namespace, flags = 0) { ); } - return call; + const id = state.scope.root.unique(name); + state.hoisted.push(b.var(id, call)); + + if (key !== null) { + state.templates.set(key, id); + } + + return id; +} + +/** + * Returns a stable key for templates that are safe to deduplicate - plain + * `$.from_html`/`from_svg`/`from_mathml` factories with literal arguments - or `null` + * for anything else. Dev-mode templates are wrapped in `$.add_locations(...)`, which + * embeds per-call-site locations, so they never produce a key and are never shared. + * @param {TemplateLiteral} template + * @param {Namespace} namespace + * @param {number} flags + * @returns {string | null} + */ +function get_template_key(template, namespace, flags) { + return `${namespace} ${flags} ${template.quasis[0].value.raw}`; } 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 287bf24ac6..7a95a2d43c 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 @@ -40,6 +40,8 @@ export interface ComponentClientTransformState extends ClientTransformState { readonly analysis: ComponentAnalysis; readonly options: ValidatedCompileOptions; readonly hoisted: Array; + /** Deduplicates hoisted templates by content, mapping a template key to its hoisted identifier */ + readonly templates: Map; readonly events: Set; readonly store_to_invalidate?: string; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index ad0c487fb4..893b1db568 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -52,7 +52,6 @@ export function Fragment(node, context) { (trimmed[0].type === 'IfBlock' && trimmed[0].elseif && /** @type {AST.IfBlock} */ (parent).metadata.flattened?.includes(trimmed[0]))); - const template_name = context.state.scope.root.unique('root'); // TODO infer name from parent /** @type {Statement[]} */ const body = []; @@ -96,8 +95,7 @@ export function Fragment(node, context) { let flags = state.template.needs_import_node ? TEMPLATE_USE_IMPORT_NODE : undefined; - const template = transform_template(state, namespace, flags); - state.hoisted.push(b.var(template_name, template)); + const template_name = transform_template(state, 'root', flags); state.init.unshift(b.var(id, b.call(template_name))); close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); @@ -147,8 +145,7 @@ export function Fragment(node, context) { // special case — we can use `$.comment` instead of creating a unique template state.init.unshift(b.var(id, b.call('$.comment'))); } else { - const template = transform_template(state, namespace, flags); - state.hoisted.push(b.var(template_name, template)); + const template_name = transform_template(state, 'root', flags); state.init.unshift(b.var(id, b.call(template_name))); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index 543f4aea17..8dcd3c7d6f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -374,7 +374,6 @@ export function RegularElement(node, context) { context.state.template.push_comment(); // Create a separate template for the rich content - const template_name = context.state.scope.root.unique(`${name}_content`); const fragment_id = b.id(context.state.scope.generate('fragment')); const anchor_id = b.id(context.state.scope.generate('anchor')); @@ -398,9 +397,8 @@ export function RegularElement(node, context) { } ); - // Transform the template to $.from_html(...) and hoist it - const template = transform_template(select_state, metadata.namespace, TEMPLATE_FRAGMENT); - context.state.hoisted.push(b.var(template_name, template)); + // Transform the template to $.from_html(...) and hoist it (deduplicating identical templates) + const template_name = transform_template(select_state, `${name}_content`, TEMPLATE_FRAGMENT); // Build the rich content function body // The anchor is the child of the element (a hydration marker during hydration) diff --git a/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js index 8bd7e97780..5c1d27ae54 100644 --- a/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/async-const/_expected/client/index.svelte.js @@ -2,7 +2,7 @@ import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/async'; import * as $ from 'svelte/internal/client'; -var root_1 = $.from_html(`

`); +var root = $.from_html(`

`); export default function Async_const($$anchor) { var fragment = $.comment(); @@ -18,7 +18,7 @@ export default function Async_const($$anchor) { () => b = $.derived(() => $.get(a) + 1) ]); - var p = root_1(); + var p = root(); var text = $.child(p, true); $.reset(p); diff --git a/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/client/index.svelte.js new file mode 100644 index 0000000000..d0d474fa29 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/client/index.svelte.js @@ -0,0 +1,44 @@ +import 'svelte/internal/disclose-version'; +import * as $ from 'svelte/internal/client'; + +var root = $.from_html(`

hello

`); +var root_1 = $.from_html(` `, 1); + +export default function Dedupe_templates($$anchor, $$props) { + var fragment = root_1(); + var node = $.first_child(fragment); + + { + var consequent = ($$anchor) => { + var p = root(); + + $.append($$anchor, p); + }; + + var alternate = ($$anchor) => { + var p_1 = root(); + + $.append($$anchor, p_1); + }; + + $.if(node, ($$render) => { + if ($$props.a) $$render(consequent); else $$render(alternate, -1); + }); + } + + var node_1 = $.sibling(node, 2); + + { + var consequent_1 = ($$anchor) => { + var p_2 = root(); + + $.append($$anchor, p_2); + }; + + $.if(node_1, ($$render) => { + if ($$props.b) $$render(consequent_1); + }); + } + + $.append($$anchor, fragment); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/server/index.svelte.js new file mode 100644 index 0000000000..1f4a56779e --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/dedupe-templates/_expected/server/index.svelte.js @@ -0,0 +1,24 @@ +import * as $ from 'svelte/internal/server'; + +export default function Dedupe_templates($$renderer, $$props) { + let { a, b } = $$props; + + if (a) { + $$renderer.push(''); + $$renderer.push(`

hello

`); + } else { + $$renderer.push(''); + $$renderer.push(`

hello

`); + } + + $$renderer.push(` `); + + if (b) { + $$renderer.push(''); + $$renderer.push(`

hello

`); + } else { + $$renderer.push(''); + } + + $$renderer.push(``); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/dedupe-templates/index.svelte b/packages/svelte/tests/snapshot/samples/dedupe-templates/index.svelte new file mode 100644 index 0000000000..c75b5481a5 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/dedupe-templates/index.svelte @@ -0,0 +1,14 @@ + + + +{#if a} +

hello

+{:else} +

hello

+{/if} + +{#if b} +

hello

+{/if} diff --git a/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js index 43f2eadc09..d2f2f87f20 100644 --- a/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/delegated-locally-declared-shadowed/_expected/client/index.svelte.js @@ -2,14 +2,14 @@ import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; -var root_1 = $.from_html(``); +var root = $.from_html(``); export default function Delegated_locally_declared_shadowed($$anchor) { var fragment = $.comment(); var node = $.first_child(fragment); $.each(node, 0, () => ({ length: 1 }), $.index, ($$anchor, $$item, index) => { - var button = root_1(); + var button = root(); $.set_attribute(button, 'data-index', index); diff --git a/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js index 804a7c26f1..049d47a96f 100644 --- a/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client/index.svelte.js @@ -2,14 +2,14 @@ import 'svelte/internal/disclose-version'; import 'svelte/internal/flags/legacy'; import * as $ from 'svelte/internal/client'; -var root_1 = $.from_html(`

`); +var root = $.from_html(`

`); export default function Each_index_non_null($$anchor) { var fragment = $.comment(); var node = $.first_child(fragment); $.each(node, 0, () => Array(10), $.index, ($$anchor, $$item, i) => { - var p = root_1(); + var p = root(); p.textContent = `index: ${i}`; $.append($$anchor, p); diff --git a/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js index 8f8b115d70..c8354fe67e 100644 --- a/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/select-with-rich-content/_expected/client/index.svelte.js @@ -4,63 +4,51 @@ import * as $ from 'svelte/internal/client'; import Option from './Option.svelte'; const opt = ($$anchor) => { - var option = root_1(); + var option = root(); $.append($$anchor, option); }; const option_snippet = ($$anchor) => { - var option_1 = root_2(); + var option_1 = root_1(); $.append($$anchor, option_1); }; const option_snippet2 = ($$anchor) => { - var option_2 = root_3(); + var option_2 = root_2(); $.append($$anchor, option_2); }; const conditional_option = ($$anchor) => { - var option_3 = root_4(); + var option_3 = root_3(); $.append($$anchor, option_3); }; -var root_1 = $.from_html(``); -var root_2 = $.from_html(``); -var root_3 = $.from_html(``); -var root_4 = $.from_html(``); +var root = $.from_html(``); +var root_1 = $.from_html(``); +var root_2 = $.from_html(``); +var root_3 = $.from_html(``); var option_content = $.from_html(`Rich`, 1); -var root_5 = $.from_html(``); -var root_6 = $.from_html(``); -var root_7 = $.from_html(``); +var root_4 = $.from_html(``); +var root_5 = $.from_html(``); +var root_6 = $.from_html(``); var select_content = $.from_html(``, 1); -var root_8 = $.from_html(``); var option_content_1 = $.from_html(`Bold`, 1); -var root_9 = $.from_html(``); var option_content_2 = $.from_html(`Italic text`, 1); var option_content_3 = $.from_html(` `, 1); -var root_10 = $.from_html(``); -var root_12 = $.from_html(``); -var root_13 = $.from_html(``); +var root_7 = $.from_html(``); +var root_8 = $.from_html(``); var option_content_4 = $.from_html(`Rich in boundary`, 1); -var root_14 = $.from_html(``); -var select_content_1 = $.from_html(``, 1); -var select_content_2 = $.from_html(``, 1); -var select_content_3 = $.from_html(``, 1); -var optgroup_content = $.from_html(``, 1); -var optgroup_content_1 = $.from_html(``, 1); -var option_content_5 = $.from_html(``, 1); -var select_content_4 = $.from_html(``, 1); -var select_content_5 = $.from_html(``, 1); -var root = $.from_html(` `, 1); +var root_9 = $.from_html(` `, 1); export default function Select_with_rich_content($$anchor) { let items = [1, 2, 3]; let show = true; let html = ''; - var fragment = root(); + var fragment = root_9(); var select = $.first_child(fragment); var option_4 = $.child(select); @@ -76,7 +64,7 @@ export default function Select_with_rich_content($$anchor) { var select_1 = $.sibling(select, 2); $.each(select_1, 5, () => items, $.index, ($$anchor, item) => { - var option_5 = root_5(); + var option_5 = root_4(); var text = $.child(option_5, true); $.reset(option_5); @@ -101,7 +89,7 @@ export default function Select_with_rich_content($$anchor) { { var consequent = ($$anchor) => { - var option_6 = root_6(); + var option_6 = root_5(); $.append($$anchor, option_6); }; @@ -117,7 +105,7 @@ export default function Select_with_rich_content($$anchor) { var node_1 = $.child(select_3); $.key(node_1, () => items, ($$anchor) => { - var option_7 = root_7(); + var option_7 = root_6(); $.append($$anchor, option_7); }); @@ -139,7 +127,7 @@ export default function Select_with_rich_content($$anchor) { $.each(select_5, 5, () => items, $.index, ($$anchor, item) => { const x = $.derived_safe_equal(() => $.get(item) * 2); - var option_8 = root_8(); + var option_8 = root_4(); var text_1 = $.child(option_8, true); $.reset(option_8); @@ -177,7 +165,7 @@ export default function Select_with_rich_content($$anchor) { var optgroup_1 = $.child(select_7); $.each(optgroup_1, 5, () => items, $.index, ($$anchor, item) => { - var option_10 = root_9(); + var option_10 = root_4(); var text_2 = $.child(option_10, true); $.reset(option_10); @@ -215,7 +203,7 @@ export default function Select_with_rich_content($$anchor) { var select_9 = $.sibling(select_8, 2); $.each(select_9, 5, () => items, $.index, ($$anchor, item) => { - var option_12 = root_10(); + var option_12 = root_7(); $.customizable_select(option_12, () => { var anchor_4 = $.child(option_12); @@ -242,7 +230,7 @@ export default function Select_with_rich_content($$anchor) { var node_4 = $.first_child(fragment_6); $.each(node_4, 1, () => items, $.index, ($$anchor, item) => { - var option_13 = root_12(); + var option_13 = root_4(); var text_4 = $.child(option_13, true); $.reset(option_13); @@ -274,7 +262,7 @@ export default function Select_with_rich_content($$anchor) { var node_5 = $.child(select_11); $.boundary(node_5, {}, ($$anchor) => { - var option_14 = root_13(); + var option_14 = root_8(); $.append($$anchor, option_14); }); @@ -285,7 +273,7 @@ export default function Select_with_rich_content($$anchor) { var node_6 = $.child(select_12); $.boundary(node_6, {}, ($$anchor) => { - var option_15 = root_14(); + var option_15 = root_7(); $.customizable_select(option_15, () => { var anchor_5 = $.child(option_15); @@ -303,7 +291,7 @@ export default function Select_with_rich_content($$anchor) { $.customizable_select(select_13, () => { var anchor_6 = $.child(select_13); - var fragment_8 = select_content_1(); + var fragment_8 = select_content(); var node_7 = $.first_child(fragment_8); Option(node_7, {}); @@ -314,7 +302,7 @@ export default function Select_with_rich_content($$anchor) { $.customizable_select(select_14, () => { var anchor_7 = $.child(select_14); - var fragment_9 = select_content_2(); + var fragment_9 = select_content(); var node_8 = $.first_child(fragment_9); option_snippet(node_8); @@ -325,7 +313,7 @@ export default function Select_with_rich_content($$anchor) { $.customizable_select(select_15, () => { var anchor_8 = $.child(select_15); - var fragment_10 = select_content_3(); + var fragment_10 = select_content(); var node_9 = $.first_child(fragment_10); $.html(node_9, () => html); @@ -337,7 +325,7 @@ export default function Select_with_rich_content($$anchor) { $.customizable_select(optgroup_2, () => { var anchor_9 = $.child(optgroup_2); - var fragment_11 = optgroup_content(); + var fragment_11 = select_content(); var node_10 = $.first_child(fragment_11); Option(node_10, {}); @@ -351,7 +339,7 @@ export default function Select_with_rich_content($$anchor) { $.customizable_select(optgroup_3, () => { var anchor_10 = $.child(optgroup_3); - var fragment_12 = optgroup_content_1(); + var fragment_12 = select_content(); var node_11 = $.first_child(fragment_12); option_snippet2(node_11); @@ -365,7 +353,7 @@ export default function Select_with_rich_content($$anchor) { $.customizable_select(option_16, () => { var anchor_11 = $.child(option_16); - var fragment_13 = option_content_5(); + var fragment_13 = select_content(); var node_12 = $.first_child(fragment_13); $.html(node_12, () => 'Bold HTML'); @@ -378,7 +366,7 @@ export default function Select_with_rich_content($$anchor) { $.customizable_select(select_19, () => { var anchor_12 = $.child(select_19); - var fragment_14 = select_content_4(); + var fragment_14 = select_content(); var node_13 = $.first_child(fragment_14); $.each(node_13, 1, () => items, $.index, ($$anchor, item) => { @@ -392,7 +380,7 @@ export default function Select_with_rich_content($$anchor) { $.customizable_select(select_20, () => { var anchor_13 = $.child(select_20); - var fragment_16 = select_content_5(); + var fragment_16 = select_content(); var node_14 = $.first_child(fragment_16); {