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 2ab5bd1a60..3e7ef24aee 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 | 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; export type Visitors = import('zimmerframe').Visitors; 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 d045b83298..7d0840ab80 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 534fdba446..a9ccad0377 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} */ - 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 410c9ad9ab..87a886fd3b 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 c1f7c390ce..2e6f9123dd 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 0ce4451e8b..0000000000 --- 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} */ - public_state = new Map(); - - /** @type {Map} */ - private_state = new Map(); - - /** - * Any state fields discovered from {@link register_assignment} that need to be added to the class body. - * @type {Array} - */ - constructor_state_fields = []; - - /** @type {Map<(MethodDefinition | PropertyDefinition)["key"], string>} */ - #definition_names = new Map(); - - /** @type {Set} */ - #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 | 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} - */ - 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} 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 0000000000..fad75b142d --- /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} body + * @returns {ClassAnalysis} + */ +export function create_client_class_analysis(body) { + /** @type {StateFieldBuilder} */ + 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} */ + 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 e7896991d9..e159e2ff4e 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 971271642c..0f1b2647a2 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; - readonly private_derived: Map; + readonly class_analysis: ClassAnalysis | 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 ff4a1206dd..8d67b38361 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} */ - const public_derived = new Map(); - - /** @type {Map} */ - 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} */ - 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 73631395e6..2f72327191 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 0000000000..a94972ed93 --- /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} body + * @returns {ClassAnalysis} + */ +export function create_server_class_analysis(body) { + /** @type {StateFieldBuilder} */ + 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} */ + 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 0000000000..5eb001d438 --- /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} body + * @param {StateFieldBuilder} build_state_field + * @param {AssignmentBuilder} build_assignment + * @returns {ClassAnalysis} + */ +export function create_class_analysis(body, build_state_field, build_assignment) { + /** @type {Map} */ + const public_fields = new Map(); + + /** @type {Map} */ + const private_fields = new Map(); + + /** @type {Array} */ + 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} + */ + 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} [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 0000000000..45bd3b5a00 --- /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 = { + is_private: boolean; + field: StateField; + node: StatefulAssignment | StatefulPropertyDefinition; + context: TContext; +}; + +export type StateFieldBuilder = ( + params: StateFieldBuilderParams +) => Array; + +export type AssignmentBuilderParams = { + node: StatefulAssignment; + field: StateField; + context: TContext; +}; + +export type AssignmentBuilder = (params: AssignmentBuilderParams) => void; + +export type ClassAnalysis = { + /** + * @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) => 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; + + /** + * 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 5d860207dd..8999f09614 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; } + +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 2758b9fcbc..654b4bb2d0 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 2133974176..620623ebfd 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 2a115a4983..a27bcb8623 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();