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 ac8263b916..bdf70408f1 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: [], + prevent_template_cloning: options.preventTemplateCloning, // 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/index.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js index 7cda7a4759..ca7f29505e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/index.js @@ -1,12 +1,98 @@ /** - * @import { TemplateOperations } from "../types.js" + * @import { ComponentContext, TemplateOperations, ComponentClientTransformState } from "../types.js" + * @import { Identifier, Expression } from "estree" + * @import { AST, Namespace } from '#compiler' + * @import { SourceLocation } from '#shared' */ +import { TEMPLATE_FRAGMENT } from '../../../../../constants.js'; +import { dev } from '../../../../state.js'; +import * as b from '../../../../utils/builders.js'; +import { template_to_functions } from './to-functions.js'; import { template_to_string } from './to-string.js'; /** - * @param {TemplateOperations} items + * + * @param {Namespace} namespace + * @param {ComponentClientTransformState} state + * @returns */ -export function transform_template(items) { - // here we will check if we need to use `$.template` or create a series of `document.createElement` calls - return template_to_string(items); +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; + }) + ); +} + +/** + * @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) { + if (context.state.prevent_template_cloning) { + context.state.hoisted.push( + b.var( + template_name, + template_to_functions( + state.template, + namespace, + flags != null && (flags & TEMPLATE_FRAGMENT) !== 0 + ) + ) + ); + + return; + } + + /** + * @param {Identifier} template_name + * @param {Expression[]} args + */ + 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 = [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 0000000000..9b9cab28a2 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-template/to-functions.js @@ -0,0 +1,188 @@ +/** + * @import { TemplateOperations } from "../types.js" + * @import { Namespace } from "#compiler" + * @import { Statement } from "estree" + */ +import { NAMESPACE_SVG } from 'svelte/internal/client'; +import * as b from '../../../../utils/builders.js'; +import { NAMESPACE_MATHML } from '../../../../../constants.js'; + +class Scope { + declared = new Map(); + + /** + * @param {string} _name + */ + generate(_name) { + let name = _name.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[0-9]/, '_'); + if (!this.declared.has(name)) { + this.declared.set(name, 1); + return name; + } + let count = this.declared.get(name); + this.declared.set(name, count + 1); + return `${name}_${count}`; + } +} + +/** + * @param {TemplateOperations} items + * @param {Namespace} namespace + * @param {boolean} use_fragment + */ +export function template_to_functions(items, namespace, use_fragment = false) { + let elements = []; + + let body = []; + + let scope = new Scope(); + + /** + * @type {Array} + */ + let elements_stack = []; + + /** + * @type {Element | undefined} + */ + let last_current_element; + + for (let instruction of items) { + if (instruction.kind === 'push_element' && last_current_element) { + elements_stack.push(last_current_element); + continue; + } + if (instruction.kind === 'pop_element') { + elements_stack.pop(); + continue; + } + + // @ts-expect-error we can't be here if `swap_current_element` but TS doesn't know that + const value = map[instruction.kind]( + ...[ + ...(instruction.kind === 'set_prop' ? [last_current_element] : [scope]), + ...(instruction.kind === 'create_element' ? [namespace] : []), + ...(instruction.args ?? []) + ] + ); + + if (value) { + body.push(value.call); + } + + if (instruction.kind !== 'set_prop') { + if (elements_stack.length >= 1 && value) { + const { call } = map.insert(/** @type {Element} */ (elements_stack.at(-1)), value); + body.push(call); + } else if (value) { + elements.push(b.id(value.name)); + } + if (instruction.kind === 'create_element') { + last_current_element = /** @type {Element} */ (value); + } + } + } + if (elements.length > 1 || use_fragment) { + const fragment = scope.generate('fragment'); + body.push(b.var(fragment, b.call('document.createDocumentFragment'))); + body.push(b.call(fragment + '.append', ...elements)); + body.push(b.return(b.id(fragment))); + } else { + body.push(b.return(elements[0])); + } + + return b.arrow([], b.block(body)); +} + +/** + * @typedef {{ call: Statement, name: string }} Element + */ + +/** + * @typedef {{ call: Statement, name: string }} Anchor + */ + +/** + * @typedef {{ call: Statement, name: string }} Text + */ + +/** + * @typedef { Element | Anchor| Text } Node + */ + +/** + * @param {Scope} scope + * @param {Namespace} namespace + * @param {string} element + * @returns {Element} + */ +function create_element(scope, namespace, element) { + const name = scope.generate(element); + let fn = namespace !== 'html' ? 'document.createElementNS' : 'document.createElement'; + let args = [b.literal(element)]; + if (namespace !== 'html') { + args.unshift(namespace === 'svg' ? b.literal(NAMESPACE_SVG) : b.literal(NAMESPACE_MATHML)); + } + return { + call: b.var(name, b.call(fn, ...args)), + name + }; +} + +/** + * @param {Scope} scope + * @param {string} data + * @returns {Anchor} + */ +function create_anchor(scope, data = '') { + const name = scope.generate('comment'); + return { + call: b.var(name, b.call('document.createComment', b.literal(data))), + name + }; +} + +/** + * @param {Scope} scope + * @param {string} value + * @returns {Text} + */ +function create_text(scope, value) { + const name = scope.generate('text'); + return { + call: b.var(name, b.call('document.createTextNode', b.literal(value))), + name + }; +} + +/** + * + * @param {Element} el + * @param {string} prop + * @param {string} value + */ +function set_prop(el, prop, value) { + return { + call: b.call(el.name + '.setAttribute', b.literal(prop), b.literal(value)) + }; +} + +/** + * + * @param {Element} el + * @param {Node} child + * @param {Node} [anchor] + */ +function insert(el, child, anchor) { + return { + call: b.call(el.name + '.insertBefore', b.id(child.name), b.id(anchor?.name ?? 'undefined')) + }; +} + +let map = { + create_element, + create_text, + create_anchor, + set_prop, + insert +}; 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 98917e7372..c32e507554 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 @@ -90,6 +90,7 @@ export interface ComponentClientTransformState extends ClientTransformState { }; }; readonly preserve_whitespace: boolean; + readonly prevent_template_cloning?: boolean; /** The anchor node for the current context */ readonly node: Identifier; 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 27ad894ccf..29a34d6db1 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,11 +1,8 @@ -/** @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 '../../../../utils/builders.js'; -import { sanitize_template_string } from '../../../../utils/sanitize_template_string.js'; import { clean_nodes, infer_namespace } from '../../utils.js'; import { transform_template } from '../transform-template/index.js'; import { process_children } from './shared/fragment.js'; @@ -90,24 +87,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]); @@ -118,14 +97,13 @@ export function Fragment(node, context) { node: id }); - /** @type {Expression[]} */ - const args = [b.template([b.quasi(transform_template(state.template), true)], [])]; + 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)); @@ -173,10 +151,7 @@ export function Fragment(node, context) { // 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, [ - b.template([b.quasi(transform_template(state.template), true)], []), - b.literal(flags) - ]); + transform_template(state, context, namespace, template_name, flags); body.push(b.var(id, b.call(template_name))); } @@ -203,86 +178,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/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index eec41bad9d..4161c71206 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -113,6 +113,12 @@ export interface CompileOptions extends ModuleCompileOptions { * @default false */ preserveWhitespace?: boolean; + /** + * If `true`, the template will get compiled to a series of `document.createElement` calls instead of using `template.innerHTML`. + * + * @default false + */ + preventTemplateCloning?: boolean; /** * 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 ab932ed5bc..5e6eaf5ef6 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), + preventTemplateCloning: boolean(false), + preserveWhitespace: boolean(false), runes: boolean(undefined), diff --git a/packages/svelte/tests/snapshot/samples/prevent-template-cloning/_config.js b/packages/svelte/tests/snapshot/samples/prevent-template-cloning/_config.js new file mode 100644 index 0000000000..e37acdeaf2 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/prevent-template-cloning/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + compileOptions: { + preventTemplateCloning: true + } +}); diff --git a/packages/svelte/tests/snapshot/samples/prevent-template-cloning/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/prevent-template-cloning/_expected/client/index.svelte.js new file mode 100644 index 0000000000..074de003b4 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/prevent-template-cloning/_expected/client/index.svelte.js @@ -0,0 +1,49 @@ +import 'svelte/internal/disclose-version'; +import * as $ from 'svelte/internal/client'; + +function increment(_, counter) { + counter.count += 1; +} + +var root = () => { + var button = document.createElement('button'); + var text = document.createTextNode(' '); + + button.insertBefore(text, undefined) + + var text_1 = document.createTextNode(' '); + var comment = document.createComment(''); + var text_2 = document.createTextNode(' '); + var fragment = document.createDocumentFragment(); + + fragment.append(button, text_1, comment, text_2) + return fragment; +}; + +export default function Prevent_template_cloning($$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/prevent-template-cloning/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/prevent-template-cloning/_expected/server/index.svelte.js new file mode 100644 index 0000000000..2f5584e5f4 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/prevent-template-cloning/_expected/server/index.svelte.js @@ -0,0 +1,14 @@ +import * as $ from 'svelte/internal/server'; + +export default function Prevent_template_cloning($$payload) { + let counter = { count: 0 }; + const promise = Promise.resolve(counter); + + function increment() { + counter.count += 1; + } + + $$payload.out += ` `; + $.await(promise, () => {}, (counter) => {}, () => {}); + $$payload.out += ` ${$.escape(counter.count)}`; +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/prevent-template-cloning/index.svelte b/packages/svelte/tests/snapshot/samples/prevent-template-cloning/index.svelte new file mode 100644 index 0000000000..3dc6c66262 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/prevent-template-cloning/index.svelte @@ -0,0 +1,16 @@ + + + + +{#await promise then counter}{/await} + +{counter.count} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index c6000fc4b6..ef0179f3f1 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -844,6 +844,12 @@ declare module 'svelte/compiler' { * @default false */ preserveWhitespace?: boolean; + /** + * If `true`, the template will get compiled to a series of `document.createElement` calls instead of using `template.innerHTML`. + * + * @default false + */ + preventTemplateCloning?: boolean; /** * 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. @@ -2554,6 +2560,12 @@ declare module 'svelte/types/compiler/interfaces' { * @default false */ preserveWhitespace?: boolean; + /** + * If `true`, the template will get compiled to a series of `document.createElement` calls instead of using `template.innerHTML`. + * + * @default false + */ + preventTemplateCloning?: boolean; /** * 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.