From 134d43539e7c722669bd33a911cd95639c3af6ef Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" <sejohnson@torchcloudconsulting.com> Date: Wed, 23 Apr 2025 00:19:08 -0600 Subject: [PATCH 01/10] feat: State declarations in class constructors --- .../2-analyze/visitors/CallExpression.js | 100 +++++++++++++++++- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 904817b014e4..683e343aa56a 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -116,9 +116,11 @@ export function CallExpression(node, context) { case '$derived': case '$derived.by': if ( - (parent.type !== 'VariableDeclarator' || - get_parent(context.path, -3).type === 'ConstTag') && - !(parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) + !( + call_expression_is_variable_declaration(parent, context) || + call_expression_is_class_property_definition(parent) || + call_expression_is_valid_class_property_assignment_in_constructor(parent, context) + ) ) { e.state_invalid_placement(node, rune); } @@ -270,3 +272,95 @@ function get_function_label(nodes) { return parent.id.name; } } + +/** + * + * @param {AST.SvelteNode} parent + * @param {Context} context + */ +function call_expression_is_variable_declaration(parent, context) { + return parent.type === 'VariableDeclarator' && get_parent(context.path, -3).type !== 'ConstTag'; +} + +/** + * + * @param {AST.SvelteNode} parent + */ +function call_expression_is_class_property_definition(parent) { + return parent.type === 'PropertyDefinition' && !parent.static && !parent.computed; +} + +/** + * + * @param {AST.SvelteNode} parent + * @param {Context} context + * @returns + */ +function call_expression_is_valid_class_property_assignment_in_constructor(parent, context) { + return ( + expression_is_assignment_to_top_level_property_of_this(parent) && + current_node_is_in_constructor_root_or_control_flow_blocks(context) + ); +} + +/** + * yes: + * - `this.foo = bar` + * + * no: + * - `this = bar` + * - `this.foo.baz = bar` + * - `anything_other_than_this = bar` + * + * @param {AST.SvelteNode} node + */ +function expression_is_assignment_to_top_level_property_of_this(node) { + return ( + node.type === 'AssignmentExpression' && + node.operator === '=' && + node.left.type === 'MemberExpression' && + node.left.object.type === 'ThisExpression' && + node.left.property.type === 'Identifier' + ); +} + +/** + * @param {AST.SvelteNode} node + */ +function node_is_constructor(node) { + return ( + node.type === 'MethodDefinition' && + node.key.type === 'Identifier' && + node.key.name === 'constructor' + ); +} + +// if blocks are just IfStatements with BlockStatements or other IfStatements as consequents +const allowed_parent_types = new Set([ + 'IfStatement', + 'BlockStatement', + 'SwitchCase', + 'SwitchStatement' +]); + +/** + * Succeeds if the node's only direct parents are `if` / `else if` / `else` blocks _and_ + * those blocks are the direct children of the constructor. + * + * @param {Context} context + */ +function current_node_is_in_constructor_root_or_control_flow_blocks(context) { + let parent_index = -3; // this gets us from CallExpression -> AssignmentExpression -> ExpressionStatement -> Whatever is here + while (true) { + const grandparent = get_parent(context.path, parent_index - 1); + const parent = get_parent(context.path, parent_index); + if (grandparent && node_is_constructor(grandparent)) { + // if this is the case then `parent` is the FunctionExpression + return true; + } + if (!allowed_parent_types.has(parent.type)) { + return false; + } + parent_index--; + } +} From fb8d6d7975ff53e833779cb0ac8d3e117f768fb8 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" <sejohnson@torchcloudconsulting.com> Date: Thu, 24 Apr 2025 15:02:43 -0600 Subject: [PATCH 02/10] feat: Analysis phase --- .../98-reference/.generated/compile-errors.md | 33 +++- .../svelte/messages/compile-errors/script.md | 31 +++- packages/svelte/src/compiler/errors.js | 15 +- .../src/compiler/phases/2-analyze/index.js | 10 +- .../src/compiler/phases/2-analyze/types.d.ts | 5 +- .../visitors/AssignmentExpression.js | 1 + .../2-analyze/visitors/CallExpression.js | 80 +--------- .../phases/2-analyze/visitors/ClassBody.js | 30 ---- .../2-analyze/visitors/ClassDeclaration.js | 3 +- .../2-analyze/visitors/PropertyDefinition.js | 12 ++ .../visitors/shared/class-analysis.js | 147 ++++++++++++++++++ .../class-state-field-static/_config.js | 2 +- .../samples/runes-no-rune-each/_config.js | 3 +- .../runes-wrong-derived-placement/_config.js | 2 +- .../runes-wrong-state-placement/_config.js | 3 +- .../const-tag-invalid-rune-usage/errors.json | 2 +- 16 files changed, 257 insertions(+), 122 deletions(-) delete mode 100644 packages/svelte/src/compiler/phases/2-analyze/visitors/ClassBody.js create mode 100644 packages/svelte/src/compiler/phases/2-analyze/visitors/PropertyDefinition.js create mode 100644 packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index e8669ead533d..fb0591b884a5 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -208,6 +208,37 @@ Cannot assign to %thing% Cannot bind to %thing% ``` +### constructor_state_reassignment + +``` +Cannot redeclare stateful field `%name%` in the constructor. The field was originally declared here: `%original_location%` +``` + +To create stateful class fields in the constructor, the rune assignment must be the _first_ assignment to the class field. +Assignments thereafter must not use the rune. + +```ts +constructor() { + this.count = $state(0); + this.count = $state(1); // invalid, assigning to the same property with `$state` again +} + +constructor() { + this.count = $state(0); + this.count = $state.raw(1); // invalid, assigning to the same property with a different rune +} + +constructor() { + this.count = 0; + this.count = $state(1); // invalid, this property was created as a regular property, not state +} + +constructor() { + this.count = $state(0); + this.count = 1; // valid, this is setting the state that has already been declared +} +``` + ### css_empty_declaration ``` @@ -855,7 +886,7 @@ Cannot export state from a module if it is reassigned. Either export a function ### state_invalid_placement ``` -`%rune%(...)` can only be used as a variable declaration initializer or a class field +`%rune%(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor. ``` ### store_invalid_scoped_subscription diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md index aabcbeae4812..780c9f4d2863 100644 --- a/packages/svelte/messages/compile-errors/script.md +++ b/packages/svelte/messages/compile-errors/script.md @@ -10,6 +10,35 @@ > Cannot bind to %thing% +## constructor_state_reassignment + +> Cannot redeclare stateful field `%name%` in the constructor. The field was originally declared here: `%original_location%` + +To create stateful class fields in the constructor, the rune assignment must be the _first_ assignment to the class field. +Assignments thereafter must not use the rune. + +```ts +constructor() { + this.count = $state(0); + this.count = $state(1); // invalid, assigning to the same property with `$state` again +} + +constructor() { + this.count = $state(0); + this.count = $state.raw(1); // invalid, assigning to the same property with a different rune +} + +constructor() { + this.count = 0; + this.count = $state(1); // invalid, this property was created as a regular property, not state +} + +constructor() { + this.count = $state(0); + this.count = 1; // valid, this is setting the state that has already been declared +} +``` + ## declaration_duplicate > `%name%` has already been declared @@ -218,7 +247,7 @@ It's possible to export a snippet from a `<script module>` block, but only if it ## state_invalid_placement -> `%rune%(...)` can only be used as a variable declaration initializer or a class field +> `%rune%(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor. ## store_invalid_scoped_subscription diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index c99f59746863..fc814a16d31a 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -104,6 +104,17 @@ export function constant_binding(node, thing) { e(node, 'constant_binding', `Cannot bind to ${thing}\nhttps://svelte.dev/e/constant_binding`); } +/** + * Cannot redeclare stateful field `%name%` in the constructor. The field was originally declared here: `%original_location%` + * @param {null | number | NodeLike} node + * @param {string} name + * @param {string} original_location + * @returns {never} + */ +export function constructor_state_reassignment(node, name, original_location) { + e(node, 'constructor_state_reassignment', `Cannot redeclare stateful field \`${name}\` in the constructor. The field was originally declared here: \`${original_location}\`\nhttps://svelte.dev/e/constructor_state_reassignment`); +} + /** * `%name%` has already been declared * @param {null | number | NodeLike} node @@ -471,13 +482,13 @@ export function state_invalid_export(node) { } /** - * `%rune%(...)` can only be used as a variable declaration initializer or a class field + * `%rune%(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor. * @param {null | number | NodeLike} node * @param {string} rune * @returns {never} */ export function state_invalid_placement(node, rune) { - e(node, 'state_invalid_placement', `\`${rune}(...)\` can only be used as a variable declaration initializer or a class field\nhttps://svelte.dev/e/state_invalid_placement`); + e(node, 'state_invalid_placement', `\`${rune}(...)\` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.\nhttps://svelte.dev/e/state_invalid_placement`); } /** diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 2e36a896493f..ac9ab7689b41 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -22,7 +22,6 @@ import { Attribute } from './visitors/Attribute.js'; import { AwaitBlock } from './visitors/AwaitBlock.js'; import { BindDirective } from './visitors/BindDirective.js'; import { CallExpression } from './visitors/CallExpression.js'; -import { ClassBody } from './visitors/ClassBody.js'; import { ClassDeclaration } from './visitors/ClassDeclaration.js'; import { ClassDirective } from './visitors/ClassDirective.js'; import { Component } from './visitors/Component.js'; @@ -46,6 +45,7 @@ import { LetDirective } from './visitors/LetDirective.js'; import { MemberExpression } from './visitors/MemberExpression.js'; import { NewExpression } from './visitors/NewExpression.js'; import { OnDirective } from './visitors/OnDirective.js'; +import { PropertyDefinition } from './visitors/PropertyDefinition.js'; import { RegularElement } from './visitors/RegularElement.js'; import { RenderTag } from './visitors/RenderTag.js'; import { SlotElement } from './visitors/SlotElement.js'; @@ -135,7 +135,6 @@ const visitors = { AwaitBlock, BindDirective, CallExpression, - ClassBody, ClassDeclaration, ClassDirective, Component, @@ -159,6 +158,7 @@ const visitors = { MemberExpression, NewExpression, OnDirective, + PropertyDefinition, RegularElement, RenderTag, SlotElement, @@ -259,7 +259,7 @@ export function analyze_module(ast, options) { scope, scopes, analysis: /** @type {ComponentAnalysis} */ (analysis), - derived_state: [], + class_state: null, // TODO the following are not needed for modules, but we have to pass them in order to avoid type error, // and reducing the type would result in a lot of tedious type casts elsewhere - find a good solution one day ast_type: /** @type {any} */ (null), @@ -618,7 +618,7 @@ export function analyze_component(root, source, options) { has_props_rune: false, component_slots: new Set(), expression: null, - derived_state: [], + class_state: null, function_depth: scope.function_depth, reactive_statement: null }; @@ -685,7 +685,7 @@ export function analyze_component(root, source, options) { reactive_statement: null, component_slots: new Set(), expression: null, - derived_state: [], + class_state: null, function_depth: scope.function_depth }; diff --git a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts index 17c8123de1fe..5f9887e98980 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts +++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts @@ -1,6 +1,7 @@ import type { Scope } from '../scope.js'; import type { ComponentAnalysis, ReactiveStatement } from '../types.js'; import type { AST, ExpressionMetadata, ValidatedCompileOptions } from '#compiler'; +import type { ClassAnalysis } from './visitors/shared/class-analysis.js'; export interface AnalysisState { scope: Scope; @@ -18,7 +19,9 @@ export interface AnalysisState { component_slots: Set<string>; /** Information about the current expression/directive/block value */ expression: ExpressionMetadata | null; - derived_state: { name: string; private: boolean }[]; + + /** Used to analyze class state. */ + class_state: ClassAnalysis | null; function_depth: number; // legacy stuff diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AssignmentExpression.js index a64c89cd88f1..9a94fc668ed9 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AssignmentExpression.js @@ -23,5 +23,6 @@ export function AssignmentExpression(node, context) { } } + context.state.class_state?.register?.(node, context); context.next(); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 683e343aa56a..2abf8a88b1d5 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -119,7 +119,10 @@ export function CallExpression(node, context) { !( call_expression_is_variable_declaration(parent, context) || call_expression_is_class_property_definition(parent) || - call_expression_is_valid_class_property_assignment_in_constructor(parent, context) + context.state.class_state?.is_class_property_assignment_at_constructor_root( + parent, + context.path.slice(0, -1) + ) ) ) { e.state_invalid_placement(node, rune); @@ -289,78 +292,3 @@ function call_expression_is_variable_declaration(parent, context) { function call_expression_is_class_property_definition(parent) { return parent.type === 'PropertyDefinition' && !parent.static && !parent.computed; } - -/** - * - * @param {AST.SvelteNode} parent - * @param {Context} context - * @returns - */ -function call_expression_is_valid_class_property_assignment_in_constructor(parent, context) { - return ( - expression_is_assignment_to_top_level_property_of_this(parent) && - current_node_is_in_constructor_root_or_control_flow_blocks(context) - ); -} - -/** - * yes: - * - `this.foo = bar` - * - * no: - * - `this = bar` - * - `this.foo.baz = bar` - * - `anything_other_than_this = bar` - * - * @param {AST.SvelteNode} node - */ -function expression_is_assignment_to_top_level_property_of_this(node) { - return ( - node.type === 'AssignmentExpression' && - node.operator === '=' && - node.left.type === 'MemberExpression' && - node.left.object.type === 'ThisExpression' && - node.left.property.type === 'Identifier' - ); -} - -/** - * @param {AST.SvelteNode} node - */ -function node_is_constructor(node) { - return ( - node.type === 'MethodDefinition' && - node.key.type === 'Identifier' && - node.key.name === 'constructor' - ); -} - -// if blocks are just IfStatements with BlockStatements or other IfStatements as consequents -const allowed_parent_types = new Set([ - 'IfStatement', - 'BlockStatement', - 'SwitchCase', - 'SwitchStatement' -]); - -/** - * Succeeds if the node's only direct parents are `if` / `else if` / `else` blocks _and_ - * those blocks are the direct children of the constructor. - * - * @param {Context} context - */ -function current_node_is_in_constructor_root_or_control_flow_blocks(context) { - let parent_index = -3; // this gets us from CallExpression -> AssignmentExpression -> ExpressionStatement -> Whatever is here - while (true) { - const grandparent = get_parent(context.path, parent_index - 1); - const parent = get_parent(context.path, parent_index); - if (grandparent && node_is_constructor(grandparent)) { - // if this is the case then `parent` is the FunctionExpression - return true; - } - if (!allowed_parent_types.has(parent.type)) { - return false; - } - parent_index--; - } -} diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassBody.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassBody.js deleted file mode 100644 index 0463e4da8563..000000000000 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassBody.js +++ /dev/null @@ -1,30 +0,0 @@ -/** @import { ClassBody } from 'estree' */ -/** @import { Context } from '../types' */ -import { get_rune } from '../../scope.js'; - -/** - * @param {ClassBody} node - * @param {Context} context - */ -export function ClassBody(node, context) { - /** @type {{name: string, private: boolean}[]} */ - const derived_state = []; - - for (const definition of node.body) { - if ( - definition.type === 'PropertyDefinition' && - (definition.key.type === 'PrivateIdentifier' || definition.key.type === 'Identifier') && - definition.value?.type === 'CallExpression' - ) { - const rune = get_rune(definition.value, context.state.scope); - if (rune === '$derived' || rune === '$derived.by') { - derived_state.push({ - name: definition.key.name, - private: definition.key.type === 'PrivateIdentifier' - }); - } - } - } - - context.next({ ...context.state, derived_state }); -} diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassDeclaration.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassDeclaration.js index 20925a65b737..7e1df6aa2979 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassDeclaration.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassDeclaration.js @@ -1,6 +1,7 @@ /** @import { ClassDeclaration } from 'estree' */ /** @import { Context } from '../types' */ import * as w from '../../../warnings.js'; +import { ClassAnalysis } from './shared/class-analysis.js'; import { validate_identifier_name } from './shared/utils.js'; /** @@ -21,5 +22,5 @@ export function ClassDeclaration(node, context) { w.perf_avoid_nested_class(node); } - context.next(); + context.next({ ...context.state, class_state: new ClassAnalysis() }); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/PropertyDefinition.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/PropertyDefinition.js new file mode 100644 index 000000000000..6bf5eb73d5db --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/PropertyDefinition.js @@ -0,0 +1,12 @@ +/** @import { PropertyDefinition } from 'estree' */ +/** @import { Context } from '../types' */ + +/** + * + * @param {PropertyDefinition} node + * @param {Context} context + */ +export function PropertyDefinition(node, context) { + context.state.class_state?.register?.(node, context); + context.next(); +} diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js new file mode 100644 index 000000000000..d6709eabc41e --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js @@ -0,0 +1,147 @@ +/** @import { AssignmentExpression, PropertyDefinition, Expression } from 'estree' */ +/** @import { AST } from '#compiler' */ +/** @import { Context } from '../../types' */ + +import { get_parent } from '../../../../utils/ast.js'; +import { get_rune } from '../../../scope.js'; +import * as e from '../../../../errors.js'; +import { locate_node } from '../../../../state.js'; + +/** @typedef {'$state' | '$state.raw' | '$derived' | '$derived.by' | 'regular'} PropertyAssignmentType */ +/** @typedef {{ type: PropertyAssignmentType; node: AssignmentExpression | PropertyDefinition; }} PropertyAssignmentDetails */ + +const reassignable_assignments = new Set(['$state', '$state.raw', 'regular']); + +const property_assignment_types = new Set([ + '$state', + '$state.raw', + '$derived', + '$derived.by', + 'regular' +]); + +export class ClassAnalysis { + // TODO: Probably need to include property definitions here too + /** @type {Map<string, PropertyAssignmentDetails>} */ + property_assignments = new Map(); + + /** + * Determines if the node is a valid assignment to a class property, and if so, + * registers the assignment. + * @param {AssignmentExpression | PropertyDefinition} node + * @param {Context} context + */ + register(node, context) { + /** @type {string} */ + let name; + /** @type {PropertyAssignmentType} */ + let type; + + if (node.type === 'AssignmentExpression') { + if (!this.is_class_property_assignment_at_constructor_root(node, context.path)) { + return; + } + name = node.left.property.name; + type = this.#get_assignment_type(node, context); + + this.#check_for_conflicts(node, name, type); + } else { + if (!this.#is_assigned_property(node)) { + return; + } + + name = node.key.name; + type = this.#get_assignment_type(node, context); + + // we don't need to check for conflicts here because they're not possible yet + } + + // we don't have to validate anything other than conflicts here, because the rune placement rules + // catch all of the other weirdness. + if (!this.property_assignments.has(name)) { + this.property_assignments.set(name, { type, node }); + } + } + + /** + * @template {AST.SvelteNode} T + * @param {AST.SvelteNode} node + * @param {T[]} path + * @returns {node is AssignmentExpression & { left: { type: 'MemberExpression' } & { object: { type: 'ThisExpression' }; property: { type: 'Identifier' } } }} + */ + is_class_property_assignment_at_constructor_root(node, path) { + if ( + !( + node.type === 'AssignmentExpression' && + node.operator === '=' && + node.left.type === 'MemberExpression' && + node.left.object.type === 'ThisExpression' && + node.left.property.type === 'Identifier' + ) + ) { + return false; + } + // AssignmentExpression (here) -> ExpressionStatement (-1) -> BlockStatement (-2) -> FunctionExpression (-3) -> MethodDefinition (-4) + const maybe_constructor = get_parent(path, -4); + return ( + maybe_constructor && + maybe_constructor.type === 'MethodDefinition' && + maybe_constructor.kind === 'constructor' + ); + } + + /** + * We only care about properties that have values assigned to them -- if they don't, + * they can't be a conflict for state declared in the constructor. + * @param {PropertyDefinition} node + * @returns {node is PropertyDefinition & { key: { type: 'PrivateIdentifier' | 'Identifier' }; value: Expression; static: false; computed: false }} + */ + #is_assigned_property(node) { + return ( + (node.key.type === 'PrivateIdentifier' || node.key.type === 'Identifier') && + Boolean(node.value) && + !node.static && + !node.computed + ); + } + + /** + * Checks for conflicts with existing assignments. A conflict occurs if: + * - The original assignment used `$derived` or `$derived.by` (these can never be reassigned) + * - The original assignment used `$state`, `$state.raw`, or `regular` and is being assigned to with any type other than `regular` + * @param {AssignmentExpression} node + * @param {string} name + * @param {PropertyAssignmentType} type + */ + #check_for_conflicts(node, name, type) { + const existing = this.property_assignments.get(name); + if (!existing) { + return; + } + + if (reassignable_assignments.has(existing.type) && type === 'regular') { + return; + } + + e.constructor_state_reassignment(node, name, locate_node(existing.node)); + } + + /** + * @param {AssignmentExpression | PropertyDefinition} node + * @param {Context} context + * @returns {PropertyAssignmentType} + */ + #get_assignment_type(node, context) { + const value = node.type === 'AssignmentExpression' ? node.right : node.value; + const rune = get_rune(value, context.state.scope); + if (rune === null) { + return 'regular'; + } + if (property_assignment_types.has(rune)) { + return /** @type {PropertyAssignmentType} */ (rune); + } + // this does mean we return `regular` for some other runes (like `$trace` or `$state.raw`) + // -- this is ok because the rune placement rules will throw if they're invalid. + return 'regular'; + } +} diff --git a/packages/svelte/tests/compiler-errors/samples/class-state-field-static/_config.js b/packages/svelte/tests/compiler-errors/samples/class-state-field-static/_config.js index e6551031cc8e..f5b116117ed3 100644 --- a/packages/svelte/tests/compiler-errors/samples/class-state-field-static/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/class-state-field-static/_config.js @@ -4,7 +4,7 @@ export default test({ error: { code: 'state_invalid_placement', message: - '`$state(...)` can only be used as a variable declaration initializer or a class field', + '`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.', position: [33, 41] } }); diff --git a/packages/svelte/tests/compiler-errors/samples/runes-no-rune-each/_config.js b/packages/svelte/tests/compiler-errors/samples/runes-no-rune-each/_config.js index 1185ca6b4548..592b2634a389 100644 --- a/packages/svelte/tests/compiler-errors/samples/runes-no-rune-each/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/runes-no-rune-each/_config.js @@ -3,6 +3,7 @@ import { test } from '../../test'; export default test({ error: { code: 'state_invalid_placement', - message: '`$state(...)` can only be used as a variable declaration initializer or a class field' + message: + '`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.' } }); diff --git a/packages/svelte/tests/compiler-errors/samples/runes-wrong-derived-placement/_config.js b/packages/svelte/tests/compiler-errors/samples/runes-wrong-derived-placement/_config.js index 476d7239a307..f31748d26a40 100644 --- a/packages/svelte/tests/compiler-errors/samples/runes-wrong-derived-placement/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-derived-placement/_config.js @@ -4,6 +4,6 @@ export default test({ error: { code: 'state_invalid_placement', message: - '`$derived(...)` can only be used as a variable declaration initializer or a class field' + '`$derived(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.' } }); diff --git a/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-placement/_config.js b/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-placement/_config.js index 1185ca6b4548..592b2634a389 100644 --- a/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-placement/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-placement/_config.js @@ -3,6 +3,7 @@ import { test } from '../../test'; export default test({ error: { code: 'state_invalid_placement', - message: '`$state(...)` can only be used as a variable declaration initializer or a class field' + message: + '`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.' } }); diff --git a/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json b/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json index 32594e426846..e1906b181a4d 100644 --- a/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json +++ b/packages/svelte/tests/validator/samples/const-tag-invalid-rune-usage/errors.json @@ -1,7 +1,7 @@ [ { "code": "state_invalid_placement", - "message": "`$derived(...)` can only be used as a variable declaration initializer or a class field", + "message": "`$derived(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.", "start": { "line": 2, "column": 15 From 033a466e846d198e90c9a2942d04f44b52951cdb Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" <sejohnson@torchcloudconsulting.com> Date: Fri, 25 Apr 2025 14:54:34 -0600 Subject: [PATCH 03/10] misc --- .../2-analyze/visitors/ClassDeclaration.js | 5 ++++- .../2-analyze/visitors/shared/class-analysis.js | 10 ++++++---- packages/svelte/src/utils.js | 16 +++++++++++++++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassDeclaration.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassDeclaration.js index 7e1df6aa2979..b49ad099e59c 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassDeclaration.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ClassDeclaration.js @@ -22,5 +22,8 @@ export function ClassDeclaration(node, context) { w.perf_avoid_nested_class(node); } - context.next({ ...context.state, class_state: new ClassAnalysis() }); + context.next({ + ...context.state, + class_state: context.state.analysis.runes ? new ClassAnalysis() : null + }); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js index d6709eabc41e..777762bc323f 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js @@ -6,6 +6,7 @@ import { get_parent } from '../../../../utils/ast.js'; import { get_rune } from '../../../scope.js'; import * as e from '../../../../errors.js'; import { locate_node } from '../../../../state.js'; +import { is_state_creation_rune } from '../../../../../utils.js'; /** @typedef {'$state' | '$state.raw' | '$derived' | '$derived.by' | 'regular'} PropertyAssignmentType */ /** @typedef {{ type: PropertyAssignmentType; node: AssignmentExpression | PropertyDefinition; }} PropertyAssignmentDetails */ @@ -67,7 +68,7 @@ export class ClassAnalysis { * @template {AST.SvelteNode} T * @param {AST.SvelteNode} node * @param {T[]} path - * @returns {node is AssignmentExpression & { left: { type: 'MemberExpression' } & { object: { type: 'ThisExpression' }; property: { type: 'Identifier' } } }} + * @returns {node is AssignmentExpression & { left: { type: 'MemberExpression' } & { object: { type: 'ThisExpression' }; property: { type: 'Identifier' | 'PrivateIdentifier' } } }} */ is_class_property_assignment_at_constructor_root(node, path) { if ( @@ -76,7 +77,8 @@ export class ClassAnalysis { node.operator === '=' && node.left.type === 'MemberExpression' && node.left.object.type === 'ThisExpression' && - node.left.property.type === 'Identifier' + (node.left.property.type === 'Identifier' || + node.left.property.type === 'PrivateIdentifier') ) ) { return false; @@ -137,8 +139,8 @@ export class ClassAnalysis { if (rune === null) { return 'regular'; } - if (property_assignment_types.has(rune)) { - return /** @type {PropertyAssignmentType} */ (rune); + if (is_state_creation_rune(rune)) { + return rune; } // this does mean we return `regular` for some other runes (like `$trace` or `$state.raw`) // -- this is ok because the rune placement rules will throw if they're invalid. diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index ada318e85ac7..2758b9fcbc19 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -447,14 +447,28 @@ const RUNES = /** @type {const} */ ([ '$host' ]); +/** @typedef {RUNES[number]} RuneName */ + /** * @param {string} name - * @returns {name is RUNES[number]} + * @returns {name is RuneName} */ export function is_rune(name) { return RUNES.includes(/** @type {RUNES[number]} */ (name)); } +/** @typedef {'$state' | '$state.raw' | '$derived' | '$derived.by'} StateCreationRuneName */ + +/** + * @param {string} name + * @returns {name is StateCreationRuneName} + */ +export function is_state_creation_rune(name) { + return ( + name === '$state' || name === '$state.raw' || name === '$derived' || name === '$derived.by' + ); +} + /** List of elements that require raw contents and should not have SSR comments put in them */ const RAW_TEXT_ELEMENTS = /** @type {const} */ (['textarea', 'script', 'style', 'title']); From 005ba299684a1f30f1d462aac994fe1884de512a Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" <sejohnson@torchcloudconsulting.com> Date: Fri, 25 Apr 2025 20:19:58 -0600 Subject: [PATCH 04/10] feat: client --- .../3-transform/client/transform-client.js | 8 +- .../phases/3-transform/client/types.d.ts | 7 +- .../client/visitors/AssignmentExpression.js | 7 +- .../3-transform/client/visitors/ClassBody.js | 176 ++-------- .../client/visitors/MemberExpression.js | 5 +- .../client/visitors/UpdateExpression.js | 2 +- .../client/visitors/shared/class-analysis.js | 301 ++++++++++++++++++ .../3-transform/server/visitors/ClassBody.js | 6 +- 8 files changed, 344 insertions(+), 168 deletions(-) create mode 100644 packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index f0da5a491887..ff448f31a126 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -161,8 +161,7 @@ export function client_component(analysis, options) { }, events: new Set(), preserve_whitespace: options.preserveWhitespace, - public_state: new Map(), - private_state: new Map(), + class_analysis: null, transform: {}, in_constructor: false, instance_level_snippets: [], @@ -669,10 +668,9 @@ export function client_module(analysis, options) { options, scope: analysis.module.scope, scopes: analysis.module.scopes, - public_state: new Map(), - private_state: new Map(), transform: {}, - in_constructor: false + in_constructor: false, + class_analysis: null }; const module = /** @type {ESTree.Program} */ ( diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index 243e1c64a33c..2ab5bd1a6094 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -13,10 +13,11 @@ import type { AST, Namespace, ValidatedCompileOptions } from '#compiler'; import type { TransformState } from '../types.js'; import type { ComponentAnalysis } from '../../types.js'; import type { SourceLocation } from '#shared'; +import type { StateCreationRuneName } from '../../../../utils.js'; +import type { ClassAnalysis } from './visitors/shared/class-analysis.js'; export interface ClientTransformState extends TransformState { - readonly private_state: Map<string, StateField>; - readonly public_state: Map<string, StateField>; + readonly class_analysis: ClassAnalysis | null; /** * `true` if the current lexical scope belongs to a class constructor. this allows @@ -95,7 +96,7 @@ export interface ComponentClientTransformState extends ClientTransformState { } export interface StateField { - kind: 'state' | 'raw_state' | 'derived' | 'derived_by'; + kind: StateCreationRuneName; id: PrivateIdentifier; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js index e3f945005051..d045b83298f3 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js @@ -1,4 +1,4 @@ -/** @import { AssignmentExpression, AssignmentOperator, Expression, Identifier, Pattern } from 'estree' */ +/** @import { AssignmentExpression, AssignmentOperator, Expression, Identifier, Pattern, MemberExpression, ThisExpression, PrivateIdentifier, CallExpression } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { Context } from '../types.js' */ import * as b from '#compiler/builders'; @@ -17,6 +17,7 @@ import { validate_mutation } from './shared/utils.js'; * @param {Context} context */ export function AssignmentExpression(node, context) { + context.state.class_analysis?.register_assignment(node, context); const expression = /** @type {Expression} */ ( visit_assignment_expression(node, context, build_assignment) ?? context.next() ); @@ -56,7 +57,7 @@ function build_assignment(operator, left, right, context) { left.type === 'MemberExpression' && left.property.type === 'PrivateIdentifier' ) { - const private_state = context.state.private_state.get(left.property.name); + const private_state = context.state.class_analysis?.private_state.get(left.property.name); if (private_state !== undefined) { let value = /** @type {Expression} */ ( @@ -64,7 +65,7 @@ function build_assignment(operator, left, right, context) { ); const needs_proxy = - private_state.kind === 'state' && + private_state.kind === '$state' && is_non_coercive_operator(operator) && should_proxy(value, context.state.scope); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js index f3ebd940699c..534fdba44672 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js @@ -1,9 +1,7 @@ -/** @import { ClassBody, Expression, Identifier, Literal, MethodDefinition, PrivateIdentifier, PropertyDefinition } from 'estree' */ +/** @import { ClassBody, Identifier, Literal, MethodDefinition, PrivateIdentifier, PropertyDefinition } from 'estree' */ /** @import { Context, StateField } from '../types' */ -import * as b from '#compiler/builders'; import { regex_invalid_identifier_chars } from '../../../patterns.js'; -import { get_rune } from '../../../scope.js'; -import { should_proxy } from '../utils.js'; +import { ClassAnalysis } from './shared/class-analysis.js'; /** * @param {ClassBody} node @@ -15,170 +13,46 @@ export function ClassBody(node, context) { return; } - /** @type {Map<string, StateField>} */ - const public_state = new Map(); - - /** @type {Map<string, StateField>} */ - const private_state = new Map(); - - /** @type {Map<(MethodDefinition|PropertyDefinition)["key"], string>} */ - const definition_names = new Map(); - - /** @type {string[]} */ - const private_ids = []; + const class_analysis = new ClassAnalysis(); for (const definition of node.body) { - if ( - (definition.type === 'PropertyDefinition' || definition.type === 'MethodDefinition') && - (definition.key.type === 'Identifier' || - definition.key.type === 'PrivateIdentifier' || - definition.key.type === 'Literal') - ) { - const type = definition.key.type; - const name = get_name(definition.key, public_state); - if (!name) continue; - - // we store the deconflicted name in the map so that we can access it later - definition_names.set(definition.key, name); - - const is_private = type === 'PrivateIdentifier'; - if (is_private) private_ids.push(name); - - if (definition.value?.type === 'CallExpression') { - const rune = get_rune(definition.value, context.state.scope); - if ( - rune === '$state' || - rune === '$state.raw' || - rune === '$derived' || - rune === '$derived.by' - ) { - /** @type {StateField} */ - const field = { - kind: - rune === '$state' - ? 'state' - : rune === '$state.raw' - ? 'raw_state' - : rune === '$derived.by' - ? 'derived_by' - : 'derived', - // @ts-expect-error this is set in the next pass - id: is_private ? definition.key : null - }; - - if (is_private) { - private_state.set(name, field); - } else { - public_state.set(name, field); - } - } - } - } + class_analysis.register_body_definition(definition, context.state.scope); } - // each `foo = $state()` needs a backing `#foo` field - for (const [name, field] of public_state) { - let deconflicted = name; - while (private_ids.includes(deconflicted)) { - deconflicted = '_' + deconflicted; - } - - private_ids.push(deconflicted); - field.id = b.private_id(deconflicted); - } + class_analysis.finalize_property_definitions(); /** @type {Array<MethodDefinition | PropertyDefinition>} */ const body = []; - const child_state = { ...context.state, public_state, private_state }; + const child_state = { + ...context.state, + class_analysis + }; + + // we need to visit the constructor first so that it can add to the field maps. + const constructor_node = node.body.find( + (child) => child.type === 'MethodDefinition' && child.kind === 'constructor' + ); + const constructor = constructor_node && context.visit(constructor_node, child_state); // Replace parts of the class body for (const definition of node.body) { - if ( - definition.type === 'PropertyDefinition' && - (definition.key.type === 'Identifier' || - definition.key.type === 'PrivateIdentifier' || - definition.key.type === 'Literal') - ) { - const name = definition_names.get(definition.key); - if (!name) continue; - - const is_private = definition.key.type === 'PrivateIdentifier'; - const field = (is_private ? private_state : public_state).get(name); - - if (definition.value?.type === 'CallExpression' && field !== undefined) { - let value = null; - - if (definition.value.arguments.length > 0) { - const init = /** @type {Expression} **/ ( - context.visit(definition.value.arguments[0], child_state) - ); - - value = - field.kind === 'state' - ? b.call( - '$.state', - should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init - ) - : field.kind === 'raw_state' - ? b.call('$.state', init) - : field.kind === 'derived_by' - ? b.call('$.derived', init) - : b.call('$.derived', b.thunk(init)); - } else { - // if no arguments, we know it's state as `$derived()` is a compile error - value = b.call('$.state'); - } - - if (is_private) { - body.push(b.prop_def(field.id, value)); - } else { - // #foo; - const member = b.member(b.this, field.id); - body.push(b.prop_def(field.id, value)); - - // get foo() { return this.#foo; } - body.push(b.method('get', definition.key, [], [b.return(b.call('$.get', member))])); + if (definition === constructor_node) { + body.push(/** @type {MethodDefinition} */ (constructor)); + continue; + } - // set foo(value) { this.#foo = value; } - const val = b.id('value'); + const state_field = class_analysis.build_state_field_from_body_definition(definition, context); - body.push( - b.method( - 'set', - definition.key, - [val], - [b.stmt(b.call('$.set', member, val, field.kind === 'state' && b.true))] - ) - ); - } - continue; - } + if (state_field) { + body.push(...state_field); + continue; } body.push(/** @type {MethodDefinition} **/ (context.visit(definition, child_state))); } - return { ...node, body }; -} + body.push(...class_analysis.constructor_state_fields); -/** - * @param {Identifier | PrivateIdentifier | Literal} node - * @param {Map<string, StateField>} public_state - */ -function get_name(node, public_state) { - if (node.type === 'Literal') { - let name = node.value?.toString().replace(regex_invalid_identifier_chars, '_'); - - // the above could generate conflicts because it has to generate a valid identifier - // so stuff like `0` and `1` or `state%` and `state^` will result in the same string - // so we have to de-conflict. We can only check `public_state` because private state - // can't have literal keys - while (name && public_state.has(name)) { - name = '_' + name; - } - return name; - } else { - return node.name; - } + return { ...node, body }; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/MemberExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/MemberExpression.js index ab88345ddd3c..410c9ad9ab79 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/MemberExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/MemberExpression.js @@ -9,9 +9,10 @@ import * as b from '#compiler/builders'; export function MemberExpression(node, context) { // rewrite `this.#foo` as `this.#foo.v` inside a constructor if (node.property.type === 'PrivateIdentifier') { - const field = context.state.private_state.get(node.property.name); + const field = context.state.class_analysis?.private_state.get(node.property.name); if (field) { - return context.state.in_constructor && (field.kind === 'raw_state' || field.kind === 'state') + return context.state.in_constructor && + (field.kind === '$state.raw' || field.kind === '$state') ? b.member(node, 'v') : b.call('$.get', node); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/UpdateExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/UpdateExpression.js index 96be119b840a..c1f7c390ced9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/UpdateExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/UpdateExpression.js @@ -15,7 +15,7 @@ export function UpdateExpression(node, context) { argument.type === 'MemberExpression' && argument.object.type === 'ThisExpression' && argument.property.type === 'PrivateIdentifier' && - context.state.private_state.has(argument.property.name) + context.state.class_analysis?.private_state.has(argument.property.name) ) { let fn = '$.update'; if (node.prefix) fn += '_pre'; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js new file mode 100644 index 000000000000..77618a482c12 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js @@ -0,0 +1,301 @@ +/** @import { Context, StateField } from '../../types.js' */ +/** @import { AssignmentExpression, Identifier, Literal, MethodDefinition, PrivateIdentifier, PropertyDefinition, CallExpression, Expression, StaticBlock, SpreadElement } from 'estree' */ +/** @import { Scope } from '#compiler' */ +/** @import { StateCreationRuneName } from '../../../../../../utils.js' */ +import * as b from '#compiler/builders'; +import { is_state_creation_rune } from '../../../../../../utils.js'; +import { regex_invalid_identifier_chars } from '../../../../patterns.js'; +import { get_rune } from '../../../../scope.js'; +import { should_proxy } from '../../utils.js'; + +export class ClassAnalysis { + /** @type {Map<string, StateField>} */ + public_state = new Map(); + + /** @type {Map<string, StateField>} */ + private_state = new Map(); + + /** + * Any state fields discovered from {@link register_assignment} that need to be added to the class body. + * @type {Array<PropertyDefinition | MethodDefinition>} + */ + constructor_state_fields = []; + + /** @type {Map<(MethodDefinition | PropertyDefinition)["key"], string>} */ + #definition_names = new Map(); + + /** @type {Set<string>} */ + #private_ids = new Set(); + + /** + * @param {MethodDefinition | PropertyDefinition | StaticBlock} node + * @param {Scope} scope + */ + register_body_definition(node, scope) { + if ( + !( + (node.type === 'PropertyDefinition' || node.type === 'MethodDefinition') && + (node.key.type === 'Identifier' || + node.key.type === 'PrivateIdentifier' || + node.key.type === 'Literal') + ) + ) { + return; + } + + /* + * We don't know if the node is stateful yet, but we still need to register some details. + * For example: If the node is a private identifier, we could accidentally conflict with it later + * if we create a private field for public state (as would happen in this example:) + * + * ```ts + * class Foo { + * #count = 0; + * count = $state(0); // would become #count if we didn't know about the private field above + * } + */ + + const name = ClassAnalysis.#get_name(node.key, this.public_state); + if (!name) { + return; + } + + // we store the deconflicted name in the map so that we can access it later + this.#definition_names.set(node.key, name); + + const is_private = node.key.type === 'PrivateIdentifier'; + if (is_private) { + this.#private_ids.add(name); + } + + const rune = get_rune(node.value, scope); + if (!rune || !is_state_creation_rune(rune)) { + return; + } + + if (is_private) { + this.private_state.set(name, { kind: rune, id: /** @type {PrivateIdentifier} */ (node.key) }); + } else { + // We can't set the ID until we've identified all of the private state fields, + // otherwise we might conflict with them. After registering all property definitions, + // call `finalize_property_definitions` to populate the IDs. + // @ts-expect-error this is set in `finalize_property_definitions` + this.public_state.set(name, { kind: rune, id: undefined }); + } + } + + /** + * Resolves all of the registered public state fields to their final private IDs. + * Must be called after all property definitions have been registered. + */ + finalize_property_definitions() { + for (const [name, field] of this.public_state) { + field.id = this.#deconflict(name); + } + } + + /** + * Important note: It is a syntax error in JavaScript to try to assign to a private class field + * that was not declared in the class body. So there is absolutely no risk of unresolvable conflicts here. + * + * This function will modify the assignment expression passed to it if it is registered as a state field. + * @param {AssignmentExpression} node + * @param {Context} context + */ + register_assignment(node, context) { + if ( + !( + node.operator === '=' && + node.left.type === 'MemberExpression' && + node.left.object.type === 'ThisExpression' && + node.left.property.type === 'Identifier' + ) + ) { + return; + } + + const name = ClassAnalysis.#get_name(node.left.property, this.public_state); + if (!name) { + return; + } + + const rune = get_rune(node.right, context.state.scope); + if (!rune || !is_state_creation_rune(rune)) { + return false; + } + + const id = this.#deconflict(name); + const field = { kind: rune, id }; + this.public_state.set(name, field); + + // We need to do two things: + // - Communicate to the class body visitor that it needs to append nodes to create a state field + // - Modify the assignment expression so that it's valid + this.constructor_state_fields.push( + ...this.build_state_field( + false, + field, + node.left.property, + // this will initialize the field without assigning to it, delegating to the constructor + null, + context + ) + ); + + // ...swap out the assignment to go directly against the private field + node.left.property = id; + // ...and swap out the assignment's value for the state field init + node.right = this.#build_init_value( + rune, + /** @type {CallExpression} */ (node.right).arguments[0], + context + ); + } + + /** + * + * @param {PropertyDefinition | MethodDefinition | StaticBlock} node + * @param {Context} context + * @returns {Array<PropertyDefinition | MethodDefinition> | null} + */ + build_state_field_from_body_definition(node, context) { + if ( + !( + node.type === 'PropertyDefinition' && + (node.key.type === 'Identifier' || + node.key.type === 'PrivateIdentifier' || + node.key.type === 'Literal') + ) + ) { + return null; + } + + const name = this.#definition_names.get(node.key); + if (!name) throw new Error('This should not be possible'); // TODO + + const is_private = node.key.type === 'PrivateIdentifier'; + const field = (is_private ? this.private_state : this.public_state).get(name); + + if (!field) { + return null; + } + + return this.build_state_field( + is_private, + field, + node.key, + // we can cast this because if we have a field for this definition it definitely is a call + // expression, otherwise it wouldn't have produced a rune earlier. + /** @type {CallExpression} */ (node.value), + context + ); + } + + /** + * @param {boolean} is_private + * @param {StateField} field + * @param {(MethodDefinition | PropertyDefinition)["key"]} original_definition_id + * @param {CallExpression | null} call_expression + * @param {Context} context + * + * @returns {Array<PropertyDefinition | MethodDefinition>} + */ + build_state_field(is_private, field, original_definition_id, call_expression, context) { + let value; + if (!call_expression) { + // if there's no call expression, this is state that's created in the constructor. + // it's guaranteed to be the very first assignment to this field, so we initialize + // the field but don't assign to it. + value = null; + } else if (call_expression.arguments.length > 0) { + value = this.#build_init_value(field.kind, call_expression.arguments[0], context); + } else { + // if no arguments, we know it's state as `$derived()` is a compile error + value = b.call('$.state'); + } + + if (is_private) { + return [b.prop_def(field.id, value)]; + } + + const member = b.member(b.this, field.id); + const val = b.id('value'); + return [ + // #foo; + b.prop_def(field.id, value), + // get foo() { return this.#foo; } + b.method('get', original_definition_id, [], [b.return(b.call('$.get', member))]), + // set foo(value) { this.#foo = value; } + b.method( + 'set', + original_definition_id, + [val], + [b.stmt(b.call('$.set', member, val, field.kind === '$state' && b.true))] + ) + ]; + } + + /** + * + * @param {StateCreationRuneName} kind + * @param {Expression | SpreadElement} arg + * @param {Context} context + */ + #build_init_value(kind, arg, context) { + const init = /** @type {Expression} **/ ( + context.visit(arg, { + ...context.state, + class_analysis: this + }) + ); + + switch (kind) { + case '$state': + return b.call( + '$.state', + should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init + ); + case '$state.raw': + return b.call('$.state', init); + case '$derived': + return b.call('$.derived', b.thunk(init)); + case '$derived.by': + return b.call('$.derived', init); + } + } + + /** + * @param {string} name + * @returns {PrivateIdentifier} + */ + #deconflict(name) { + let deconflicted = name; + while (this.#private_ids.has(deconflicted)) { + deconflicted = '_' + deconflicted; + } + + this.#private_ids.add(deconflicted); + return b.private_id(deconflicted); + } + + /** + * @param {Identifier | PrivateIdentifier | Literal} node + * @param {Map<string, unknown>} public_state + */ + static #get_name(node, public_state) { + if (node.type === 'Literal') { + let name = node.value?.toString().replace(regex_invalid_identifier_chars, '_'); + + // the above could generate conflicts because it has to generate a valid identifier + // so stuff like `0` and `1` or `state%` and `state^` will result in the same string + // so we have to de-conflict. We can only check `public_state` because private state + // can't have literal keys + while (name && public_state.has(name)) { + name = '_' + name; + } + return name; + } else { + return node.name; + } + } +} diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ClassBody.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ClassBody.js index c0ebdeae0822..ff4a1206ddaa 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ClassBody.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ClassBody.js @@ -39,7 +39,7 @@ export function ClassBody(node, context) { if (rune === '$derived' || rune === '$derived.by') { /** @type {StateField} */ const field = { - kind: rune === '$derived.by' ? 'derived_by' : 'derived', + kind: rune, // @ts-expect-error this is set in the next pass id: is_private ? definition.key : null }; @@ -86,7 +86,7 @@ export function ClassBody(node, context) { context.visit(definition.value.arguments[0], child_state) ); const value = - field.kind === 'derived_by' ? b.call('$.once', init) : b.call('$.once', b.thunk(init)); + field.kind === '$derived.by' ? b.call('$.once', init) : b.call('$.once', b.thunk(init)); if (is_private) { body.push(b.prop_def(field.id, value)); @@ -98,7 +98,7 @@ export function ClassBody(node, context) { // get foo() { return this.#foo; } body.push(b.method('get', definition.key, [], [b.return(b.call(member))])); - if (dev && (field.kind === 'derived' || field.kind === 'derived_by')) { + if (dev && (field.kind === '$derived' || field.kind === '$derived.by')) { body.push( b.method( 'set', From 4d6422cca425de4cccdddec4632e638959b18296 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" <sejohnson@torchcloudconsulting.com> Date: Fri, 25 Apr 2025 20:44:59 -0600 Subject: [PATCH 05/10] improvements --- .../2-analyze/visitors/shared/class-analysis.js | 11 ++--------- .../client/visitors/shared/class-analysis.js | 2 +- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js index 777762bc323f..4b3d758200b6 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js @@ -1,6 +1,7 @@ /** @import { AssignmentExpression, PropertyDefinition, Expression } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { Context } from '../../types' */ +/** @import { StateCreationRuneName } from '../../../../../utils.js' */ import { get_parent } from '../../../../utils/ast.js'; import { get_rune } from '../../../scope.js'; @@ -8,19 +9,11 @@ import * as e from '../../../../errors.js'; import { locate_node } from '../../../../state.js'; import { is_state_creation_rune } from '../../../../../utils.js'; -/** @typedef {'$state' | '$state.raw' | '$derived' | '$derived.by' | 'regular'} PropertyAssignmentType */ +/** @typedef { StateCreationRuneName | 'regular'} PropertyAssignmentType */ /** @typedef {{ type: PropertyAssignmentType; node: AssignmentExpression | PropertyDefinition; }} PropertyAssignmentDetails */ const reassignable_assignments = new Set(['$state', '$state.raw', 'regular']); -const property_assignment_types = new Set([ - '$state', - '$state.raw', - '$derived', - '$derived.by', - 'regular' -]); - export class ClassAnalysis { // TODO: Probably need to include property definitions here too /** @type {Map<string, PropertyAssignmentDetails>} */ diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js index 77618a482c12..0ce4451e8ba2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js @@ -121,7 +121,7 @@ export class ClassAnalysis { const rune = get_rune(node.right, context.state.scope); if (!rune || !is_state_creation_rune(rune)) { - return false; + return; } const id = this.#deconflict(name); From adb6e712e90133817cf4ea76430436d69213c675 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" <sejohnson@torchcloudconsulting.com> Date: Mon, 28 Apr 2025 23:23:23 -0400 Subject: [PATCH 06/10] feat: It is now at least backwards compatible. though the new stuff may still be wrong --- .../phases/3-transform/client/types.d.ts | 11 +- .../client/visitors/AssignmentExpression.js | 4 +- .../3-transform/client/visitors/ClassBody.js | 49 +-- .../client/visitors/MemberExpression.js | 2 +- .../client/visitors/UpdateExpression.js | 2 +- .../client/visitors/shared/class-analysis.js | 301 ---------------- .../visitors/shared/client-class-analysis.js | 85 +++++ .../3-transform/server/transform-server.js | 4 +- .../phases/3-transform/server/types.d.ts | 4 +- .../3-transform/server/visitors/ClassBody.js | 110 +----- .../server/visitors/MemberExpression.js | 5 +- .../visitors/shared/server-class-analysis.js | 71 ++++ .../3-transform/shared/class_analysis.js | 334 ++++++++++++++++++ .../phases/3-transform/shared/types.d.ts | 63 ++++ .../compiler/phases/3-transform/types.d.ts | 7 + packages/svelte/src/utils.js | 11 +- .../_expected/client/index.svelte.js | 10 +- .../_expected/server/index.svelte.js | 6 +- 18 files changed, 598 insertions(+), 481 deletions(-) delete mode 100644 packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js create mode 100644 packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js create mode 100644 packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js create mode 100644 packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js create mode 100644 packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index 2ab5bd1a6094..3e7ef24aee58 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -3,7 +3,6 @@ import type { Statement, LabeledStatement, Identifier, - PrivateIdentifier, Expression, AssignmentExpression, UpdateExpression, @@ -13,11 +12,10 @@ import type { AST, Namespace, ValidatedCompileOptions } from '#compiler'; import type { TransformState } from '../types.js'; import type { ComponentAnalysis } from '../../types.js'; import type { SourceLocation } from '#shared'; -import type { StateCreationRuneName } from '../../../../utils.js'; -import type { ClassAnalysis } from './visitors/shared/class-analysis.js'; +import type { ClassAnalysis } from '../shared/types.js'; export interface ClientTransformState extends TransformState { - readonly class_analysis: ClassAnalysis | null; + readonly class_analysis: ClassAnalysis<Context> | null; /** * `true` if the current lexical scope belongs to a class constructor. this allows @@ -95,11 +93,6 @@ export interface ComponentClientTransformState extends ClientTransformState { readonly module_level_snippets: VariableDeclaration[]; } -export interface StateField { - kind: StateCreationRuneName; - id: PrivateIdentifier; -} - export type Context = import('zimmerframe').Context<AST.SvelteNode, ClientTransformState>; export type Visitors = import('zimmerframe').Visitors<AST.SvelteNode, any>; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js index d045b83298f3..7d0840ab8098 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js @@ -57,7 +57,7 @@ function build_assignment(operator, left, right, context) { left.type === 'MemberExpression' && left.property.type === 'PrivateIdentifier' ) { - const private_state = context.state.class_analysis?.private_state.get(left.property.name); + const private_state = context.state.class_analysis?.get_field(left.property.name, true); if (private_state !== undefined) { let value = /** @type {Expression} */ ( @@ -65,7 +65,7 @@ function build_assignment(operator, left, right, context) { ); const needs_proxy = - private_state.kind === '$state' && + private_state?.kind === '$state' && is_non_coercive_operator(operator) && should_proxy(value, context.state.scope); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js index 534fdba44672..a9ccad03770c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js @@ -1,7 +1,6 @@ -/** @import { ClassBody, Identifier, Literal, MethodDefinition, PrivateIdentifier, PropertyDefinition } from 'estree' */ -/** @import { Context, StateField } from '../types' */ -import { regex_invalid_identifier_chars } from '../../../patterns.js'; -import { ClassAnalysis } from './shared/class-analysis.js'; +/** @import { ClassBody } from 'estree' */ +/** @import { Context } from '../types' */ +import { create_client_class_analysis } from './shared/client-class-analysis.js'; /** * @param {ClassBody} node @@ -13,46 +12,8 @@ export function ClassBody(node, context) { return; } - const class_analysis = new ClassAnalysis(); - - for (const definition of node.body) { - class_analysis.register_body_definition(definition, context.state.scope); - } - - class_analysis.finalize_property_definitions(); - - /** @type {Array<MethodDefinition | PropertyDefinition>} */ - const body = []; - - const child_state = { - ...context.state, - class_analysis - }; - - // we need to visit the constructor first so that it can add to the field maps. - const constructor_node = node.body.find( - (child) => child.type === 'MethodDefinition' && child.kind === 'constructor' - ); - const constructor = constructor_node && context.visit(constructor_node, child_state); - - // Replace parts of the class body - for (const definition of node.body) { - if (definition === constructor_node) { - body.push(/** @type {MethodDefinition} */ (constructor)); - continue; - } - - const state_field = class_analysis.build_state_field_from_body_definition(definition, context); - - if (state_field) { - body.push(...state_field); - continue; - } - - body.push(/** @type {MethodDefinition} **/ (context.visit(definition, child_state))); - } - - body.push(...class_analysis.constructor_state_fields); + const class_analysis = create_client_class_analysis(node.body); + const body = class_analysis.generate_body(context); return { ...node, body }; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/MemberExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/MemberExpression.js index 410c9ad9ab79..87a886fd3b78 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/MemberExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/MemberExpression.js @@ -9,7 +9,7 @@ import * as b from '#compiler/builders'; export function MemberExpression(node, context) { // rewrite `this.#foo` as `this.#foo.v` inside a constructor if (node.property.type === 'PrivateIdentifier') { - const field = context.state.class_analysis?.private_state.get(node.property.name); + const field = context.state.class_analysis?.get_field(node.property.name, true); if (field) { return context.state.in_constructor && (field.kind === '$state.raw' || field.kind === '$state') diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/UpdateExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/UpdateExpression.js index c1f7c390ced9..2e6f9123ddc9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/UpdateExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/UpdateExpression.js @@ -15,7 +15,7 @@ export function UpdateExpression(node, context) { argument.type === 'MemberExpression' && argument.object.type === 'ThisExpression' && argument.property.type === 'PrivateIdentifier' && - context.state.class_analysis?.private_state.has(argument.property.name) + context.state.class_analysis?.get_field(argument.property.name, true) ) { let fn = '$.update'; if (node.prefix) fn += '_pre'; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js deleted file mode 100644 index 0ce4451e8ba2..000000000000 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/class-analysis.js +++ /dev/null @@ -1,301 +0,0 @@ -/** @import { Context, StateField } from '../../types.js' */ -/** @import { AssignmentExpression, Identifier, Literal, MethodDefinition, PrivateIdentifier, PropertyDefinition, CallExpression, Expression, StaticBlock, SpreadElement } from 'estree' */ -/** @import { Scope } from '#compiler' */ -/** @import { StateCreationRuneName } from '../../../../../../utils.js' */ -import * as b from '#compiler/builders'; -import { is_state_creation_rune } from '../../../../../../utils.js'; -import { regex_invalid_identifier_chars } from '../../../../patterns.js'; -import { get_rune } from '../../../../scope.js'; -import { should_proxy } from '../../utils.js'; - -export class ClassAnalysis { - /** @type {Map<string, StateField>} */ - public_state = new Map(); - - /** @type {Map<string, StateField>} */ - private_state = new Map(); - - /** - * Any state fields discovered from {@link register_assignment} that need to be added to the class body. - * @type {Array<PropertyDefinition | MethodDefinition>} - */ - constructor_state_fields = []; - - /** @type {Map<(MethodDefinition | PropertyDefinition)["key"], string>} */ - #definition_names = new Map(); - - /** @type {Set<string>} */ - #private_ids = new Set(); - - /** - * @param {MethodDefinition | PropertyDefinition | StaticBlock} node - * @param {Scope} scope - */ - register_body_definition(node, scope) { - if ( - !( - (node.type === 'PropertyDefinition' || node.type === 'MethodDefinition') && - (node.key.type === 'Identifier' || - node.key.type === 'PrivateIdentifier' || - node.key.type === 'Literal') - ) - ) { - return; - } - - /* - * We don't know if the node is stateful yet, but we still need to register some details. - * For example: If the node is a private identifier, we could accidentally conflict with it later - * if we create a private field for public state (as would happen in this example:) - * - * ```ts - * class Foo { - * #count = 0; - * count = $state(0); // would become #count if we didn't know about the private field above - * } - */ - - const name = ClassAnalysis.#get_name(node.key, this.public_state); - if (!name) { - return; - } - - // we store the deconflicted name in the map so that we can access it later - this.#definition_names.set(node.key, name); - - const is_private = node.key.type === 'PrivateIdentifier'; - if (is_private) { - this.#private_ids.add(name); - } - - const rune = get_rune(node.value, scope); - if (!rune || !is_state_creation_rune(rune)) { - return; - } - - if (is_private) { - this.private_state.set(name, { kind: rune, id: /** @type {PrivateIdentifier} */ (node.key) }); - } else { - // We can't set the ID until we've identified all of the private state fields, - // otherwise we might conflict with them. After registering all property definitions, - // call `finalize_property_definitions` to populate the IDs. - // @ts-expect-error this is set in `finalize_property_definitions` - this.public_state.set(name, { kind: rune, id: undefined }); - } - } - - /** - * Resolves all of the registered public state fields to their final private IDs. - * Must be called after all property definitions have been registered. - */ - finalize_property_definitions() { - for (const [name, field] of this.public_state) { - field.id = this.#deconflict(name); - } - } - - /** - * Important note: It is a syntax error in JavaScript to try to assign to a private class field - * that was not declared in the class body. So there is absolutely no risk of unresolvable conflicts here. - * - * This function will modify the assignment expression passed to it if it is registered as a state field. - * @param {AssignmentExpression} node - * @param {Context} context - */ - register_assignment(node, context) { - if ( - !( - node.operator === '=' && - node.left.type === 'MemberExpression' && - node.left.object.type === 'ThisExpression' && - node.left.property.type === 'Identifier' - ) - ) { - return; - } - - const name = ClassAnalysis.#get_name(node.left.property, this.public_state); - if (!name) { - return; - } - - const rune = get_rune(node.right, context.state.scope); - if (!rune || !is_state_creation_rune(rune)) { - return; - } - - const id = this.#deconflict(name); - const field = { kind: rune, id }; - this.public_state.set(name, field); - - // We need to do two things: - // - Communicate to the class body visitor that it needs to append nodes to create a state field - // - Modify the assignment expression so that it's valid - this.constructor_state_fields.push( - ...this.build_state_field( - false, - field, - node.left.property, - // this will initialize the field without assigning to it, delegating to the constructor - null, - context - ) - ); - - // ...swap out the assignment to go directly against the private field - node.left.property = id; - // ...and swap out the assignment's value for the state field init - node.right = this.#build_init_value( - rune, - /** @type {CallExpression} */ (node.right).arguments[0], - context - ); - } - - /** - * - * @param {PropertyDefinition | MethodDefinition | StaticBlock} node - * @param {Context} context - * @returns {Array<PropertyDefinition | MethodDefinition> | null} - */ - build_state_field_from_body_definition(node, context) { - if ( - !( - node.type === 'PropertyDefinition' && - (node.key.type === 'Identifier' || - node.key.type === 'PrivateIdentifier' || - node.key.type === 'Literal') - ) - ) { - return null; - } - - const name = this.#definition_names.get(node.key); - if (!name) throw new Error('This should not be possible'); // TODO - - const is_private = node.key.type === 'PrivateIdentifier'; - const field = (is_private ? this.private_state : this.public_state).get(name); - - if (!field) { - return null; - } - - return this.build_state_field( - is_private, - field, - node.key, - // we can cast this because if we have a field for this definition it definitely is a call - // expression, otherwise it wouldn't have produced a rune earlier. - /** @type {CallExpression} */ (node.value), - context - ); - } - - /** - * @param {boolean} is_private - * @param {StateField} field - * @param {(MethodDefinition | PropertyDefinition)["key"]} original_definition_id - * @param {CallExpression | null} call_expression - * @param {Context} context - * - * @returns {Array<PropertyDefinition | MethodDefinition>} - */ - build_state_field(is_private, field, original_definition_id, call_expression, context) { - let value; - if (!call_expression) { - // if there's no call expression, this is state that's created in the constructor. - // it's guaranteed to be the very first assignment to this field, so we initialize - // the field but don't assign to it. - value = null; - } else if (call_expression.arguments.length > 0) { - value = this.#build_init_value(field.kind, call_expression.arguments[0], context); - } else { - // if no arguments, we know it's state as `$derived()` is a compile error - value = b.call('$.state'); - } - - if (is_private) { - return [b.prop_def(field.id, value)]; - } - - const member = b.member(b.this, field.id); - const val = b.id('value'); - return [ - // #foo; - b.prop_def(field.id, value), - // get foo() { return this.#foo; } - b.method('get', original_definition_id, [], [b.return(b.call('$.get', member))]), - // set foo(value) { this.#foo = value; } - b.method( - 'set', - original_definition_id, - [val], - [b.stmt(b.call('$.set', member, val, field.kind === '$state' && b.true))] - ) - ]; - } - - /** - * - * @param {StateCreationRuneName} kind - * @param {Expression | SpreadElement} arg - * @param {Context} context - */ - #build_init_value(kind, arg, context) { - const init = /** @type {Expression} **/ ( - context.visit(arg, { - ...context.state, - class_analysis: this - }) - ); - - switch (kind) { - case '$state': - return b.call( - '$.state', - should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init - ); - case '$state.raw': - return b.call('$.state', init); - case '$derived': - return b.call('$.derived', b.thunk(init)); - case '$derived.by': - return b.call('$.derived', init); - } - } - - /** - * @param {string} name - * @returns {PrivateIdentifier} - */ - #deconflict(name) { - let deconflicted = name; - while (this.#private_ids.has(deconflicted)) { - deconflicted = '_' + deconflicted; - } - - this.#private_ids.add(deconflicted); - return b.private_id(deconflicted); - } - - /** - * @param {Identifier | PrivateIdentifier | Literal} node - * @param {Map<string, unknown>} public_state - */ - static #get_name(node, public_state) { - if (node.type === 'Literal') { - let name = node.value?.toString().replace(regex_invalid_identifier_chars, '_'); - - // the above could generate conflicts because it has to generate a valid identifier - // so stuff like `0` and `1` or `state%` and `state^` will result in the same string - // so we have to de-conflict. We can only check `public_state` because private state - // can't have literal keys - while (name && public_state.has(name)) { - name = '_' + name; - } - return name; - } else { - return node.name; - } - } -} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js new file mode 100644 index 000000000000..fad75b142d31 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js @@ -0,0 +1,85 @@ +/** @import { Context } from '../../types.js' */ +/** @import { MethodDefinition, PropertyDefinition, Expression, StaticBlock, SpreadElement } from 'estree' */ +/** @import { StateCreationRuneName } from '../../../../../../utils.js' */ +/** @import { AssignmentBuilder, ClassAnalysis, StateFieldBuilder } from '../../../shared/types.js' */ +import * as b from '#compiler/builders'; +import { create_class_analysis } from '../../../shared/class_analysis.js'; +import { should_proxy } from '../../utils.js'; + +/** + * @param {Array<MethodDefinition | PropertyDefinition | StaticBlock>} body + * @returns {ClassAnalysis<Context>} + */ +export function create_client_class_analysis(body) { + /** @type {StateFieldBuilder<Context>} */ + function build_state_field({ is_private, field, node, context }) { + let original_id = node.type === 'AssignmentExpression' ? node.left : node.key; + let value; + if (node.type === 'AssignmentExpression') { + // if there's no call expression, this is state that's created in the constructor. + // it's guaranteed to be the very first assignment to this field, so we initialize + // the field but don't assign to it. + value = null; + } else if (node.value.arguments.length > 0) { + value = build_init_value(field.kind, node.value.arguments[0], context); + } else { + // if no arguments, we know it's state as `$derived()` is a compile error + value = b.call('$.state'); + } + + if (is_private) { + return [b.prop_def(field.id, value)]; + } + + const member = b.member(b.this, field.id); + const val = b.id('value'); + + return [ + // #foo; + b.prop_def(field.id, value), + // get foo() { return this.#foo; } + b.method('get', original_id, [], [b.return(b.call('$.get', member))]), + // set foo(value) { this.#foo = value; } + b.method( + 'set', + original_id, + [val], + [b.stmt(b.call('$.set', member, val, field.kind === '$state' && b.true))] + ) + ]; + } + + /** @type {AssignmentBuilder<Context>} */ + function build_assignment({ field, node, context }) { + // ...swap out the assignment to go directly against the private field + node.left.property = field.id; + // ...and swap out the assignment's value for the state field init + node.right = build_init_value(field.kind, node.right.arguments[0], context); + } + + return create_class_analysis(body, build_state_field, build_assignment); +} + +/** + * + * @param {StateCreationRuneName} kind + * @param {Expression | SpreadElement} arg + * @param {Context} context + */ +function build_init_value(kind, arg, context) { + const init = /** @type {Expression} **/ (context.visit(arg, context.state)); + + switch (kind) { + case '$state': + return b.call( + '$.state', + should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init + ); + case '$state.raw': + return b.call('$.state', init); + case '$derived': + return b.call('$.derived', b.thunk(init)); + case '$derived.by': + return b.call('$.derived', init); + } +} diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index e7896991d98f..e159e2ff4e7a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -99,7 +99,7 @@ export function server_component(analysis, options) { template: /** @type {any} */ (null), namespace: options.namespace, preserve_whitespace: options.preserveWhitespace, - private_derived: new Map(), + class_analysis: null, skip_hydration_boundaries: false }; @@ -395,7 +395,7 @@ export function server_module(analysis, options) { // to be present for `javascript_visitors_legacy` and so is included in module // transform state as well as component transform state legacy_reactive_statements: new Map(), - private_derived: new Map() + class_analysis: null }; const module = /** @type {Program} */ ( diff --git a/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts index 971271642cfc..0f1b2647a288 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/server/types.d.ts @@ -2,12 +2,12 @@ import type { Expression, Statement, ModuleDeclaration, LabeledStatement } from import type { AST, Namespace, ValidatedCompileOptions } from '#compiler'; import type { TransformState } from '../types.js'; import type { ComponentAnalysis } from '../../types.js'; -import type { StateField } from '../client/types.js'; +import type { ClassAnalysis } from '../shared/types.js'; export interface ServerTransformState extends TransformState { /** The $: calls, which will be ordered in the end */ readonly legacy_reactive_statements: Map<LabeledStatement, Statement>; - readonly private_derived: Map<string, StateField>; + readonly class_analysis: ClassAnalysis<Context> | null; } export interface ComponentServerTransformState extends ServerTransformState { diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ClassBody.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ClassBody.js index ff4a1206ddaa..8d67b3836155 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/ClassBody.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/ClassBody.js @@ -1,9 +1,6 @@ -/** @import { ClassBody, Expression, MethodDefinition, PropertyDefinition } from 'estree' */ +/** @import { ClassBody } from 'estree' */ /** @import { Context } from '../types.js' */ -/** @import { StateField } from '../../client/types.js' */ -import { dev } from '../../../../state.js'; -import * as b from '#compiler/builders'; -import { get_rune } from '../../../scope.js'; +import { create_server_class_analysis } from './shared/server-class-analysis.js'; /** * @param {ClassBody} node @@ -15,107 +12,8 @@ export function ClassBody(node, context) { return; } - /** @type {Map<string, StateField>} */ - const public_derived = new Map(); - - /** @type {Map<string, StateField>} */ - const private_derived = new Map(); - - /** @type {string[]} */ - const private_ids = []; - - for (const definition of node.body) { - if ( - definition.type === 'PropertyDefinition' && - (definition.key.type === 'Identifier' || definition.key.type === 'PrivateIdentifier') - ) { - const { type, name } = definition.key; - - const is_private = type === 'PrivateIdentifier'; - if (is_private) private_ids.push(name); - - if (definition.value?.type === 'CallExpression') { - const rune = get_rune(definition.value, context.state.scope); - if (rune === '$derived' || rune === '$derived.by') { - /** @type {StateField} */ - const field = { - kind: rune, - // @ts-expect-error this is set in the next pass - id: is_private ? definition.key : null - }; - - if (is_private) { - private_derived.set(name, field); - } else { - public_derived.set(name, field); - } - } - } - } - } - - // each `foo = $derived()` needs a backing `#foo` field - for (const [name, field] of public_derived) { - let deconflicted = name; - while (private_ids.includes(deconflicted)) { - deconflicted = '_' + deconflicted; - } - - private_ids.push(deconflicted); - field.id = b.private_id(deconflicted); - } - - /** @type {Array<MethodDefinition | PropertyDefinition>} */ - const body = []; - - const child_state = { ...context.state, private_derived }; - - // Replace parts of the class body - for (const definition of node.body) { - if ( - definition.type === 'PropertyDefinition' && - (definition.key.type === 'Identifier' || definition.key.type === 'PrivateIdentifier') - ) { - const name = definition.key.name; - - const is_private = definition.key.type === 'PrivateIdentifier'; - const field = (is_private ? private_derived : public_derived).get(name); - - if (definition.value?.type === 'CallExpression' && field !== undefined) { - const init = /** @type {Expression} **/ ( - context.visit(definition.value.arguments[0], child_state) - ); - const value = - field.kind === '$derived.by' ? b.call('$.once', init) : b.call('$.once', b.thunk(init)); - - if (is_private) { - body.push(b.prop_def(field.id, value)); - } else { - // #foo; - const member = b.member(b.this, field.id); - body.push(b.prop_def(field.id, value)); - - // get foo() { return this.#foo; } - body.push(b.method('get', definition.key, [], [b.return(b.call(member))])); - - if (dev && (field.kind === '$derived' || field.kind === '$derived.by')) { - body.push( - b.method( - 'set', - definition.key, - [b.id('_')], - [b.throw_error(`Cannot update a derived property ('${name}')`)] - ) - ); - } - } - - continue; - } - } - - body.push(/** @type {MethodDefinition} **/ (context.visit(definition, child_state))); - } + const class_analysis = create_server_class_analysis(node.body); + const body = class_analysis.generate_body(context); return { ...node, body }; } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/MemberExpression.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/MemberExpression.js index 73631395e66e..2f72327191d0 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/MemberExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/MemberExpression.js @@ -12,7 +12,10 @@ export function MemberExpression(node, context) { node.object.type === 'ThisExpression' && node.property.type === 'PrivateIdentifier' ) { - const field = context.state.private_derived.get(node.property.name); + const field = context.state.class_analysis?.get_field(node.property.name, true, [ + '$derived', + '$derived.by' + ]); if (field) { return b.call(node); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js new file mode 100644 index 000000000000..a94972ed93d9 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js @@ -0,0 +1,71 @@ +/** @import { Expression, MethodDefinition, StaticBlock, PropertyDefinition } from 'estree' */ +/** @import { Context } from '../../types.js' */ +/** @import { AssignmentBuilder, StateFieldBuilder } from '../../../shared/types.js' */ +/** @import { ClassAnalysis } from '../../../shared/types.js' */ + +import * as b from '#compiler/builders'; +import { dev } from '../../../../../state.js'; +import { create_class_analysis } from '../../../shared/class_analysis.js'; + +/** + * @param {Array<MethodDefinition | PropertyDefinition | StaticBlock>} body + * @returns {ClassAnalysis<Context>} + */ +export function create_server_class_analysis(body) { + /** @type {StateFieldBuilder<Context>} */ + function build_state_field({ is_private, field, node, context }) { + let original_id = node.type === 'AssignmentExpression' ? node.left : node.key; + let value; + if (node.type === 'AssignmentExpression') { + // This means it's a state assignment in the constructor (this.foo = $state('bar')) + // which means the state field needs to have no default value so that the initial + // value can be assigned in the constructor. + value = null; + } else if (field.kind !== '$derived' && field.kind !== '$derived.by') { + return [/** @type {PropertyDefinition} */ (context.visit(node, context.state))]; + } else { + const init = /** @type {Expression} **/ ( + context.visit(node.value.arguments[0], context.state) + ); + value = + field.kind === '$derived.by' ? b.call('$.once', init) : b.call('$.once', b.thunk(init)); + } + + if (is_private) { + return [b.prop_def(field.id, value)]; + } + // #foo; + const member = b.member(b.this, field.id); + + const defs = [ + // #foo; + b.prop_def(field.id, value), + // get foo() { return this.#foo; } + b.method('get', original_id, [], [b.return(b.call(member))]) + ]; + + // TODO make this work on server + if (dev) { + defs.push( + b.method( + 'set', + original_id, + [b.id('_')], + [b.throw_error(`Cannot update a derived property ('${name}')`)] + ) + ); + } + + return defs; + } + + /** @type {AssignmentBuilder<Context>} */ + function build_assignment({ field, node, context }) { + node.left.property = field.id; + const init = /** @type {Expression} **/ (context.visit(node.right.arguments[0], context.state)); + node.right = + field.kind === '$derived.by' ? b.call('$.once', init) : b.call('$.once', b.thunk(init)); + } + + return create_class_analysis(body, build_state_field, build_assignment); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js b/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js new file mode 100644 index 000000000000..5eb001d438b8 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js @@ -0,0 +1,334 @@ +/** @import { AssignmentExpression, Identifier, Literal, MethodDefinition, PrivateIdentifier, PropertyDefinition, StaticBlock } from 'estree' */ +/** @import { StateField } from '../types.js' */ +/** @import { Context as ClientContext } from '../client/types.js' */ +/** @import { Context as ServerContext } from '../server/types.js' */ +/** @import { StateCreationRuneName } from '../../../../utils.js' */ +/** @import { AssignmentBuilder, ClassAnalysis, StateFieldBuilder, StatefulAssignment, StatefulPropertyDefinition } from './types.js' */ +/** @import { Scope } from '../../scope.js' */ +import * as b from '#compiler/builders'; +import { once } from '../../../../internal/server/index.js'; +import { is_state_creation_rune, STATE_CREATION_RUNES } from '../../../../utils.js'; +import { regex_invalid_identifier_chars } from '../../patterns.js'; +import { get_rune } from '../../scope.js'; + +/** + * @template {ClientContext | ServerContext} TContext + * @param {Array<PropertyDefinition | MethodDefinition | StaticBlock>} body + * @param {StateFieldBuilder<TContext>} build_state_field + * @param {AssignmentBuilder<TContext>} build_assignment + * @returns {ClassAnalysis<TContext>} + */ +export function create_class_analysis(body, build_state_field, build_assignment) { + /** @type {Map<string, StateField>} */ + const public_fields = new Map(); + + /** @type {Map<string, StateField>} */ + const private_fields = new Map(); + + /** @type {Array<PropertyDefinition | MethodDefinition>} */ + const new_body = []; + + /** + * Private identifiers in use by this analysis. + * Factoid: Unlike public class fields, private fields _must_ be declared in the class body + * before use. So the following is actually a JavaScript syntax error, which means we can + * be 100% certain we know all private fields after parsing the class body: + * + * ```ts + * class Example { + * constructor() { + * this.public = 'foo'; // not a problem! + * this.#private = 'bar'; // JavaScript parser error + * } + * } + * ``` + * @type {Set<string>} + */ + const private_ids = new Set(); + + /** + * A registry of functions to call to complete body modifications. + * Replacements may insert more than one node to the body. The original + * body should not be modified -- instead, replacers should push new + * nodes to new_body. + * + * @type {Array<() => void>} + */ + const replacers = []; + + /** + * @param {string} name + * @param {boolean} is_private + * @param {ReadonlyArray<StateCreationRuneName>} [kinds] + */ + function get_field(name, is_private, kinds = STATE_CREATION_RUNES) { + const value = (is_private ? private_fields : public_fields).get(name); + if (value && kinds.includes(value.kind)) { + return value; + } + } + + /** + * @param {TContext} context + * @returns {TContext} + */ + function create_child_context(context) { + return { + ...context, + state: { + ...context.state, + class_analysis + } + }; + } + + /** + * @param {TContext} context + */ + function generate_body(context) { + const child_context = create_child_context(context); + for (const node of body) { + const was_registered = register_body_definition(node, child_context); + if (!was_registered) { + new_body.push( + /** @type {PropertyDefinition | MethodDefinition} */ ( + // @ts-expect-error generics silliness + child_context.visit(node, child_context.state) + ) + ); + } + } + + for (const replacer of replacers) { + replacer(); + } + + return new_body; + } + + /** + * Important note: It is a syntax error in JavaScript to try to assign to a private class field + * that was not declared in the class body. So there is absolutely no risk of unresolvable conflicts here. + * + * This function will modify the assignment expression passed to it if it is registered as a state field. + * @param {AssignmentExpression} node + * @param {TContext} context + */ + function register_assignment(node, context) { + const child_context = create_child_context(context); + if ( + !( + node.operator === '=' && + node.left.type === 'MemberExpression' && + node.left.object.type === 'ThisExpression' && + node.left.property.type === 'Identifier' + ) + ) { + return; + } + + const name = get_name(node.left.property); + if (!name) { + return; + } + + const parsed = parse_stateful_assignment(node, child_context.state.scope); + if (!parsed) { + return; + } + const { stateful_assignment, rune } = parsed; + + const id = deconflict(name); + const field = { kind: rune, id }; + public_fields.set(name, field); + + const replacer = () => { + const nodes = build_state_field({ + is_private: false, + field, + node: stateful_assignment, + context: child_context + }); + if (!nodes) { + return; + } + new_body.push(...nodes); + }; + replacers.push(replacer); + + build_assignment({ + node: stateful_assignment, + field, + context: child_context + }); + } + + /** + * @param {PropertyDefinition | MethodDefinition | StaticBlock} node + * @param {TContext} child_context + * @returns {boolean} if this node is stateful and was registered + */ + function register_body_definition(node, child_context) { + if (node.type === 'MethodDefinition' && node.kind === 'constructor') { + // life is easier to reason about if we've visited the constructor + // and registered its public state field before we start building + // anything else + replacers.unshift(() => { + new_body.push( + /** @type {MethodDefinition} */ ( + // @ts-expect-error generics silliness + child_context.visit(node, child_context.state) + ) + ); + }); + return true; + } + + if ( + !( + (node.type === 'PropertyDefinition' || node.type === 'MethodDefinition') && + (node.key.type === 'Identifier' || + node.key.type === 'PrivateIdentifier' || + node.key.type === 'Literal') + ) + ) { + return false; + } + + /* + * We don't know if the node is stateful yet, but we still need to register some details. + * For example: If the node is a private identifier, we could accidentally conflict with it later + * if we create a private field for public state (as would happen in this example:) + * + * ```ts + * class Foo { + * #count = 0; + * count = $state(0); // would become #count if we didn't know about the private field above + * } + */ + + const name = get_name(node.key); + if (!name) { + return false; + } + + const is_private = node.key.type === 'PrivateIdentifier'; + if (is_private) { + private_ids.add(name); + } + + const parsed = prop_def_is_stateful(node, child_context.state.scope); + if (!parsed) { + return false; + } + const { stateful_prop_def, rune } = parsed; + + let field; + if (is_private) { + field = { + kind: rune, + id: /** @type {PrivateIdentifier} */ (stateful_prop_def.key) + }; + private_fields.set(name, field); + } else { + // We can't set the ID until we've identified all of the private state fields, + // otherwise we might conflict with them. After registering all property definitions, + // call `finalize_property_definitions` to populate the IDs. So long as we don't + // access the ID before the end of this loop, we're fine! + const id = once(() => deconflict(name)); + field = { + kind: rune, + get id() { + return id(); + } + }; + public_fields.set(name, field); + } + + const replacer = () => { + const nodes = build_state_field({ + is_private, + field, + node: stateful_prop_def, + context: child_context + }); + if (!nodes) { + return; + } + new_body.push(...nodes); + }; + replacers.push(replacer); + + return true; + } + + /** + * @param {string} name + * @returns {PrivateIdentifier} + */ + function deconflict(name) { + let deconflicted = name; + while (private_ids.has(deconflicted)) { + deconflicted = '_' + deconflicted; + } + + private_ids.add(deconflicted); + return b.private_id(deconflicted); + } + + /** + * @param {Identifier | PrivateIdentifier | Literal} node + */ + function get_name(node) { + if (node.type === 'Literal') { + let name = node.value?.toString().replace(regex_invalid_identifier_chars, '_'); + + // the above could generate conflicts because it has to generate a valid identifier + // so stuff like `0` and `1` or `state%` and `state^` will result in the same string + // so we have to de-conflict. We can only check `public_fields` because private state + // can't have literal keys + while (name && public_fields.has(name)) { + name = '_' + name; + } + return name; + } else { + return node.name; + } + } + + const class_analysis = { + get_field, + generate_body, + register_assignment + }; + + return class_analysis; +} + +/** + * `get_rune` is really annoying because it really guarantees this already + * we just need this to tell the type system about it + * @param {AssignmentExpression} node + * @param {Scope} scope + * @returns {{ stateful_assignment: StatefulAssignment, rune: StateCreationRuneName } | null} + */ +function parse_stateful_assignment(node, scope) { + const rune = get_rune(node.right, scope); + if (!rune || !is_state_creation_rune(rune)) { + return null; + } + return { stateful_assignment: /** @type {StatefulAssignment} */ (node), rune }; +} + +/** + * @param {PropertyDefinition | MethodDefinition} node + * @param {Scope} scope + * @returns {{ stateful_prop_def: StatefulPropertyDefinition, rune: StateCreationRuneName } | null} + */ +function prop_def_is_stateful(node, scope) { + const rune = get_rune(node.value, scope); + if (!rune || !is_state_creation_rune(rune)) { + return null; + } + return { stateful_prop_def: /** @type {StatefulPropertyDefinition} */ (node), rune }; +} diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts new file mode 100644 index 000000000000..45bd3b5a00ae --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts @@ -0,0 +1,63 @@ +import type { AssignmentExpression, CallExpression, Identifier, MemberExpression, PropertyDefinition, MethodDefinition, PrivateIdentifier, ThisExpression } from 'estree'; +import type { StateField } from '../types'; +import type { Context as ServerContext } from '../server/types'; +import type { Context as ClientContext } from '../client/types'; +import type { StateCreationRuneName } from '../../../../utils'; + +export type StatefulAssignment = AssignmentExpression & { + left: MemberExpression & { + object: ThisExpression; + property: Identifier | PrivateIdentifier + }; + right: CallExpression; +}; + +export type StatefulPropertyDefinition = PropertyDefinition & { + key: Identifier | PrivateIdentifier; + value: CallExpression; +}; + +export type StateFieldBuilderParams<TContext extends ServerContext | ClientContext> = { + is_private: boolean; + field: StateField; + node: StatefulAssignment | StatefulPropertyDefinition; + context: TContext; +}; + +export type StateFieldBuilder<TContext extends ServerContext | ClientContext> = ( + params: StateFieldBuilderParams<TContext> +) => Array<PropertyDefinition | MethodDefinition>; + +export type AssignmentBuilderParams<TContext extends ServerContext | ClientContext> = { + node: StatefulAssignment; + field: StateField; + context: TContext; +}; + +export type AssignmentBuilder<TContext extends ServerContext | ClientContext> = (params: AssignmentBuilderParams<TContext>) => void; + +export type ClassAnalysis<TContext extends ServerContext | ClientContext> = { + /** + * @param name - The name of the field. + * @param is_private - Whether the field is private (whether its name starts with '#'). + * @param kinds - What kinds of state creation runes you're looking for, eg. only '$derived.by'. + * @returns The field if it exists and matches the given criteria, or null. + */ + get_field: (name: string, is_private: boolean, kinds?: Array<StateCreationRuneName>) => StateField | undefined; + + /** + * Given the body of a class, generate a new body with stateful fields. + * This assumes that {@link register_assignment} is registered to be called + * for all `AssignmentExpression` nodes in the class body. + * @param context - The context associated with the `ClassBody`. + * @returns The new body. + */ + generate_body: (context: TContext) => Array<PropertyDefinition | MethodDefinition>; + + /** + * Register an assignment expression. This checks to see if the assignment is creating + * a state field on the class. If it is, it registers that state field and modifies the + * assignment expression. + */ + register_assignment: (node: AssignmentExpression, context: TContext) => void; +} diff --git a/packages/svelte/src/compiler/phases/3-transform/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/types.d.ts index 5d860207dde6..8999f096142e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/types.d.ts @@ -1,6 +1,8 @@ import type { Scope } from '../scope.js'; import type { AST, ValidatedModuleCompileOptions } from '#compiler'; import type { Analysis } from '../types.js'; +import type { StateCreationRuneName } from '../../../utils.js'; +import type { PrivateIdentifier } from 'estree'; export interface TransformState { readonly analysis: Analysis; @@ -8,3 +10,8 @@ export interface TransformState { readonly scope: Scope; readonly scopes: Map<AST.SvelteNode, Scope>; } + +export interface StateField { + kind: StateCreationRuneName; + id: PrivateIdentifier; +} \ No newline at end of file diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index 2758b9fcbc19..654b4bb2d0c1 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -428,15 +428,18 @@ export function is_mathml(name) { return MATHML_ELEMENTS.includes(name); } -const RUNES = /** @type {const} */ ([ +export const STATE_CREATION_RUNES = /** @type {const} */ ([ '$state', '$state.raw', + '$derived', + '$derived.by' +]); +const RUNES = /** @type {const} */ ([ + ...STATE_CREATION_RUNES, '$state.snapshot', '$props', '$props.id', '$bindable', - '$derived', - '$derived.by', '$effect', '$effect.pre', '$effect.tracking', @@ -457,7 +460,7 @@ export function is_rune(name) { return RUNES.includes(/** @type {RUNES[number]} */ (name)); } -/** @typedef {'$state' | '$state.raw' | '$derived' | '$derived.by'} StateCreationRuneName */ +/** @typedef {STATE_CREATION_RUNES[number]} StateCreationRuneName */ /** * @param {string} name diff --git a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js index 21339741761f..620623ebfd7d 100644 --- a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js @@ -5,6 +5,11 @@ export default function Class_state_field_constructor_assignment($$anchor, $$pro $.push($$props, true); class Foo { + constructor() { + this.a = 1; + $.set(this.#b, 2); + } + #a = $.state(); get a() { @@ -16,11 +21,6 @@ export default function Class_state_field_constructor_assignment($$anchor, $$pro } #b = $.state(); - - constructor() { - this.a = 1; - $.set(this.#b, 2); - } } $.pop(); diff --git a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/server/index.svelte.js index 2a115a498373..a27bcb8623a7 100644 --- a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/server/index.svelte.js @@ -4,13 +4,13 @@ export default function Class_state_field_constructor_assignment($$payload, $$pr $.push(); class Foo { - a; - #b; - constructor() { this.a = 1; this.#b = 2; } + + a; + #b; } $.pop(); From 92940ffbfdb46dd7a1edb25fd361bc3a3c2092f8 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" <sejohnson@torchcloudconsulting.com> Date: Tue, 29 Apr 2025 12:15:08 -0400 Subject: [PATCH 07/10] feat: It works I think? --- .../client/visitors/AssignmentExpression.js | 6 +- .../visitors/shared/client-class-analysis.js | 20 +++++-- .../server/visitors/AssignmentExpression.js | 5 ++ .../visitors/shared/server-class-analysis.js | 51 ++++++++++++++--- .../3-transform/shared/class_analysis.js | 56 ++++++++++++++----- .../phases/3-transform/shared/types.d.ts | 4 +- .../_config.js | 0 .../main.svelte | 0 .../_config.js | 13 +++++ .../main.svelte | 13 +++++ .../_config.js | 45 +++++++++++++++ .../main.svelte | 37 ++++++++++++ .../_config.js | 20 +++++++ .../main.svelte | 22 ++++++++ .../class-state-constructor/_config.js | 20 +++++++ .../class-state-constructor/main.svelte | 18 ++++++ .../class-state-constructor-1/errors.json | 14 +++++ .../class-state-constructor-1/input.svelte.js | 7 +++ .../class-state-constructor-2/errors.json | 14 +++++ .../class-state-constructor-2/input.svelte.js | 7 +++ .../class-state-constructor-3/errors.json | 14 +++++ .../class-state-constructor-3/input.svelte.js | 7 +++ .../class-state-constructor-4/errors.json | 14 +++++ .../class-state-constructor-4/input.svelte.js | 7 +++ 24 files changed, 381 insertions(+), 33 deletions(-) rename packages/svelte/tests/runtime-runes/samples/{class-state-constructor-closure-private => class-state-constructor-closure-private-1}/_config.js (100%) rename packages/svelte/tests/runtime-runes/samples/{class-state-constructor-closure-private => class-state-constructor-closure-private-1}/main.svelte (100%) create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor/main.svelte create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-1/errors.json create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-1/input.svelte.js create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-2/errors.json create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-2/input.svelte.js create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-3/errors.json create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-3/input.svelte.js create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-4/errors.json create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-4/input.svelte.js diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js index 7d0840ab8098..ff8d2b5ce12c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js @@ -17,7 +17,11 @@ import { validate_mutation } from './shared/utils.js'; * @param {Context} context */ export function AssignmentExpression(node, context) { - context.state.class_analysis?.register_assignment(node, context); + const stripped_node = context.state.class_analysis?.register_assignment(node, context); + if (stripped_node) { + return stripped_node; + } + const expression = /** @type {Expression} */ ( visit_assignment_expression(node, context, build_assignment) ?? context.next() ); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js index fad75b142d31..70dd3646765b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js @@ -13,7 +13,7 @@ import { should_proxy } from '../../utils.js'; export function create_client_class_analysis(body) { /** @type {StateFieldBuilder<Context>} */ function build_state_field({ is_private, field, node, context }) { - let original_id = node.type === 'AssignmentExpression' ? node.left : node.key; + let original_id = node.type === 'AssignmentExpression' ? node.left.property : node.key; let value; if (node.type === 'AssignmentExpression') { // if there's no call expression, this is state that's created in the constructor. @@ -51,10 +51,16 @@ export function create_client_class_analysis(body) { /** @type {AssignmentBuilder<Context>} */ function build_assignment({ field, node, context }) { - // ...swap out the assignment to go directly against the private field - node.left.property = field.id; - // ...and swap out the assignment's value for the state field init - node.right = build_init_value(field.kind, node.right.arguments[0], context); + return { + ...node, + left: { + ...node.left, + // ...swap out the assignment to go directly against the private field + property: field.id + }, + // ...and swap out the assignment's value for the state field init + right: build_init_value(field.kind, node.right.arguments[0], context) + }; } return create_class_analysis(body, build_state_field, build_assignment); @@ -67,7 +73,9 @@ export function create_client_class_analysis(body) { * @param {Context} context */ function build_init_value(kind, arg, context) { - const init = /** @type {Expression} **/ (context.visit(arg, context.state)); + const init = /** @type {Expression} **/ ( + context.visit(arg, { ...context.state, in_constructor: false }) + ); switch (kind) { case '$state': diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AssignmentExpression.js index 071a12f9bcdd..9977c979db3f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AssignmentExpression.js @@ -10,6 +10,11 @@ import { visit_assignment_expression } from '../../shared/assignments.js'; * @param {Context} context */ export function AssignmentExpression(node, context) { + const stripped_node = context.state.class_analysis?.register_assignment(node, context); + if (stripped_node) { + return stripped_node; + } + return visit_assignment_expression(node, context, build_assignment) ?? context.next(); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js index a94972ed93d9..44a641508766 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js @@ -1,7 +1,8 @@ -/** @import { Expression, MethodDefinition, StaticBlock, PropertyDefinition } from 'estree' */ +/** @import { Expression, MethodDefinition, StaticBlock, PropertyDefinition, SpreadElement } from 'estree' */ /** @import { Context } from '../../types.js' */ /** @import { AssignmentBuilder, StateFieldBuilder } from '../../../shared/types.js' */ /** @import { ClassAnalysis } from '../../../shared/types.js' */ +/** @import { StateCreationRuneName } from '../../../../../../utils.js' */ import * as b from '#compiler/builders'; import { dev } from '../../../../../state.js'; @@ -14,7 +15,7 @@ import { create_class_analysis } from '../../../shared/class_analysis.js'; export function create_server_class_analysis(body) { /** @type {StateFieldBuilder<Context>} */ function build_state_field({ is_private, field, node, context }) { - let original_id = node.type === 'AssignmentExpression' ? node.left : node.key; + let original_id = node.type === 'AssignmentExpression' ? node.left.property : node.key; let value; if (node.type === 'AssignmentExpression') { // This means it's a state assignment in the constructor (this.foo = $state('bar')) @@ -37,13 +38,19 @@ export function create_server_class_analysis(body) { // #foo; const member = b.member(b.this, field.id); + /** @type {Array<MethodDefinition | PropertyDefinition>} */ const defs = [ // #foo; - b.prop_def(field.id, value), - // get foo() { return this.#foo; } - b.method('get', original_id, [], [b.return(b.call(member))]) + b.prop_def(field.id, value) ]; + // get foo() { return this.#foo; } + if (field.kind === '$state' || field.kind === '$state.raw') { + defs.push(b.method('get', original_id, [], [b.return(member)])); + } else { + defs.push(b.method('get', original_id, [], [b.return(b.call(member))])); + } + // TODO make this work on server if (dev) { defs.push( @@ -61,11 +68,37 @@ export function create_server_class_analysis(body) { /** @type {AssignmentBuilder<Context>} */ function build_assignment({ field, node, context }) { - node.left.property = field.id; - const init = /** @type {Expression} **/ (context.visit(node.right.arguments[0], context.state)); - node.right = - field.kind === '$derived.by' ? b.call('$.once', init) : b.call('$.once', b.thunk(init)); + return { + ...node, + left: { + ...node.left, + // ...swap out the assignment to go directly against the private field + property: field.id + }, + // ...and swap out the assignment's value for the state field init + right: build_init_value(field.kind, node.right.arguments[0], context) + }; } return create_class_analysis(body, build_state_field, build_assignment); } + +/** + * + * @param {StateCreationRuneName} kind + * @param {Expression | SpreadElement} arg + * @param {Context} context + */ +function build_init_value(kind, arg, context) { + const init = /** @type {Expression} **/ (context.visit(arg, context.state)); + + switch (kind) { + case '$state': + case '$state.raw': + return init; + case '$derived': + return b.call('$.once', b.thunk(init)); + case '$derived.by': + return b.call('$.once', init); + } +} diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js b/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js index 5eb001d438b8..e482f10d49dc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js +++ b/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js @@ -107,12 +107,9 @@ export function create_class_analysis(body, build_state_field, build_assignment) } /** - * Important note: It is a syntax error in JavaScript to try to assign to a private class field - * that was not declared in the class body. So there is absolutely no risk of unresolvable conflicts here. - * - * This function will modify the assignment expression passed to it if it is registered as a state field. * @param {AssignmentExpression} node * @param {TContext} context + * @returns {AssignmentExpression | null} The node, if `register_assignment` handled its transformation. */ function register_assignment(node, context) { const child_context = create_child_context(context); @@ -121,30 +118,46 @@ export function create_class_analysis(body, build_state_field, build_assignment) node.operator === '=' && node.left.type === 'MemberExpression' && node.left.object.type === 'ThisExpression' && - node.left.property.type === 'Identifier' + (node.left.property.type === 'Identifier' || + node.left.property.type === 'PrivateIdentifier') ) ) { - return; + return null; } const name = get_name(node.left.property); if (!name) { - return; + return null; } const parsed = parse_stateful_assignment(node, child_context.state.scope); if (!parsed) { - return; + return null; } const { stateful_assignment, rune } = parsed; - const id = deconflict(name); - const field = { kind: rune, id }; - public_fields.set(name, field); + const is_private = stateful_assignment.left.property.type === 'PrivateIdentifier'; + + let field; + if (is_private) { + field = { + kind: rune, + id: /** @type {PrivateIdentifier} */ (stateful_assignment.left.property) + }; + private_fields.set(name, field); + } else { + field = { + kind: rune, + // it's safe to do this upfront now because we're guaranteed to already know about all private + // identifiers (they had to have been declared at the class root, before we visited the constructor) + id: deconflict(name) + }; + public_fields.set(name, field); + } const replacer = () => { const nodes = build_state_field({ - is_private: false, + is_private, field, node: stateful_assignment, context: child_context @@ -156,9 +169,9 @@ export function create_class_analysis(body, build_state_field, build_assignment) }; replacers.push(replacer); - build_assignment({ - node: stateful_assignment, + return build_assignment({ field, + node: stateful_assignment, context: child_context }); } @@ -219,7 +232,20 @@ export function create_class_analysis(body, build_state_field, build_assignment) const parsed = prop_def_is_stateful(node, child_context.state.scope); if (!parsed) { - return false; + // this isn't a stateful field definition, but if could become one in the constructor -- so we register + // it, but conditionally -- so that if it's added as a field in the constructor (which causes us to create) + // a field definition for it), we don't end up with a duplicate definition (this one, plus the one we create) + replacers.push(() => { + if (!get_field(name, is_private)) { + new_body.push( + /** @type {PropertyDefinition | MethodDefinition} */ ( + // @ts-expect-error generics silliness + child_context.visit(node, child_context.state) + ) + ); + } + }); + return true; } const { stateful_prop_def, rune } = parsed; diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts index 45bd3b5a00ae..96fe27ca5dcc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts @@ -34,7 +34,7 @@ export type AssignmentBuilderParams<TContext extends ServerContext | ClientConte context: TContext; }; -export type AssignmentBuilder<TContext extends ServerContext | ClientContext> = (params: AssignmentBuilderParams<TContext>) => void; +export type AssignmentBuilder<TContext extends ServerContext | ClientContext> = (params: AssignmentBuilderParams<TContext>) => AssignmentExpression; export type ClassAnalysis<TContext extends ServerContext | ClientContext> = { /** @@ -59,5 +59,5 @@ export type ClassAnalysis<TContext extends ServerContext | ClientContext> = { * a state field on the class. If it is, it registers that state field and modifies the * assignment expression. */ - register_assignment: (node: AssignmentExpression, context: TContext) => void; + register_assignment: (node: AssignmentExpression, context: TContext) => AssignmentExpression | null; } diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-1/_config.js similarity index 100% rename from packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private/_config.js rename to packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-1/_config.js diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-1/main.svelte similarity index 100% rename from packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private/main.svelte rename to packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-1/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-2/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-2/_config.js new file mode 100644 index 000000000000..dd847ce2f2a6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-2/_config.js @@ -0,0 +1,13 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `<button>10</button>`, + ssrHtml: `<button>0</button>`, + + async test({ assert, target }) { + flushSync(); + + assert.htmlEqual(target.innerHTML, `<button>10</button>`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-2/main.svelte new file mode 100644 index 000000000000..3d8ea414187e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-closure-private-2/main.svelte @@ -0,0 +1,13 @@ +<script> + class Counter { + constructor() { + this.count = $state(0); + $effect(() => { + this.count = 10; + }); + } + } + const counter = new Counter(); +</script> + +<button on:click={() => counter.count++}>{counter.count}</button> diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/_config.js new file mode 100644 index 000000000000..4cf1aea213dc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/_config.js @@ -0,0 +1,45 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + // The component context class instance gets shared between tests, strangely, causing hydration to fail? + mode: ['client', 'server'], + + async test({ assert, target, logs }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1]); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1, 2]); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1, 2, 3]); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(logs, [ + 0, + 'class trigger false', + 'local trigger false', + 1, + 2, + 3, + 4, + 'class trigger true', + 'local trigger true' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/main.svelte new file mode 100644 index 000000000000..03687d01bb3d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-derived-unowned/main.svelte @@ -0,0 +1,37 @@ +<script module> + class SomeLogic { + trigger() { + this.someValue++; + } + + constructor() { + this.someValue = $state(0); + this.isAboveThree = $derived(this.someValue > 3); + } + } + + const someLogic = new SomeLogic(); +</script> + +<script> + function increment() { + someLogic.trigger(); + } + + let localDerived = $derived(someLogic.someValue > 3); + + $effect(() => { + console.log(someLogic.someValue); + }); + $effect(() => { + console.log('class trigger ' + someLogic.isAboveThree) + }); + $effect(() => { + console.log('local trigger ' + localDerived) + }); + +</script> + +<button on:click={increment}> + clicks: {someLogic.someValue} +</button> diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/_config.js new file mode 100644 index 000000000000..32cca6c69375 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/_config.js @@ -0,0 +1,20 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `<button>10: 20</button>`, + + test({ assert, target }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, `<button>11: 22</button>`); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, `<button>12: 24</button>`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/main.svelte new file mode 100644 index 000000000000..d8feb554cd18 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-subclass/main.svelte @@ -0,0 +1,22 @@ +<script> + class Counter { + constructor(initial) { + this.count = $state(initial); + } + + increment = () => { + this.count++; + } + } + + class PluggableCounter extends Counter { + constructor(initial, plugin) { + super(initial) + this.custom = $derived(plugin(this.count)); + } + } + + const counter = new PluggableCounter(10, (count) => count * 2); +</script> + +<button onclick={counter.increment}>{counter.count}: {counter.custom}</button> diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/_config.js new file mode 100644 index 000000000000..f35dc57228a1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/_config.js @@ -0,0 +1,20 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `<button>20</button>`, + + test({ assert, target }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, `<button>22</button>`); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, `<button>24</button>`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/main.svelte new file mode 100644 index 000000000000..aa8ba1658b03 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor/main.svelte @@ -0,0 +1,18 @@ +<script> + class Counter { + /** @type {number} */ + #count; + + constructor(initial) { + this.#count = $state(initial); + this.doubled = $derived(this.#count * 2); + } + + increment = () => { + this.#count++; + } + } + const counter = new Counter(10); +</script> + +<button onclick={counter.increment}>{counter.doubled}</button> diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-1/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-1/errors.json new file mode 100644 index 000000000000..eaff12b89645 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-1/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "constructor_state_reassignment", + "message": "Cannot redeclare stateful field `count` in the constructor. The field was originally declared here: `(unknown):2:1`", + "start": { + "line": 5, + "column": 2 + }, + "end": { + "line": 5, + "column": 24 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-1/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-1/input.svelte.js new file mode 100644 index 000000000000..05cd4d9d9d64 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-1/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + count = $state(0); + + constructor() { + this.count = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-2/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-2/errors.json new file mode 100644 index 000000000000..a27d7411d1f6 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-2/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "constructor_state_reassignment", + "message": "Cannot redeclare stateful field `count` in the constructor. The field was originally declared here: `(unknown):3:2`", + "start": { + "line": 5, + "column": 2 + }, + "end": { + "line": 5, + "column": 24 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-2/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-2/input.svelte.js new file mode 100644 index 000000000000..e37be4b3e691 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-2/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + constructor() { + this.count = $state(0); + this.count = 1; + this.count = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-3/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-3/errors.json new file mode 100644 index 000000000000..8017794a679d --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-3/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "constructor_state_reassignment", + "message": "Cannot redeclare stateful field `count` in the constructor. The field was originally declared here: `(unknown):3:2`", + "start": { + "line": 5, + "column": 2 + }, + "end": { + "line": 5, + "column": 28 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-3/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-3/input.svelte.js new file mode 100644 index 000000000000..f9196ff3cd51 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-3/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + constructor() { + this.count = $state(0); + this.count = 1; + this.count = $state.raw(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-4/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-4/errors.json new file mode 100644 index 000000000000..9f959874c80e --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-4/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "state_invalid_placement", + "message": "`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.", + "start": { + "line": 4, + "column": 16 + }, + "end": { + "line": 4, + "column": 25 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-4/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-4/input.svelte.js new file mode 100644 index 000000000000..bf1aada1b5df --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-4/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + constructor() { + if (true) { + this.count = $state(0); + } + } +} From ac42ad5580125ab3eb99eeee7618d521a8100671 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" <sejohnson@torchcloudconsulting.com> Date: Tue, 29 Apr 2025 16:22:57 -0400 Subject: [PATCH 08/10] final cleanup?? --- .../2-analyze/visitors/PropertyDefinition.js | 2 +- .../visitors/shared/class-analysis.js | 58 ++++++++++++++----- .../client/visitors/AssignmentExpression.js | 2 +- .../visitors/shared/client-class-analysis.js | 12 ++-- .../server/visitors/AssignmentExpression.js | 2 +- .../visitors/shared/server-class-analysis.js | 11 ++-- .../3-transform/shared/class_analysis.js | 52 +++++++++++++---- .../phases/3-transform/shared/types.d.ts | 33 ++++++++--- .../compiler/phases/3-transform/types.d.ts | 2 +- packages/svelte/src/compiler/phases/nodes.js | 1 + .../_config.js | 3 + .../main.svelte | 9 +++ .../class-state-constructor-5/errors.json | 14 +++++ .../class-state-constructor-5/input.svelte.js | 7 +++ .../class-state-constructor-6/errors.json | 14 +++++ .../class-state-constructor-6/input.svelte.js | 6 ++ 16 files changed, 182 insertions(+), 46 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/main.svelte create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-5/input.svelte.js create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-6/errors.json create mode 100644 packages/svelte/tests/validator/samples/class-state-constructor-6/input.svelte.js diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/PropertyDefinition.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/PropertyDefinition.js index 6bf5eb73d5db..aed7ff7fe8e8 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/PropertyDefinition.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/PropertyDefinition.js @@ -7,6 +7,6 @@ * @param {Context} context */ export function PropertyDefinition(node, context) { - context.state.class_state?.register?.(node, context); + context.state.class_state?.register(node, context); context.next(); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js index 4b3d758200b6..02b585fd4dba 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/class-analysis.js @@ -1,4 +1,4 @@ -/** @import { AssignmentExpression, PropertyDefinition, Expression } from 'estree' */ +/** @import { AssignmentExpression, PropertyDefinition, Expression, Identifier, PrivateIdentifier, Literal, MethodDefinition } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { Context } from '../../types' */ /** @import { StateCreationRuneName } from '../../../../../utils.js' */ @@ -15,9 +15,11 @@ import { is_state_creation_rune } from '../../../../../utils.js'; const reassignable_assignments = new Set(['$state', '$state.raw', 'regular']); export class ClassAnalysis { - // TODO: Probably need to include property definitions here too /** @type {Map<string, PropertyAssignmentDetails>} */ - property_assignments = new Map(); + #public_assignments = new Map(); + + /** @type {Map<string, PropertyAssignmentDetails>} */ + #private_assignments = new Map(); /** * Determines if the node is a valid assignment to a class property, and if so, @@ -30,30 +32,46 @@ export class ClassAnalysis { let name; /** @type {PropertyAssignmentType} */ let type; + /** @type {boolean} */ + let is_private; if (node.type === 'AssignmentExpression') { if (!this.is_class_property_assignment_at_constructor_root(node, context.path)) { return; } - name = node.left.property.name; + + let maybe_name = get_name_for_identifier(node.left.property); + if (!maybe_name) { + return; + } + + name = maybe_name; type = this.#get_assignment_type(node, context); + is_private = node.left.property.type === 'PrivateIdentifier'; - this.#check_for_conflicts(node, name, type); + this.#check_for_conflicts(node, name, type, is_private); } else { if (!this.#is_assigned_property(node)) { return; } - name = node.key.name; + let maybe_name = get_name_for_identifier(node.key); + if (!maybe_name) { + return; + } + + name = maybe_name; type = this.#get_assignment_type(node, context); + is_private = node.key.type === 'PrivateIdentifier'; // we don't need to check for conflicts here because they're not possible yet } // we don't have to validate anything other than conflicts here, because the rune placement rules // catch all of the other weirdness. - if (!this.property_assignments.has(name)) { - this.property_assignments.set(name, { type, node }); + const map = is_private ? this.#private_assignments : this.#public_assignments; + if (!map.has(name)) { + map.set(name, { type, node }); } } @@ -61,7 +79,7 @@ export class ClassAnalysis { * @template {AST.SvelteNode} T * @param {AST.SvelteNode} node * @param {T[]} path - * @returns {node is AssignmentExpression & { left: { type: 'MemberExpression' } & { object: { type: 'ThisExpression' }; property: { type: 'Identifier' | 'PrivateIdentifier' } } }} + * @returns {node is AssignmentExpression & { left: { type: 'MemberExpression' } & { object: { type: 'ThisExpression' }; property: { type: 'Identifier' | 'PrivateIdentifier' | 'Literal' } } }} */ is_class_property_assignment_at_constructor_root(node, path) { if ( @@ -71,7 +89,8 @@ export class ClassAnalysis { node.left.type === 'MemberExpression' && node.left.object.type === 'ThisExpression' && (node.left.property.type === 'Identifier' || - node.left.property.type === 'PrivateIdentifier') + node.left.property.type === 'PrivateIdentifier' || + node.left.property.type === 'Literal') ) ) { return false; @@ -89,11 +108,13 @@ export class ClassAnalysis { * We only care about properties that have values assigned to them -- if they don't, * they can't be a conflict for state declared in the constructor. * @param {PropertyDefinition} node - * @returns {node is PropertyDefinition & { key: { type: 'PrivateIdentifier' | 'Identifier' }; value: Expression; static: false; computed: false }} + * @returns {node is PropertyDefinition & { key: { type: 'PrivateIdentifier' | 'Identifier' | 'Literal' }; value: Expression; static: false; computed: false }} */ #is_assigned_property(node) { return ( - (node.key.type === 'PrivateIdentifier' || node.key.type === 'Identifier') && + (node.key.type === 'PrivateIdentifier' || + node.key.type === 'Identifier' || + node.key.type === 'Literal') && Boolean(node.value) && !node.static && !node.computed @@ -107,9 +128,10 @@ export class ClassAnalysis { * @param {AssignmentExpression} node * @param {string} name * @param {PropertyAssignmentType} type + * @param {boolean} is_private */ - #check_for_conflicts(node, name, type) { - const existing = this.property_assignments.get(name); + #check_for_conflicts(node, name, type, is_private) { + const existing = (is_private ? this.#private_assignments : this.#public_assignments).get(name); if (!existing) { return; } @@ -140,3 +162,11 @@ export class ClassAnalysis { return 'regular'; } } + +/** + * + * @param {PrivateIdentifier | Identifier | Literal} node + */ +function get_name_for_identifier(node) { + return node.type === 'Literal' ? node.value?.toString() : node.name; +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js index ff8d2b5ce12c..34d80d388ca1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js @@ -17,7 +17,7 @@ import { validate_mutation } from './shared/utils.js'; * @param {Context} context */ export function AssignmentExpression(node, context) { - const stripped_node = context.state.class_analysis?.register_assignment(node, context); + const stripped_node = context.state.class_analysis?.generate_assignment(node, context); if (stripped_node) { return stripped_node; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js index 70dd3646765b..778fea32e5c2 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/client-class-analysis.js @@ -56,7 +56,10 @@ export function create_client_class_analysis(body) { left: { ...node.left, // ...swap out the assignment to go directly against the private field - property: field.id + property: field.id, + // this could be a transformation from `this.[1]` to `this.#_` (the private field we generated) + // -- private fields are never computed + computed: false }, // ...and swap out the assignment's value for the state field init right: build_init_value(field.kind, node.right.arguments[0], context) @@ -67,15 +70,14 @@ export function create_client_class_analysis(body) { } /** - * * @param {StateCreationRuneName} kind * @param {Expression | SpreadElement} arg * @param {Context} context */ function build_init_value(kind, arg, context) { - const init = /** @type {Expression} **/ ( - context.visit(arg, { ...context.state, in_constructor: false }) - ); + const init = arg + ? /** @type {Expression} **/ (context.visit(arg, { ...context.state, in_constructor: false })) + : b.void0; switch (kind) { case '$state': diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AssignmentExpression.js index 9977c979db3f..ae84f0878228 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AssignmentExpression.js @@ -10,7 +10,7 @@ import { visit_assignment_expression } from '../../shared/assignments.js'; * @param {Context} context */ export function AssignmentExpression(node, context) { - const stripped_node = context.state.class_analysis?.register_assignment(node, context); + const stripped_node = context.state.class_analysis?.generate_assignment(node, context); if (stripped_node) { return stripped_node; } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js index 44a641508766..73390d0e38ad 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/server-class-analysis.js @@ -23,11 +23,9 @@ export function create_server_class_analysis(body) { // value can be assigned in the constructor. value = null; } else if (field.kind !== '$derived' && field.kind !== '$derived.by') { - return [/** @type {PropertyDefinition} */ (context.visit(node, context.state))]; + return [/** @type {PropertyDefinition} */ (context.visit(node))]; } else { - const init = /** @type {Expression} **/ ( - context.visit(node.value.arguments[0], context.state) - ); + const init = /** @type {Expression} **/ (context.visit(node.value.arguments[0])); value = field.kind === '$derived.by' ? b.call('$.once', init) : b.call('$.once', b.thunk(init)); } @@ -73,7 +71,8 @@ export function create_server_class_analysis(body) { left: { ...node.left, // ...swap out the assignment to go directly against the private field - property: field.id + property: field.id, + computed: false }, // ...and swap out the assignment's value for the state field init right: build_init_value(field.kind, node.right.arguments[0], context) @@ -90,7 +89,7 @@ export function create_server_class_analysis(body) { * @param {Context} context */ function build_init_value(kind, arg, context) { - const init = /** @type {Expression} **/ (context.visit(arg, context.state)); + const init = arg ? /** @type {Expression} **/ (context.visit(arg)) : b.void0; switch (kind) { case '$state': diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js b/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js index e482f10d49dc..20d9e75eca18 100644 --- a/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js +++ b/packages/svelte/src/compiler/phases/3-transform/shared/class_analysis.js @@ -19,13 +19,25 @@ import { get_rune } from '../../scope.js'; * @returns {ClassAnalysis<TContext>} */ export function create_class_analysis(body, build_state_field, build_assignment) { - /** @type {Map<string, StateField>} */ + /** + * Public, stateful fields. + * @type {Map<string, StateField>} + */ const public_fields = new Map(); - /** @type {Map<string, StateField>} */ + /** + * Private, stateful fields. These are namespaced separately because + * public and private fields can have the same name in the AST -- ex. + * `count` and `#count` are both named `count` -- and because it's useful + * in a couple of cases to be able to check for only one or the other. + * @type {Map<string, StateField>} + */ const private_fields = new Map(); - /** @type {Array<PropertyDefinition | MethodDefinition>} */ + /** + * Accumulates nodes for the new class body. + * @type {Array<PropertyDefinition | MethodDefinition>} + */ const new_body = []; /** @@ -57,6 +69,8 @@ export function create_class_analysis(body, build_state_field, build_assignment) const replacers = []; /** + * Get a state field by name. + * * @param {string} name * @param {boolean} is_private * @param {ReadonlyArray<StateCreationRuneName>} [kinds] @@ -69,20 +83,30 @@ export function create_class_analysis(body, build_state_field, build_assignment) } /** + * Create a child context that makes sense for passing to the child analyzers. * @param {TContext} context * @returns {TContext} */ function create_child_context(context) { + const state = { + ...context.state, + class_analysis + }; + // @ts-expect-error - I can't find a way to make TypeScript happy with these + const visit = (node, state_override) => context.visit(node, { ...state, ...state_override }); + // @ts-expect-error - I can't find a way to make TypeScript happy with these + const next = (state_override) => context.next({ ...state, ...state_override }); return { ...context, - state: { - ...context.state, - class_analysis - } + state, + visit, + next }; } /** + * Generate a new body for the class. Ensure there is a visitor for AssignmentExpression that + * calls `generate_assignment` to capture any stateful fields declared in the constructor. * @param {TContext} context */ function generate_body(context) { @@ -107,11 +131,16 @@ export function create_class_analysis(body, build_state_field, build_assignment) } /** + * Given an assignment expression, check to see if that assignment expression declares + * a stateful field. If it does, register that field and then return the processed + * assignment expression. If an assignment expression is returned from this function, + * it should be considered _fully processed_ and should replace the existing assignment + * expression node. * @param {AssignmentExpression} node * @param {TContext} context * @returns {AssignmentExpression | null} The node, if `register_assignment` handled its transformation. */ - function register_assignment(node, context) { + function generate_assignment(node, context) { const child_context = create_child_context(context); if ( !( @@ -119,7 +148,8 @@ export function create_class_analysis(body, build_state_field, build_assignment) node.left.type === 'MemberExpression' && node.left.object.type === 'ThisExpression' && (node.left.property.type === 'Identifier' || - node.left.property.type === 'PrivateIdentifier') + node.left.property.type === 'PrivateIdentifier' || + node.left.property.type === 'Literal') ) ) { return null; @@ -177,6 +207,8 @@ export function create_class_analysis(body, build_state_field, build_assignment) } /** + * Register a class body definition. + * * @param {PropertyDefinition | MethodDefinition | StaticBlock} node * @param {TContext} child_context * @returns {boolean} if this node is stateful and was registered @@ -325,7 +357,7 @@ export function create_class_analysis(body, build_state_field, build_assignment) const class_analysis = { get_field, generate_body, - register_assignment + generate_assignment }; return class_analysis; diff --git a/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts index 96fe27ca5dcc..2d02e705c317 100644 --- a/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/shared/types.d.ts @@ -1,4 +1,14 @@ -import type { AssignmentExpression, CallExpression, Identifier, MemberExpression, PropertyDefinition, MethodDefinition, PrivateIdentifier, ThisExpression } from 'estree'; +import type { + AssignmentExpression, + CallExpression, + Identifier, + MemberExpression, + PropertyDefinition, + MethodDefinition, + PrivateIdentifier, + ThisExpression, + Literal +} from 'estree'; import type { StateField } from '../types'; import type { Context as ServerContext } from '../server/types'; import type { Context as ClientContext } from '../client/types'; @@ -7,13 +17,13 @@ import type { StateCreationRuneName } from '../../../../utils'; export type StatefulAssignment = AssignmentExpression & { left: MemberExpression & { object: ThisExpression; - property: Identifier | PrivateIdentifier + property: Identifier | PrivateIdentifier | Literal; }; right: CallExpression; }; export type StatefulPropertyDefinition = PropertyDefinition & { - key: Identifier | PrivateIdentifier; + key: Identifier | PrivateIdentifier | Literal; value: CallExpression; }; @@ -34,7 +44,9 @@ export type AssignmentBuilderParams<TContext extends ServerContext | ClientConte context: TContext; }; -export type AssignmentBuilder<TContext extends ServerContext | ClientContext> = (params: AssignmentBuilderParams<TContext>) => AssignmentExpression; +export type AssignmentBuilder<TContext extends ServerContext | ClientContext> = ( + params: AssignmentBuilderParams<TContext> +) => AssignmentExpression; export type ClassAnalysis<TContext extends ServerContext | ClientContext> = { /** @@ -43,7 +55,11 @@ export type ClassAnalysis<TContext extends ServerContext | ClientContext> = { * @param kinds - What kinds of state creation runes you're looking for, eg. only '$derived.by'. * @returns The field if it exists and matches the given criteria, or null. */ - get_field: (name: string, is_private: boolean, kinds?: Array<StateCreationRuneName>) => StateField | undefined; + get_field: ( + name: string, + is_private: boolean, + kinds?: Array<StateCreationRuneName> + ) => StateField | undefined; /** * Given the body of a class, generate a new body with stateful fields. @@ -59,5 +75,8 @@ export type ClassAnalysis<TContext extends ServerContext | ClientContext> = { * a state field on the class. If it is, it registers that state field and modifies the * assignment expression. */ - register_assignment: (node: AssignmentExpression, context: TContext) => AssignmentExpression | null; -} + generate_assignment: ( + node: AssignmentExpression, + context: TContext + ) => AssignmentExpression | null; +}; diff --git a/packages/svelte/src/compiler/phases/3-transform/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/types.d.ts index 8999f096142e..c610110cef9d 100644 --- a/packages/svelte/src/compiler/phases/3-transform/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/types.d.ts @@ -14,4 +14,4 @@ export interface TransformState { export interface StateField { kind: StateCreationRuneName; id: PrivateIdentifier; -} \ No newline at end of file +} diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index 003c3a2c4945..29fb8c5c83c2 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -1,4 +1,5 @@ /** @import { AST, ExpressionMetadata } from '#compiler' */ + /** * All nodes that can appear elsewhere than the top level, have attributes and can contain children */ diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/_config.js new file mode 100644 index 000000000000..f47bee71df87 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/_config.js @@ -0,0 +1,3 @@ +import { test } from '../../test'; + +export default test({}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/main.svelte new file mode 100644 index 000000000000..e2c4f302b397 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-conflicting-get-name/main.svelte @@ -0,0 +1,9 @@ +<script> + class Test { + 0 = $state(); + + constructor() { + this[1] = $state(); + } + } +</script> \ No newline at end of file diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json new file mode 100644 index 000000000000..359274d15661 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "constructor_state_reassignment", + "message": "Cannot redeclare stateful field `count` in the constructor. The field was originally declared here: `(unknown):2:1`", + "start": { + "line": 4, + "column": 2 + }, + "end": { + "line": 4, + "column": 27 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-5/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-5/input.svelte.js new file mode 100644 index 000000000000..bc3d19a14fae --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-5/input.svelte.js @@ -0,0 +1,7 @@ +export class Counter { + // prettier-ignore + 'count' = $state(0); + constructor() { + this['count'] = $state(0); + } +} diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-6/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-6/errors.json new file mode 100644 index 000000000000..359274d15661 --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-6/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "constructor_state_reassignment", + "message": "Cannot redeclare stateful field `count` in the constructor. The field was originally declared here: `(unknown):2:1`", + "start": { + "line": 4, + "column": 2 + }, + "end": { + "line": 4, + "column": 27 + } + } +] diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-6/input.svelte.js b/packages/svelte/tests/validator/samples/class-state-constructor-6/input.svelte.js new file mode 100644 index 000000000000..2ebe52e685ed --- /dev/null +++ b/packages/svelte/tests/validator/samples/class-state-constructor-6/input.svelte.js @@ -0,0 +1,6 @@ +export class Counter { + count = $state(0); + constructor() { + this['count'] = $state(0); + } +} From b44eed9a091c360a31d3b0529c7fbd555dc1fffe Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" <sejohnson@torchcloudconsulting.com> Date: Tue, 29 Apr 2025 16:49:00 -0400 Subject: [PATCH 09/10] tests --- .../validator/samples/class-state-constructor-5/errors.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json b/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json index 359274d15661..7942b016cb2b 100644 --- a/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json +++ b/packages/svelte/tests/validator/samples/class-state-constructor-5/errors.json @@ -1,13 +1,13 @@ [ { "code": "constructor_state_reassignment", - "message": "Cannot redeclare stateful field `count` in the constructor. The field was originally declared here: `(unknown):2:1`", + "message": "Cannot redeclare stateful field `count` in the constructor. The field was originally declared here: `(unknown):3:1`", "start": { - "line": 4, + "line": 5, "column": 2 }, "end": { - "line": 4, + "line": 5, "column": 27 } } From 12a02b7eed5c3b01bc4be6ea2f060ecd3a8d5e4d Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" <sejohnson@torchcloudconsulting.com> Date: Tue, 29 Apr 2025 17:00:13 -0400 Subject: [PATCH 10/10] test for better types --- .../_config.js | 20 +++++++++++++++++++ .../main.svelte | 12 +++++++++++ 2 files changed, 32 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/_config.js new file mode 100644 index 000000000000..02cf36d900cc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/_config.js @@ -0,0 +1,20 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `<button>0</button>`, + + test({ assert, target }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, `<button>1</button>`); + + flushSync(() => { + btn?.click(); + }); + assert.htmlEqual(target.innerHTML, `<button>2</button>`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/main.svelte new file mode 100644 index 000000000000..5dbbb10afd35 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-constructor-predeclared-field/main.svelte @@ -0,0 +1,12 @@ +<script> + class Counter { + count; + + constructor(count) { + this.count = $state(count); + } + } + const counter = new Counter(0); +</script> + +<button onclick={() => counter.count++}>{counter.count}</button>