perf: deduplicate identical hoisted templates within a component (#18320)

Byte-identical templates hoisted from different fragments, elements, or
branches now share a single `$.from_html` factory instead of each
emitting its own module-scope variable - this shrinks generated output
and avoids redundant runtime template parsing. Dedup is keyed on
`(content, flags)` and is skipped in dev mode (templates there are
wrapped in `$.add_locations`, which embeds per-call-site info).

### Before submitting the PR, please make sure you do the following

- [ ] It's really useful if your PR references an issue where it is
discussed ahead of time. In many cases, features are absent for a
reason. For large changes, please create an RFC:
https://github.com/sveltejs/rfcs
- [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`.
- [x] This message body should clearly illustrate what problems it
solves.
- [x] Ideally, include a test that fails without this PR but passes with
it.
- [x] If this PR changes code within `packages/svelte/src`, add a
changeset (`npx changeset`).

### Tests and linting

- [x] Run the tests with `pnpm test` and lint the project with `pnpm
lint`

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/18322/head
Mathias Picker 2 days ago committed by GitHub
parent a40c745fd9
commit 2fae91af6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: deduplicate identical hoisted templates within a component

@ -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(),

@ -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}`;
}

@ -40,6 +40,8 @@ export interface ComponentClientTransformState extends ClientTransformState {
readonly analysis: ComponentAnalysis;
readonly options: ValidatedCompileOptions;
readonly hoisted: Array<Statement | ModuleDeclaration>;
/** Deduplicates hoisted templates by content, mapping a template key to its hoisted identifier */
readonly templates: Map<string, Identifier>;
readonly events: Set<string>;
readonly store_to_invalidate?: string;

@ -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)));
}

@ -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)

@ -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(`<p> </p>`);
var root = $.from_html(`<p> </p>`);
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);

@ -0,0 +1,44 @@
import 'svelte/internal/disclose-version';
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<p class="x">hello</p>`);
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);
}

@ -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('<!--[0-->');
$$renderer.push(`<p class="x">hello</p>`);
} else {
$$renderer.push('<!--[-1-->');
$$renderer.push(`<p class="x">hello</p>`);
}
$$renderer.push(`<!--]--> `);
if (b) {
$$renderer.push('<!--[0-->');
$$renderer.push(`<p class="x">hello</p>`);
} else {
$$renderer.push('<!--[-1-->');
}
$$renderer.push(`<!--]-->`);
}

@ -0,0 +1,14 @@
<script>
let { a, b } = $props();
</script>
<!-- identical static markup repeated across branches should share one hoisted template -->
{#if a}
<p class="x">hello</p>
{:else}
<p class="x">hello</p>
{/if}
{#if b}
<p class="x">hello</p>
{/if}

@ -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(`<button type="button">B</button>`);
var root = $.from_html(`<button type="button">B</button>`);
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);

@ -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(`<p></p>`);
var root = $.from_html(`<p></p>`);
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);

@ -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(`<option>Snippet</option>`);
var root_2 = $.from_html(`<option>Rendered</option>`);
var root_3 = $.from_html(`<option>Rendered in group</option>`);
var root_4 = $.from_html(`<option>Conditional</option>`);
var root = $.from_html(`<option>Snippet</option>`);
var root_1 = $.from_html(`<option>Rendered</option>`);
var root_2 = $.from_html(`<option>Rendered in group</option>`);
var root_3 = $.from_html(`<option>Conditional</option>`);
var option_content = $.from_html(`<span>Rich</span>`, 1);
var root_5 = $.from_html(`<option> </option>`);
var root_6 = $.from_html(`<option>Visible</option>`);
var root_7 = $.from_html(`<option>Keyed</option>`);
var root_4 = $.from_html(`<option> </option>`);
var root_5 = $.from_html(`<option>Visible</option>`);
var root_6 = $.from_html(`<option>Keyed</option>`);
var select_content = $.from_html(`<!>`, 1);
var root_8 = $.from_html(`<option> </option>`);
var option_content_1 = $.from_html(`<strong>Bold</strong>`, 1);
var root_9 = $.from_html(`<option> </option>`);
var option_content_2 = $.from_html(`<em>Italic</em> text`, 1);
var option_content_3 = $.from_html(`<span> </span>`, 1);
var root_10 = $.from_html(`<option><!></option>`);
var root_12 = $.from_html(`<option> </option>`);
var root_13 = $.from_html(`<option>Boundary</option>`);
var root_7 = $.from_html(`<option><!></option>`);
var root_8 = $.from_html(`<option>Boundary</option>`);
var option_content_4 = $.from_html(`<span>Rich in boundary</span>`, 1);
var root_14 = $.from_html(`<option><!></option>`);
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(`<select><option><!></option></select> <select></select> <select><!></select> <select><!></select> <select><!></select> <select></select> <select><optgroup label="Group"><option><!></option></optgroup></select> <select><optgroup label="Group"></optgroup></select> <select><option><!></option></select> <select></select> <select><!></select> <select><!></select> <select><!></select> <select><!></select> <select><!></select> <select><!></select> <select><optgroup label="Group"><!></optgroup></select> <select><optgroup label="Group"><!></optgroup></select> <select><option><!></option></select> <select><!></select> <select><!></select>`, 1);
var root_9 = $.from_html(`<select><option><!></option></select> <select></select> <select><!></select> <select><!></select> <select><!></select> <select></select> <select><optgroup label="Group"><option><!></option></optgroup></select> <select><optgroup label="Group"></optgroup></select> <select><option><!></option></select> <select></select> <select><!></select> <select><!></select> <select><!></select> <select><!></select> <select><!></select> <select><!></select> <select><optgroup label="Group"><!></optgroup></select> <select><optgroup label="Group"><!></optgroup></select> <select><option><!></option></select> <select><!></select> <select><!></select>`, 1);
export default function Select_with_rich_content($$anchor) {
let items = [1, 2, 3];
let show = true;
let html = '<option>From HTML</option>';
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, () => '<strong>Bold HTML</strong>');
@ -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);
{

Loading…
Cancel
Save