diff --git a/.changeset/calm-beds-decide.md b/.changeset/calm-beds-decide.md new file mode 100644 index 000000000000..fd6d75a1dc6c --- /dev/null +++ b/.changeset/calm-beds-decide.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: static template expression evaluation diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js index 084c1e7c675e..257799a8a49a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js @@ -8,6 +8,7 @@ import { is_event_attribute } from '../../../../../utils/ast.js'; import * as b from '../../../../../utils/builders.js'; import { build_class_directives_object, build_style_directives_object } from '../RegularElement.js'; import { build_template_chunk, get_expression_id } from './utils.js'; +import { evaluate_static_expression, DYNAMIC } from '../../../shared/static-evaluation.js'; /** * @param {Array} attributes @@ -131,7 +132,10 @@ export function build_attribute_value(value, context, memoize = (value) => value } let expression = /** @type {Expression} */ (context.visit(chunk.expression)); - + let evaluated = evaluate_static_expression(expression, context.state); + if (evaluated !== DYNAMIC) { + return { value: b.literal(evaluated), has_state: false }; + } return { value: memoize(expression, chunk.metadata.expression), has_state: chunk.metadata.expression.has_state diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index df6308d6316a..c4d33c6ebd6d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -9,6 +9,7 @@ import { regex_is_valid_identifier } from '../../../../patterns.js'; import is_reference from 'is-reference'; import { locator } from '../../../../../state.js'; import { create_derived } from '../../utils.js'; +import { evaluate_static_expression, DYNAMIC } from '../../../shared/static-evaluation.js'; /** * @param {ComponentClientTransformState} state @@ -64,6 +65,13 @@ export function build_template_chunk( node.expression.name !== 'undefined' || state.scope.get('undefined') ) { + let evaluated = evaluate_static_expression(node.expression, state); + if (evaluated !== DYNAMIC) { + if (evaluated != null) { + quasi.value.cooked += evaluated + ''; + } + continue; + } let value = memoize( /** @type {Expression} */ (visit(node.expression, state)), node.metadata.expression diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js index 2c6aa2f316aa..439c30286580 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js @@ -11,6 +11,7 @@ import { import * as b from '../../../../../utils/builders.js'; import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js'; import { regex_whitespaces_strict } from '../../../../patterns.js'; +import { evaluate_static_expression, DYNAMIC } from '../../../shared/static-evaluation.js'; /** Opens an if/each block, so that we can remove nodes in the case of a mismatch */ export const block_open = b.literal(BLOCK_OPEN); @@ -49,7 +50,15 @@ export function process_children(nodes, { visit, state }) { quasi.value.cooked += escape_html(node.expression.value + ''); } } else { - expressions.push(b.call('$.escape', /** @type {Expression} */ (visit(node.expression)))); + let evaluated = evaluate_static_expression(node.expression, state, true); + if (evaluated === DYNAMIC) { + expressions.push(b.call('$.escape', /** @type {Expression} */ (visit(node.expression)))); + } else { + if (evaluated != null) { + quasi.value.cooked += escape_html(evaluated + ''); + } + continue; + } quasi = b.quasi('', i + 1 === sequence.length); quasis.push(quasi); diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/static-evaluation.js b/packages/svelte/src/compiler/phases/3-transform/shared/static-evaluation.js new file mode 100644 index 000000000000..5bac45a5ec45 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/shared/static-evaluation.js @@ -0,0 +1,332 @@ +/** @import { Node, BinaryExpression, LogicalExpression, UnaryExpression, Expression, SequenceExpression, TemplateLiteral, ConditionalExpression, CallExpression } from 'estree' */ +/** @import { ComponentClientTransformState } from '../client/types' */ +/** @import { ComponentServerTransformState } from '../server/types' */ +export const DYNAMIC = Symbol('DYNAMIC'); +if (!('difference' in Set.prototype)) { + /** + * Quick and dirty polfill for `Set.prototype.difference` + * @template T + * @this {Set} + * @param {Set} other + * @returns {Set} + */ + //@ts-ignore + Set.prototype.difference = function difference(other) { + /** @type {Set} */ + let res = new Set(); + for (let item of this) { + if (!other.has(item)) { + res.add(item); + } + } + return res; + }; +} +/** + * @template {boolean} S + * @param {Node} node + * @param {S extends true ? ComponentServerTransformState : ComponentClientTransformState} state + * @param {S} [server=false] + * @returns {any} + */ +export function evaluate_static_expression(node, state, server) { + /** + * @template {boolean} S + * @param {Node} node + * @param {S extends true ? ComponentServerTransformState : ComponentClientTransformState} state + * @param {S} [server] + * @returns {any} + */ + function internal(node, state, server) { + if (node == undefined) return DYNAMIC; + /** + * @param {BinaryExpression | LogicalExpression} node + */ + function handle_left_right(node) { + const left = internal(node?.left, state, server); + const right = internal(node?.right, state, server); + if (left === DYNAMIC || right === DYNAMIC) { + return DYNAMIC; + } + switch (node.operator) { + case '+': + return left + right; + case '-': + return left - right; + case '&': + return left & right; + case '|': + return left | right; + case '<<': + return left << right; + case '>>': + return left >> right; + case '>': + return left > right; + case '<': + return left < right; + case '>=': + return left >= right; + case '<=': + return left <= right; + case '==': + return left == right; + case '===': + return left === right; + case '||': + return left || right; + case '??': + return left ?? right; + case '&&': + return left && right; + case '%': + return left % right; + case '>>>': + return left >>> right; + case '^': + return left ^ right; + case '**': + return left ** right; + case '*': + return left * right; + case '/': + return left / right; + case '!=': + return left != right; + case '!==': + return left !== right; + default: + return DYNAMIC; + } + } + /** + * @param {UnaryExpression} node + */ + function handle_unary(node) { + const argument = internal(node?.argument, state, server); + if (argument === DYNAMIC) return DYNAMIC; + /** + * @param {Expression} argument + */ + function handle_void(argument) { + //@ts-ignore + const evaluated = internal(argument, state, server); + if (evaluated !== DYNAMIC) { + return undefined; + } + return DYNAMIC; + } + switch (node.operator) { + case '!': + return !argument; + case '-': + return -argument; + case 'typeof': + return typeof argument; + case '~': + return ~argument; + case '+': + return +argument; + case 'void': + return handle_void(argument); + default: + // `delete` is ignored, since it may have side effects + return DYNAMIC; + } + } + /** + * @param {SequenceExpression} node + */ + function handle_sequence(node) { + const is_static = node.expressions.reduce( + (a, b) => a && internal(b, state, server) !== DYNAMIC, + true + ); + if (is_static) { + //@ts-ignore + return internal(node.expressions.at(-1), state, server); + } + return DYNAMIC; + } + /** + * @param {string} name + */ + function handle_ident(name) { + if (server) return DYNAMIC; + const scope = state.scope.get(name); + // TODO tweak this when implicit top-level reactivity is removed + if (scope?.kind === 'normal' && scope?.declaration_kind !== 'import') { + if (scope.initial && !scope.mutated && !scope.reassigned && !scope.updated) { + //@ts-ignore + let evaluated = internal(scope.initial, state); + return evaluated; + } + } + return DYNAMIC; + } + /** + * @param {TemplateLiteral} node + */ + function handle_template(node) { + const expressions = node.expressions; + const quasis = node.quasis; + const is_static = expressions.reduce( + (a, b) => a && internal(b, state, server) !== DYNAMIC, + true + ); + if (is_static) { + let res = ''; + let last_was_quasi = false; + let expr_index = 0; + let quasi_index = 0; + for (let index = 0; index < quasis.length + expressions.length; index++) { + if (last_was_quasi) { + res += internal(expressions[expr_index++], state, server); + last_was_quasi = false; + } else { + res += quasis[quasi_index++].value.cooked; + last_was_quasi = true; + } + } + return res; + } + return DYNAMIC; + } + /** + * @param {ConditionalExpression} node + */ + function handle_ternary(node) { + const test = internal(node.test, state, server); + if (test !== DYNAMIC) { + if (test) { + return internal(node.consequent, state, server); + } else { + return internal(node.alternate, state, server); + } + } + return DYNAMIC; + } + /** + * @param {CallExpression} node + */ + function handle_call(node) { + /** + * There isn't much we can really do here (without having an unreasonable amount of code), + * so we don't optimize for much + * We only optimize for these: + * ``` + * (() => identifier_or_evaluable_value)(); + * (() => { + * return evaluable; + * })(); + * (() => { + * let variable = ident_or_evaluable_value; + * return variable; + * }); + * ``` + * I would like to possibly optimize this: + * ``` + * (() => { + * let variable = ident_or_evaluable_value; + * return variable_combined_with_evaluable; + * })(); + * ``` + * But I don't know how to do that with the `Scope` class. + */ + let { callee } = node; + if ( + callee.type !== 'ArrowFunctionExpression' || + node.arguments.length || + callee.params.length + ) { + return DYNAMIC; + } + let { body } = callee; + if (body.type === 'BlockStatement') { + let children = body.body; + if (children.length === 1 && children[0].type === 'ReturnStatement') { + return children[0].argument == null + ? undefined + : internal(children[0].argument, state, server); + } + let valid_body_children = new Set([ + 'VariableDeclaration', + 'EmptyStatement', + 'ReturnStatement' + ]); + let types = new Set(children.map(({ type }) => type)); + if (types.difference(valid_body_children).size) { + return DYNAMIC; + } + if (types.has('EmptyStatement')) { + children = children.filter(({ type }) => type !== 'EmptyStatement'); + } + if (children.length > 2) return DYNAMIC; + if (children[0].type !== 'VariableDeclaration' || children[1].type !== 'ReturnStatement') + return DYNAMIC; + let [declaration, return_statement] = children; + if (declaration.declarations.length > 1) return DYNAMIC; + let [declarator] = declaration.declarations; + if (declarator.id.type !== 'Identifier') return DYNAMIC; + let variable_value; + if (declarator.init != null) { + variable_value = internal(declarator.init, state, server); + if (variable_value === DYNAMIC) { + //might be unpure + return DYNAMIC; + } + } + let { argument } = return_statement; + if (argument == null) return undefined; + if (argument.type === 'Identifier' && argument.name === declarator.id.name) { + return variable_value; + } + return internal(argument, state, server); + } else { + return internal(body, state, server); + } + } + switch (node.type) { + case 'Literal': + return node.value; + case 'BinaryExpression': + return handle_left_right(node); + case 'LogicalExpression': + return handle_left_right(node); + case 'UnaryExpression': + return handle_unary(node); + case 'Identifier': + return handle_ident(node.name); + case 'SequenceExpression': + return handle_sequence(node); + case 'TemplateLiteral': + return handle_template(node); + case 'ConditionalExpression': + return handle_ternary(node); + case 'CallExpression': + return handle_call(node); + default: + return DYNAMIC; + } + } + try { + return internal(node, state, server ?? false); + } catch (err) { + // if the expression is so nested it causes a call stack overflow, then it's probably not static + // this probably won't ever happen, but just in case... + if (err instanceof RangeError && err.message === 'Maximum call stack size exceeded') { + return DYNAMIC; + } else if ( + //@ts-expect-error firefox has a non-standard recursion error + typeof globalThis['InternalError'] === 'function' && + //@ts-expect-error + err instanceof globalThis['InternalError'] && + //@ts-ignore + err.message === 'too much recursion' + ) { + return DYNAMIC; + } else { + throw err; + } + } +} diff --git a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js index 332c909ebed9..21f6ed9680a9 100644 --- a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/client/index.svelte.js @@ -10,11 +10,11 @@ export default function Nullish_coallescence_omittance($$anchor) { var fragment = root(); var h1 = $.first_child(fragment); - h1.textContent = `Hello, ${name ?? ''}!`; + h1.textContent = 'Hello, world!'; var b = $.sibling(h1, 2); - b.textContent = `${1 ?? 'stuff'}${2 ?? 'more stuff'}${3 ?? 'even more stuff'}`; + b.textContent = '123'; var button = $.sibling(b, 2); @@ -26,7 +26,7 @@ export default function Nullish_coallescence_omittance($$anchor) { var h1_1 = $.sibling(button, 2); - h1_1.textContent = `Hello, ${name ?? 'earth' ?? ''}`; + h1_1.textContent = 'Hello, world'; $.template_effect(() => $.set_text(text, `Count is ${$.get(count) ?? ''}`)); $.append($$anchor, fragment); } diff --git a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/server/index.svelte.js index 8181bfd98eeb..19fed683a772 100644 --- a/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/nullish-coallescence-omittance/_expected/server/index.svelte.js @@ -4,5 +4,5 @@ export default function Nullish_coallescence_omittance($$payload) { let name = 'world'; let count = 0; - $$payload.out += `

Hello, ${$.escape(name)}!

${$.escape(1 ?? 'stuff')}${$.escape(2 ?? 'more stuff')}${$.escape(3 ?? 'even more stuff')}

Hello, ${$.escape(name ?? 'earth' ?? null)}

`; + $$payload.out += `

Hello, ${$.escape(name)}!

123

Hello, ${$.escape(name ?? 'earth' ?? null)}

`; } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/_config.js b/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/_config.js new file mode 100644 index 000000000000..f47bee71df87 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/_expected/client/index.svelte.js new file mode 100644 index 000000000000..712dabf63cfa --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/_expected/client/index.svelte.js @@ -0,0 +1,72 @@ +import 'svelte/internal/disclose-version'; +import * as $ from 'svelte/internal/client'; + +var on_click = (_, count) => $.update(count); +var root = $.template(`



`, 1); + +export default function Static_template_expression_evaluation($$anchor, $$props) { + $.push($$props, true); + + let a = 1; + let b = 2; + let name = 'world'; + let count = $.state(0); + + function Component() {} // placeholder component + + var fragment = root(); + var h1 = $.first_child(fragment); + + h1.textContent = 'Hello, world!'; + + var p = $.sibling(h1, 2); + + p.textContent = '1 + 2 = 3'; + + var button = $.sibling(p, 2); + + button.__click = [on_click, count]; + + var text = $.child(button); + + $.reset(button); + + var p_1 = $.sibling(button, 2); + + p_1.textContent = '1 + 2 = 3'; + + var p_2 = $.sibling(p_1, 2); + + p_2.textContent = 'Sum is 3'; + + var p_3 = $.sibling(p_2, 2); + + p_3.textContent = '1'; + + var node = $.sibling(p_3, 2); + + Component(node, { + a: 1, + get count() { + return $.get(count); + }, + c: 3 + }); + + var text_1 = $.sibling(node); + + text_1.nodeValue = ' 0'; + + var text_2 = $.sibling(text_1, 2); + + text_2.nodeValue = ' Hello, world!'; + + var text_3 = $.sibling(text_2, 2); + + text_3.nodeValue = ' 3'; + $.template_effect(() => $.set_text(text, `Count is ${$.get(count) ?? ''}`)); + $.append($$anchor, fragment); + $.pop(); +} + +$.delegate(['click']); \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/_expected/server/index.svelte.js new file mode 100644 index 000000000000..f4f450bc34f2 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/_expected/server/index.svelte.js @@ -0,0 +1,20 @@ +import * as $ from 'svelte/internal/server'; + +export default function Static_template_expression_evaluation($$payload, $$props) { + $.push(); + + let a = 1; + let b = 2; + let name = 'world'; + let count = 0; + + function Component() {} // placeholder component + $$payload.out += `

Hello, ${$.escape(name)}!

${$.escape(a)} + ${$.escape(b)} = ${$.escape(a + b)}

1 + 2 = 3

Sum is ${$.escape((a, b, a + b))}

${$.escape(a === 1 ? a : b)}

`; + Component($$payload, { a, count, c: a + b }); + + $$payload.out += ` 0
Hello, world!
${$.escape((() => { + return a + b; + })())}`; + + $.pop(); +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/index.svelte b/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/index.svelte new file mode 100644 index 000000000000..59649e31fd49 --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/static-template-expression-evaluation/index.svelte @@ -0,0 +1,24 @@ + +

Hello, {name}!

+

{a} + {b} = {a + b}

+ +

{1} + {2} = {1 + 2}

+

Sum is {(a, b, a + b)}

+

{a === 1 ? a : b}

+ +{(() => { + let thing = 0; + return thing; +})()}
+{(() => 'Hello, world!')()}
+{(() => { + return a + b; +})()} \ No newline at end of file diff --git a/packages/svelte/tests/sourcemaps/samples/attached-sourcemap/_config.js b/packages/svelte/tests/sourcemaps/samples/attached-sourcemap/_config.js index ee9a3d92c4fb..5fc835d58c65 100644 --- a/packages/svelte/tests/sourcemaps/samples/attached-sourcemap/_config.js +++ b/packages/svelte/tests/sourcemaps/samples/attached-sourcemap/_config.js @@ -53,10 +53,7 @@ export default test({ get_processor('style', '.replace_me_style', '.done_replace_style_1'), get_processor('style', '.done_replace_style_1', '.done_replace_style_2') ], - client: [ - { str: 'replace_me_script', strGenerated: 'done_replace_script_2' }, - { str: 'done_replace_script_2', idxGenerated: 1 } - ], + client: [{ str: 'replace_me_script', strGenerated: 'done_replace_script_2' }], css: [{ str: '.replace_me_style', strGenerated: '.done_replace_style_2.svelte-o6vre' }], test({ assert, code_preprocessed, code_css }) { assert.equal(