Skip to content

feat: add partial evaluation #15494

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Apr 14, 2025
5 changes: 5 additions & 0 deletions .changeset/selfish-onions-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: partially evaluate certain expressions
Original file line number Diff line number Diff line change
@@ -685,14 +685,13 @@ 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(
Original file line number Diff line number Diff line change
@@ -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);
}
}
}

Original file line number Diff line number Diff line change
@@ -44,15 +44,17 @@ export function process_children(nodes, { visit, state }) {
if (node.type === 'Text' || node.type === 'Comment') {
quasi.value.cooked +=
node.type === 'Comment' ? `<!--${node.data}-->` : 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);
}
}
}

322 changes: 320 additions & 2 deletions packages/svelte/src/compiler/phases/scope.js
Original file line number Diff line number Diff line change
@@ -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,11 @@ 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');
/** Includes `BigInt` */
export const NUMBER = Symbol('number');
export const STRING = Symbol('string');

export class Binding {
/** @type {Scope} */
scope;
@@ -34,7 +39,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 +105,264 @@ export class Binding {
}
}

class Evaluation {
/** @type {Set<any>} */
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': {
const 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': {
const a = scope.evaluate(/** @type {Expression} */ (expression.left)); // `left` cannot be `PrivateIdentifier` unless operator is `in`
const 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:
this.values.add(UNKNOWN);
}
break;
}

case 'ConditionalExpression': {
const test = scope.evaluate(expression.test);
const consequent = scope.evaluate(expression.consequent);
const 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': {
const a = scope.evaluate(expression.left);
const 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': {
const 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:
this.values.add(UNKNOWN);
}
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 +542,63 @@ export class Scope {
this.root.conflicts.add(node.name);
}
}

/**
* Does partial evaluation to find an exact value or at least the rough type of the expression.
* Only call this once scope has been fully generated in a first pass,
* else this evaluates on incomplete data and may yield wrong results.
* @param {Expression} expression
* @param {Set<any>} values
*/
evaluate(expression, values = new Set()) {
return new Evaluation(this, expression);
}
}

/** @type {Record<BinaryOperator, (left: any, right: any) => 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<UnaryOperator, (argument: any) => any>} */
const unary = {
'-': (argument) => -argument,
'+': (argument) => +argument,
'!': (argument) => !argument,
'~': (argument) => ~argument,
typeof: (argument) => typeof argument,
void: () => undefined,
delete: () => true
};

/** @type {Record<LogicalOperator, (left: any, right: any) => any>} */
const logical = {
'||': (left, right) => left || right,
'&&': (left, right) => left && right,
'??': (left, right) => left ?? right
};

export class ScopeRoot {
/** @type {Set<string>} */
conflicts = new Set();
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -4,5 +4,5 @@ export default function Nullish_coallescence_omittance($$payload) {
let name = 'world';
let count = 0;

$$payload.out += `<h1>Hello, ${$.escape(name)}!</h1> <b>${$.escape(1 ?? 'stuff')}${$.escape(2 ?? 'more stuff')}${$.escape(3 ?? 'even more stuff')}</b> <button>Count is ${$.escape(count)}</button> <h1>Hello, ${$.escape(name ?? 'earth' ?? null)}</h1>`;
$$payload.out += `<h1>Hello, world!</h1> <b>123</b> <button>Count is ${$.escape(count)}</button> <h1>Hello, world</h1>`;
}
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -8,4 +8,4 @@
replace_me_script = 'hello'
;
</script>
<h1 class="done_replace_style_2">{done_replace_script_2}</h1>
<h1 class="done_replace_style_2">{Math.random() < 1 && done_replace_script_2}</h1>