Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-wings-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: allow `$state` in computed class fields
4 changes: 2 additions & 2 deletions packages/svelte/src/compiler/phases/2-analyze/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Scope } from '../scope.js';
import type { ComponentAnalysis, ReactiveStatement } from '../types.js';
import type { AST, ExpressionMetadata, StateField, ValidatedCompileOptions } from '#compiler';
import type { AST, ExpressionMetadata, StateFields, ValidatedCompileOptions } from '#compiler';

export interface AnalysisState {
scope: Scope;
Expand All @@ -21,7 +21,7 @@ export interface AnalysisState {
expression: ExpressionMetadata | null;

/** Used to analyze class state */
state_fields: Map<string, StateField>;
state_fields: StateFields;

function_depth: number;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ function is_variable_declaration(parent, context) {
* @param {AST.SvelteNode} parent
*/
function is_class_property_definition(parent) {
return parent.type === 'PropertyDefinition' && !parent.static && !parent.computed;
return parent.type === 'PropertyDefinition' && !parent.static;
}

/**
Expand All @@ -325,10 +325,7 @@ function is_class_property_assignment_at_constructor_root(node, context) {
node.type === 'AssignmentExpression' &&
node.operator === '=' &&
node.left.type === 'MemberExpression' &&
node.left.object.type === 'ThisExpression' &&
((node.left.property.type === 'Identifier' && !node.left.computed) ||
node.left.property.type === 'PrivateIdentifier' ||
node.left.property.type === 'Literal')
node.left.object.type === 'ThisExpression'
) {
// MethodDefinition (-5) -> FunctionExpression (-4) -> BlockStatement (-3) -> ExpressionStatement (-2) -> AssignmentExpression (-1)
const parent = get_parent(context.path, -5);
Expand Down
44 changes: 30 additions & 14 deletions packages/svelte/src/compiler/phases/2-analyze/visitors/ClassBody.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,54 +30,63 @@ export function ClassBody(node, context) {
}
}

/** @type {Map<string, StateField>} */
/** @type {Map<string | number, StateField>} */
const state_fields = new Map();

/** @type {Map<string, Array<MethodDefinition['kind'] | 'prop' | 'assigned_prop'>>} */
/** @type {Map<string | number, Array<MethodDefinition['kind'] | 'prop' | 'assigned_prop'>>} */
const fields = new Map();

context.state.analysis.classes.set(node, state_fields);

/** @type {MethodDefinition | null} */
let constructor = null;

function increment_computed() {
const numbered_keys = [...state_fields.keys()].filter((key) => typeof key === 'number');
return numbered_keys.length;
}
/**
* @param {PropertyDefinition | AssignmentExpression} node
* @param {Expression | PrivateIdentifier} key
* @param {Expression | null | undefined} value
* @param {boolean} [computed]
*/
function handle(node, key, value) {
const name = get_name(key);
function handle(node, key, value, computed = false) {
const name = computed ? increment_computed() : get_name(key);
if (name === null) return;

const rune = get_rune(value, context.state.scope);

if (rune && is_state_creation_rune(rune)) {
if (state_fields.has(name)) {
if (typeof name === 'string' && state_fields.has(name)) {
e.state_field_duplicate(node, name);
}

const _key = (node.type === 'AssignmentExpression' || !node.static ? '' : '@') + name;
const _key =
typeof name === 'string'
? (node.type === 'AssignmentExpression' || !node.static ? '' : '@') + name
: name;
const field = fields.get(_key);

// if there's already a method or assigned field, error
if (field && !(field.length === 1 && field[0] === 'prop')) {
e.duplicate_class_field(node, _key);
e.duplicate_class_field(node, typeof _key === 'string' ? _key : '[computed key]');
}

state_fields.set(name, {
node,
type: rune,
// @ts-expect-error for public state this is filled out in a moment
key: key.type === 'PrivateIdentifier' ? key : null,
value: /** @type {CallExpression} */ (value)
value: /** @type {CallExpression} */ (value),
computed_key: computed ? /** @type {Expression} */ (key) : null
});
}
}

for (const child of node.body) {
if (child.type === 'PropertyDefinition' && !child.computed && !child.static) {
handle(child, child.key, child.value);
if (child.type === 'PropertyDefinition' && !child.static) {
handle(child, child.key, child.value, child.computed && child.key.type !== 'Literal');
const key = /** @type {string} */ (get_name(child.key));
const field = fields.get(key);
if (!field) {
Expand Down Expand Up @@ -132,18 +141,25 @@ export function ClassBody(node, context) {

if (left.type !== 'MemberExpression') continue;
if (left.object.type !== 'ThisExpression') continue;
if (left.computed && left.property.type !== 'Literal') continue;

handle(statement.expression, left.property, right);
handle(
statement.expression,
left.property,
right,
left.computed && left.property.type !== 'Literal'
);
}
}

for (const [name, field] of state_fields) {
if (name[0] === '#') {
if (typeof name === 'string' && name[0] === '#') {
continue;
}

let deconflicted = name.replace(regex_invalid_identifier_chars, '_');
let deconflicted = `${typeof name === 'number' ? '_' : ''}${name}`.replace(
regex_invalid_identifier_chars,
'_'
);
while (private_ids.includes(deconflicted)) {
deconflicted = '_' + deconflicted;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { BlockStatement } from './visitors/BlockStatement.js';
import { BreakStatement } from './visitors/BreakStatement.js';
import { CallExpression } from './visitors/CallExpression.js';
import { ClassBody } from './visitors/ClassBody.js';
import { ClassDeclaration } from './visitors/ClassDeclaration.js';
import { ClassExpression } from './visitors/ClassExpression.js';
import { Comment } from './visitors/Comment.js';
import { Component } from './visitors/Component.js';
import { ConstTag } from './visitors/ConstTag.js';
Expand Down Expand Up @@ -97,6 +99,8 @@ const visitors = {
BreakStatement,
CallExpression,
ClassBody,
ClassDeclaration,
ClassExpression,
Comment,
Component,
ConstTag,
Expand Down Expand Up @@ -168,6 +172,7 @@ export function client_component(analysis, options) {
in_constructor: false,
instance_level_snippets: [],
module_level_snippets: [],
computed_field_declarations: null,

// these are set inside the `Fragment` visitor, and cannot be used until then
init: /** @type {any} */ (null),
Expand Down Expand Up @@ -714,7 +719,8 @@ export function client_module(analysis, options) {
state_fields: new Map(),
transform: {},
in_constructor: false,
is_instance: false
is_instance: false,
computed_field_declarations: null
};

const module = /** @type {ESTree.Program} */ (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** @import { CallExpression, ClassBody, ClassDeclaration, ClassExpression, MethodDefinition, PropertyDefinition, StaticBlock } from 'estree' */
/** @import { CallExpression, ClassBody, ClassDeclaration, ClassExpression, Expression, MethodDefinition, PropertyDefinition, StaticBlock, VariableDeclaration } from 'estree' */
/** @import { StateField } from '#compiler' */
/** @import { Context } from '../types' */
import * as b from '#compiler/builders';
Expand All @@ -23,33 +23,68 @@ export function ClassBody(node, context) {
const body = [];

const child_state = { ...context.state, state_fields };
const computed_field_declarations = /** @type {VariableDeclaration[]} */ (
context.state.computed_field_declarations
);

// insert backing fields for stuff declared in the constructor
for (const [name, field] of state_fields) {
if (name[0] === '#') {
if (
(typeof name === 'string' && name[0] === '#') ||
field.node.type !== 'AssignmentExpression'
) {
continue;
}

// insert backing fields for stuff declared in the constructor
if (field.node.type === 'AssignmentExpression') {
if (typeof name === 'number' && field.computed_key) {
const key = context.state.scope.generate('key');
computed_field_declarations.push(b.let(key));
const member = b.member(b.this, field.key);

const should_proxy = field.type === '$state' && true; // TODO

const key = b.key(name);

body.push(
b.prop_def(field.key, null),

b.method('get', key, [], [b.return(b.call('$.get', member))]),

b.method(
'get',
b.assignment(
'=',
b.id(key),
/** @type {Expression} */ (context.visit(field.computed_key))
),
[],
[b.return(b.call('$.get', member))],
true
),
b.method(
'set',
key,
b.id(key),
[b.id('value')],
[b.stmt(b.call('$.set', member, b.id('value'), should_proxy && b.true))]
[b.stmt(b.call('$.set', member, b.id('value'), should_proxy && b.true))],
true
)
);
continue;
}

const member = b.member(b.this, field.key);

const should_proxy = field.type === '$state' && true; // TODO
Copy link

@dangodai dangodai Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This predates your change, but it looks like the second condition (true) is from a leftover TODO that never came to fruition? There are 2 more copies of it now in this file. (I was bothered by it when reading the source, just not enough to make a PR for it 😅 )

Also looks like at least member and should_proxy can be de-duplicated and shared between computed and non-computed branches.


const key = b.key(/** @type {string} */ (name));

body.push(
b.prop_def(field.key, null),

b.method('get', key, [], [b.return(b.call('$.get', member))]),

b.method(
'set',
key,
[b.id('value')],
[b.stmt(b.call('$.set', member, b.id('value'), should_proxy && b.true))]
)
);
}

const declaration = /** @type {ClassDeclaration | ClassExpression} */ (
Expand All @@ -65,15 +100,17 @@ export function ClassBody(node, context) {
continue;
}

const name = get_name(definition.key);
const field = name && /** @type {StateField} */ (state_fields.get(name));
const name = definition.computed
? [...state_fields.entries()].find(([, field]) => field.node === definition)?.[0] ?? null
: get_name(definition.key);
const field = name !== null && /** @type {StateField} */ (state_fields.get(name));

if (!field) {
body.push(/** @type {PropertyDefinition} */ (context.visit(definition, child_state)));
continue;
}

if (name[0] === '#') {
if (typeof name === 'string' && name[0] === '#') {
let value = definition.value
? /** @type {CallExpression} */ (context.visit(definition.value, child_state))
: undefined;
Expand All @@ -83,7 +120,7 @@ export function ClassBody(node, context) {
}

body.push(b.prop_def(definition.key, value));
} else if (field.node === definition) {
} else if (field.node === definition && typeof name === 'string') {
let call = /** @type {CallExpression} */ (context.visit(field.value, child_state));

if (dev) {
Expand All @@ -104,6 +141,42 @@ export function ClassBody(node, context) {
[b.stmt(b.call('$.set', member, b.id('value'), should_proxy && b.true))]
)
);
} else if (field.computed_key) {
let call = /** @type {CallExpression} */ (context.visit(field.value, child_state));
if (dev) {
call = b.call(
'$.tag',
call,
b.literal(`${declaration.id?.name ?? '[class]'}[computed key]`)
);
}
const key = context.state.scope.generate('key');
computed_field_declarations.push(b.let(key));
const member = b.member(b.this, field.key);

const should_proxy = field.type === '$state' && true; // TODO

body.push(
b.prop_def(field.key, call),
b.method(
'get',
b.assignment(
'=',
b.id(key),
/** @type {Expression} */ (context.visit(field.computed_key))
),
[],
[b.return(b.call('$.get', member))],
true
),
b.method(
'set',
b.id(key),
[b.id('value')],
[b.stmt(b.call('$.set', member, b.id('value'), should_proxy && b.true))],
true
)
);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/** @import { ClassBody, ClassDeclaration, Expression, VariableDeclaration } from 'estree' */
/** @import { ClientTransformState, Context } from '../types' */
import * as b from '#compiler/builders';

/**
* @param {ClassDeclaration} node
* @param {Context} context
*/
export function ClassDeclaration(node, context) {
/** @type {ClientTransformState & { computed_field_declarations: VariableDeclaration[] }} */
const state = {
...context.state,
computed_field_declarations: []
};
const super_class = node.superClass
? /** @type {Expression} */ (context.visit(node.superClass))
: null;
const body = /** @type {ClassBody} */ (context.visit(node.body, state));
if (state.computed_field_declarations.length > 0) {
const init = b.call(
b.arrow(
[],
b.block([
...state.computed_field_declarations,
b.return(b.class(node.id, body, super_class))
])
)
);
return node.id ? b.var(node.id, init) : init;
}
return b.class_declaration(node.id, body, super_class);
}
Loading
Loading