diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js index 42427dd9c407..041632739316 100644 --- a/packages/svelte/src/compiler/index.js +++ b/packages/svelte/src/compiler/index.js @@ -122,6 +122,7 @@ function to_public_ast(source, ast, modern) { if (modern) { const clean = (/** @type {any} */ node) => { delete node.metadata; + delete node.type_information; }; ast.options?.attributes.forEach((attribute) => { diff --git a/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js b/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js index aba94ee20db4..cac0f1de3880 100644 --- a/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js +++ b/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js @@ -20,12 +20,30 @@ const visitors = { _(node, context) { const n = context.next() ?? node; - // TODO there may come a time when we decide to preserve type annotations. - // until that day comes, we just delete them so they don't confuse esrap + const type_information = {}; + if (Object.hasOwn(n, 'typeAnnotation')) { + type_information.annotation = n.typeAnnotation; + } + if (Object.hasOwn(n, 'typeParameters')) { + type_information.parameters = n.typeParameters; + } + if (Object.hasOwn(n, 'typeArguments')) { + type_information.arguments = n.typeArguments; + } + if (Object.hasOwn(n, 'returnType')) { + type_information.return = n.returnType; + } + Object.defineProperty(n, 'type_information', { + value: type_information, + writable: true, + configurable: true, + enumerable: false + }); delete n.typeAnnotation; delete n.typeParameters; delete n.typeArguments; delete n.returnType; + // TODO figure out what this is exactly, and if it should be added to `type_information` delete n.accessibility; }, Decorator(node) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock_unfinished.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock_unfinished.js new file mode 100644 index 000000000000..0057c02514df --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock_unfinished.js @@ -0,0 +1,84 @@ +/** @import { BlockStatement, Expression } from 'estree' */ +/** @import { AST } from '#compiler' */ +/** @import { ComponentContext } from '../types' */ +import * as b from '#compiler/builders'; + +/** + * @param {AST.IfBlock} node + * @param {ComponentContext} context + */ +export function IfBlock(node, context) { + const test = /** @type {Expression} */ (context.visit(node.test)); + const evaluated = context.state.scope.evaluate(test); + + const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent)); + context.state.template.push(''); + if (evaluated.is_truthy) { + context.state.init.push( + b.stmt(b.call(b.arrow([b.id('$$anchor')], consequent), context.state.node)) + ); + } else { + const statements = []; + const consequent_id = context.state.scope.generate('consequent'); + statements.push(b.var(b.id(consequent_id), b.arrow([b.id('$$anchor')], consequent))); + + let alternate_id; + + if (node.alternate) { + alternate_id = context.state.scope.generate('alternate'); + const alternate = /** @type {BlockStatement} */ (context.visit(node.alternate)); + const nodes = node.alternate.nodes; + + let alternate_args = [b.id('$$anchor')]; + if (nodes.length === 1 && nodes[0].type === 'IfBlock' && nodes[0].elseif) { + alternate_args.push(b.id('$$elseif')); + } + + statements.push(b.var(b.id(alternate_id), b.arrow(alternate_args, alternate))); + } + + /** @type {Expression[]} */ + const args = [ + node.elseif ? b.id('$$anchor') : context.state.node, + b.arrow( + [b.id('$$render')], + b.block([ + b.if( + test, + b.stmt(b.call(b.id('$$render'), b.id(consequent_id))), + alternate_id ? b.stmt(b.call(b.id('$$render'), b.id(alternate_id), b.false)) : undefined + ) + ]) + ) + ]; + + if (node.elseif) { + // We treat this... + // + // {#if x} + // ... + // {:else} + // {#if y} + //
...
+ // {/if} + // {/if} + // + // ...slightly differently to this... + // + // {#if x} + // ... + // {:else if y} + //
...
+ // {/if} + // + // ...even though they're logically equivalent. In the first case, the + // transition will only play when `y` changes, but in the second it + // should play when `x` or `y` change — both are considered 'local' + args.push(b.id('$$elseif')); + } + + statements.push(b.stmt(b.call('$.if', ...args))); + + context.state.init.push(b.block(statements)); + } +} 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 bc79b760431c..67fe9af903b8 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 @@ -64,20 +64,21 @@ export function build_template_chunk( node.expression.name !== 'undefined' || state.scope.get('undefined') ) { - let value = memoize( - /** @type {Expression} */ (visit(node.expression, state)), - node.metadata.expression - ); + let value = /** @type {Expression} */ (visit(node.expression, state)); const evaluated = state.scope.evaluate(value); + if (!evaluated.is_known) { + value = memoize(value, node.metadata.expression); + } + has_state ||= node.metadata.expression.has_state && !evaluated.is_known; if (values.length === 1) { // If we have a single expression, then pass that in directly to possibly avoid doing // extra work in the template_effect (instead we do the work in set_text). if (evaluated.is_known) { - value = b.literal(evaluated.value); + value = b.literal(evaluated.value ?? ''); } return { value, has_state }; @@ -96,7 +97,7 @@ export function build_template_chunk( } if (evaluated.is_known) { - quasi.value.cooked += evaluated.value + ''; + quasi.value.cooked += (evaluated.value ?? '') + ''; } else { if (!evaluated.is_defined) { // add `?? ''` where necessary diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock_unfinished.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock_unfinished.js new file mode 100644 index 000000000000..9a9aefd85759 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock_unfinished.js @@ -0,0 +1,54 @@ +/** @import { BlockStatement, Expression } from 'estree' */ +/** @import { AST } from '#compiler' */ +/** @import { ComponentContext } from '../types.js' */ +import { BLOCK_OPEN_ELSE } from '../../../../../internal/server/hydration.js'; +import * as b from '#compiler/builders'; +import { block_close, block_open } from './shared/utils.js'; +import { needs_new_scope } from '../../utils.js'; + +/** + * @param {AST.IfBlock} node + * @param {ComponentContext} context + */ +export function IfBlock(node, context) { + const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent)); + const test = /** @type {Expression} */ (context.visit(node.test)); + const evaluated = context.state.scope.evaluate(test); + if (evaluated.is_truthy) { + if (needs_new_scope(consequent)) { + context.state.template.push(consequent); + } else { + context.state.template.push(...consequent.body); + } + } else { + consequent.body.unshift(b.stmt(b.assignment('+=', b.id('$$payload.out'), block_open))); + let if_statement = b.if(test, consequent); + + context.state.template.push(if_statement, block_close); + + let index = 1; + let alt = node.alternate; + while ( + alt && + alt.nodes.length === 1 && + alt.nodes[0].type === 'IfBlock' && + alt.nodes[0].elseif + ) { + const elseif = alt.nodes[0]; + const alternate = /** @type {BlockStatement} */ (context.visit(elseif.consequent)); + alternate.body.unshift( + b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(``))) + ); + if_statement = if_statement.alternate = b.if( + /** @type {Expression} */ (context.visit(elseif.test)), + alternate + ); + alt = elseif.alternate; + } + + if_statement.alternate = alt ? /** @type {BlockStatement} */ (context.visit(alt)) : b.block([]); + if_statement.alternate.body.unshift( + b.stmt(b.assignment('+=', b.id('$$payload.out'), b.literal(BLOCK_OPEN_ELSE))) + ); + } +} 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 8fcf8efa68b6..b7fabacc2c55 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 '#compiler/builders'; import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js'; import { regex_whitespaces_strict } from '../../../../patterns.js'; +import { NUMBER } from '../../../../scope.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); @@ -45,13 +46,19 @@ export function process_children(nodes, { visit, state }) { quasi.value.cooked += node.type === 'Comment' ? `` : escape_html(node.data); } else { - const evaluated = state.scope.evaluate(node.expression); - + const expression = /** @type {Expression} */ (visit(node.expression)); + const evaluated = state.scope.evaluate(expression); if (evaluated.is_known) { quasi.value.cooked += escape_html((evaluated.value ?? '') + ''); } else { - expressions.push(b.call('$.escape', /** @type {Expression} */ (visit(node.expression)))); - + if ( + (evaluated.values.size === 1 && [...evaluated.values][0] === NUMBER) || + [...evaluated.values].every((value) => typeof value === 'string' && !/[&<]/.test(value)) + ) { + expressions.push(expression); + } else { + expressions.push(b.call('$.escape', expression)); + } quasi = b.quasi('', i + 1 === sequence.length); quasis.push(quasi); } diff --git a/packages/svelte/src/compiler/phases/3-transform/utils.js b/packages/svelte/src/compiler/phases/3-transform/utils.js index 5aa40c8abb5c..6e7e2f550d5b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/utils.js @@ -1,7 +1,7 @@ /** @import { Context } from 'zimmerframe' */ /** @import { TransformState } from './types.js' */ /** @import { AST, Binding, Namespace, ValidatedCompileOptions } from '#compiler' */ -/** @import { Node, Expression, CallExpression } from 'estree' */ +/** @import { Node, Expression, CallExpression, BlockStatement } from 'estree' */ import { regex_ends_with_whitespaces, regex_not_whitespace, @@ -486,3 +486,14 @@ export function transform_inspect_rune(node, context) { return b.call('$.inspect', as_fn ? b.thunk(b.array(arg)) : b.array(arg)); } } + +/** + * Whether a `BlockStatement` needs to be a block statement as opposed to just inlining all of its statements. + * @param {BlockStatement} block + */ +export function needs_new_scope(block) { + const has_vars = block.body.some((child) => child.type === 'VariableDeclaration'); + const has_fns = block.body.some((child) => child.type === 'FunctionDeclaration'); + const has_class = block.body.some((child) => child.type === 'ClassDeclaration'); + return has_vars || has_fns || has_class; +} diff --git a/packages/svelte/src/compiler/phases/scope.js b/packages/svelte/src/compiler/phases/scope.js index 8297f174d3de..048f6ccef5ef 100644 --- a/packages/svelte/src/compiler/phases/scope.js +++ b/packages/svelte/src/compiler/phases/scope.js @@ -1,4 +1,4 @@ -/** @import { ArrowFunctionExpression, BinaryOperator, ClassDeclaration, Expression, FunctionDeclaration, FunctionExpression, Identifier, ImportDeclaration, MemberExpression, LogicalOperator, Node, Pattern, UnaryOperator, VariableDeclarator, Super } from 'estree' */ +/** @import { ArrowFunctionExpression, BinaryOperator, ClassDeclaration, Expression, FunctionDeclaration, FunctionExpression, Identifier, ImportDeclaration, MemberExpression, LogicalOperator, Node, Pattern, UnaryOperator, VariableDeclarator, Super, CallExpression, NewExpression, AssignmentExpression, UpdateExpression } from 'estree' */ /** @import { Context, Visitor } from 'zimmerframe' */ /** @import { AST, BindingKind, DeclarationKind } from '#compiler' */ import is_reference from 'is-reference'; @@ -15,20 +15,25 @@ import { 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'; +import { regex_is_valid_identifier } from './patterns.js'; +/** Highest precedence, could be any type, including `undefined` */ const UNKNOWN = Symbol('unknown'); /** Includes `BigInt` */ export const NUMBER = Symbol('number'); export const STRING = Symbol('string'); - -/** @type {Record} */ +const NOT_NULL = Symbol('not null'); +/** @typedef {NUMBER | STRING | UNKNOWN | undefined | boolean} TYPE */ +const TYPES = [NUMBER, STRING, UNKNOWN, NOT_NULL, undefined, true, false]; +/** @type {Record} */ const globals = { - BigInt: [NUMBER, BigInt], + BigInt: [NUMBER], // `BigInt` throws when a decimal is passed to it + 'Date.now': [NUMBER], 'Math.min': [NUMBER, Math.min], 'Math.max': [NUMBER, Math.max], 'Math.random': [NUMBER], 'Math.floor': [NUMBER, Math.floor], - // @ts-expect-error + // @ts-ignore 'Math.f16round': [NUMBER, Math.f16round], 'Math.round': [NUMBER, Math.round], 'Math.abs': [NUMBER, Math.abs], @@ -67,6 +72,7 @@ const globals = { 'Number.isSafeInteger': [NUMBER, Number.isSafeInteger], 'Number.parseFloat': [NUMBER, Number.parseFloat], 'Number.parseInt': [NUMBER, Number.parseInt], + 'Object.is': [[true, false], Object.is], String: [STRING, String], 'String.fromCharCode': [STRING, String.fromCharCode], 'String.fromCodePoint': [STRING, String.fromCodePoint] @@ -84,6 +90,54 @@ const global_constants = { 'Math.SQRT1_2': Math.SQRT1_2 }; +/** + * @template T + * @param {(...args: any) => T} fn + * @returns {(this: unknown, ...args: any) => T} + */ +function call_bind(fn) { + return /** @type {(this: unknown, ...args: any) => T} */ (fn.call.bind(fn)); +} + +const string_proto = String.prototype; +const number_proto = Number.prototype; + +/** @type {Record>} */ +const prototype_methods = { + string: { + //@ts-ignore + toString: [STRING, call_bind(string_proto.toString)], + toLowerCase: [STRING, call_bind(string_proto.toLowerCase)], + toUpperCase: [STRING, call_bind(string_proto.toUpperCase)], + slice: [STRING, call_bind(string_proto.slice)], + at: [STRING, call_bind(string_proto.at)], + charAt: [STRING, call_bind(string_proto.charAt)], + trim: [STRING, call_bind(string_proto.trim)], + indexOf: [NUMBER, call_bind(string_proto.indexOf)], + charCodeAt: [NUMBER, call_bind(string_proto.charCodeAt)], + codePointAt: [[NUMBER, undefined], call_bind(string_proto.codePointAt)], + startsWith: [[true, false], call_bind(string_proto.startsWith)], + endsWith: [[true, false], call_bind(string_proto.endsWith)], + normalize: [STRING, call_bind(string_proto.normalize)], + padEnd: [STRING, call_bind(string_proto.padEnd)], + padStart: [STRING, call_bind(string_proto.padStart)], + repeat: [STRING, call_bind(string_proto.repeat)], + substring: [STRING, call_bind(string_proto.substring)], + trimEnd: [STRING, call_bind(string_proto.trimEnd)], + trimStart: [STRING, call_bind(string_proto.trimStart)], + //@ts-ignore + valueOf: [STRING, call_bind(string_proto.valueOf)] + }, + number: { + //@ts-ignore + toString: [STRING, call_bind(number_proto.toString)], + toFixed: [NUMBER, call_bind(number_proto.toFixed)], + toExponential: [NUMBER, call_bind(number_proto.toExponential)], + toPrecision: [NUMBER, call_bind(number_proto.toPrecision)], + //@ts-ignore + valueOf: [NUMBER, call_bind(number_proto.valueOf)] + } +}; export class Binding { /** @type {Scope} */ scope; @@ -97,6 +151,12 @@ export class Binding { /** @type {DeclarationKind} */ declaration_kind; + /** + * Any nodes that may have updated the value of the binding + * @type {AST.SvelteNode[]} + */ + updates = []; + /** * What the value was initialized with. * For destructured props such as `let { foo = 'bar' } = $props()` this is `'bar'` and not `$props()` @@ -104,7 +164,7 @@ export class Binding { */ initial = null; - /** @type {Array<{ node: Identifier; path: AST.SvelteNode[] }>} */ + /** @type {Array<{ node: Identifier; path: AST.SvelteNode[], scope: Scope }>} */ references = []; /** @@ -200,6 +260,20 @@ class Evaluation { */ is_number = true; + /** + * True if the value is known to be truthy + * @readonly + * @type {boolean} + */ + is_truthy = true; + + /** + * True if the value is known to be falsy + * @readonly + * @type {boolean} + */ + is_falsy = true; + /** * @readonly * @type {any} @@ -211,318 +285,696 @@ class Evaluation { * @param {Scope} scope * @param {Expression} expression * @param {Set} values + * @param {Binding[]} seen_bindings */ - constructor(scope, expression, values) { + constructor(scope, expression, values, seen_bindings, from_fn_call = false) { this.values = values; + try { + switch (expression.type) { + case 'Literal': { + this.values.add(expression.value); + break; + } - switch (expression.type) { - case 'Literal': { - this.values.add(expression.value); - break; - } + case 'Identifier': { + const binding = scope.get(expression.name); - case 'Identifier': { - const binding = scope.get(expression.name); + if (binding && seen_bindings.includes(binding)) break; + if (binding) { + if ( + binding.initial?.type === 'CallExpression' && + get_rune(binding.initial, scope) === '$props.id' + ) { + this.values.add(STRING); + break; + } - if (binding) { - if ( - binding.initial?.type === 'CallExpression' && - get_rune(binding.initial, scope) === '$props.id' - ) { - this.values.add(STRING); + const is_prop = + binding.kind === 'prop' || + binding.kind === 'rest_prop' || + binding.kind === 'bindable_prop'; + + if ( + binding.initial?.type === 'EachBlock' && + binding.initial.index === expression.name + ) { + this.values.add(NUMBER); + break; + } + + if (!binding.updated && binding.initial !== null && !is_prop) { + binding.scope.evaluate( + /** @type {Expression} */ (binding.initial), + this.values, + [...seen_bindings, binding], + from_fn_call + ); + break; + } + + if (binding.kind === 'rest_prop' && !binding.updated) { + this.values.add(NOT_NULL); + break; + } + + if ( + Object.hasOwn(binding.node, 'type_information') && + //@ts-expect-error + Object.keys(binding.node?.type_information).includes('annotation') + ) { + //@ts-ignore todo add this to types + const { type_information } = binding.node; + if (type_information.annotation?.type_information?.annotation) { + const type_annotation = get_type_of_ts_node( + type_information?.annotation?.type_information?.annotation + ); + if (Array.isArray(type_annotation)) { + for (let type of type_annotation) { + this.values.add(type); + } + } else { + this.values.add(type_annotation); + } + } + if ( + !( + binding.updated && + !is_prop && + binding.kind !== 'snippet' && + binding.kind !== 'template' && + binding.declaration_kind !== 'param' && + binding.declaration_kind !== 'rest_param' + ) + ) + break; + } + if ( + binding.updated && + !is_prop && + binding.kind !== 'snippet' && + binding.kind !== 'template' && + binding.declaration_kind !== 'param' && + binding.declaration_kind !== 'rest_param' + ) { + if (binding.initial !== null) { + binding.scope.evaluate( + /** @type {Expression} */ (binding.initial), + this.values, + [...seen_bindings, binding], + from_fn_call + ); + } + for (const update of binding.updates) { + switch (update.type) { + case 'AssignmentExpression': + if (binding.references.find(({ node }) => update.left === node)) { + const { scope } = /** @type {Binding['references'][number]} */ ( + binding.references.find(({ node }) => update.left === node) + ); + switch (update.operator) { + case '=': + case '??=': + case '||=': + case '&&=': + scope.evaluate(update.right, this.values, seen_bindings, from_fn_call); + break; + case '+=': { + this.values.add(NUMBER); + this.values.add(STRING); + break; + } + case '-=': + case '*=': + case '/=': + case '%=': + case '**=': + case '<<=': + case '>>=': + case '>>>=': + case '|=': + case '^=': + case '&=': + this.values.add(NUMBER); + break; + default: { + this.values.add(UNKNOWN); + } + } + } else { + this.values.add(UNKNOWN); + } + break; + case 'UpdateExpression': { + if (binding.references.find(({ node }) => update.argument === node)) { + this.values.add(NUMBER); + } else { + this.values.add(UNKNOWN); + } + break; + } + default: { + this.values.add(UNKNOWN); + } + } + } + break; + } + } else if (expression.name === 'undefined') { + this.values.add(undefined); break; } - const is_prop = - binding.kind === 'prop' || - binding.kind === 'rest_prop' || - binding.kind === 'bindable_prop'; + // TODO one day, expose props and imports somehow - if (binding.initial?.type === 'EachBlock' && binding.initial.index === expression.name) { - this.values.add(NUMBER); + this.values.add(UNKNOWN); + break; + } + + case 'BinaryExpression': { + const a = scope.evaluate( + /** @type {Expression} */ (expression.left), + new Set(), + seen_bindings, + from_fn_call + ); // `left` cannot be `PrivateIdentifier` unless operator is `in` + const b = scope.evaluate(expression.right, new Set(), seen_bindings, from_fn_call); + + if (a.is_known && b.is_known) { + this.values.add(binary[expression.operator](a.value, b.value)); break; } - if (!binding.updated && binding.initial !== null && !is_prop) { - binding.scope.evaluate(/** @type {Expression} */ (binding.initial), this.values); - 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); } - } else if (expression.name === 'undefined') { - this.values.add(undefined); break; } - // TODO glean what we can from reassignments - // TODO one day, expose props and imports somehow - - this.values.add(UNKNOWN); - break; - } + case 'ConditionalExpression': { + const test = scope.evaluate(expression.test, new Set(), seen_bindings, from_fn_call); + const consequent = scope.evaluate( + expression.consequent, + new Set(), + seen_bindings, + from_fn_call + ); + const alternate = scope.evaluate( + expression.alternate, + new Set(), + seen_bindings, + from_fn_call + ); - 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 (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); + } - if (a.is_known && b.is_known) { - this.values.add(binary[expression.operator](a.value, b.value)); + for (const value of alternate.values) { + this.values.add(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 'LogicalExpression': { + const a = scope.evaluate(expression.left, new Set(), seen_bindings, from_fn_call); + const b = scope.evaluate(expression.right, new Set(), seen_bindings, from_fn_call); - case '%': - case '&': - case '*': - case '**': - case '-': - case '/': - case '<<': - case '>>': - case '>>>': - case '^': - case '|': - this.values.add(NUMBER); - break; + if (a.is_known) { + if (b.is_known) { + this.values.add(logical[expression.operator](a.value, b.value)); + 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); + if ( + (expression.operator === '&&' && !a.value) || + (expression.operator === '||' && a.value) || + (expression.operator === '??' && a.value != null) + ) { + this.values.add(a.value); } else { - this.values.add(STRING); - this.values.add(NUMBER); + for (const value of b.values) { + this.values.add(value); + } } - 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); + break; } - } else { - for (const value of consequent.values) { + + for (const value of a.values) { this.values.add(value); } - for (const value of alternate.values) { + for (const value of b.values) { this.values.add(value); } + break; } - break; - } - case 'LogicalExpression': { - const a = scope.evaluate(expression.left); - const b = scope.evaluate(expression.right); + case 'UnaryExpression': { + const argument = scope.evaluate( + expression.argument, + new Set(), + seen_bindings, + from_fn_call + ); - if (a.is_known) { - if (b.is_known) { - this.values.add(logical[expression.operator](a.value, b.value)); + if (argument.is_known) { + this.values.add(unary[expression.operator](argument.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); - } - } + switch (expression.operator) { + case '!': + case 'delete': + this.values.add(false); + this.values.add(true); + break; - break; - } + case '+': + case '-': + case '~': + this.values.add(NUMBER); + break; - for (const value of a.values) { - this.values.add(value); - } + case 'typeof': + this.values.add(STRING); + break; - for (const value of b.values) { - this.values.add(value); - } - break; - } + case 'void': + this.values.add(undefined); + break; - case 'UnaryExpression': { - const argument = scope.evaluate(expression.argument); + default: + this.values.add(UNKNOWN); + } + break; + } - if (argument.is_known) { - this.values.add(unary[expression.operator](argument.value)); + case 'SequenceExpression': { + const { expressions } = expression; + const evaluated = expressions.map((expression) => + scope.evaluate(expression, new Set(), seen_bindings, from_fn_call) + ); + if (evaluated.every((ev) => ev.is_known)) { + this.values.add(evaluated.at(-1)?.value); + } else { + this.values.add(UNKNOWN); + } break; } - switch (expression.operator) { - case '!': - case 'delete': - this.values.add(false); - this.values.add(true); - break; + case 'CallExpression': { + const keypath = get_global_keypath(expression.callee, scope); + + if (keypath !== null) { + if (is_rune(keypath)) { + const arg = /** @type {Expression | undefined} */ (expression.arguments[0]); + + switch (keypath) { + case '$state': + case '$state.raw': + case '$derived': + if (arg) { + scope.evaluate(arg, this.values, seen_bindings, from_fn_call); + } else { + this.values.add(undefined); + } + break; - case '+': - case '-': - case '~': - this.values.add(NUMBER); - break; + case '$props.id': + this.values.add(STRING); + break; - case 'typeof': - this.values.add(STRING); - break; + case '$effect.tracking': + this.values.add(false); + this.values.add(true); + break; - case 'void': - this.values.add(undefined); - break; + case '$derived.by': + scope.evaluate( + b.call(/** @type {Expression} */ (arg)), + this.values, + seen_bindings, + from_fn_call + ); + break; - default: - this.values.add(UNKNOWN); - } - break; - } + case '$effect.root': + this.values.add(NOT_NULL); + break; - case 'CallExpression': { - const keypath = get_global_keypath(expression.callee, scope); + default: { + this.values.add(UNKNOWN); + } + } - if (keypath) { - if (is_rune(keypath)) { - const arg = /** @type {Expression | undefined} */ (expression.arguments[0]); + break; + } - switch (keypath) { - case '$state': - case '$state.raw': - case '$derived': - if (arg) { - scope.evaluate(arg, this.values); + if ( + Object.hasOwn(globals, keypath) && + expression.arguments.every((arg) => arg.type !== 'SpreadElement') + ) { + const [type, fn] = globals[keypath]; + const values = expression.arguments.map((arg) => + scope.evaluate(arg, new Set(), seen_bindings, from_fn_call) + ); + + if (fn && values.every((e) => e.is_known)) { + this.values.add(fn(...values.map((e) => e.value))); + } else { + if (Array.isArray(type)) { + for (const t of type) { + this.values.add(t); + } } else { - this.values.add(undefined); + this.values.add(type); } - break; - - case '$props.id': - this.values.add(STRING); - break; + } - case '$effect.tracking': - this.values.add(false); - this.values.add(true); + break; + } + } else if ( + expression.callee.type === 'MemberExpression' && + expression.callee.object.type !== 'Super' && + expression.arguments.every((arg) => arg.type !== 'SpreadElement') + ) { + const object = scope.evaluate( + expression.callee.object, + new Set(), + seen_bindings, + from_fn_call + ); + if (!object.is_known) { + this.values.add(UNKNOWN); + break; + } + let property; + if ( + expression.callee.computed && + expression.callee.property.type !== 'PrivateIdentifier' + ) { + property = scope.evaluate( + expression.callee.property, + new Set(), + seen_bindings, + from_fn_call + ); + if (property.is_known) { + property = property.value; + } else { + this.values.add(UNKNOWN); break; - - case '$derived.by': - if (arg?.type === 'ArrowFunctionExpression' && arg.body.type !== 'BlockStatement') { - scope.evaluate(arg.body, this.values); + } + } else if (expression.callee.property.type === 'Identifier') { + property = expression.callee.property.name; + } + if (property === undefined) { + this.values.add(UNKNOWN); + break; + } + if (typeof object.value !== 'string' && typeof object.value !== 'number') { + this.values.add(UNKNOWN); + break; + } + const available_methods = + prototype_methods[/** @type {'string' | 'number'} */ (typeof object.value)]; + if (Object.hasOwn(available_methods, property)) { + const [type, fn] = available_methods[property]; + const values = expression.arguments.map((arg) => + scope.evaluate(arg, new Set(), seen_bindings, from_fn_call) + ); + + if (fn && values.every((e) => e.is_known)) { + this.values.add(fn(object.value, ...values.map((e) => e.value))); + } else { + if (Array.isArray(type)) { + for (const t of type) { + this.values.add(t); + } + } else { + this.values.add(type); + } + } + break; + } + } else if (expression.callee.type === 'Identifier' && !expression.arguments.length) { + const binding = scope.get(expression.callee.name); + if (binding) { + if (binding.is_function()) { + const fn = + /** @type {FunctionExpression | FunctionDeclaration | ArrowFunctionExpression} */ ( + binding.initial + ); + if (fn && fn.async === false && !fn?.generator) { + const analysis = evaluate_function(fn, binding); // typescript won't tell you if a function is pure or if it could throw, so we have to do this regardless of type annotations + // console.log({ fn, binding, analysis }); + if (!analysis.pure || !analysis.never_throws) { + // if its not pure, or we don't know if it could throw, we can't use any constant return values from the evaluation, but we can check if its nullish + this.values.add(NOT_NULL); // `NOT_NULL` doesn't have precedence over `UNKNOWN`, so if the value is nullish, this won't have precedence + } + if (Object.hasOwn(fn, 'type_information')) { + // @ts-ignore + const { type_information } = fn; + if (Object.hasOwn(type_information, 'return')) { + const return_types = get_type_of_ts_node( + type_information.return?.type_information?.annotation + ); + if (Array.isArray(return_types)) { + for (let type of return_types) { + this.values.add(type); + } + } else { + this.values.add(return_types); + } + } else if (analysis.is_known) { + this.values.add(analysis.value); + break; + } else { + for (let value of analysis.values) { + this.values.add(value); + } + } + } else if (analysis.is_known) { + this.values.add(analysis.value); + break; + } else { + for (let value of analysis.values) { + this.values.add(value); + } + } break; } - - this.values.add(UNKNOWN); + } + } + } else if ( + expression.callee.type === 'ArrowFunctionExpression' || + expression.callee.type === 'FunctionExpression' + ) { + const fn = expression.callee; + const binding = /** @type {Binding} */ ({ scope }); + if (fn && fn.async === false && !fn?.generator) { + const analysis = evaluate_function( + fn, + binding, + new Set(), + from_fn_call ? seen_bindings : [] + ); + if (!analysis.pure || !analysis.never_throws) { + this.values.add(NOT_NULL); + } + if (Object.hasOwn(fn, 'type_information')) { + // @ts-ignore + const { type_information } = fn; + if (Object.hasOwn(type_information, 'return')) { + const return_types = get_type_of_ts_node( + type_information.return?.type_information?.annotation + ); + if (Array.isArray(return_types)) { + for (let type of return_types) { + this.values.add(type); + } + } else { + this.values.add(return_types); + } + } else if (analysis.is_known) { + this.values.add(analysis.value); + break; + } else { + for (let value of analysis.values) { + this.values.add(value); + } + } + } else if (analysis.is_known) { + this.values.add(analysis.value); break; - - default: { - this.values.add(UNKNOWN); + } else { + for (let value of analysis.values) { + this.values.add(value); + } } + break; } - - break; } - if ( - Object.hasOwn(globals, keypath) && - expression.arguments.every((arg) => arg.type !== 'SpreadElement') - ) { - const [type, fn] = globals[keypath]; - const values = expression.arguments.map((arg) => scope.evaluate(arg)); + this.values.add(UNKNOWN); + break; + } + + case 'TemplateLiteral': { + let result = expression.quasis[0].value.cooked; - if (fn && values.every((e) => e.is_known)) { - this.values.add(fn(...values.map((e) => e.value))); + for (let i = 0; i < expression.expressions.length; i += 1) { + const e = scope.evaluate(expression.expressions[i], new Set(), seen_bindings); + + if (e.is_known) { + result += e.value + expression.quasis[i + 1].value.cooked; } else { - this.values.add(type); + this.values.add(STRING); + break; } - - break; } - } - - this.values.add(UNKNOWN); - break; - } - case 'TemplateLiteral': { - let result = expression.quasis[0].value.cooked; + this.values.add(result); + break; + } - for (let i = 0; i < expression.expressions.length; i += 1) { - const e = scope.evaluate(expression.expressions[i]); + case 'MemberExpression': { + const keypath = get_global_keypath(expression, scope); - if (e.is_known) { - result += e.value + expression.quasis[i + 1].value.cooked; - } else { - this.values.add(STRING); + if (keypath !== null && Object.hasOwn(global_constants, keypath)) { + this.values.add(global_constants[keypath]); + break; + } else if (keypath?.match?.(/\.name$/) && Object.hasOwn(globals, keypath.slice(0, -5))) { + this.values.add(globals[keypath.slice(0, -5)]?.[1]?.name ?? STRING); break; + } else if ( + expression.object.type !== 'Super' && + expression.property.type !== 'PrivateIdentifier' + ) { + const object = scope.evaluate( + expression.object, + new Set(), + seen_bindings, + from_fn_call + ); + if (object.is_string) { + let property; + if (expression.computed) { + let prop = scope.evaluate( + expression.property, + new Set(), + seen_bindings, + from_fn_call + ); + if (prop.is_known && prop.value === 'length') { + property = 'length'; + } + } else if (expression.property.type === 'Identifier') { + property = expression.property.name; + } + if (property === 'length') { + if (object.is_known) { + this.values.add(object.value.length); + } else { + this.values.add(NUMBER); + } + break; + } + } } + + this.values.add(UNKNOWN); + break; } - this.values.add(result); - break; + default: { + this.values.add(UNKNOWN); + } } - case 'MemberExpression': { - const keypath = get_global_keypath(expression, scope); + for (const value of this.values) { + this.value = value; // saves having special logic for `size === 1` - if (keypath && Object.hasOwn(global_constants, keypath)) { - this.values.add(global_constants[keypath]); - break; + if (value !== STRING && typeof value !== 'string') { + this.is_string = false; } - this.values.add(UNKNOWN); - break; - } - - default: { - this.values.add(UNKNOWN); - } - } + if (value !== NUMBER && typeof value !== 'number') { + this.is_number = false; + } - for (const value of this.values) { - this.value = value; // saves having special logic for `size === 1` + if (value === NUMBER || value === STRING || value === NOT_NULL || !value) { + this.is_truthy = false; + this.is_falsy = !value; + } - if (value !== STRING && typeof value !== 'string') { - this.is_string = false; + if (value == null || value === UNKNOWN) { + this.is_defined = false; + this.is_truthy = false; + } } - if (value !== NUMBER && typeof value !== 'number') { - this.is_number = false; + if (this.values.size > 1 || typeof this.value === 'symbol') { + this.is_known = false; } - if (value == null || value === UNKNOWN) { - this.is_defined = false; + if (!this.value) { + this.is_truthy = false; + } + } catch (err) { + if ( + /** @type {Error} */ (err).message !== 'Maximum call stack size exceeded' && + /** @type {Error} */ (err).message !== 'too much recursion' + ) { + throw err; } - } - - if (this.values.size > 1 || typeof this.value === 'symbol') { - this.is_known = false; } } } @@ -559,7 +1011,7 @@ export class Scope { /** * A set of all the names referenced with this scope * — useful for generating unique names - * @type {Map} + * @type {Map} */ references = new Map(); @@ -693,11 +1145,11 @@ export class Scope { if (!references) this.references.set(node.name, (references = [])); - references.push({ node, path }); + references.push({ node, path, scope: this }); const binding = this.declarations.get(node.name); if (binding) { - binding.references.push({ node, path }); + binding.references.push({ node, path, scope: this }); } else if (this.parent) { this.parent.reference(node, path); } else { @@ -713,9 +1165,27 @@ export class Scope { * else this evaluates on incomplete data and may yield wrong results. * @param {Expression} expression * @param {Set} [values] + * @param {Binding[]} [seen_bindings] + */ + evaluate(expression, values = new Set(), seen_bindings = [], from_fn_call = false) { + return new Evaluation(this, expression, values, seen_bindings, from_fn_call); + } + + /** + * @param {Scope} child */ - evaluate(expression, values = new Set()) { - return new Evaluation(this, expression, values); + contains(child) { + let contains = false; + /** @type {Scope | null} */ + let curr = child; + while (curr?.parent != null) { + curr = curr?.parent; + if (curr === this) { + contains = true; + break; + } + } + return contains; } } @@ -766,6 +1236,8 @@ const logical = { export class ScopeRoot { /** @type {Set} */ conflicts = new Set(); + /** @type {Map} */ + scopes = new Map(); /** * @param {string} preferred_name @@ -802,13 +1274,14 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) { const scope = new Scope(root, parent, false); scopes.set(ast, scope); + root.scopes = scopes; /** @type {State} */ const state = { scope }; /** @type {[Scope, { node: Identifier; path: AST.SvelteNode[] }][]} */ const references = []; - /** @type {[Scope, Pattern | MemberExpression][]} */ + /** @type {[Scope, Pattern | MemberExpression, AssignmentExpression | UpdateExpression | AST.BindDirective][]} */ const updates = []; /** @@ -978,12 +1451,16 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) { // updates AssignmentExpression(node, { state, next }) { - updates.push([state.scope, node.left]); + updates.push([state.scope, node.left, node]); next(); }, UpdateExpression(node, { state, next }) { - updates.push([state.scope, /** @type {Identifier | MemberExpression} */ (node.argument)]); + updates.push([ + state.scope, + /** @type {Identifier | MemberExpression} */ (node.argument), + node + ]); next(); }, @@ -1203,7 +1680,8 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) { BindDirective(node, context) { updates.push([ context.state.scope, - /** @type {Identifier | MemberExpression} */ (node.expression) + /** @type {Identifier | MemberExpression} */ (node.expression), + node ]); context.next(); }, @@ -1239,7 +1717,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) { scope.reference(node, path); } - for (const [scope, node] of updates) { + for (const [scope, node, update] of updates) { for (const expression of unwrap_pattern(node)) { const left = object(expression); const binding = left && scope.get(left.name); @@ -1250,6 +1728,7 @@ export function create_scopes(ast, root, allow_reactive_declarations, parent) { } else { binding.mutated = true; } + binding.updates.push(update); } } } @@ -1281,7 +1760,7 @@ export function get_rune(node, scope) { const keypath = get_global_keypath(node.callee, scope); - if (!keypath || !is_rune(keypath)) return null; + if (keypath === null || !is_rune(keypath)) return null; return keypath; } @@ -1296,7 +1775,17 @@ function get_global_keypath(node, scope) { let joined = ''; while (n.type === 'MemberExpression') { - if (n.computed) return null; + if (n.computed && n.property.type !== 'PrivateIdentifier') { + const property = scope.evaluate(n.property); + if (property.is_known) { + if (!regex_is_valid_identifier.test(property.value)) { + return null; + } + joined = '.' + property.value + joined; + n = n.object; + continue; + } + } if (n.property.type !== 'Identifier') return null; joined = '.' + n.property.name + joined; n = n.object; @@ -1314,3 +1803,355 @@ function get_global_keypath(node, scope) { return n.name + joined; } + +/** + * @param {{type: string} & Record} node + * @returns {any} + */ +function get_type_of_ts_node(node) { + /** + * @param {any[]} types + * @returns {any[]} + */ + function intersect_types(types) { + if ( + types.includes(UNKNOWN) || + types.filter((type) => !['symbol', 'string', 'number', 'bigint'].includes(typeof type)) + .length > 1 + ) + return [UNKNOWN]; + /** @type {any[]} */ + let res = []; + if ( + types.filter((type) => typeof type === 'number' || typeof type === 'bigint').length > 1 || + (!types.some((type) => typeof type === 'number' || typeof type === 'bigint') && + types.includes(NUMBER)) + ) { + res.push(NUMBER); + } else { + res.push(...types.filter((type) => typeof type === 'number' || typeof type === 'bigint')); + } + if ( + types.filter((type) => typeof type === 'string').length > 1 || + (!types.some((type) => typeof type === 'string') && types.includes(STRING)) + ) { + res.push(STRING); + } else { + res.push(...types.filter((type) => typeof type === 'string')); + } + if (types.some((type) => !['symbol', 'string', 'number', 'bigint'].includes(typeof type))) { + types.push( + types.find((type) => !['symbol', 'string', 'number', 'bigint'].includes(typeof type)) + ); + } + return res; + } + switch (node?.type) { + case 'TypeAnnotation': + return get_type_of_ts_node(node.annotation); + case 'TSCheckType': + return [get_type_of_ts_node(node.trueType), get_type_of_ts_node(node.falseType)].flat(); + case 'TSUnionType': + //@ts-ignore + return node.types.map((type) => get_type_of_ts_node(type)).flat(); + case 'TSIntersectionType': + //@ts-ignore + return intersect_types(node.types.map((type) => get_type_of_ts_node(type)).flat()); + case 'TSBigIntKeyword': + case 'TSNumberKeyword': + return NUMBER; + case 'TSStringKeyword': + return STRING; + case 'TSLiteralType': + return node.literal.type === 'Literal' + ? node.literal.value + : node.literal.type === 'TemplateLiteral' + ? STRING + : UNKNOWN; + case 'TSBooleanKeyword': + return [true, false]; + case 'TSNeverKeyword': + case 'TSVoidKeyword': + return undefined; + case 'TSNullKeyword': + return null; + default: + return UNKNOWN; + } +} + +// TODO add more +const global_classes = [ + 'String', + 'BigInt', + 'Object', + 'Set', + 'Array', + 'Proxy', + 'Map', + 'Boolean', + 'WeakMap', + 'WeakRef', + 'WeakSet', + 'Number', + 'RegExp', + 'Error', + 'Date' +]; + +// TODO ditto +const known_globals = [ + ...global_classes, + 'Symbol', + 'console', + 'Math', + 'isNaN', + 'isFinite', + 'setTimeout', + 'setInterval', + 'NaN', + 'undefined', + 'globalThis' +]; + +let fn_cache = new Map(); + +/** + * @param {Expression} callee + * @param {Scope} scope + */ +function is_global_class(callee, scope) { + let keypath = get_global_keypath(callee, scope); + if (keypath === null) return false; + if (keypath.match(/^(globalThis\.)+/)) { + keypath = keypath.replace(/^(globalThis\.)+/, ''); + } + return global_classes.includes(keypath); +} + +/** + * Analyzes and partially evaluates the provided function. + * @param {FunctionExpression | ArrowFunctionExpression | FunctionDeclaration} fn + * @param {Binding} binding + * @param {Set} [stack] + * @param {Binding[]} [seen_bindings] + */ +function evaluate_function(fn, binding, stack = new Set(), [...seen_bindings] = []) { + if (fn_cache.has(fn)) { + return fn_cache.get(fn); + } + /** + * This big blob of comments is for my (https://github.com/Ocean-OS) sanity and for that of anyone who tries working with this function. Feel free to modify this as the function evolves. + * So, when evaluating functions at compile-time, there are a few things you have to avoid evaluating: + * + * - Side effects + * A function that modifies state from outside of its scope should not be evaluated. + * Additionally, since `$effect`s and `$derived`s exist, any reference to an external value could lead to a missed dependency if the function is evaluated by the compiler. + * - Errors + * A function that could throw an error should not be evaluated. Additionally, `$derived`s could be reevaluated upon reading, which could throw an error. + * The purpose of a compile-time evaluator is to replicate the behavior the function would have at runtime, without actually running the function. + * If an error is/could be thrown, that can not be replicated. + * + * So, how do we figure out if either of these things (could) happen in a function? + * Well, for errors, it's relatively simple. If a `throw` statement is used in the function, then we assume that the error could be thrown at any time. + * For side effects, it gets a bit tricky. External `Identifier`s that change their value are definitely side effects, but also any `MemberExpression` that isn't a known global constant could have a side effect, due to getters and `Proxy`s. + * Additionally, since a function can call other functions, we check each individual function call: if it's a known global, we know its pure, and if we can find its definition, the parent function inherits its throwability and purity. If we cannot find its definition, we assume it is impure and could throw. + * + * A few other things to note/remember: + * - Not all functions rely on return statements to determine the return value. + * Arrow functions without a `BlockStatement` for a body use their expression body as an implicit `ReturnStatement`. + * - While currently all the globals we have are pure and error-free, that could change, so we shouldn't be too dependent on that in the future. + * Things like `JSON.stringify` and a *lot* of array methods are prime examples. + */ + let thing; + const analysis = { + pure: true, + is_known: false, + is_defined: true, + values: new Set(), + /** @type {any} */ + value: undefined, + never_throws: true + }; + const fn_binding = binding; + const fn_scope = fn.metadata.scope; + const CALL_EXPRESSION = 1 << 1; + const NEW_EXPRESSION = 1 << 2; + const state = { + scope: fn_scope, + scope_path: [fn_scope], + current_call: 0 + }; + const uses_implicit_return = + fn.type === 'ArrowFunctionExpression' && fn.body.type !== 'BlockStatement'; + function needs_check(purity = true, throwability = true) { + if (!throwability) { + return analysis.pure; + } + if (!purity) { + return analysis.never_throws; + } + return analysis.pure || analysis.never_throws; + } + /** + * @param {CallExpression | NewExpression} node + * @param {import('zimmerframe').Context} context + */ + function handle_call_expression(node, context) { + const { callee: call, arguments: args } = node; + const callee = /** @type {Expression} */ ( + context.visit(call, { + ...context.state, + current_call: (node.type === 'CallExpression' ? CALL_EXPRESSION : NEW_EXPRESSION) | 0 + }) + ); + for (let arg of args) { + context.visit(arg); + } + if (needs_check()) { + if (callee.type === 'Identifier') { + const binding = context.state.scope.get(callee.name); + if ( + binding && + binding !== fn_binding && + !stack.has(binding) && + binding.is_function() && + node.type === 'CallExpression' + ) { + const child_analysis = evaluate_function( + binding.initial, + binding, + new Set([...stack, fn_binding]) + ); + analysis.pure &&= child_analysis.pure; + analysis.never_throws &&= child_analysis.never_throws; + } + } else if ( + node.type === 'CallExpression' && + callee !== fn && + (callee.type === 'FunctionExpression' || callee.type === 'ArrowFunctionExpression') && + [...stack].every(({ scope }) => scope !== callee.metadata.scope) + ) { + const child_analysis = evaluate_function( + callee, + /** @type {Binding} */ ({ scope: callee.metadata.scope }), + new Set([...stack, fn_binding]) + ); + analysis.pure &&= child_analysis.pure; + analysis.never_throws &&= child_analysis.never_throws; + } else if (node.type === 'NewExpression' && !is_global_class(callee, context.state.scope)) { + analysis.pure = false; + analysis.never_throws = false; + } + } + } + walk(/** @type {AST.SvelteNode} */ (fn), state, { + MemberExpression(node, context) { + const keypath = get_global_keypath(node, context.state.scope); + const evaluated = context.state.scope.evaluate(node, new Set(), seen_bindings, true); + if (!(keypath !== null && Object.hasOwn(globals, keypath)) && !evaluated.is_known) { + analysis.pure = false; + analysis.never_throws = false; + } + context.next(); + }, + Identifier(node, context) { + if (is_reference(node, /** @type {Node} */ (context.path.at(-1))) && needs_check()) { + const binding = context.state.scope.get(node.name); + if (binding !== fn_binding) { + if (binding === null) { + if (!known_globals.includes(node.name)) { + analysis.pure = false; + } + return; + } + if ( + binding.scope !== fn_scope && + binding.updated && + context.state.current_call === 0 && + !seen_bindings.includes(binding) && + needs_check(true, false) + ) { + analysis.pure &&= fn_scope.contains(binding.scope); + seen_bindings.push(binding); + } + if (binding.kind === 'derived') { + analysis.never_throws = false; //derived evaluation could throw + } + } + } + context.next(); + }, + CallExpression: handle_call_expression, + NewExpression: handle_call_expression, + TaggedTemplateExpression(node, context) { + return handle_call_expression(b.call(node.tag, node.quasi), context); + }, + ThrowStatement(node, context) { + if ( + fn.type !== 'FunctionDeclaration' || + context.path.findLast((parent) => parent.type === 'FunctionDeclaration') === fn // FunctionDeclarations are separately declared functions; we treat other types of functions as functions that could be evaluated by the parent + ) { + analysis.never_throws = false; + } + context.next(); + }, + ReturnStatement(node, context) { + if ( + !uses_implicit_return && + context.path.findLast((parent) => + ['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes( + parent.type + ) + ) === fn + ) { + if (node.argument) { + const argument = /** @type {Expression} */ (context.visit(node.argument)); + context.state.scope.evaluate(argument, analysis.values, seen_bindings, true); + } else { + analysis.values.add(undefined); + } + } + }, + _(node, context) { + const new_scope = + node.type === 'FunctionDeclaration' || + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionExpression' + ? node.metadata.scope + : binding.scope.root.scopes.get(node); + if ( + new_scope && + context.state.scope !== new_scope && + (node.type !== 'FunctionDeclaration' || node === fn) + ) { + context.next({ + scope: new_scope, + scope_path: [...context.state.scope_path, new_scope], + current_call: context.state.current_call + }); + } else { + context.next(); + } + } + }); + if (uses_implicit_return) { + fn_scope.evaluate(/** @type {Expression} */ (fn.body), analysis.values, seen_bindings, true); + } + for (const value of analysis.values) { + analysis.value = value; // saves having special logic for `size === 1` + + if (value == null || value === UNKNOWN) { + analysis.is_defined = false; + } + } + + if ( + (analysis.values.size <= 1 && !TYPES.includes(analysis.value)) || + analysis.values.size === 0 + ) { + analysis.is_known = true; + } + fn_cache.set(fn, analysis); + return analysis; +} diff --git a/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/server/index.svelte.js index 3431e36833b5..90418e626797 100644 --- a/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/each-index-non-null/_expected/server/index.svelte.js @@ -6,7 +6,7 @@ export default function Each_index_non_null($$payload) { $$payload.out += ``; for (let i = 0, $$length = each_array.length; i < $$length; i++) { - $$payload.out += `

index: ${$.escape(i)}

`; + $$payload.out += `

index: ${i}

`; } $$payload.out += ``; diff --git a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js index d520d1ef2488..b42be84c4da0 100644 --- a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js @@ -18,7 +18,7 @@ export default function Text_nodes_deriveds($$anchor) { var p = root(); var text = $.child(p); + text.nodeValue = '00'; $.reset(p); - $.template_effect(($0, $1) => $.set_text(text, `${$0 ?? ''}${$1 ?? ''}`), [text1, text2]); $.append($$anchor, p); } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/server/index.svelte.js index 6f019647f58b..f53007638d92 100644 --- a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/server/index.svelte.js @@ -12,5 +12,5 @@ export default function Text_nodes_deriveds($$payload) { return count2; } - $$payload.out += `

${$.escape(text1())}${$.escape(text2())}

`; + $$payload.out += `

00

`; } \ No newline at end of file