feat: add option `preventTemplateCloning` and functions transformation

custom-render-shim-dom
paoloricciuti 6 months ago
parent 575908afc1
commit 8a737f1070

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

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

@ -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<Element>}
*/
let elements_stack = [];
/**
* @type {Element | undefined}
*/
let last_current_element;
for (let instruction of items) {
if (instruction.kind === 'push_element' && last_current_element) {
elements_stack.push(last_current_element);
continue;
}
if (instruction.kind === 'pop_element') {
elements_stack.pop();
continue;
}
// @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
};

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

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

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

@ -110,6 +110,8 @@ export const validate_component_options =
preserveComments: boolean(false),
preventTemplateCloning: boolean(false),
preserveWhitespace: boolean(false),
runes: boolean(undefined),

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
compileOptions: {
preventTemplateCloning: true
}
});

@ -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']);

@ -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 += `<button>clicks: ${$.escape(counter.count)}</button> <!---->`;
$.await(promise, () => {}, (counter) => {}, () => {});
$$payload.out += `<!----> ${$.escape(counter.count)}`;
}

@ -0,0 +1,16 @@
<script>
let counter = $state({ count: 0 });
const promise = $derived(Promise.resolve(counter))
function increment() {
counter.count += 1;
}
</script>
<button onclick={increment}>
clicks: {counter.count}
</button>
{#await promise then counter}{/await}
{counter.count}

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

Loading…
Cancel
Save