diff --git a/.changeset/selfish-onions-begin.md b/.changeset/selfish-onions-begin.md new file mode 100644 index 000000000000..7ecc1a2ebaa0 --- /dev/null +++ b/.changeset/selfish-onions-begin.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: partial evaluation 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 45a594af1f06..0292fecdf1f0 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 @@ -685,14 +685,14 @@ function build_element_special_value_attribute(element, node_id, attribute, cont : value ); + const evaluated = context.state.scope.evaluate(value); + + const assignment = b.assignment('=', b.member(node_id, '__value'), value); + const inner_assignment = b.assignment( '=', b.member(node_id, 'value'), - b.conditional( - b.binary('==', b.null, b.assignment('=', b.member(node_id, '__value'), value)), - b.literal(''), // render null/undefined values as empty string to support placeholder options - value - ) + evaluated.is_defined ? assignment : b.logical('??', assignment, b.literal('')) ); const update = b.stmt( 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..4fbc51f84a48 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 @@ -89,21 +89,21 @@ export function build_template_chunk( } } - const is_defined = - value.type === 'BinaryExpression' || - (value.type === 'UnaryExpression' && value.operator !== 'void') || - (value.type === 'LogicalExpression' && value.right.type === 'Literal') || - (value.type === 'Identifier' && value.name === state.analysis.props_id?.name); - - if (!is_defined) { - // add `?? ''` where necessary (TODO optimise more cases) - value = b.logical('??', value, b.literal('')); - } + const evaluated = state.scope.evaluate(value); + + if (evaluated.is_known) { + quasi.value.cooked += evaluated.value + ''; + } else { + if (!evaluated.is_defined) { + // add `?? ''` where necessary + value = b.logical('??', value, b.literal('')); + } - expressions.push(value); + expressions.push(value); - quasi = b.quasi('', i + 1 === values.length); - quasis.push(quasi); + quasi = b.quasi('', i + 1 === values.length); + quasis.push(quasi); + } } } 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..807e12a8fa92 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 @@ -44,15 +44,17 @@ export function process_children(nodes, { visit, state }) { if (node.type === 'Text' || node.type === 'Comment') { quasi.value.cooked += node.type === 'Comment' ? `` : escape_html(node.data); - } else if (node.type === 'ExpressionTag' && node.expression.type === 'Literal') { - if (node.expression.value != null) { - quasi.value.cooked += escape_html(node.expression.value + ''); - } } else { - expressions.push(b.call('$.escape', /** @type {Expression} */ (visit(node.expression)))); + const evaluated = state.scope.evaluate(node.expression); + + if (evaluated.is_known) { + quasi.value.cooked += escape_html((evaluated.value ?? '') + ''); + } else { + expressions.push(b.call('$.escape', /** @type {Expression} */ (visit(node.expression)))); - quasi = b.quasi('', i + 1 === sequence.length); - quasis.push(quasi); + quasi = b.quasi('', i + 1 === sequence.length); + quasis.push(quasi); + } } } diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index b6063c32343f..89668462f39a 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -1,4 +1,4 @@ -/** @import { ArrowFunctionExpression, ClassDeclaration, Expression, FunctionDeclaration, FunctionExpression, Identifier, ImportDeclaration, MemberExpression, Node, Pattern, VariableDeclarator } from 'estree' */ +/** @import { ArrowFunctionExpression, BinaryOperator, ClassDeclaration, Expression, FunctionDeclaration, FunctionExpression, Identifier, ImportDeclaration, MemberExpression, LogicalOperator, Node, Pattern, UnaryOperator, VariableDeclarator } from 'estree' */ /** @import { Context, Visitor } from 'zimmerframe' */ /** @import { AST, BindingKind, DeclarationKind } from '#compiler' */ import is_reference from 'is-reference'; @@ -16,6 +16,10 @@ import { is_reserved, is_rune } from '../../utils.js'; import { determine_slot } from '../utils/slot.js'; import { validate_identifier_name } from './2-analyze/visitors/shared/utils.js'; +export const UNKNOWN = Symbol('unknown'); +export const NUMBER = Symbol('number'); // includes BigInt +export const STRING = Symbol('string'); + export class Binding { /** @type {Scope} */ scope; @@ -34,7 +38,7 @@ export class Binding { * For destructured props such as `let { foo = 'bar' } = $props()` this is `'bar'` and not `$props()` * @type {null | Expression | FunctionDeclaration | ClassDeclaration | ImportDeclaration | AST.EachBlock | AST.SnippetBlock} */ - initial; + initial = null; /** @type {Array<{ node: Identifier; path: AST.SvelteNode[] }>} */ references = []; @@ -100,6 +104,261 @@ export class Binding { } } +class Evaluation { + /** @type {Set} */ + values = new Set(); + + /** + * True if there is exactly one possible value + * @readonly + * @type {boolean} + */ + is_known = true; + + /** + * True if the value is known to not be null/undefined + * @readonly + * @type {boolean} + */ + is_defined = true; + + /** + * True if the value is known to be a string + * @readonly + * @type {boolean} + */ + is_string = true; + + /** + * True if the value is known to be a number + * @readonly + * @type {boolean} + */ + is_number = true; + + /** + * @readonly + * @type {any} + */ + value = undefined; + + /** + * + * @param {Scope} scope + * @param {Expression} expression + */ + constructor(scope, expression) { + switch (expression.type) { + case 'Literal': + this.values.add(expression.value); + break; + + case 'Identifier': + var binding = scope.get(expression.name); + + if (binding) { + if ( + binding.initial?.type === 'CallExpression' && + get_rune(binding.initial, scope) === '$props.id' + ) { + this.values.add(STRING); + break; + } + + const is_prop = + binding.kind === 'prop' || + binding.kind === 'rest_prop' || + binding.kind === 'bindable_prop'; + + if (!binding.updated && binding.initial !== null && !is_prop) { + const evaluation = binding.scope.evaluate(/** @type {Expression} */ (binding.initial)); + for (const value of evaluation.values) { + this.values.add(value); + } + break; + } + + // TODO each index is always defined + } + + // TODO glean what we can from reassignments + // TODO one day, expose props and imports somehow + + this.values.add(UNKNOWN); + break; + + case 'BinaryExpression': + var a = scope.evaluate(/** @type {Expression} */ (expression.left)); // `left` cannot be `PrivateIdentifier` unless operator is `in` + var b = scope.evaluate(expression.right); + + if (a.is_known && b.is_known) { + this.values.add(binary[expression.operator](a.value, b.value)); + break; + } + + switch (expression.operator) { + case '!=': + case '!==': + case '<': + case '<=': + case '>': + case '>=': + case '==': + case '===': + case 'in': + case 'instanceof': + this.values.add(true); + this.values.add(false); + break; + + case '%': + case '&': + case '*': + case '**': + case '-': + case '/': + case '<<': + case '>>': + case '>>>': + case '^': + case '|': + this.values.add(NUMBER); + break; + + case '+': + if (a.is_string || b.is_string) { + this.values.add(STRING); + } else if (a.is_number && b.is_number) { + this.values.add(NUMBER); + } else { + this.values.add(STRING); + this.values.add(NUMBER); + } + break; + + default: + // @ts-expect-error we can't guard against future operators without + // TypeScript getting confused + throw new Error(`Unknown operator ${expression.operator}`); + } + break; + + case 'ConditionalExpression': + var test = scope.evaluate(expression.test); + var consequent = scope.evaluate(expression.consequent); + var alternate = scope.evaluate(expression.alternate); + + if (test.is_known) { + for (const value of (test.value ? consequent : alternate).values) { + this.values.add(value); + } + } else { + for (const value of consequent.values) { + this.values.add(value); + } + + for (const value of alternate.values) { + this.values.add(value); + } + } + break; + + case 'LogicalExpression': + a = scope.evaluate(expression.left); + b = scope.evaluate(expression.right); + + if (a.is_known) { + if (b.is_known) { + this.values.add(logical[expression.operator](a.value, b.value)); + break; + } + + if ( + (expression.operator === '&&' && !a.value) || + (expression.operator === '||' && a.value) || + (expression.operator === '??' && a.value != null) + ) { + this.values.add(a.value); + } else { + for (const value of b.values) { + this.values.add(value); + } + } + + break; + } + + for (const value of a.values) { + this.values.add(value); + } + + for (const value of b.values) { + this.values.add(value); + } + break; + + case 'UnaryExpression': + var argument = scope.evaluate(expression.argument); + + if (argument.is_known) { + this.values.add(unary[expression.operator](argument.value)); + break; + } + + switch (expression.operator) { + case '!': + case 'delete': + this.values.add(false); + this.values.add(true); + break; + + case '+': + case '-': + case '~': + this.values.add(NUMBER); + break; + + case 'typeof': + this.values.add(STRING); + break; + + case 'void': + this.values.add(undefined); + break; + + default: + // @ts-expect-error we can't guard against future operators without + // TypeScript getting confused + throw new Error(`Unknown operator ${expression.operator}`); + } + break; + + default: + this.values.add(UNKNOWN); + } + + for (const value of this.values) { + this.value = value; // saves having special logic for `size === 1` + + if (value !== STRING && typeof value !== 'string') { + this.is_string = false; + } + + if (value !== NUMBER && typeof value !== 'number') { + this.is_number = false; + } + + if (value == null || value === UNKNOWN) { + this.is_defined = false; + } + } + + if (this.values.size > 1 || typeof this.value === 'symbol') { + this.is_known = false; + } + } +} + export class Scope { /** @type {ScopeRoot} */ root; @@ -279,8 +538,61 @@ export class Scope { this.root.conflicts.add(node.name); } } + + /** + * + * @param {Expression} expression + * @param {Set} values + */ + evaluate(expression, values = new Set()) { + return new Evaluation(this, expression); + } } +/** @type {Record any>} */ +const binary = { + '!=': (left, right) => left != right, + '!==': (left, right) => left !== right, + '<': (left, right) => left < right, + '<=': (left, right) => left <= right, + '>': (left, right) => left > right, + '>=': (left, right) => left >= right, + '==': (left, right) => left == right, + '===': (left, right) => left === right, + in: (left, right) => left in right, + instanceof: (left, right) => left instanceof right, + '%': (left, right) => left % right, + '&': (left, right) => left & right, + '*': (left, right) => left * right, + '**': (left, right) => left ** right, + '+': (left, right) => left + right, + '-': (left, right) => left - right, + '/': (left, right) => left / right, + '<<': (left, right) => left << right, + '>>': (left, right) => left >> right, + '>>>': (left, right) => left >>> right, + '^': (left, right) => left ^ right, + '|': (left, right) => left | right +}; + +/** @type {Record any>} */ +const unary = { + '-': (argument) => -argument, + '+': (argument) => +argument, + '!': (argument) => !argument, + '~': (argument) => ~argument, + typeof: (argument) => typeof argument, + void: () => undefined, + delete: () => true +}; + +/** @type {Record any>} */ +const logical = { + '||': (left, right) => left || right, + '&&': (left, right) => left && right, + '??': (left, right) => left ?? right +}; + export class ScopeRoot { /** @type {Set} */ conflicts = new Set(); 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..3b23befcd44e 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, world!

123

Hello, world

`; } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js index 46d376aca2f9..b341d39f28fb 100644 --- a/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/skip-static-subtree/_expected/client/index.svelte.js @@ -38,7 +38,7 @@ export default function Skip_static_subtree($$anchor, $$props) { var select = $.sibling(div_1, 2); var option = $.child(select); - option.value = null == (option.__value = 'a') ? '' : 'a'; + option.value = option.__value = 'a'; $.reset(select); var img = $.sibling(select, 2); diff --git a/packages/svelte/tests/sourcemaps/samples/attached-sourcemap/input.svelte b/packages/svelte/tests/sourcemaps/samples/attached-sourcemap/input.svelte index 21a47a72a9c9..715bbda8d92e 100644 --- a/packages/svelte/tests/sourcemaps/samples/attached-sourcemap/input.svelte +++ b/packages/svelte/tests/sourcemaps/samples/attached-sourcemap/input.svelte @@ -8,4 +8,4 @@ replace_me_script = 'hello' ; -

{done_replace_script_2}

+

{Math.random() < 1 && done_replace_script_2}