From de8a38ba0e3bc46b54cac0817aec26ca73f73fbf Mon Sep 17 00:00:00 2001
From: paoloricciuti <ricciutipaolo@gmail.com>
Date: Tue, 18 Mar 2025 18:16:18 +0100
Subject: [PATCH] feat: templateless template generation

---
 .changeset/smart-boats-accept.md              |   5 +
 .../client/transform-template/index.js        |  12 ++
 .../client/transform-template/to-string.js    | 170 ++++++++++++++++++
 .../phases/3-transform/client/types.d.ts      |  14 +-
 .../3-transform/client/visitors/AwaitBlock.js |   2 +-
 .../3-transform/client/visitors/Comment.js    |   2 +-
 .../3-transform/client/visitors/EachBlock.js  |   2 +-
 .../3-transform/client/visitors/Fragment.js   |  10 +-
 .../3-transform/client/visitors/HtmlTag.js    |   2 +-
 .../3-transform/client/visitors/IfBlock.js    |   2 +-
 .../3-transform/client/visitors/KeyBlock.js   |   2 +-
 .../client/visitors/RegularElement.js         |  36 ++--
 .../3-transform/client/visitors/RenderTag.js  |   2 +-
 .../client/visitors/SlotElement.js            |   2 +-
 .../client/visitors/SvelteBoundary.js         |   2 +-
 .../client/visitors/SvelteElement.js          |   2 +-
 .../client/visitors/shared/component.js       |  25 ++-
 .../client/visitors/shared/fragment.js        |  11 +-
 18 files changed, 265 insertions(+), 38 deletions(-)
 create mode 100644 .changeset/smart-boats-accept.md
 create mode 100644 packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js
 create mode 100644 packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-string.js

diff --git a/.changeset/smart-boats-accept.md b/.changeset/smart-boats-accept.md
new file mode 100644
index 0000000000..22be423e1f
--- /dev/null
+++ b/.changeset/smart-boats-accept.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: templateless template generation
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
new file mode 100644
index 0000000000..a2854abad2
--- /dev/null
+++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js
@@ -0,0 +1,12 @@
+/**
+ * @import { TemplateOperations } from "../types.js"
+ */
+import { template_to_string } from './to-string';
+
+/**
+ * @param {TemplateOperations} items
+ */
+export function transform_template(items) {
+	// here we will check if we need to use `$.template` or create a series of `document.createElement` calls
+	return template_to_string(items);
+}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-string.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-string.js
new file mode 100644
index 0000000000..c4a0b1565c
--- /dev/null
+++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-string.js
@@ -0,0 +1,170 @@
+/**
+ * @import { TemplateOperations } from "../types.js"
+ */
+import { is_void } from '../../../../../utils.js';
+
+/**
+ * @param {TemplateOperations} items
+ */
+export function template_to_string(items) {
+	let elements = [];
+
+	/**
+	 * @type {Array<Element>}
+	 */
+	let elements_stack = [];
+
+	/**
+	 * @type {Element | undefined}
+	 */
+	let last_current_element;
+
+	for (let instruction of items) {
+		if (instruction.kind === 'push_element' && last_current_element) {
+			elements_stack.push(last_current_element);
+			continue;
+		}
+		if (instruction.kind === 'pop_element') {
+			elements_stack.pop();
+			continue;
+		}
+		/**
+		 * @type {Node | void}
+		 */
+		// @ts-expect-error we can't be here if `swap_current_element` but TS doesn't know that
+		const value = map[instruction.kind](
+			...[
+				...(instruction.kind === 'set_prop' ? [last_current_element] : []),
+				...(instruction.args ?? [])
+			]
+		);
+		if (instruction.kind !== 'set_prop') {
+			if (elements_stack.length >= 1 && value) {
+				map.insert(/** @type {Element} */ (elements_stack.at(-1)), value);
+			} else if (value) {
+				elements.push(value);
+			}
+			if (instruction.kind === 'create_element') {
+				last_current_element = /** @type {Element} */ (value);
+			}
+		}
+	}
+
+	return elements.map((el) => stringify(el)).join('');
+}
+
+/**
+ * @typedef {{ kind: "element", element: string, props?: Record<string, string>, children?: Array<Node> }} Element
+ */
+
+/**
+ * @typedef {{ kind: "anchor", data?: string }} Anchor
+ */
+
+/**
+ * @typedef {{ kind: "text", value?: string }} Text
+ */
+
+/**
+ * @typedef { Element | Anchor| Text } Node
+ */
+
+/**
+ *
+ * @param {string} element
+ * @returns {Element}
+ */
+function create_element(element) {
+	return {
+		kind: 'element',
+		element
+	};
+}
+
+/**
+ * @param {string} data
+ * @returns {Anchor}
+ */
+function create_anchor(data) {
+	return {
+		kind: 'anchor',
+		data
+	};
+}
+
+/**
+ * @param {string} value
+ * @returns {Text}
+ */
+function create_text(value) {
+	return {
+		kind: 'text',
+		value
+	};
+}
+
+/**
+ *
+ * @param {Element} el
+ * @param {string} prop
+ * @param {string} value
+ */
+function set_prop(el, prop, value) {
+	el.props ??= {};
+	el.props[prop] = value;
+}
+
+/**
+ *
+ * @param {Element} el
+ * @param {Node} child
+ * @param {Node} [anchor]
+ */
+function insert(el, child, anchor) {
+	el.children ??= [];
+	el.children.push(child);
+}
+
+let map = {
+	create_element,
+	create_text,
+	create_anchor,
+	set_prop,
+	insert
+};
+
+/**
+ *
+ * @param {Node} el
+ * @returns
+ */
+function stringify(el) {
+	let str = ``;
+	if (el.kind === 'element') {
+		str += `<${el.element}`;
+		for (let [prop, value] of Object.entries(el.props ?? {})) {
+			if (value == null) {
+				str += ` ${prop}`;
+			} else {
+				str += ` ${prop}="${value}"`;
+			}
+		}
+		str += `>`;
+		for (let child of el.children ?? []) {
+			str += stringify(child);
+		}
+		if (!is_void(el.element)) {
+			str += `</${el.element}>`;
+		}
+	} else if (el.kind === 'text') {
+		str += el.value;
+	} else if (el.kind === 'anchor') {
+		if (el.data) {
+			str += `<!--${el.data}-->`;
+		} else {
+			str += `<!>`;
+		}
+	}
+
+	return str;
+}
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 63fe3223cf..98917e7372 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
@@ -39,6 +39,18 @@ export interface ClientTransformState extends TransformState {
 	>;
 }
 
+type TemplateOperationsKind =
+	| 'create_element'
+	| 'create_text'
+	| 'create_anchor'
+	| 'set_prop'
+	| 'push_element'
+	| 'pop_element';
+
+type TemplateOperations = Array<{
+	kind: TemplateOperationsKind;
+	args?: Array<string>;
+}>;
 export interface ComponentClientTransformState extends ClientTransformState {
 	readonly analysis: ComponentAnalysis;
 	readonly options: ValidatedCompileOptions;
@@ -56,7 +68,7 @@ export interface ComponentClientTransformState extends ClientTransformState {
 	/** Expressions used inside the render effect */
 	readonly expressions: Expression[];
 	/** The HTML template string */
-	readonly template: Array<string | Expression>;
+	readonly template: TemplateOperations;
 	readonly locations: SourceLocation[];
 	readonly metadata: {
 		namespace: Namespace;
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js
index 7588b24280..404124762f 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitBlock.js
@@ -11,7 +11,7 @@ import { get_value } from './shared/declarations.js';
  * @param {ComponentContext} context
  */
 export function AwaitBlock(node, context) {
-	context.state.template.push('<!>');
+	context.state.template.push({ kind: 'create_anchor' });
 
 	// Visit {#await <expression>} first to ensure that scopes are in the correct order
 	const expression = b.thunk(/** @type {Expression} */ (context.visit(node.expression)));
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Comment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Comment.js
index 24011e62aa..758abc6a67 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Comment.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Comment.js
@@ -7,5 +7,5 @@
  */
 export function 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(`<!--${node.data}-->`);
+	context.state.template.push({ kind: 'create_anchor', args: [node.data] });
 }
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js
index 629cacda01..9cdb6f1b4c 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js
@@ -32,7 +32,7 @@ export function EachBlock(node, context) {
 	);
 
 	if (!each_node_meta.is_controlled) {
-		context.state.template.push('<!>');
+		context.state.template.push({ kind: 'create_anchor' });
 	}
 
 	let flags = 0;
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 389a694741..27ad894ccf 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
@@ -7,6 +7,7 @@ import { dev } from '../../../../state.js';
 import * as b from '../../../../utils/builders.js';
 import { sanitize_template_string } from '../../../../utils/sanitize_template_string.js';
 import { clean_nodes, infer_namespace } from '../../utils.js';
+import { transform_template } from '../transform-template/index.js';
 import { process_children } from './shared/fragment.js';
 import { build_render_statement } from './shared/utils.js';
 
@@ -118,7 +119,7 @@ export function Fragment(node, context) {
 		});
 
 		/** @type {Expression[]} */
-		const args = [join_template(state.template)];
+		const args = [b.template([b.quasi(transform_template(state.template), true)], [])];
 
 		if (state.metadata.context.template_needs_import_node) {
 			args.push(b.literal(TEMPLATE_USE_IMPORT_NODE));
@@ -168,11 +169,14 @@ export function Fragment(node, context) {
 					flags |= TEMPLATE_USE_IMPORT_NODE;
 				}
 
-				if (state.template.length === 1 && state.template[0] === '<!>') {
+				if (state.template.length === 1 && state.template[0].kind === 'create_anchor') {
 					// special case — we can use `$.comment` instead of creating a unique template
 					body.push(b.var(id, b.call('$.comment')));
 				} else {
-					add_template(template_name, [join_template(state.template), b.literal(flags)]);
+					add_template(template_name, [
+						b.template([b.quasi(transform_template(state.template), true)], []),
+						b.literal(flags)
+					]);
 
 					body.push(b.var(id, b.call(template_name)));
 				}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js
index 32439879de..fd2256f16b 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js
@@ -9,7 +9,7 @@ import * as b from '../../../../utils/builders.js';
  * @param {ComponentContext} context
  */
 export function HtmlTag(node, context) {
-	context.state.template.push('<!>');
+	context.state.template.push({ kind: 'create_anchor' });
 
 	// push into init, so that bindings run afterwards, which might trigger another run and override hydration
 	context.state.init.push(
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js
index fdd21b2b7e..a7735c65b8 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js
@@ -8,7 +8,7 @@ import * as b from '../../../../utils/builders.js';
  * @param {ComponentContext} context
  */
 export function IfBlock(node, context) {
-	context.state.template.push('<!>');
+	context.state.template.push({ kind: 'create_anchor' });
 	const statements = [];
 
 	const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent));
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js
index a013827f60..04ef6195e7 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js
@@ -8,7 +8,7 @@ import * as b from '../../../../utils/builders.js';
  * @param {ComponentContext} context
  */
 export function KeyBlock(node, context) {
-	context.state.template.push('<!>');
+	context.state.template.push({ kind: 'create_anchor' });
 
 	const key = /** @type {Expression} */ (context.visit(node.expression));
 	const body = /** @type {Expression} */ (context.visit(node.fragment));
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 45a594af1f..78cfd4d222 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
@@ -52,7 +52,10 @@ export function RegularElement(node, context) {
 	}
 
 	if (node.name === 'noscript') {
-		context.state.template.push('<noscript></noscript>');
+		context.state.template.push({
+			kind: 'create_element',
+			args: ['noscript']
+		});
 		return;
 	}
 
@@ -72,7 +75,10 @@ export function RegularElement(node, context) {
 		context.state.metadata.context.template_contains_script_tag = true;
 	}
 
-	context.state.template.push(`<${node.name}`);
+	context.state.template.push({
+		kind: 'create_element',
+		args: [node.name]
+	});
 
 	/** @type {Array<AST.Attribute | AST.SpreadAttribute>} */
 	const attributes = [];
@@ -110,7 +116,10 @@ export function RegularElement(node, context) {
 					const { value } = build_attribute_value(attribute.value, context);
 
 					if (value.type === 'Literal' && typeof value.value === 'string') {
-						context.state.template.push(` is="${escape_html(value.value, true)}"`);
+						context.state.template.push({
+							kind: 'set_prop',
+							args: ['is', escape_html(value.value, true)]
+						});
 						continue;
 					}
 				}
@@ -286,13 +295,14 @@ export function RegularElement(node, context) {
 				}
 
 				if (name !== 'class' || value) {
-					context.state.template.push(
-						` ${attribute.name}${
+					context.state.template.push({
+						kind: 'set_prop',
+						args: [attribute.name].concat(
 							is_boolean_attribute(name) && value === true
-								? ''
-								: `="${value === true ? '' : escape_html(value, true)}"`
-						}`
-					);
+								? []
+								: [value === true ? '' : escape_html(value, true)]
+						)
+					});
 				}
 			} else if (name === 'autofocus') {
 				let { value } = build_attribute_value(attribute.value, context);
@@ -324,8 +334,7 @@ export function RegularElement(node, context) {
 	) {
 		context.state.after_update.push(b.stmt(b.call('$.replay_events', node_id)));
 	}
-
-	context.state.template.push('>');
+	context.state.template.push({ kind: 'push_element' });
 
 	const metadata = {
 		...context.state.metadata,
@@ -446,10 +455,7 @@ export function RegularElement(node, context) {
 		// @ts-expect-error
 		location.push(state.locations);
 	}
-
-	if (!is_void(node.name)) {
-		context.state.template.push(`</${node.name}>`);
-	}
+	context.state.template.push({ kind: 'pop_element' });
 }
 
 /**
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js
index 33ae6d4d2b..a48adaf6c5 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js
@@ -9,7 +9,7 @@ import * as b from '../../../../utils/builders.js';
  * @param {ComponentContext} context
  */
 export function RenderTag(node, context) {
-	context.state.template.push('<!>');
+	context.state.template.push({ kind: 'create_anchor' });
 
 	const expression = unwrap_optional(node.expression);
 
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js
index c6f4ba1ed3..c6e7badfa5 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js
@@ -11,7 +11,7 @@ import { memoize_expression } from './shared/utils.js';
  */
 export function SlotElement(node, context) {
 	// <slot {a}>fallback</slot>  -->   $.slot($$slots.default, { get a() { .. } }, () => ...fallback);
-	context.state.template.push('<!>');
+	context.state.template.push({ kind: 'create_anchor' });
 
 	/** @type {Property[]} */
 	const props = [];
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js
index 9228df9703..40dde11e6e 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js
@@ -88,7 +88,7 @@ export function SvelteBoundary(node, context) {
 		b.call('$.boundary', context.state.node, props, b.arrow([b.id('$$anchor')], block))
 	);
 
-	context.state.template.push('<!>');
+	context.state.template.push({ kind: 'create_anchor' });
 	context.state.init.push(
 		external_statements.length > 0 ? b.block([...external_statements, boundary]) : boundary
 	);
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js
index 115eb6ccc1..90a5b7ec27 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js
@@ -13,7 +13,7 @@ import { build_render_statement, get_expression_id } from './shared/utils.js';
  * @param {ComponentContext} context
  */
 export function SvelteElement(node, context) {
-	context.state.template.push(`<!>`);
+	context.state.template.push({ kind: 'create_anchor' });
 
 	/** @type {Array<AST.Attribute | AST.SpreadAttribute>} */
 	const attributes = [];
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js
index 2bae4486dc..90fe4d93e1 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js
@@ -422,11 +422,24 @@ export function build_component(node, component_name, context, anchor = context.
 	}
 
 	if (Object.keys(custom_css_props).length > 0) {
-		context.state.template.push(
-			context.state.metadata.namespace === 'svg'
-				? '<g><!></g>'
-				: '<svelte-css-wrapper style="display: contents"><!></svelte-css-wrapper>'
-		);
+		/**
+		 * @type {typeof context.state.template}
+		 */
+		const template_operations = [];
+		if (context.state.metadata.namespace === 'svg') {
+			template_operations.push({ kind: 'create_element', args: ['g'] });
+			template_operations.push({ kind: 'push_element' });
+			template_operations.push({ kind: 'create_anchor' });
+			template_operations.push({ kind: 'pop_element' });
+		} else {
+			template_operations.push({ kind: 'create_element', args: ['svelte-css-wrapper'] });
+			template_operations.push({ kind: 'set_prop', args: ['style', 'display: contents'] });
+			template_operations.push({ kind: 'push_element' });
+			template_operations.push({ kind: 'create_anchor' });
+			template_operations.push({ kind: 'pop_element' });
+		}
+
+		context.state.template.push(...template_operations);
 
 		statements.push(
 			b.stmt(b.call('$.css_props', anchor, b.thunk(b.object(custom_css_props)))),
@@ -434,7 +447,7 @@ export function build_component(node, component_name, context, anchor = context.
 			b.stmt(b.call('$.reset', anchor))
 		);
 	} else {
-		context.state.template.push('<!>');
+		context.state.template.push({ kind: 'create_anchor' });
 		statements.push(b.stmt(fn(anchor)));
 	}
 
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js
index f076d7c11e..025d39b2e9 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js
@@ -64,11 +64,16 @@ export function process_children(nodes, initial, is_element, { visit, state }) {
 	function flush_sequence(sequence) {
 		if (sequence.every((node) => node.type === 'Text')) {
 			skipped += 1;
-			state.template.push(sequence.map((node) => node.raw).join(''));
+			state.template.push({
+				kind: 'create_text',
+				args: [sequence.map((node) => node.raw).join('')]
+			});
 			return;
 		}
-
-		state.template.push(' ');
+		state.template.push({
+			kind: 'create_text',
+			args: [' ']
+		});
 
 		const { has_state, value } = build_template_chunk(sequence, visit, state);