feat: It is now at least backwards compatible. though the new stuff may still be wrong

pull/15820/head
S. Elliott Johnson 1 year ago
parent 4d6422cca4
commit adb6e712e9

@ -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>;

@ -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);

@ -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 };
}

@ -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')

@ -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';

@ -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;
}
}
}

@ -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);
}
}

@ -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} */ (

@ -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 {

@ -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 };
}

@ -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);

@ -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);
}

@ -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 };
}

@ -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;
}

@ -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;
}

@ -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

@ -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();

@ -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();

Loading…
Cancel
Save