From 7c7a85754be206bdc57db8cf972e441cb0eba5ab Mon Sep 17 00:00:00 2001 From: paoloricciuti Date: Thu, 20 Mar 2025 10:34:50 +0100 Subject: [PATCH] feat: make all tests pass with functional templates --- .changeset/smart-boats-accept.md | 2 +- .../fix-attribute-casing.js | 18 +++ .../client/transform-template/index.js | 42 +++---- .../client/transform-template/to-functions.js | 107 ++++++++++++---- .../phases/3-transform/client/types.d.ts | 4 + .../3-transform/client/visitors/Fragment.js | 33 +++-- .../client/visitors/RegularElement.js | 38 ++++-- .../client/visitors/shared/fragment.js | 11 +- .../3-transform/server/visitors/Fragment.js | 4 +- .../server/visitors/RegularElement.js | 4 +- .../src/compiler/phases/3-transform/utils.js | 12 +- .../src/internal/client/dom/template.js | 119 ++++++++++++++++++ packages/svelte/src/internal/client/index.js | 5 + 13 files changed, 328 insertions(+), 71 deletions(-) create mode 100644 packages/svelte/src/compiler/phases/3-transform/client/transform-template/fix-attribute-casing.js diff --git a/.changeset/smart-boats-accept.md b/.changeset/smart-boats-accept.md index 22be423e1f..b08063eb8d 100644 --- a/.changeset/smart-boats-accept.md +++ b/.changeset/smart-boats-accept.md @@ -2,4 +2,4 @@ 'svelte': minor --- -feat: templateless template generation +feat: functional template generation diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/fix-attribute-casing.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/fix-attribute-casing.js new file mode 100644 index 0000000000..ce56c43d7c --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/fix-attribute-casing.js @@ -0,0 +1,18 @@ +const svg_attributes = + 'accent-height accumulate additive alignment-baseline allowReorder alphabetic amplitude arabic-form ascent attributeName attributeType autoReverse azimuth baseFrequency baseline-shift baseProfile bbox begin bias by calcMode cap-height class clip clipPathUnits clip-path clip-rule color color-interpolation color-interpolation-filters color-profile color-rendering contentScriptType contentStyleType cursor cx cy d decelerate descent diffuseConstant direction display divisor dominant-baseline dur dx dy edgeMode elevation enable-background end exponent externalResourcesRequired fill fill-opacity fill-rule filter filterRes filterUnits flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight format from fr fx fy g1 g2 glyph-name glyph-orientation-horizontal glyph-orientation-vertical glyphRef gradientTransform gradientUnits hanging height href horiz-adv-x horiz-origin-x id ideographic image-rendering in in2 intercept k k1 k2 k3 k4 kernelMatrix kernelUnitLength kerning keyPoints keySplines keyTimes lang lengthAdjust letter-spacing lighting-color limitingConeAngle local marker-end marker-mid marker-start markerHeight markerUnits markerWidth mask maskContentUnits maskUnits mathematical max media method min mode name numOctaves offset onabort onactivate onbegin onclick onend onerror onfocusin onfocusout onload onmousedown onmousemove onmouseout onmouseover onmouseup onrepeat onresize onscroll onunload opacity operator order orient orientation origin overflow overline-position overline-thickness panose-1 paint-order pathLength patternContentUnits patternTransform patternUnits pointer-events points pointsAtX pointsAtY pointsAtZ preserveAlpha preserveAspectRatio primitiveUnits r radius refX refY rendering-intent repeatCount repeatDur requiredExtensions requiredFeatures restart result rotate rx ry scale seed shape-rendering slope spacing specularConstant specularExponent speed spreadMethod startOffset stdDeviation stemh stemv stitchTiles stop-color stop-opacity strikethrough-position strikethrough-thickness string stroke stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width style surfaceScale systemLanguage tabindex tableValues target targetX targetY text-anchor text-decoration text-rendering textLength to transform type u1 u2 underline-position underline-thickness unicode unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical values version vert-adv-y vert-origin-x vert-origin-y viewBox viewTarget visibility width widths word-spacing writing-mode x x-height x1 x2 xChannelSelector xlink:actuate xlink:arcrole xlink:href xlink:role xlink:show xlink:title xlink:type xml:base xml:lang xml:space y y1 y2 yChannelSelector z zoomAndPan'.split( + ' ' + ); + +const svg_attribute_lookup = new Map(); + +svg_attributes.forEach((name) => { + svg_attribute_lookup.set(name.toLowerCase(), name); +}); + +/** + * @param {string} name + */ +export default function fix_attribute_casing(name) { + name = name.toLowerCase(); + return svg_attribute_lookup.get(name) || name; +} 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 ca7f29505e..98b830a907 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 @@ -4,7 +4,6 @@ * @import { AST, Namespace } from '#compiler' * @import { SourceLocation } from '#shared' */ -import { TEMPLATE_FRAGMENT } from '../../../../../constants.js'; import { dev } from '../../../../state.js'; import * as b from '../../../../utils/builders.js'; import { template_to_functions } from './to-functions.js'; @@ -18,15 +17,17 @@ import { template_to_string } from './to-string.js'; */ function get_template_function(namespace, state) { const contains_script_tag = state.metadata.context.template_contains_script_tag; - return namespace === 'svg' - ? contains_script_tag - ? '$.svg_template_with_script' - : '$.ns_template' - : namespace === 'mathml' - ? '$.mathml_template' - : contains_script_tag - ? '$.template_with_script' - : '$.template'; + return ( + namespace === 'svg' + ? contains_script_tag + ? '$.svg_template_with_script' + : '$.ns_template' + : namespace === 'mathml' + ? '$.mathml_template' + : contains_script_tag + ? '$.template_with_script' + : '$.template' + ).concat(state.prevent_template_cloning ? '_fn' : ''); } /** @@ -54,21 +55,6 @@ function build_locations(locations) { * @param {number} [flags] */ export function transform_template(state, context, namespace, template_name, flags) { - if (context.state.prevent_template_cloning) { - context.state.hoisted.push( - b.var( - template_name, - template_to_functions( - state.template, - namespace, - flags != null && (flags & TEMPLATE_FRAGMENT) !== 0 - ) - ) - ); - - return; - } - /** * @param {Identifier} template_name * @param {Expression[]} args @@ -88,7 +74,11 @@ export function transform_template(state, context, namespace, template_name, fla }; /** @type {Expression[]} */ - const args = [b.template([b.quasi(template_to_string(state.template), true)], [])]; + const args = [ + state.prevent_template_cloning + ? template_to_functions(state.template, namespace) + : b.template([b.quasi(template_to_string(state.template), true)], []) + ]; if (flags) { args.push(b.literal(flags)); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-functions.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-functions.js index 9b9cab28a2..9437fae20e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-functions.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-functions.js @@ -1,11 +1,12 @@ /** * @import { TemplateOperations } from "../types.js" * @import { Namespace } from "#compiler" - * @import { Statement } from "estree" + * @import { CallExpression, Statement } from "estree" */ import { NAMESPACE_SVG } from 'svelte/internal/client'; -import * as b from '../../../../utils/builders.js'; import { NAMESPACE_MATHML } from '../../../../../constants.js'; +import * as b from '../../../../utils/builders.js'; +import fix_attribute_casing from './fix-attribute-casing.js'; class Scope { declared = new Map(); @@ -28,9 +29,8 @@ class Scope { /** * @param {TemplateOperations} items * @param {Namespace} namespace - * @param {boolean} use_fragment */ -export function template_to_functions(items, namespace, use_fragment = false) { +export function template_to_functions(items, namespace) { let elements = []; let body = []; @@ -42,26 +42,61 @@ export function template_to_functions(items, namespace, use_fragment = false) { */ let elements_stack = []; + /** + * @type {Array} + */ + let namespace_stack = []; + + /** + * @type {number} + */ + let foreign_object_count = 0; + /** * @type {Element | undefined} */ let last_current_element; + if (items[0].kind === 'create_anchor') { + items.unshift({ kind: 'create_anchor' }); + } + 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(); + const removed = elements_stack.pop(); + if (removed?.namespaced) { + namespace_stack.pop(); + } + if (removed?.element === 'foreignObject') { + foreign_object_count--; + } continue; } + if (instruction.metadata?.svg || instruction.metadata?.mathml) { + namespace_stack.push(instruction.metadata.svg ? NAMESPACE_SVG : NAMESPACE_MATHML); + } + // @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] : [scope]), - ...(instruction.kind === 'create_element' ? [namespace] : []), + ...(instruction.kind === 'create_element' + ? [ + foreign_object_count > 0 + ? undefined + : namespace_stack.at(-1) ?? + (namespace === 'svg' + ? NAMESPACE_SVG + : namespace === 'mathml' + ? NAMESPACE_MATHML + : undefined) + ] + : []), ...(instruction.args ?? []) ] ); @@ -79,23 +114,22 @@ export function template_to_functions(items, namespace, use_fragment = false) { } if (instruction.kind === 'create_element') { last_current_element = /** @type {Element} */ (value); + if (last_current_element.element === 'foreignObject') { + foreign_object_count++; + } } } } - if (elements.length > 1 || use_fragment) { - const fragment = scope.generate('fragment'); - body.push(b.var(fragment, b.call('document.createDocumentFragment'))); - body.push(b.call(fragment + '.append', ...elements)); - body.push(b.return(b.id(fragment))); - } else { - body.push(b.return(elements[0])); - } + const fragment = scope.generate('fragment'); + body.push(b.var(fragment, b.call('document.createDocumentFragment'))); + body.push(b.call(fragment + '.append', ...elements)); + body.push(b.return(b.id(fragment))); return b.arrow([], b.block(body)); } /** - * @typedef {{ call: Statement, name: string }} Element + * @typedef {{ call: Statement, name: string, add_is: (value: string)=>void, namespaced: boolean; element: string; }} Element */ /** @@ -118,14 +152,26 @@ export function template_to_functions(items, namespace, use_fragment = false) { */ function create_element(scope, namespace, element) { const name = scope.generate(element); - let fn = namespace !== 'html' ? 'document.createElementNS' : 'document.createElement'; + let fn = namespace != null ? 'document.createElementNS' : 'document.createElement'; let args = [b.literal(element)]; - if (namespace !== 'html') { - args.unshift(namespace === 'svg' ? b.literal(NAMESPACE_SVG) : b.literal(NAMESPACE_MATHML)); + if (namespace != null) { + args.unshift(b.literal(namespace)); + } + const call = b.var(name, b.call(fn, ...args)); + /** + * @param {string} value + */ + function add_is(value) { + /** @type {CallExpression} */ (call.declarations[0].init).arguments.push( + b.object([b.prop('init', b.literal('is'), b.literal(value))]) + ); } return { - call: b.var(name, b.call(fn, ...args)), - name + call, + name, + element, + add_is, + namespaced: namespace != null }; } @@ -162,8 +208,21 @@ function create_text(scope, value) { * @param {string} value */ function set_prop(el, prop, value) { + if (prop === 'is') { + el.add_is(value); + return; + } + + const [namespace] = prop.split(':'); + let fn = namespace !== prop ? '.setAttributeNS' : '.setAttribute'; + let args = [b.literal(fix_attribute_casing(prop)), b.literal(value ?? '')]; + + if (namespace === 'xlink') { + args.unshift(b.literal('http://www.w3.org/1999/xlink')); + } + return { - call: b.call(el.name + '.setAttribute', b.literal(prop), b.literal(value)) + call: b.call(el.name + fn, ...args) }; } @@ -175,7 +234,11 @@ function set_prop(el, prop, value) { */ function insert(el, child, anchor) { return { - call: b.call(el.name + '.insertBefore', b.id(child.name), b.id(anchor?.name ?? 'undefined')) + call: b.call( + el.name + (el.element === 'template' ? '.content' : '') + '.insertBefore', + b.id(child.name), + b.id(anchor?.name ?? 'undefined') + ) }; } 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 c32e507554..a73e0e0e83 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 @@ -50,6 +50,10 @@ type TemplateOperationsKind = type TemplateOperations = Array<{ kind: TemplateOperationsKind; args?: Array; + metadata?: { + svg: boolean; + mathml: boolean; + }; }>; export interface ComponentClientTransformState extends ClientTransformState { readonly analysis: ComponentAnalysis; 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 29a34d6db1..98f8b0c2e9 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 @@ -36,7 +36,8 @@ export function Fragment(node, context) { namespace, context.state, context.state.preserve_whitespace, - context.state.options.preserveComments + context.state.options.preserveComments, + context.state.prevent_template_cloning ); if (hoisted.length === 0 && trimmed.length === 0) { @@ -124,22 +125,40 @@ export function Fragment(node, context) { // special case — we can use `$.text` instead of creating a unique template const id = b.id(context.state.scope.generate('text')); - process_children(trimmed, () => id, false, { - ...context, - state - }); + process_children( + trimmed, + () => id, + false, + { + ...context, + state + }, + context.state.prevent_template_cloning + ); body.push(b.var(id, b.call('$.text'))); close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); } else { if (is_standalone) { // no need to create a template, we can just use the existing block's anchor - process_children(trimmed, () => b.id('$$anchor'), false, { ...context, state }); + process_children( + trimmed, + () => b.id('$$anchor'), + false, + { ...context, state }, + context.state.prevent_template_cloning + ); } else { /** @type {(is_text: boolean) => Expression} */ const expression = (is_text) => b.call('$.first_child', id, is_text && b.true); - process_children(trimmed, expression, false, { ...context, state }); + process_children( + trimmed, + expression, + false, + { ...context, state }, + context.state.prevent_template_cloning + ); let flags = TEMPLATE_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 78cfd4d222..fb2bb1da20 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 @@ -77,7 +77,11 @@ export function RegularElement(node, context) { context.state.template.push({ kind: 'create_element', - args: [node.name] + args: [node.name], + metadata: { + svg: node.metadata.svg, + mathml: node.metadata.mathml + } }); /** @type {Array} */ @@ -118,7 +122,12 @@ export function RegularElement(node, context) { if (value.type === 'Literal' && typeof value.value === 'string') { context.state.template.push({ kind: 'set_prop', - args: ['is', escape_html(value.value, true)] + args: [ + 'is', + context.state.prevent_template_cloning + ? value.value + : escape_html(value.value, true) + ] }); continue; } @@ -300,7 +309,13 @@ export function RegularElement(node, context) { args: [attribute.name].concat( is_boolean_attribute(name) && value === true ? [] - : [value === true ? '' : escape_html(value, true)] + : [ + value === true + ? '' + : context.state.prevent_template_cloning + ? value + : escape_html(value, true) + ] ) }); } @@ -370,7 +385,8 @@ export function RegularElement(node, context) { state.metadata.namespace, state, node.name === 'script' || state.preserve_whitespace, - state.options.preserveComments + state.options.preserveComments, + state.prevent_template_cloning ); /** @type {typeof state} */ @@ -414,10 +430,16 @@ export function RegularElement(node, context) { arg = b.member(arg, 'content'); } - process_children(trimmed, (is_text) => b.call('$.child', arg, is_text && b.true), true, { - ...context, - state: child_state - }); + process_children( + trimmed, + (is_text) => b.call('$.child', arg, is_text && b.true), + true, + { + ...context, + state: child_state + }, + context.state.prevent_template_cloning + ); if (needs_reset) { child_state.init.push(b.stmt(b.call('$.reset', context.state.node))); 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 025d39b2e9..6ec6b5322c 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 @@ -15,8 +15,15 @@ import { build_template_chunk } from './utils.js'; * @param {(is_text: boolean) => Expression} initial * @param {boolean} is_element * @param {ComponentContext} context + * @param {boolean} [prevent_template_cloning] */ -export function process_children(nodes, initial, is_element, { visit, state }) { +export function process_children( + nodes, + initial, + is_element, + { visit, state }, + prevent_template_cloning +) { const within_bound_contenteditable = state.metadata.bound_contenteditable; let prev = initial; let skipped = 0; @@ -66,7 +73,7 @@ export function process_children(nodes, initial, is_element, { visit, state }) { skipped += 1; state.template.push({ kind: 'create_text', - args: [sequence.map((node) => node.raw).join('')] + args: [sequence.map((node) => (prevent_template_cloning ? node.data : node.raw)).join('')] }); return; } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js index a293b98e7e..57856ac204 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Fragment.js @@ -19,7 +19,9 @@ export function Fragment(node, context) { namespace, context.state, context.state.preserve_whitespace, - context.state.options.preserveComments + context.state.options.preserveComments, + // prevent template cloning should always be false on the server + false ); /** @type {ComponentServerTransformState} */ diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js index af50695efa..ff7115f961 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js @@ -47,7 +47,9 @@ export function RegularElement(node, context) { scope: /** @type {Scope} */ (state.scopes.get(node.fragment)) }, state.preserve_whitespace, - state.options.preserveComments + state.options.preserveComments, + // prevent template cloning should always be false on the server + false ); for (const node of hoisted) { diff --git a/packages/svelte/src/compiler/phases/3-transform/utils.js b/packages/svelte/src/compiler/phases/3-transform/utils.js index 46872fbfcf..e1ef6ef4c9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/utils.js @@ -141,6 +141,7 @@ function sort_const_tags(nodes, state) { * @param {TransformState & { options: ValidatedCompileOptions }} state * @param {boolean} preserve_whitespace * @param {boolean} preserve_comments + * @param {boolean} [prevent_template_cloning] */ export function clean_nodes( parent, @@ -152,7 +153,8 @@ export function clean_nodes( // first, we need to make `Component(Client|Server)TransformState` inherit from a new `ComponentTransformState` // rather than from `ClientTransformState` and `ServerTransformState` preserve_whitespace, - preserve_comments + preserve_comments, + prevent_template_cloning ) { if (!state.analysis.runes) { nodes = sort_const_tags(nodes, state); @@ -272,11 +274,15 @@ export function clean_nodes( var first = trimmed[0]; // initial newline inside a `
` is disregarded, if not followed by another newline
-	if (parent.type === 'RegularElement' && parent.name === 'pre' && first?.type === 'Text') {
+	if (
+		parent.type === 'RegularElement' &&
+		(parent.name === 'pre' || (prevent_template_cloning && parent.name === 'textarea')) &&
+		first?.type === 'Text'
+	) {
 		const text = first.data.replace(regex_starts_with_newline, '');
 		if (text !== first.data) {
 			const tmp = text.replace(regex_starts_with_newline, '');
-			if (text === tmp) {
+			if (text === tmp || prevent_template_cloning) {
 				first.data = text;
 				first.raw = first.raw.replace(regex_starts_with_newline, '');
 				if (first.data === '') {
diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js
index de2df62c92..bd4d60837d 100644
--- a/packages/svelte/src/internal/client/dom/template.js
+++ b/packages/svelte/src/internal/client/dom/template.js
@@ -64,6 +64,47 @@ export function template(content, flags) {
 	};
 }
 
+/**
+ * @param {()=>(DocumentFragment | Node)} fn
+ * @param {number} flags
+ * @returns {() => Node | Node[]}
+ */
+/*#__NO_SIDE_EFFECTS__*/
+export function template_fn(fn, flags) {
+	var is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;
+	var use_import_node = (flags & TEMPLATE_USE_IMPORT_NODE) !== 0;
+
+	/** @type {Node} */
+	var node;
+
+	return () => {
+		if (hydrating) {
+			assign_nodes(hydrate_node, null);
+			return hydrate_node;
+		}
+
+		if (node === undefined) {
+			node = fn();
+			if (!is_fragment) node = /** @type {Node} */ (get_first_child(node));
+		}
+
+		var clone = /** @type {TemplateNode} */ (
+			use_import_node || is_firefox ? document.importNode(node, true) : node.cloneNode(true)
+		);
+
+		if (is_fragment) {
+			var start = /** @type {TemplateNode} */ (get_first_child(clone));
+			var end = /** @type {TemplateNode} */ (clone.lastChild);
+
+			assign_nodes(start, end);
+		} else {
+			assign_nodes(clone, clone);
+		}
+
+		return clone;
+	};
+}
+
 /**
  * @param {string} content
  * @param {number} flags
@@ -75,6 +116,16 @@ export function template_with_script(content, flags) {
 	return () => run_scripts(/** @type {Element | DocumentFragment} */ (fn()));
 }
 
+/**
+ * @param {()=>(DocumentFragment | Node)} fn
+ * @param {number} flags
+ * @returns {() => Node | Node[]}
+ */ /*#__NO_SIDE_EFFECTS__*/
+export function template_with_script_fn(fn, flags) {
+	var templated_fn = template_fn(fn, flags);
+	return () => run_scripts(/** @type {Element | DocumentFragment} */ (templated_fn()));
+}
+
 /**
  * @param {string} content
  * @param {number} flags
@@ -130,6 +181,53 @@ export function ns_template(content, flags, ns = 'svg') {
 	};
 }
 
+/**
+ * @param {()=>(DocumentFragment | Node)} fn
+ * @param {number} flags
+ * @param {'svg' | 'math'} ns
+ * @returns {() => Node | Node[]}
+ */
+/*#__NO_SIDE_EFFECTS__*/
+export function ns_template_fn(fn, flags, ns = 'svg') {
+	var is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;
+
+	/** @type {Element | DocumentFragment} */
+	var node;
+
+	return () => {
+		if (hydrating) {
+			assign_nodes(hydrate_node, null);
+			return hydrate_node;
+		}
+
+		if (!node) {
+			var fragment = /** @type {DocumentFragment} */ (fn());
+
+			if (is_fragment) {
+				node = document.createDocumentFragment();
+				while (get_first_child(fragment)) {
+					node.appendChild(/** @type {Node} */ (get_first_child(fragment)));
+				}
+			} else {
+				node = /** @type {Element} */ (get_first_child(fragment));
+			}
+		}
+
+		var clone = /** @type {TemplateNode} */ (node.cloneNode(true));
+
+		if (is_fragment) {
+			var start = /** @type {TemplateNode} */ (get_first_child(clone));
+			var end = /** @type {TemplateNode} */ (clone.lastChild);
+
+			assign_nodes(start, end);
+		} else {
+			assign_nodes(clone, clone);
+		}
+
+		return clone;
+	};
+}
+
 /**
  * @param {string} content
  * @param {number} flags
@@ -141,6 +239,17 @@ export function svg_template_with_script(content, flags) {
 	return () => run_scripts(/** @type {Element | DocumentFragment} */ (fn()));
 }
 
+/**
+ * @param {()=>(DocumentFragment | Node)} fn
+ * @param {number} flags
+ * @returns {() => Node | Node[]}
+ */
+/*#__NO_SIDE_EFFECTS__*/
+export function svg_template_with_script_fn(fn, flags) {
+	var templated_fn = ns_template_fn(fn, flags);
+	return () => run_scripts(/** @type {Element | DocumentFragment} */ (templated_fn()));
+}
+
 /**
  * @param {string} content
  * @param {number} flags
@@ -151,6 +260,16 @@ export function mathml_template(content, flags) {
 	return ns_template(content, flags, 'math');
 }
 
+/**
+ * @param {()=>(DocumentFragment | Node)} fn
+ * @param {number} flags
+ * @returns {() => Node | Node[]}
+ */
+/*#__NO_SIDE_EFFECTS__*/
+export function mathml_template_fn(fn, flags) {
+	return ns_template_fn(fn, flags, 'math');
+}
+
 /**
  * Creating a document fragment from HTML that contains script tags will not execute
  * the scripts. We need to replace the script tags with new ones so that they are executed.
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index 31da00dbb4..018d0529a1 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -94,10 +94,15 @@ export {
 	append,
 	comment,
 	ns_template,
+	ns_template_fn,
 	svg_template_with_script,
+	svg_template_with_script_fn,
 	mathml_template,
+	mathml_template_fn,
 	template,
+	template_fn,
 	template_with_script,
+	template_with_script_fn,
 	text,
 	props_id
 } from './dom/template.js';