diff --git a/.changeset/smart-boats-accept.md b/.changeset/smart-boats-accept.md new file mode 100644 index 000000000000..b08063eb8d00 --- /dev/null +++ b/.changeset/smart-boats-accept.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: functional template generation 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 f0da5a491887..c7c60ae8eb12 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 @@ -167,6 +167,7 @@ export function client_component(analysis, options) { in_constructor: false, instance_level_snippets: [], module_level_snippets: [], + is_functional_template_mode: options.templatingMode === 'functional', // these are set inside the `Fragment` visitor, and cannot be used until then init: /** @type {any} */ (null), 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 000000000000..ce56c43d7c50 --- /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 new file mode 100644 index 000000000000..becf987be915 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js @@ -0,0 +1,88 @@ +/** + * @import { ComponentContext, TemplateOperations, ComponentClientTransformState } from "../types.js" + * @import { Identifier, Expression } from "estree" + * @import { AST, Namespace } from '#compiler' + * @import { SourceLocation } from '#shared' + */ +import { dev } from '../../../../state.js'; +import * as b from '../../../../utils/builders.js'; +import { template_to_functions } from './to-functions.js'; +import { template_to_string } from './to-string.js'; + +/** + * + * @param {Namespace} namespace + * @param {ComponentClientTransformState} state + * @returns + */ +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' + ).concat(state.is_functional_template_mode ? '_fn' : ''); +} + +/** + * @param {SourceLocation[]} locations + */ +function build_locations(locations) { + return b.array( + locations.map((loc) => { + const expression = b.array([b.literal(loc[0]), b.literal(loc[1])]); + + if (loc.length === 3) { + expression.elements.push(build_locations(loc[2])); + } + + return expression; + }) + ); +} + +/** + * @param {ComponentClientTransformState} state + * @param {ComponentContext} context + * @param {Namespace} namespace + * @param {Identifier} template_name + * @param {number} [flags] + */ +export function transform_template(state, context, namespace, template_name, flags) { + /** + * @param {Identifier} template_name + * @param {Expression[]} args + */ + const add_template = (template_name, args) => { + let call = b.call(get_template_function(namespace, state), ...args); + if (dev) { + call = b.call( + '$.add_locations', + call, + b.member(b.id(context.state.analysis.name), '$.FILENAME', true), + build_locations(state.locations) + ); + } + + context.state.hoisted.push(b.var(template_name, call)); + }; + + /** @type {Expression[]} */ + const args = [ + state.is_functional_template_mode + ? template_to_functions(state.template) + : b.template([b.quasi(template_to_string(state.template), true)], []) + ]; + + if (flags) { + args.push(b.literal(flags)); + } + + add_template(template_name, args); +} 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 new file mode 100644 index 000000000000..ecf8151836ae --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-functions.js @@ -0,0 +1,182 @@ +/** + * @import { TemplateOperations } from "../types.js" + * @import { Namespace } from "#compiler" + * @import { CallExpression, Statement, ObjectExpression, Identifier, ArrayExpression, Property, Expression, Literal } from "estree" + */ +import { NAMESPACE_SVG, NAMESPACE_MATHML } from '../../../../../constants.js'; +import * as b from '../../../../utils/builders.js'; +import { regex_is_valid_identifier } from '../../../patterns.js'; +import fix_attribute_casing from './fix-attribute-casing.js'; + +/** + * @param {TemplateOperations} items + */ +export function template_to_functions(items) { + let elements = b.array([]); + + /** + * @type {Array} + */ + let elements_stack = []; + + /** + * @type {Element | undefined} + */ + let last_current_element; + + // if the first item is a comment we need to add another comment for effect.start + if (items[0].kind === 'create_anchor') { + items.unshift({ kind: 'create_anchor' }); + } + + for (let instruction of items) { + // on push element we add the element to the stack, from this moment on every insert will + // happen on the last element in the stack + if (instruction.kind === 'push_element' && last_current_element) { + elements_stack.push(last_current_element); + continue; + } + // we closed one element, we remove it from the stack and eventually revert back + // the namespace to the previous one + if (instruction.kind === 'pop_element') { + elements_stack.pop(); + continue; + } + + // @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 === 'create_element' + ? [] + : [instruction.kind === 'set_prop' ? last_current_element : elements_stack.at(-1)]), + ...(instruction.args ?? []) + ] + ); + + // with set_prop we don't need to do anything else, in all other cases we also need to + // append the element/node/anchor to the current active element or push it in the elements array + if (instruction.kind !== 'set_prop') { + if (elements_stack.length >= 1 && value !== undefined) { + map.insert(/** @type {Element} */ (elements_stack.at(-1)), value); + } else if (value !== undefined) { + elements.elements.push(value); + } + // keep track of the last created element (it will be pushed to the stack after the props are set) + if (instruction.kind === 'create_element') { + last_current_element = /** @type {Element} */ (value); + } + } + } + + return elements; +} + +/** + * @typedef {ObjectExpression} Element + */ + +/** + * @typedef {void | null | ArrayExpression} Anchor + */ + +/** + * @typedef {void | Literal} Text + */ + +/** + * @typedef { Element | Anchor| Text } Node + */ + +/** + * @param {string} element + * @returns {Element} + */ +function create_element(element) { + return b.object([b.prop('init', b.id('e'), b.literal(element))]); +} + +/** + * + * @param {Element} element + * @param {string} name + * @param {Expression} init + * @returns {Property} + */ +function get_or_create_prop(element, name, init) { + let prop = element.properties.find( + (prop) => prop.type === 'Property' && /** @type {Identifier} */ (prop.key).name === name + ); + if (!prop) { + prop = b.prop('init', b.id(name), init); + element.properties.push(prop); + } + return /** @type {Property} */ (prop); +} + +/** + * @param {Element} element + * @param {string} data + * @returns {Anchor} + */ +function create_anchor(element, data = '') { + if (!element) return data ? b.array([b.literal(data)]) : null; + const c = get_or_create_prop(element, 'c', b.array([])); + /** @type {ArrayExpression} */ (c.value).elements.push(data ? b.array([b.literal(data)]) : null); +} + +/** + * @param {Element} element + * @param {string} value + * @returns {Text} + */ +function create_text(element, value) { + if (!element) return b.literal(value); + const c = get_or_create_prop(element, 'c', b.array([])); + /** @type {ArrayExpression} */ (c.value).elements.push(b.literal(value)); +} + +/** + * + * @param {Element} element + * @param {string} prop + * @param {string} value + */ +function set_prop(element, prop, value) { + const p = get_or_create_prop(element, 'p', b.object([])); + + if (prop === 'is') { + element.properties.push(b.prop('init', b.id(prop), b.literal(value))); + return; + } + + const prop_correct_case = fix_attribute_casing(prop); + + const is_valid_id = regex_is_valid_identifier.test(prop_correct_case); + + /** @type {ObjectExpression} */ (p.value).properties.push( + b.prop( + 'init', + (is_valid_id ? b.id : b.literal)(prop_correct_case), + b.literal(value), + !is_valid_id + ) + ); +} + +/** + * + * @param {Element} element + * @param {Element} child + */ +function insert(element, child) { + const c = get_or_create_prop(element, 'c', b.array([])); + /** @type {ArrayExpression} */ (c.value).elements.push(child); +} + +let map = { + create_element, + create_text, + create_anchor, + set_prop, + insert +}; 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 000000000000..ed0b73dd3ee0 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-string.js @@ -0,0 +1,183 @@ +/** + * @import { TemplateOperations } from "../types.js" + */ +import { is_void } from '../../../../../utils.js'; + +/** + * @param {TemplateOperations} items + */ +export function template_to_string(items) { + let elements = []; + + /** + * @type {Array} + */ + let elements_stack = []; + + /** + * @type {Element | undefined} + */ + let last_current_element; + + for (let instruction of items) { + // on push element we add the element to the stack, from this moment on every insert will + // happen on the last element in the stack + if (instruction.kind === 'push_element' && last_current_element) { + elements_stack.push(last_current_element); + continue; + } + // we closed one element, we remove it from the stack + 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]( + ...[ + // for set prop we need to send the last element (not the one in the stack since + // it get's added to the stack only after the push_element instruction) + ...(instruction.kind === 'set_prop' ? [last_current_element] : []), + ...(instruction.args ?? []) + ] + ); + // with set_prop we don't need to do anything else, in all other cases we also need to + // append the element/node/anchor to the current active element or push it in the elements array + 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); + } + // keep track of the last created element (it will be pushed to the stack after the props are set) + 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, children?: Array }} 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') { + // we create the `; + // we stringify all the children and concatenate them + for (let child of el.children ?? []) { + str += stringify(child); + } + // if it's not void we also add the closing tag + if (!is_void(el.element)) { + str += ``; + } + } else if (el.kind === 'text') { + str += el.value; + } else if (el.kind === 'anchor') { + if (el.data) { + str += ``; + } 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 243e1c64a33c..35dee0a24928 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,22 @@ 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; + metadata?: { + svg: boolean; + mathml: boolean; + }; +}>; export interface ComponentClientTransformState extends ClientTransformState { readonly analysis: ComponentAnalysis; readonly options: ValidatedCompileOptions; @@ -56,7 +72,7 @@ export interface ComponentClientTransformState extends ClientTransformState { /** Expressions used inside the render effect */ readonly expressions: Expression[]; /** The HTML template string */ - readonly template: Array; + readonly template: TemplateOperations; readonly locations: SourceLocation[]; readonly metadata: { namespace: Namespace; @@ -78,6 +94,7 @@ export interface ComponentClientTransformState extends ClientTransformState { }; }; readonly preserve_whitespace: boolean; + readonly is_functional_template_mode?: boolean; /** The anchor node for the current context */ readonly node: Identifier; 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 96a4addb728a..9680305b157b 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 } 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 24011e62aabd..758abc6a6752 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(``); + 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 e5aee2476579..96be23dd160e 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 b6dca0779add..4e7e246d063c 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 @@ -1,12 +1,10 @@ -/** @import { Expression, Identifier, Statement, TemplateElement } from 'estree' */ -/** @import { AST, Namespace } from '#compiler' */ -/** @import { SourceLocation } from '#shared' */ +/** @import { Expression, Statement } from 'estree' */ +/** @import { AST } from '#compiler' */ /** @import { ComponentClientTransformState, ComponentContext } from '../types' */ import { TEMPLATE_FRAGMENT, TEMPLATE_USE_IMPORT_NODE } from '../../../../../constants.js'; -import { dev } from '../../../../state.js'; import * as b from '#compiler/builders'; -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'; @@ -38,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.is_functional_template_mode ); if (hoisted.length === 0 && trimmed.length === 0) { @@ -70,6 +69,7 @@ export function Fragment(node, context) { template: [], locations: [], transform: { ...context.state.transform }, + is_functional_template_mode: context.state.is_functional_template_mode, metadata: { context: { template_needs_import_node: false, @@ -89,24 +89,6 @@ export function Fragment(node, context) { body.push(b.stmt(b.call('$.next'))); } - /** - * @param {Identifier} template_name - * @param {Expression[]} args - */ - const add_template = (template_name, args) => { - let call = b.call(get_template_function(namespace, state), ...args); - if (dev) { - call = b.call( - '$.add_locations', - call, - b.member(b.id(context.state.analysis.name), '$.FILENAME', true), - build_locations(state.locations) - ); - } - - context.state.hoisted.push(b.var(template_name, call)); - }; - if (is_single_element) { const element = /** @type {AST.RegularElement} */ (trimmed[0]); @@ -117,14 +99,13 @@ export function Fragment(node, context) { node: id }); - /** @type {Expression[]} */ - const args = [join_template(state.template)]; + let flags = undefined; if (state.metadata.context.template_needs_import_node) { - args.push(b.literal(TEMPLATE_USE_IMPORT_NODE)); + flags = TEMPLATE_USE_IMPORT_NODE; } - add_template(template_name, args); + transform_template(state, context, namespace, template_name, flags); body.push(b.var(id, b.call(template_name))); close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); @@ -168,11 +149,11 @@ 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)]); + transform_template(state, context, namespace, template_name, flags); body.push(b.var(id, b.call(template_name))); } @@ -199,86 +180,3 @@ export function Fragment(node, context) { return b.block(body); } - -/** - * @param {Array} items - */ -function join_template(items) { - let quasi = b.quasi(''); - const template = b.template([quasi], []); - - /** - * @param {Expression} expression - */ - function push(expression) { - if (expression.type === 'TemplateLiteral') { - for (let i = 0; i < expression.expressions.length; i += 1) { - const q = expression.quasis[i]; - const e = expression.expressions[i]; - - quasi.value.cooked += /** @type {string} */ (q.value.cooked); - push(e); - } - - const last = /** @type {TemplateElement} */ (expression.quasis.at(-1)); - quasi.value.cooked += /** @type {string} */ (last.value.cooked); - } else if (expression.type === 'Literal') { - /** @type {string} */ (quasi.value.cooked) += expression.value; - } else { - template.expressions.push(expression); - template.quasis.push((quasi = b.quasi(''))); - } - } - - for (const item of items) { - if (typeof item === 'string') { - quasi.value.cooked += item; - } else { - push(item); - } - } - - for (const quasi of template.quasis) { - quasi.value.raw = sanitize_template_string(/** @type {string} */ (quasi.value.cooked)); - } - - quasi.tail = true; - - return template; -} - -/** - * - * @param {Namespace} namespace - * @param {ComponentClientTransformState} state - * @returns - */ -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'; -} - -/** - * @param {SourceLocation[]} locations - */ -function build_locations(locations) { - return b.array( - locations.map((loc) => { - const expression = b.array([b.literal(loc[0]), b.literal(loc[1])]); - - if (loc.length === 3) { - expression.elements.push(build_locations(loc[2])); - } - - return expression; - }) - ); -} 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 a69b9cfe701a..f5e30ac17ec2 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 '#compiler/builders'; * @param {ComponentContext} context */ export function HtmlTag(node, context) { - context.state.template.push(''); + context.state.template.push({ kind: 'create_anchor' }); const expression = /** @type {Expression} */ (context.visit(node.expression)); 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 c650a1e15ccb..15002a416661 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 '#compiler/builders'; * @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 7d6a8b000648..7692e258e19b 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 '#compiler/builders'; * @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 7468fcbbc72e..e3f8bf73f8ad 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(''); + context.state.template.push({ + kind: 'create_element', + args: ['noscript'] + }); return; } @@ -72,7 +75,14 @@ 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], + metadata: { + svg: node.metadata.svg, + mathml: node.metadata.mathml + } + }); /** @type {Array} */ const attributes = []; @@ -110,7 +120,17 @@ 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', + // if we are using the functional template mode we don't want to escape since we will + // create a text node from it which is already escaped + context.state.is_functional_template_mode + ? value.value + : escape_html(value.value, true) + ] + }); continue; } } @@ -286,13 +306,22 @@ 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 + ? '' + : // if we are using the functional template mode we don't want to escape since we will + // create a text node from it which is already escaped + context.state.is_functional_template_mode + ? value + : escape_html(value, true) + ] + ) + }); } } else if (name === 'autofocus') { let { value } = build_attribute_value(attribute.value, context); @@ -324,8 +353,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, @@ -351,7 +379,8 @@ export function RegularElement(node, context) { locations: [], scope: /** @type {Scope} */ (context.state.scopes.get(node.fragment)), preserve_whitespace: - context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea' + context.state.preserve_whitespace || node.name === 'pre' || node.name === 'textarea', + is_functional_template_mode: context.state.is_functional_template_mode }; const { hoisted, trimmed } = clean_nodes( @@ -361,7 +390,8 @@ export function RegularElement(node, context) { state.metadata.namespace, state, node.name === 'script' || state.preserve_whitespace, - state.options.preserveComments + state.options.preserveComments, + state.is_functional_template_mode ); /** @type {typeof state} */ @@ -446,10 +476,7 @@ export function RegularElement(node, context) { // @ts-expect-error location.push(state.locations); } - - if (!is_void(node.name)) { - context.state.template.push(``); - } + 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 6067c2562ad4..c199bce38ced 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 '#compiler/builders'; * @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 ba9fcc7377f6..86e0d85b7e5b 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) { // fallback --> $.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 b279b5badd6e..c496e2eaa7d3 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 ee597dd04308..dde2558e94c9 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 } 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} */ 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 c4071c67fe6c..d1b5f5a8148d 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 @@ -432,11 +432,26 @@ 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' - ? '' - : '' - ); + /** + * @type {typeof context.state.template} + */ + const template_operations = []; + if (context.state.metadata.namespace === 'svg') { + // this boils down to + 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 { + // this boils down to + 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)))), @@ -444,7 +459,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 c91e2b3b4497..6746dad31a19 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,20 @@ 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) => (state.is_functional_template_mode ? node.data : node.raw)) + .join('') + ] + }); return; } - - state.template.push(' '); + state.template.push({ + kind: 'create_text', + args: [' '] + }); const { has_state, value } = build_template_chunk(sequence, visit, state); 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 a1d25980c438..12bf191e871d 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, + // templating mode doesn't affect server builds + 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 5901cb4c504d..bcd9c0a94c4c 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, + // templating mode doesn't affect server builds + 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 5aa40c8abb5c..89d434dc2009 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} [is_functional_template_mode] */ 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, + is_functional_template_mode ) { if (!state.analysis.runes) { nodes = sort_const_tags(nodes, state); @@ -272,11 +274,19 @@ 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' &&
+		// we also want to do the replacement on the textarea if we are in functional template mode because createTextNode behave differently
+		// then template.innerHTML
+		(parent.name === 'pre' || (is_functional_template_mode && 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) {
+			// do an extra replacement if we are in functional template mode because createTextNode behave differently
+			// then template.innerHTML
+			if (text === tmp || is_functional_template_mode) {
 				first.data = text;
 				first.raw = first.raw.replace(regex_starts_with_newline, '');
 				if (first.data === '') {
diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts
index 616c346ad35a..49928b4bdf8b 100644
--- a/packages/svelte/src/compiler/types/index.d.ts
+++ b/packages/svelte/src/compiler/types/index.d.ts
@@ -115,6 +115,12 @@ export interface CompileOptions extends ModuleCompileOptions {
 	 * @default false
 	 */
 	preserveWhitespace?: boolean;
+	/**
+	 *  If `functional`, the template will get compiled to a series of `document.createElement` calls, if `string` it will render the template tp a string and use `template.innerHTML`.
+	 *
+	 * @default 'string'
+	 */
+	templatingMode?: 'string' | 'functional';
 	/**
 	 * Set to `true` to force the compiler into runes mode, even if there are no indications of runes usage.
 	 * Set to `false` to force the compiler into ignoring runes, even if there are indications of runes usage.
diff --git a/packages/svelte/src/compiler/validate-options.js b/packages/svelte/src/compiler/validate-options.js
index ab932ed5bca1..1d67951fd855 100644
--- a/packages/svelte/src/compiler/validate-options.js
+++ b/packages/svelte/src/compiler/validate-options.js
@@ -110,6 +110,8 @@ export const validate_component_options =
 
 			preserveComments: boolean(false),
 
+			templatingMode: list(['string', 'functional']),
+
 			preserveWhitespace: boolean(false),
 
 			runes: boolean(undefined),
diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js
index aae44d4b3989..97062f04e38d 100644
--- a/packages/svelte/src/internal/client/dom/operations.js
+++ b/packages/svelte/src/internal/client/dom/operations.js
@@ -204,3 +204,44 @@ export function sibling(node, count = 1, is_text = false) {
 export function clear_text_content(node) {
 	node.textContent = '';
 }
+
+/**
+ *
+ * @param {string} tag
+ * @param {string} [namespace]
+ * @param {string} [is]
+ * @returns
+ */
+export function create_element(tag, namespace, is) {
+	let options = is ? { is } : undefined;
+	if (namespace) {
+		return document.createElementNS(namespace, tag, options);
+	}
+	return document.createElement(tag, options);
+}
+
+export function create_fragment() {
+	return document.createDocumentFragment();
+}
+
+/**
+ * @param {string} data
+ * @returns
+ */
+export function create_comment(data = '') {
+	return document.createComment(data);
+}
+
+/**
+ * @param {Element} element
+ * @param {string} key
+ * @param {string} value
+ * @returns
+ */
+export function set_attribute(element, key, value = '') {
+	if (key.startsWith('xlink:')) {
+		element.setAttributeNS('http://www.w3.org/1999/xlink', key, value);
+		return;
+	}
+	return element.setAttribute(key, value);
+}
diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js
index de2df62c927f..21fba1f5d909 100644
--- a/packages/svelte/src/internal/client/dom/template.js
+++ b/packages/svelte/src/internal/client/dom/template.js
@@ -1,9 +1,22 @@
 /** @import { Effect, TemplateNode } from '#client' */
 import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js';
-import { create_text, get_first_child, is_firefox } from './operations.js';
+import {
+	create_text,
+	get_first_child,
+	is_firefox,
+	create_element,
+	create_fragment,
+	create_comment,
+	set_attribute
+} from './operations.js';
 import { create_fragment_from_html } from './reconciler.js';
 import { active_effect } from '../runtime.js';
-import { TEMPLATE_FRAGMENT, TEMPLATE_USE_IMPORT_NODE } from '../../../constants.js';
+import {
+	NAMESPACE_MATHML,
+	NAMESPACE_SVG,
+	TEMPLATE_FRAGMENT,
+	TEMPLATE_USE_IMPORT_NODE
+} from '../../../constants.js';
 
 /**
  * @param {TemplateNode} start
@@ -64,6 +77,110 @@ export function template(content, flags) {
 	};
 }
 
+/**
+ * @typedef {{e: string, is?: string, p: Record, c: Array} | null | string | [string]} TemplateStructure
+ */
+
+/**
+ * @param {Array} structure
+ * @param {'svg' | 'math'} [ns]
+ * @param {Array} [namespace_stack]
+ */
+function structure_to_fragment(structure, ns, namespace_stack = [], foreign_object_count = 0) {
+	var fragment = create_fragment();
+	for (var i = 0; i < structure.length; i += 1) {
+		var item = structure[i];
+		if (item == null || Array.isArray(item)) {
+			const data = item ? item[0] : '';
+			fragment.append(create_comment(data));
+		} else if (typeof item === 'string') {
+			fragment.append(create_text(item));
+			continue;
+		} else {
+			let namespace =
+				foreign_object_count > 0
+					? undefined
+					: namespace_stack[namespace_stack.length - 1] ??
+						(ns
+							? ns === 'svg'
+								? NAMESPACE_SVG
+								: ns === 'math'
+									? NAMESPACE_MATHML
+									: undefined
+							: item.e === 'svg'
+								? NAMESPACE_SVG
+								: item.e === 'math'
+									? NAMESPACE_MATHML
+									: undefined);
+			if (namespace !== namespace_stack[namespace_stack.length - 1]) {
+				namespace_stack.push(namespace);
+			}
+			var element = create_element(item.e, namespace, item.is);
+
+			for (var key in item.p) {
+				set_attribute(element, key, item.p[key]);
+			}
+			if (item.c) {
+				(element.tagName === 'TEMPLATE'
+					? /** @type {HTMLTemplateElement} */ (element).content
+					: element
+				).append(
+					...structure_to_fragment(
+						item.c,
+						ns,
+						namespace_stack,
+						element.tagName === 'foreignObject' ? foreign_object_count + 1 : foreign_object_count
+					).childNodes
+				);
+			}
+			namespace_stack.pop();
+			fragment.append(element);
+		}
+	}
+	return fragment;
+}
+
+/**
+ * @param {Array} structure
+ * @param {number} flags
+ * @returns {() => Node | Node[]}
+ */
+/*#__NO_SIDE_EFFECTS__*/
+export function template_fn(structure, 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 = structure_to_fragment(structure);
+			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 +192,16 @@ export function template_with_script(content, flags) {
 	return () => run_scripts(/** @type {Element | DocumentFragment} */ (fn()));
 }
 
+/**
+ * @param {Array} structure
+ * @param {number} flags
+ * @returns {() => Node | Node[]}
+ */ /*#__NO_SIDE_EFFECTS__*/
+export function template_with_script_fn(structure, flags) {
+	var templated_fn = template_fn(structure, flags);
+	return () => run_scripts(/** @type {Element | DocumentFragment} */ (templated_fn()));
+}
+
 /**
  * @param {string} content
  * @param {number} flags
@@ -130,6 +257,53 @@ export function ns_template(content, flags, ns = 'svg') {
 	};
 }
 
+/**
+ * @param {Array} structure
+ * @param {number} flags
+ * @param {'svg' | 'math'} ns
+ * @returns {() => Node | Node[]}
+ */
+/*#__NO_SIDE_EFFECTS__*/
+export function ns_template_fn(structure, 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 = structure_to_fragment(structure, ns);
+
+			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 +315,17 @@ export function svg_template_with_script(content, flags) {
 	return () => run_scripts(/** @type {Element | DocumentFragment} */ (fn()));
 }
 
+/**
+ * @param {Array} structure
+ * @param {number} flags
+ * @returns {() => Node | Node[]}
+ */
+/*#__NO_SIDE_EFFECTS__*/
+export function svg_template_with_script_fn(structure, flags) {
+	var templated_fn = ns_template_fn(structure, flags);
+	return () => run_scripts(/** @type {Element | DocumentFragment} */ (templated_fn()));
+}
+
 /**
  * @param {string} content
  * @param {number} flags
@@ -151,6 +336,16 @@ export function mathml_template(content, flags) {
 	return ns_template(content, flags, 'math');
 }
 
+/**
+ * @param {Array} structure
+ * @param {number} flags
+ * @returns {() => Node | Node[]}
+ */
+/*#__NO_SIDE_EFFECTS__*/
+export function mathml_template_fn(structure, flags) {
+	return ns_template_fn(structure, 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 14d6e29f5bb4..f02bbc629d85 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -87,10 +87,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';
diff --git a/packages/svelte/src/internal/server/dev.js b/packages/svelte/src/internal/server/dev.js
index efc761d7c5ef..2e4082270528 100644
--- a/packages/svelte/src/internal/server/dev.js
+++ b/packages/svelte/src/internal/server/dev.js
@@ -24,7 +24,7 @@ import { HeadPayload, Payload } from './payload.js';
 let parent = null;
 
 /** @type {Set} */
-let seen;
+export let seen;
 
 /**
  * @param {Payload} payload
diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js
index f853d5873c57..945f8b2c92e1 100644
--- a/packages/svelte/tests/helpers.js
+++ b/packages/svelte/tests/helpers.js
@@ -58,15 +58,17 @@ export function create_deferred() {
  * @param {Partial} compileOptions
  * @param {boolean} [output_map]
  * @param {any} [preprocessor]
+ * @param {import('./suite').TemplatingMode} [templating_mode]
  */
 export async function compile_directory(
 	cwd,
 	generate,
 	compileOptions = {},
 	output_map = false,
-	preprocessor
+	preprocessor,
+	templating_mode
 ) {
-	const output_dir = `${cwd}/_output/${generate}`;
+	const output_dir = `${cwd}/_output/${generate}${templating_mode === 'functional' ? `-functional` : ''}`;
 
 	fs.rmSync(output_dir, { recursive: true, force: true });
 
@@ -77,7 +79,8 @@ export async function compile_directory(
 		let opts = {
 			filename: path.join(cwd, file),
 			...compileOptions,
-			generate
+			generate,
+			templatingMode: templating_mode
 		};
 
 		if (file.endsWith('.js')) {
diff --git a/packages/svelte/tests/hydration/test.ts b/packages/svelte/tests/hydration/test.ts
index 266ac07bff39..0fc518773367 100644
--- a/packages/svelte/tests/hydration/test.ts
+++ b/packages/svelte/tests/hydration/test.ts
@@ -41,10 +41,24 @@ function read(path: string): string | void {
 	return fs.existsSync(path) ? fs.readFileSync(path, 'utf-8') : undefined;
 }
 
-const { test, run } = suite(async (config, cwd) => {
+const { test, run } = suite(async (config, cwd, templating_mode) => {
 	if (!config.load_compiled) {
-		await compile_directory(cwd, 'client', { accessors: true, ...config.compileOptions });
-		await compile_directory(cwd, 'server', config.compileOptions);
+		await compile_directory(
+			cwd,
+			'client',
+			{ accessors: true, ...config.compileOptions },
+			undefined,
+			undefined,
+			templating_mode
+		);
+		await compile_directory(
+			cwd,
+			'server',
+			config.compileOptions,
+			undefined,
+			undefined,
+			templating_mode
+		);
 	}
 
 	const target = window.document.body;
@@ -102,7 +116,11 @@ const { test, run } = suite(async (config, cwd) => {
 		};
 
 		const component = createClassComponent({
-			component: (await import(`${cwd}/_output/client/main.svelte.js`)).default,
+			component: (
+				await import(
+					`${cwd}/_output/client${templating_mode === 'functional' ? '-functional' : ''}/main.svelte.js`
+				)
+			).default,
 			target,
 			hydrate: true,
 			props: config.props,
@@ -169,4 +187,5 @@ const { test, run } = suite(async (config, cwd) => {
 });
 export { test, assert_ok };
 
-await run(__dirname);
+await run(__dirname, 'string');
+await run(__dirname, 'functional');
diff --git a/packages/svelte/tests/runtime-browser/test.ts b/packages/svelte/tests/runtime-browser/test.ts
index 582a10edf722..02823ad87ba6 100644
--- a/packages/svelte/tests/runtime-browser/test.ts
+++ b/packages/svelte/tests/runtime-browser/test.ts
@@ -4,7 +4,7 @@ import * as fs from 'node:fs';
 import * as path from 'node:path';
 import { compile } from 'svelte/compiler';
 import { afterAll, assert, beforeAll, describe } from 'vitest';
-import { suite, suite_with_variants } from '../suite';
+import { suite, suite_with_variants, type TemplatingMode } from '../suite';
 import { write } from '../helpers';
 import type { Warning } from '#compiler';
 
@@ -35,27 +35,41 @@ const { run: run_browser_tests } = suite_with_variants<
 		return false;
 	},
 	() => {},
-	async (config, test_dir, variant) => {
-		await run_test(test_dir, config, variant === 'hydrate');
+	async (config, test_dir, variant, _, templating_mode) => {
+		await run_test(test_dir, config, variant === 'hydrate', templating_mode);
 	}
 );
 
 describe.concurrent(
 	'runtime-browser',
-	() => run_browser_tests(__dirname),
+	() => run_browser_tests(__dirname, 'string'),
+	// Browser tests are brittle and slow on CI
+	{ timeout: 20000, retry: process.env.CI ? 1 : 0 }
+);
+
+describe.concurrent(
+	'runtime-browser-functional',
+	() => run_browser_tests(__dirname, 'functional'),
 	// Browser tests are brittle and slow on CI
 	{ timeout: 20000, retry: process.env.CI ? 1 : 0 }
 );
 
 const { run: run_ce_tests } = suite>(
-	async (config, test_dir) => {
-		await run_test(test_dir, config, false);
+	async (config, test_dir, templating_mode) => {
+		await run_test(test_dir, config, false, templating_mode);
 	}
 );
 
 describe.concurrent(
 	'custom-elements',
-	() => run_ce_tests(__dirname, 'custom-elements-samples'),
+	() => run_ce_tests(__dirname, 'string', 'custom-elements-samples'),
+	// Browser tests are brittle and slow on CI
+	{ timeout: 20000, retry: process.env.CI ? 1 : 0 }
+);
+
+describe.concurrent(
+	'custom-elements',
+	() => run_ce_tests(__dirname, 'functional', 'custom-elements-samples'),
 	// Browser tests are brittle and slow on CI
 	{ timeout: 20000, retry: process.env.CI ? 1 : 0 }
 );
@@ -63,7 +77,8 @@ describe.concurrent(
 async function run_test(
 	test_dir: string,
 	config: ReturnType,
-	hydrate: boolean
+	hydrate: boolean,
+	templating_mode: TemplatingMode
 ) {
 	const warnings: any[] = [];
 
@@ -90,10 +105,14 @@ async function run_test(
 							...config.compileOptions,
 							immutable: config.immutable,
 							customElement: test_dir.includes('custom-elements-samples'),
-							accessors: 'accessors' in config ? config.accessors : true
+							accessors: 'accessors' in config ? config.accessors : true,
+							templatingMode: templating_mode
 						});
 
-						write(`${test_dir}/_output/client/${path.basename(args.path)}.js`, compiled.js.code);
+						write(
+							`${test_dir}/_output/client${templating_mode === 'functional' ? '-functional' : ''}/${path.basename(args.path)}.js`,
+							compiled.js.code
+						);
 
 						compiled.warnings.forEach((warning) => {
 							if (warning.code === 'options_deprecated_accessors') return;
@@ -103,7 +122,7 @@ async function run_test(
 						if (compiled.css !== null) {
 							compiled.js.code += `document.head.innerHTML += \`\``;
 							write(
-								`${test_dir}/_output/client/${path.basename(args.path)}.css`,
+								`${test_dir}/_output/${templating_mode === 'functional' ? '-functional' : ''}/${path.basename(args.path)}.css`,
 								compiled.css.code
 							);
 						}
@@ -151,7 +170,8 @@ async function run_test(
 								...config.compileOptions,
 								immutable: config.immutable,
 								customElement: test_dir.includes('custom-elements-samples'),
-								accessors: 'accessors' in config ? config.accessors : true
+								accessors: 'accessors' in config ? config.accessors : true,
+								templatingMode: templating_mode
 							});
 
 							return {
diff --git a/packages/svelte/tests/runtime-legacy/samples/attribute-casing-custom-element/main.svelte b/packages/svelte/tests/runtime-legacy/samples/attribute-casing-custom-element/main.svelte
index 6433d0dc768a..81c855676a6c 100644
--- a/packages/svelte/tests/runtime-legacy/samples/attribute-casing-custom-element/main.svelte
+++ b/packages/svelte/tests/runtime-legacy/samples/attribute-casing-custom-element/main.svelte
@@ -18,8 +18,9 @@
 			this.innerHTML = 'Hello ' + this._obj.text + '!';
 		}
 	}
-
-	window.customElements.define('my-custom-element', MyCustomElement);
+	if(!window.customElements.get('my-custom-element')) {
+		window.customElements.define('my-custom-element', MyCustomElement);
+	}
 
 
 
diff --git a/packages/svelte/tests/runtime-legacy/samples/attribute-custom-element-inheritance/main.svelte b/packages/svelte/tests/runtime-legacy/samples/attribute-custom-element-inheritance/main.svelte
index 1324bcc4b129..04ac58435aa3 100644
--- a/packages/svelte/tests/runtime-legacy/samples/attribute-custom-element-inheritance/main.svelte
+++ b/packages/svelte/tests/runtime-legacy/samples/attribute-custom-element-inheritance/main.svelte
@@ -26,8 +26,10 @@
 	}
 
 	class Extended extends MyCustomElement {}
-
-	window.customElements.define('my-custom-inheritance-element', Extended);
+	
+	if(!window.customElements.get('my-custom-inheritance-element')) {
+		window.customElements.define('my-custom-inheritance-element', Extended);
+	}
 
 
 
diff --git a/packages/svelte/tests/runtime-legacy/samples/window-binding-scroll-store/_config.js b/packages/svelte/tests/runtime-legacy/samples/window-binding-scroll-store/_config.js
index 115c3cfd3867..b9ea2fb27bf5 100644
--- a/packages/svelte/tests/runtime-legacy/samples/window-binding-scroll-store/_config.js
+++ b/packages/svelte/tests/runtime-legacy/samples/window-binding-scroll-store/_config.js
@@ -9,7 +9,8 @@ export default test({
 		Object.defineProperties(window, {
 			scrollY: {
 				value: 0,
-				configurable: true
+				configurable: true,
+				writable: true
 			}
 		});
 		original_scrollTo = window.scrollTo;
diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts
index 14b6cff841bd..f7a14c043971 100644
--- a/packages/svelte/tests/runtime-legacy/shared.ts
+++ b/packages/svelte/tests/runtime-legacy/shared.ts
@@ -10,7 +10,8 @@ import { compile_directory } from '../helpers.js';
 import { setup_html_equal } from '../html_equal.js';
 import { raf } from '../animation-helpers.js';
 import type { CompileOptions } from '#compiler';
-import { suite_with_variants, type BaseTest } from '../suite.js';
+import { suite_with_variants, type BaseTest, type TemplatingMode } from '../suite.js';
+import { seen } from '../../src/internal/server/dev.js';
 
 type Assert = typeof import('vitest').assert & {
 	htmlEqual(a: string, b: string, description?: string): void;
@@ -141,16 +142,21 @@ export function runtime_suite(runes: boolean) {
 
 			return false;
 		},
-		(config, cwd) => {
-			return common_setup(cwd, runes, config);
+		(config, cwd, templating_mode) => {
+			return common_setup(cwd, runes, config, templating_mode);
 		},
-		async (config, cwd, variant, common) => {
-			await run_test_variant(cwd, config, variant, common, runes);
+		async (config, cwd, variant, common, templating_mode) => {
+			await run_test_variant(cwd, config, variant, common, runes, templating_mode);
 		}
 	);
 }
 
-async function common_setup(cwd: string, runes: boolean | undefined, config: RuntimeTest) {
+async function common_setup(
+	cwd: string,
+	runes: boolean | undefined,
+	config: RuntimeTest,
+	templating_mode: TemplatingMode
+) {
 	const force_hmr = process.env.HMR && config.compileOptions?.dev !== false && !config.error;
 
 	const compileOptions: CompileOptions = {
@@ -161,13 +167,14 @@ async function common_setup(cwd: string, runes: boolean | undefined, config: Run
 		...config.compileOptions,
 		immutable: config.immutable,
 		accessors: 'accessors' in config ? config.accessors : true,
-		runes
+		runes,
+		templatingMode: templating_mode
 	};
 
 	// load_compiled can be used for debugging a test. It means the compiler will not run on the input
 	// so you can manipulate the output manually to see what fixes it, adding console.logs etc.
 	if (!config.load_compiled) {
-		await compile_directory(cwd, 'client', compileOptions);
+		await compile_directory(cwd, 'client', compileOptions, undefined, undefined, templating_mode);
 		await compile_directory(cwd, 'server', compileOptions);
 	}
 
@@ -179,7 +186,8 @@ async function run_test_variant(
 	config: RuntimeTest,
 	variant: 'dom' | 'hydrate' | 'ssr',
 	compileOptions: CompileOptions,
-	runes: boolean
+	runes: boolean,
+	templating_mode: TemplatingMode
 ) {
 	let unintended_error = false;
 
@@ -257,8 +265,15 @@ async function run_test_variant(
 		raf.reset();
 
 		// Put things we need on window for testing
-		const styles = globSync('**/*.css', { cwd: `${cwd}/_output/client` })
-			.map((file) => fs.readFileSync(`${cwd}/_output/client/${file}`, 'utf-8'))
+		const styles = globSync('**/*.css', {
+			cwd: `${cwd}/_output/client${templating_mode === 'functional' ? '-functional' : ''}`
+		})
+			.map((file) =>
+				fs.readFileSync(
+					`${cwd}/_output/client${templating_mode === 'functional' ? '-functional' : ''}/${file}`,
+					'utf-8'
+				)
+			)
 			.join('\n')
 			.replace(/\/\*<\/?style>\*\//g, '');
 
@@ -274,7 +289,9 @@ async function run_test_variant(
 
 		globalThis.requestAnimationFrame = globalThis.setTimeout;
 
-		let mod = await import(`${cwd}/_output/client/main.svelte.js`);
+		let mod = await import(
+			`${cwd}/_output/client${templating_mode === 'functional' ? '-functional' : ''}/main.svelte.js`
+		);
 
 		const target = window.document.querySelector('main') as HTMLElement;
 
@@ -282,6 +299,8 @@ async function run_test_variant(
 
 		if (variant === 'hydrate' || variant === 'ssr') {
 			config.before_test?.();
+			// we need to clear the seen messages between tests
+			seen?.clear?.();
 			// ssr into target
 			const SsrSvelteComponent = (await import(`${cwd}/_output/server/main.svelte.js`)).default;
 			const { html, head } = render(SsrSvelteComponent, {
diff --git a/packages/svelte/tests/runtime-legacy/test.ts b/packages/svelte/tests/runtime-legacy/test.ts
index c4617a571c08..d422d8a33637 100644
--- a/packages/svelte/tests/runtime-legacy/test.ts
+++ b/packages/svelte/tests/runtime-legacy/test.ts
@@ -11,4 +11,5 @@ const { test, run } = runtime_suite(false);
 
 export { test, ok };
 
-await run(__dirname);
+await run(__dirname, 'string');
+await run(__dirname, 'functional');
diff --git a/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/main.svelte b/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/main.svelte
index 4c98245e5b6b..82774f160d3b 100644
--- a/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/main.svelte
+++ b/packages/svelte/tests/runtime-runes/samples/custom-element-attributes/main.svelte
@@ -1,18 +1,20 @@
 
 
 
diff --git a/packages/svelte/tests/runtime-runes/test.ts b/packages/svelte/tests/runtime-runes/test.ts
index 0806864060a3..5dafe62ad298 100644
--- a/packages/svelte/tests/runtime-runes/test.ts
+++ b/packages/svelte/tests/runtime-runes/test.ts
@@ -5,4 +5,5 @@ const { test, run } = runtime_suite(true);
 
 export { test, ok };
 
-await run(__dirname);
+await run(__dirname, 'string');
+await run(__dirname, 'functional');
diff --git a/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..44b0cd1557aa
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/await-block-scope/_expected/client-functional/index.svelte.js
@@ -0,0 +1,36 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+function increment(_, counter) {
+	counter.count += 1;
+}
+
+var root = $.template_fn([{ e: 'button', c: [' '] }, ' ', , ' '], 1);
+
+export default function Await_block_scope($$anchor) {
+	let counter = $.proxy({ count: 0 });
+	const promise = $.derived(() => Promise.resolve(counter));
+	var fragment = root();
+	var button = $.first_child(fragment);
+
+	button.__click = [increment, counter];
+
+	var text = $.child(button);
+
+	$.reset(button);
+
+	var node = $.sibling(button, 2);
+
+	$.await(node, () => $.get(promise), null, ($$anchor, counter) => {});
+
+	var text_1 = $.sibling(node);
+
+	$.template_effect(() => {
+		$.set_text(text, `clicks: ${counter.count ?? ''}`);
+		$.set_text(text_1, ` ${counter.count ?? ''}`);
+	});
+
+	$.append($$anchor, fragment);
+}
+
+$.delegate(['click']);
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..e06c3bbf6bc8
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client-functional/index.svelte.js
@@ -0,0 +1,34 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+import TextInput from './Child.svelte';
+
+const snippet = ($$anchor) => {
+	$.next();
+
+	var text = $.text('Something');
+
+	$.append($$anchor, text);
+};
+
+var root = $.template_fn([,, ' '], 1);
+
+export default function Bind_component_snippet($$anchor) {
+	let value = $.state('');
+	const _snippet = snippet;
+	var fragment = root();
+	var node = $.first_child(fragment);
+
+	TextInput(node, {
+		get value() {
+			return $.get(value);
+		},
+		set value($$value) {
+			$.set(value, $$value, true);
+		}
+	});
+
+	var text_1 = $.sibling(node);
+
+	$.template_effect(() => $.set_text(text_1, ` value: ${$.get(value) ?? ''}`));
+	$.append($$anchor, fragment);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/bind-this/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/bind-this/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..dfd32a04e51d
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/bind-this/_expected/client-functional/index.svelte.js
@@ -0,0 +1,7 @@
+import 'svelte/internal/disclose-version';
+import 'svelte/internal/flags/legacy';
+import * as $ from 'svelte/internal/client';
+
+export default function Bind_this($$anchor) {
+	$.bind_this(Foo($$anchor, { $$legacy: true }), ($$value) => foo = $$value, () => foo);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..21339741761f
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client-functional/index.svelte.js
@@ -0,0 +1,27 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+export default function Class_state_field_constructor_assignment($$anchor, $$props) {
+	$.push($$props, true);
+
+	class Foo {
+		#a = $.state();
+
+		get a() {
+			return $.get(this.#a);
+		}
+
+		set a(value) {
+			$.set(this.#a, value, true);
+		}
+
+		#b = $.state();
+
+		constructor() {
+			this.a = 1;
+			$.set(this.#b, 2);
+		}
+	}
+
+	$.pop();
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/destructured-assignments/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/destructured-assignments/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..47f297bce9c7
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/destructured-assignments/_expected/client-functional/index.svelte.js
@@ -0,0 +1,16 @@
+/* index.svelte.js generated by Svelte VERSION */
+import * as $ from 'svelte/internal/client';
+
+let a = $.state(1);
+let b = $.state(2);
+let c = 3;
+let d = 4;
+
+export function update(array) {
+	(
+		$.set(a, array[0], true),
+		$.set(b, array[1], true)
+	);
+
+	[c, d] = array;
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client-functional/main.svelte.js b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client-functional/main.svelte.js
new file mode 100644
index 000000000000..6bf2d77a00bd
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client-functional/main.svelte.js
@@ -0,0 +1,49 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+var root = $.template_fn(
+	[
+		{ e: 'div' },
+		' ',
+		{ e: 'svg' },
+		' ',
+		{ e: 'custom-element' },
+		' ',
+		{ e: 'div' },
+		' ',
+		{ e: 'svg' },
+		' ',
+		{ e: 'custom-element' }
+	],
+	3
+);
+
+export default function Main($$anchor) {
+	// needs to be a snapshot test because jsdom does auto-correct the attribute casing
+	let x = 'test';
+	let y = () => 'test';
+	var fragment = root();
+	var div = $.first_child(fragment);
+	var svg = $.sibling(div, 2);
+	var custom_element = $.sibling(svg, 2);
+
+	$.template_effect(() => $.set_custom_element_data(custom_element, 'fooBar', x));
+
+	var div_1 = $.sibling(custom_element, 2);
+	var svg_1 = $.sibling(div_1, 2);
+	var custom_element_1 = $.sibling(svg_1, 2);
+
+	$.template_effect(() => $.set_custom_element_data(custom_element_1, 'fooBar', y()));
+
+	$.template_effect(
+		($0, $1) => {
+			$.set_attribute(div, 'foobar', x);
+			$.set_attribute(svg, 'viewBox', x);
+			$.set_attribute(div_1, 'foobar', $0);
+			$.set_attribute(svg_1, 'viewBox', $1);
+		},
+		[y, y]
+	);
+
+	$.append($$anchor, fragment);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..e75865e9cb82
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/client-functional/index.svelte.js
@@ -0,0 +1,19 @@
+import 'svelte/internal/disclose-version';
+import 'svelte/internal/flags/legacy';
+import * as $ from 'svelte/internal/client';
+
+var root_1 = $.template_fn([{ e: '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();
+
+		p.textContent = `index: ${i}`;
+		$.append($$anchor, p);
+	});
+
+	$.append($$anchor, fragment);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..c0626bd416c9
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/each-string-template/_expected/client-functional/index.svelte.js
@@ -0,0 +1,19 @@
+import 'svelte/internal/disclose-version';
+import 'svelte/internal/flags/legacy';
+import * as $ from 'svelte/internal/client';
+
+export default function Each_string_template($$anchor) {
+	var fragment = $.comment();
+	var node = $.first_child(fragment);
+
+	$.each(node, 0, () => ['foo', 'bar', 'baz'], $.index, ($$anchor, thing) => {
+		$.next();
+
+		var text = $.text();
+
+		$.template_effect(() => $.set_text(text, `${thing ?? ''}, `));
+		$.append($$anchor, text);
+	});
+
+	$.append($$anchor, fragment);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/export-state/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/export-state/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..c2a6054bc6f6
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/export-state/_expected/client-functional/index.svelte.js
@@ -0,0 +1,4 @@
+/* index.svelte.js generated by Svelte VERSION */
+import * as $ from 'svelte/internal/client';
+
+export const object = $.proxy({ ok: true });
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..762a23754c9b
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client-functional/index.svelte.js
@@ -0,0 +1,27 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+export default function Function_prop_no_getter($$anchor) {
+	let count = $.state(0);
+
+	function onmouseup() {
+		$.set(count, $.get(count) + 2);
+	}
+
+	const plusOne = (num) => num + 1;
+
+	Button($$anchor, {
+		onmousedown: () => $.set(count, $.get(count) + 1),
+		onmouseup,
+		onmouseenter: () => $.set(count, plusOne($.get(count)), true),
+		children: ($$anchor, $$slotProps) => {
+			$.next();
+
+			var text = $.text();
+
+			$.template_effect(() => $.set_text(text, `clicks: ${$.get(count) ?? ''}`));
+			$.append($$anchor, text);
+		},
+		$$slots: { default: true }
+	});
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/hello-world/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/hello-world/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..3cc49718838f
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/hello-world/_expected/client-functional/index.svelte.js
@@ -0,0 +1,11 @@
+import 'svelte/internal/disclose-version';
+import 'svelte/internal/flags/legacy';
+import * as $ from 'svelte/internal/client';
+
+var root = $.template_fn([{ e: 'h1', c: ['hello world'] }]);
+
+export default function Hello_world($$anchor) {
+	var h1 = root();
+
+	$.append($$anchor, h1);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/hmr/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/hmr/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..d5bb01474cf9
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/hmr/_expected/client-functional/index.svelte.js
@@ -0,0 +1,22 @@
+import 'svelte/internal/disclose-version';
+import 'svelte/internal/flags/legacy';
+import * as $ from 'svelte/internal/client';
+
+var root = $.template_fn([{ e: 'h1', c: ['hello world'] }]);
+
+function Hmr($$anchor) {
+	var h1 = root();
+
+	$.append($$anchor, h1);
+}
+
+if (import.meta.hot) {
+	Hmr = $.hmr(Hmr, () => Hmr[$.HMR].source);
+
+	import.meta.hot.accept((module) => {
+		module.default[$.HMR].source = Hmr[$.HMR].source;
+		$.set(Hmr[$.HMR].source, module.default[$.HMR].original);
+	});
+}
+
+export default Hmr;
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client-functional/export.js b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client-functional/export.js
new file mode 100644
index 000000000000..b4bb7075da08
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client-functional/export.js
@@ -0,0 +1 @@
+export * from '../../export.js';
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..ebbe191dcbe4
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client-functional/index.svelte.js
@@ -0,0 +1,8 @@
+import 'svelte/internal/disclose-version';
+import 'svelte/internal/flags/legacy';
+import * as $ from 'svelte/internal/client';
+import { random } from './module.svelte';
+
+export default function Imports_in_modules($$anchor) {
+	
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client-functional/module.svelte.js b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client-functional/module.svelte.js
new file mode 100644
index 000000000000..0d366e6258ff
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/imports-in-modules/_expected/client-functional/module.svelte.js
@@ -0,0 +1,5 @@
+/* module.svelte.js generated by Svelte VERSION */
+import * as $ from 'svelte/internal/client';
+import { random } from './export';
+
+export { random };
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..d8922a1d5c83
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client-functional/index.svelte.js
@@ -0,0 +1,46 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+var on_click = (_, count) => $.update(count);
+
+var root = $.template_fn(
+	[
+		{ e: 'h1' },
+		' ',
+		{ e: 'b' },
+		' ',
+		{ e: 'button', c: [' '] },
+		' ',
+		{ e: 'h1' }
+	],
+	1
+);
+
+export default function Nullish_coallescence_omittance($$anchor) {
+	let name = 'world';
+	let count = $.state(0);
+	var fragment = root();
+	var h1 = $.first_child(fragment);
+
+	h1.textContent = 'Hello, world!';
+
+	var b = $.sibling(h1, 2);
+
+	b.textContent = '123';
+
+	var button = $.sibling(b, 2);
+
+	button.__click = [on_click, count];
+
+	var text = $.child(button);
+
+	$.reset(button);
+
+	var h1_1 = $.sibling(button, 2);
+
+	h1_1.textContent = 'Hello, world';
+	$.template_effect(() => $.set_text(text, `Count is ${$.get(count) ?? ''}`));
+	$.append($$anchor, fragment);
+}
+
+$.delegate(['click']);
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..5a46b9bbefe1
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/props-identifier/_expected/client-functional/index.svelte.js
@@ -0,0 +1,17 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+export default function Props_identifier($$anchor, $$props) {
+	$.push($$props, true);
+
+	let props = $.rest_props($$props, ['$$slots', '$$events', '$$legacy']);
+
+	$$props.a;
+	props[a];
+	$$props.a.b;
+	$$props.a.b = true;
+	props.a = true;
+	props[a] = true;
+	props;
+	$.pop();
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/purity/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/purity/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..d81ba6857a10
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/purity/_expected/client-functional/index.svelte.js
@@ -0,0 +1,21 @@
+import 'svelte/internal/disclose-version';
+import 'svelte/internal/flags/legacy';
+import * as $ from 'svelte/internal/client';
+
+var root = $.template_fn([{ e: 'p' }, ' ', { e: 'p' }, ' ', ,], 1);
+
+export default function Purity($$anchor) {
+	var fragment = root();
+	var p = $.first_child(fragment);
+
+	p.textContent = 0;
+
+	var p_1 = $.sibling(p, 2);
+
+	p_1.textContent = location.href;
+
+	var node = $.sibling(p_1, 2);
+
+	Child(node, { prop: encodeURIComponent('hello') });
+	$.append($$anchor, fragment);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..e4647c0c39bd
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client-functional/index.svelte.js
@@ -0,0 +1,139 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+var root = $.template_fn(
+	[
+		{
+			e: 'header',
+			c: [
+				{
+					e: 'nav',
+					c: [
+						{ e: 'a', p: { href: '/' }, c: ['Home'] },
+						' ',
+						{
+							e: 'a',
+							p: { href: '/away' },
+							c: ['Away']
+						}
+					]
+				}
+			]
+		},
+		' ',
+		{
+			e: 'main',
+			c: [
+				{ e: 'h1', c: [' '] },
+				' ',
+				{
+					e: 'div',
+					p: { class: 'static' },
+					c: [
+						{
+							e: 'p',
+							c: ['we don\'t need to traverse these nodes']
+						}
+					]
+				},
+				' ',
+				{ e: 'p', c: ['or'] },
+				' ',
+				{ e: 'p', c: ['these'] },
+				' ',
+				{ e: 'p', c: ['ones'] },
+				' ',
+				,
+				' ',
+				{ e: 'p', c: ['these'] },
+				' ',
+				{ e: 'p', c: ['trailing'] },
+				' ',
+				{ e: 'p', c: ['nodes'] },
+				' ',
+				{ e: 'p', c: ['can'] },
+				' ',
+				{ e: 'p', c: ['be'] },
+				' ',
+				{ e: 'p', c: ['completely'] },
+				' ',
+				{ e: 'p', c: ['ignored'] }
+			]
+		},
+		' ',
+		{
+			e: 'cant-skip',
+			c: [{ e: 'custom-elements' }]
+		},
+		' ',
+		{ e: 'div', c: [{ e: 'input' }] },
+		' ',
+		{ e: 'div', c: [{ e: 'source' }] },
+		' ',
+		{
+			e: 'select',
+			c: [{ e: 'option', c: ['a'] }]
+		},
+		' ',
+		{
+			e: 'img',
+			p: { src: '...', alt: '', loading: 'lazy' }
+		},
+		' ',
+		{
+			e: 'div',
+			c: [
+				{
+					e: 'img',
+					p: { src: '...', alt: '', loading: 'lazy' }
+				}
+			]
+		}
+	],
+	3
+);
+
+export default function Skip_static_subtree($$anchor, $$props) {
+	var fragment = root();
+	var main = $.sibling($.first_child(fragment), 2);
+	var h1 = $.child(main);
+	var text = $.child(h1, true);
+
+	$.reset(h1);
+
+	var node = $.sibling(h1, 10);
+
+	$.html(node, () => $$props.content);
+	$.next(14);
+	$.reset(main);
+
+	var cant_skip = $.sibling(main, 2);
+	var custom_elements = $.child(cant_skip);
+
+	$.set_custom_element_data(custom_elements, 'with', 'attributes');
+	$.reset(cant_skip);
+
+	var div = $.sibling(cant_skip, 2);
+	var input = $.child(div);
+
+	$.autofocus(input, true);
+	$.reset(div);
+
+	var div_1 = $.sibling(div, 2);
+	var source = $.child(div_1);
+
+	source.muted = true;
+	$.reset(div_1);
+
+	var select = $.sibling(div_1, 2);
+	var option = $.child(select);
+
+	option.value = option.__value = 'a';
+	$.reset(select);
+
+	var img = $.sibling(select, 2);
+
+	$.next(2);
+	$.template_effect(() => $.set_text(text, $$props.title));
+	$.append($$anchor, fragment);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..2be761b88dc8
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/state-proxy-literal/_expected/client-functional/index.svelte.js
@@ -0,0 +1,42 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+function reset(_, str, tpl) {
+	$.set(str, '');
+	$.set(str, ``);
+	$.set(tpl, '');
+	$.set(tpl, ``);
+}
+
+var root = $.template_fn(
+	[
+		{ e: 'input' },
+		' ',
+		{ e: 'input' },
+		' ',
+		{ e: 'button', c: ['reset'] }
+	],
+	1
+);
+
+export default function State_proxy_literal($$anchor) {
+	let str = $.state('');
+	let tpl = $.state(``);
+	var fragment = root();
+	var input = $.first_child(fragment);
+
+	$.remove_input_defaults(input);
+
+	var input_1 = $.sibling(input, 2);
+
+	$.remove_input_defaults(input_1);
+
+	var button = $.sibling(input_1, 2);
+
+	button.__click = [reset, str, tpl];
+	$.bind_value(input, () => $.get(str), ($$value) => $.set(str, $$value));
+	$.bind_value(input_1, () => $.get(tpl), ($$value) => $.set(tpl, $$value));
+	$.append($$anchor, fragment);
+}
+
+$.delegate(['click']);
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..2270005ee0dd
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client-functional/index.svelte.js
@@ -0,0 +1,11 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+export default function Svelte_element($$anchor, $$props) {
+	let tag = $.prop($$props, 'tag', 3, 'hr');
+	var fragment = $.comment();
+	var node = $.first_child(fragment);
+
+	$.element(node, tag, false);
+	$.append($$anchor, fragment);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client-functional/index.svelte.js b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client-functional/index.svelte.js
new file mode 100644
index 000000000000..9176f1ab92cc
--- /dev/null
+++ b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client-functional/index.svelte.js
@@ -0,0 +1,24 @@
+import 'svelte/internal/disclose-version';
+import * as $ from 'svelte/internal/client';
+
+var root = $.template_fn([{ e: 'p', c: [' '] }]);
+
+export default function Text_nodes_deriveds($$anchor) {
+	let count1 = 0;
+	let count2 = 0;
+
+	function text1() {
+		return count1;
+	}
+
+	function text2() {
+		return count2;
+	}
+
+	var p = root();
+	var text = $.child(p);
+
+	$.reset(p);
+	$.template_effect(($0, $1) => $.set_text(text, `${$0 ?? ''}${$1 ?? ''}`), [text1, text2]);
+	$.append($$anchor, p);
+}
\ No newline at end of file
diff --git a/packages/svelte/tests/snapshot/test.ts b/packages/svelte/tests/snapshot/test.ts
index 0a591c6e2a71..ebf1a46daa4b 100644
--- a/packages/svelte/tests/snapshot/test.ts
+++ b/packages/svelte/tests/snapshot/test.ts
@@ -9,8 +9,15 @@ interface SnapshotTest extends BaseTest {
 	compileOptions?: Partial;
 }
 
-const { test, run } = suite(async (config, cwd) => {
-	await compile_directory(cwd, 'client', config.compileOptions);
+const { test, run } = suite(async (config, cwd, templating_mode) => {
+	await compile_directory(
+		cwd,
+		'client',
+		config.compileOptions,
+		undefined,
+		undefined,
+		templating_mode
+	);
 	await compile_directory(cwd, 'server', config.compileOptions);
 
 	// run `UPDATE_SNAPSHOTS=true pnpm test snapshot` to update snapshot tests
@@ -18,8 +25,18 @@ const { test, run } = suite(async (config, cwd) => {
 		fs.rmSync(`${cwd}/_expected`, { recursive: true, force: true });
 		fs.cpSync(`${cwd}/_output`, `${cwd}/_expected`, { recursive: true, force: true });
 	} else {
-		const actual = globSync('**', { cwd: `${cwd}/_output`, onlyFiles: true });
-		const expected = globSync('**', { cwd: `${cwd}/_expected`, onlyFiles: true });
+		const actual = globSync('**', { cwd: `${cwd}/_output`, onlyFiles: true }).filter(
+			// filters out files that might not yet be compiled (functional is executed after string)
+			(expected) =>
+				expected.startsWith('server/') ||
+				expected.startsWith(`client${templating_mode === 'functional' ? '-functional' : ''}/`)
+		);
+		const expected = globSync('**', { cwd: `${cwd}/_expected`, onlyFiles: true }).filter(
+			// filters out files that might not yet be compiled (functional is executed after string)
+			(expected) =>
+				expected.startsWith('server/') ||
+				expected.startsWith(`client${templating_mode === 'functional' ? '-functional' : ''}/`)
+		);
 
 		assert.deepEqual(actual, expected);
 
@@ -41,4 +58,5 @@ const { test, run } = suite(async (config, cwd) => {
 
 export { test };
 
-await run(__dirname);
+await run(__dirname, 'string');
+await run(__dirname, 'functional');
diff --git a/packages/svelte/tests/suite.ts b/packages/svelte/tests/suite.ts
index 0ae06e727f87..c2e7743f2b96 100644
--- a/packages/svelte/tests/suite.ts
+++ b/packages/svelte/tests/suite.ts
@@ -6,6 +6,8 @@ export interface BaseTest {
 	solo?: boolean;
 }
 
+export type TemplatingMode = 'string' | 'functional';
+
 /**
  * To filter tests, run one of these:
  *
@@ -20,14 +22,22 @@ const filter = process.env.FILTER
 		)
 	: /./;
 
-export function suite(fn: (config: Test, test_dir: string) => void) {
+export function suite(
+	fn: (config: Test, test_dir: string, templating_mode: TemplatingMode) => void
+) {
 	return {
 		test: (config: Test) => config,
-		run: async (cwd: string, samples_dir = 'samples') => {
+		run: async (
+			cwd: string,
+			templating_mode: TemplatingMode = 'string',
+			samples_dir = 'samples'
+		) => {
 			await for_each_dir(cwd, samples_dir, (config, dir) => {
 				let it_fn = config.skip ? it.skip : config.solo ? it.only : it;
 
-				it_fn(dir, () => fn(config, `${cwd}/${samples_dir}/${dir}`));
+				it_fn(`${dir} (${templating_mode})`, () =>
+					fn(config, `${cwd}/${samples_dir}/${dir}`, templating_mode)
+				);
 			});
 		}
 	};
@@ -36,12 +46,26 @@ export function suite(fn: (config: Test, test_dir: string
 export function suite_with_variants(
 	variants: Variants[],
 	should_skip_variant: (variant: Variants, config: Test) => boolean | 'no-test',
-	common_setup: (config: Test, test_dir: string) => Promise | Common,
-	fn: (config: Test, test_dir: string, variant: Variants, common: Common) => void
+	common_setup: (
+		config: Test,
+		test_dir: string,
+		templating_mode: TemplatingMode
+	) => Promise | Common,
+	fn: (
+		config: Test,
+		test_dir: string,
+		variant: Variants,
+		common: Common,
+		templating_mode: TemplatingMode
+	) => void
 ) {
 	return {
 		test: (config: Test) => config,
-		run: async (cwd: string, samples_dir = 'samples') => {
+		run: async (
+			cwd: string,
+			templating_mode: TemplatingMode = 'string',
+			samples_dir = 'samples'
+		) => {
 			await for_each_dir(cwd, samples_dir, (config, dir) => {
 				let called_common = false;
 				let common: any = undefined;
@@ -54,12 +78,12 @@ export function suite_with_variants {
+					it_fn(`${dir} (${templating_mode}-${variant})`, async () => {
 						if (!called_common) {
 							called_common = true;
-							common = await common_setup(config, `${cwd}/${samples_dir}/${dir}`);
+							common = await common_setup(config, `${cwd}/${samples_dir}/${dir}`, templating_mode);
 						}
-						return fn(config, `${cwd}/${samples_dir}/${dir}`, variant, common);
+						return fn(config, `${cwd}/${samples_dir}/${dir}`, variant, common, templating_mode);
 					});
 				}
 			});
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index b233cfcc0b58..a72b45d33161 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -846,6 +846,12 @@ declare module 'svelte/compiler' {
 		 * @default false
 		 */
 		preserveWhitespace?: boolean;
+		/**
+		 *  If `functional`, the template will get compiled to a series of `document.createElement` calls, if `string` it will render the template tp a string and use `template.innerHTML`.
+		 *
+		 * @default 'string'
+		 */
+		templatingMode?: 'string' | 'functional';
 		/**
 		 * Set to `true` to force the compiler into runes mode, even if there are no indications of runes usage.
 		 * Set to `false` to force the compiler into ignoring runes, even if there are indications of runes usage.
@@ -2719,6 +2725,12 @@ declare module 'svelte/types/compiler/interfaces' {
 		 * @default false
 		 */
 		preserveWhitespace?: boolean;
+		/**
+		 *  If `functional`, the template will get compiled to a series of `document.createElement` calls, if `string` it will render the template tp a string and use `template.innerHTML`.
+		 *
+		 * @default 'string'
+		 */
+		templatingMode?: 'string' | 'functional';
 		/**
 		 * Set to `true` to force the compiler into runes mode, even if there are no indications of runes usage.
 		 * Set to `false` to force the compiler into ignoring runes, even if there are indications of runes usage.