feat: It works I think?

pull/15820/head
S. Elliott Johnson 5 months ago
parent adb6e712e9
commit 92940ffbfd

@ -17,7 +17,11 @@ import { validate_mutation } from './shared/utils.js';
* @param {Context} context
*/
export function AssignmentExpression(node, context) {
context.state.class_analysis?.register_assignment(node, context);
const stripped_node = context.state.class_analysis?.register_assignment(node, context);
if (stripped_node) {
return stripped_node;
}
const expression = /** @type {Expression} */ (
visit_assignment_expression(node, context, build_assignment) ?? context.next()
);

@ -13,7 +13,7 @@ import { should_proxy } from '../../utils.js';
export function create_client_class_analysis(body) {
/** @type {StateFieldBuilder<Context>} */
function build_state_field({ is_private, field, node, context }) {
let original_id = node.type === 'AssignmentExpression' ? node.left : node.key;
let original_id = node.type === 'AssignmentExpression' ? node.left.property : node.key;
let value;
if (node.type === 'AssignmentExpression') {
// if there's no call expression, this is state that's created in the constructor.
@ -51,10 +51,16 @@ export function create_client_class_analysis(body) {
/** @type {AssignmentBuilder<Context>} */
function build_assignment({ field, node, context }) {
// ...swap out the assignment to go directly against the private field
node.left.property = field.id;
// ...and swap out the assignment's value for the state field init
node.right = build_init_value(field.kind, node.right.arguments[0], context);
return {
...node,
left: {
...node.left,
// ...swap out the assignment to go directly against the private field
property: field.id
},
// ...and swap out the assignment's value for the state field init
right: build_init_value(field.kind, node.right.arguments[0], context)
};
}
return create_class_analysis(body, build_state_field, build_assignment);
@ -67,7 +73,9 @@ export function create_client_class_analysis(body) {
* @param {Context} context
*/
function build_init_value(kind, arg, context) {
const init = /** @type {Expression} **/ (context.visit(arg, context.state));
const init = /** @type {Expression} **/ (
context.visit(arg, { ...context.state, in_constructor: false })
);
switch (kind) {
case '$state':

@ -10,6 +10,11 @@ import { visit_assignment_expression } from '../../shared/assignments.js';
* @param {Context} context
*/
export function AssignmentExpression(node, context) {
const stripped_node = context.state.class_analysis?.register_assignment(node, context);
if (stripped_node) {
return stripped_node;
}
return visit_assignment_expression(node, context, build_assignment) ?? context.next();
}

@ -1,7 +1,8 @@
/** @import { Expression, MethodDefinition, StaticBlock, PropertyDefinition } from 'estree' */
/** @import { Expression, MethodDefinition, StaticBlock, PropertyDefinition, SpreadElement } from 'estree' */
/** @import { Context } from '../../types.js' */
/** @import { AssignmentBuilder, StateFieldBuilder } from '../../../shared/types.js' */
/** @import { ClassAnalysis } from '../../../shared/types.js' */
/** @import { StateCreationRuneName } from '../../../../../../utils.js' */
import * as b from '#compiler/builders';
import { dev } from '../../../../../state.js';
@ -14,7 +15,7 @@ import { create_class_analysis } from '../../../shared/class_analysis.js';
export function create_server_class_analysis(body) {
/** @type {StateFieldBuilder<Context>} */
function build_state_field({ is_private, field, node, context }) {
let original_id = node.type === 'AssignmentExpression' ? node.left : node.key;
let original_id = node.type === 'AssignmentExpression' ? node.left.property : node.key;
let value;
if (node.type === 'AssignmentExpression') {
// This means it's a state assignment in the constructor (this.foo = $state('bar'))
@ -37,13 +38,19 @@ export function create_server_class_analysis(body) {
// #foo;
const member = b.member(b.this, field.id);
/** @type {Array<MethodDefinition | PropertyDefinition>} */
const defs = [
// #foo;
b.prop_def(field.id, value),
// get foo() { return this.#foo; }
b.method('get', original_id, [], [b.return(b.call(member))])
b.prop_def(field.id, value)
];
// get foo() { return this.#foo; }
if (field.kind === '$state' || field.kind === '$state.raw') {
defs.push(b.method('get', original_id, [], [b.return(member)]));
} else {
defs.push(b.method('get', original_id, [], [b.return(b.call(member))]));
}
// TODO make this work on server
if (dev) {
defs.push(
@ -61,11 +68,37 @@ export function create_server_class_analysis(body) {
/** @type {AssignmentBuilder<Context>} */
function build_assignment({ field, node, context }) {
node.left.property = field.id;
const init = /** @type {Expression} **/ (context.visit(node.right.arguments[0], context.state));
node.right =
field.kind === '$derived.by' ? b.call('$.once', init) : b.call('$.once', b.thunk(init));
return {
...node,
left: {
...node.left,
// ...swap out the assignment to go directly against the private field
property: field.id
},
// ...and swap out the assignment's value for the state field init
right: build_init_value(field.kind, node.right.arguments[0], context)
};
}
return create_class_analysis(body, build_state_field, build_assignment);
}
/**
*
* @param {StateCreationRuneName} kind
* @param {Expression | SpreadElement} arg
* @param {Context} context
*/
function build_init_value(kind, arg, context) {
const init = /** @type {Expression} **/ (context.visit(arg, context.state));
switch (kind) {
case '$state':
case '$state.raw':
return init;
case '$derived':
return b.call('$.once', b.thunk(init));
case '$derived.by':
return b.call('$.once', init);
}
}

@ -107,12 +107,9 @@ export function create_class_analysis(body, build_state_field, build_assignment)
}
/**
* Important note: It is a syntax error in JavaScript to try to assign to a private class field
* that was not declared in the class body. So there is absolutely no risk of unresolvable conflicts here.
*
* This function will modify the assignment expression passed to it if it is registered as a state field.
* @param {AssignmentExpression} node
* @param {TContext} context
* @returns {AssignmentExpression | null} The node, if `register_assignment` handled its transformation.
*/
function register_assignment(node, context) {
const child_context = create_child_context(context);
@ -121,30 +118,46 @@ export function create_class_analysis(body, build_state_field, build_assignment)
node.operator === '=' &&
node.left.type === 'MemberExpression' &&
node.left.object.type === 'ThisExpression' &&
node.left.property.type === 'Identifier'
(node.left.property.type === 'Identifier' ||
node.left.property.type === 'PrivateIdentifier')
)
) {
return;
return null;
}
const name = get_name(node.left.property);
if (!name) {
return;
return null;
}
const parsed = parse_stateful_assignment(node, child_context.state.scope);
if (!parsed) {
return;
return null;
}
const { stateful_assignment, rune } = parsed;
const id = deconflict(name);
const field = { kind: rune, id };
public_fields.set(name, field);
const is_private = stateful_assignment.left.property.type === 'PrivateIdentifier';
let field;
if (is_private) {
field = {
kind: rune,
id: /** @type {PrivateIdentifier} */ (stateful_assignment.left.property)
};
private_fields.set(name, field);
} else {
field = {
kind: rune,
// it's safe to do this upfront now because we're guaranteed to already know about all private
// identifiers (they had to have been declared at the class root, before we visited the constructor)
id: deconflict(name)
};
public_fields.set(name, field);
}
const replacer = () => {
const nodes = build_state_field({
is_private: false,
is_private,
field,
node: stateful_assignment,
context: child_context
@ -156,9 +169,9 @@ export function create_class_analysis(body, build_state_field, build_assignment)
};
replacers.push(replacer);
build_assignment({
node: stateful_assignment,
return build_assignment({
field,
node: stateful_assignment,
context: child_context
});
}
@ -219,7 +232,20 @@ export function create_class_analysis(body, build_state_field, build_assignment)
const parsed = prop_def_is_stateful(node, child_context.state.scope);
if (!parsed) {
return false;
// this isn't a stateful field definition, but if could become one in the constructor -- so we register
// it, but conditionally -- so that if it's added as a field in the constructor (which causes us to create)
// a field definition for it), we don't end up with a duplicate definition (this one, plus the one we create)
replacers.push(() => {
if (!get_field(name, is_private)) {
new_body.push(
/** @type {PropertyDefinition | MethodDefinition} */ (
// @ts-expect-error generics silliness
child_context.visit(node, child_context.state)
)
);
}
});
return true;
}
const { stateful_prop_def, rune } = parsed;

@ -34,7 +34,7 @@ export type AssignmentBuilderParams<TContext extends ServerContext | ClientConte
context: TContext;
};
export type AssignmentBuilder<TContext extends ServerContext | ClientContext> = (params: AssignmentBuilderParams<TContext>) => void;
export type AssignmentBuilder<TContext extends ServerContext | ClientContext> = (params: AssignmentBuilderParams<TContext>) => AssignmentExpression;
export type ClassAnalysis<TContext extends ServerContext | ClientContext> = {
/**
@ -59,5 +59,5 @@ export type ClassAnalysis<TContext extends ServerContext | ClientContext> = {
* a state field on the class. If it is, it registers that state field and modifies the
* assignment expression.
*/
register_assignment: (node: AssignmentExpression, context: TContext) => void;
register_assignment: (node: AssignmentExpression, context: TContext) => AssignmentExpression | null;
}

@ -0,0 +1,13 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>10</button>`,
ssrHtml: `<button>0</button>`,
async test({ assert, target }) {
flushSync();
assert.htmlEqual(target.innerHTML, `<button>10</button>`);
}
});

@ -0,0 +1,13 @@
<script>
class Counter {
constructor() {
this.count = $state(0);
$effect(() => {
this.count = 10;
});
}
}
const counter = new Counter();
</script>
<button on:click={() => counter.count++}>{counter.count}</button>

@ -0,0 +1,45 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
// The component context class instance gets shared between tests, strangely, causing hydration to fail?
mode: ['client', 'server'],
async test({ assert, target, logs }) {
const btn = target.querySelector('button');
flushSync(() => {
btn?.click();
});
assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1]);
flushSync(() => {
btn?.click();
});
assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1, 2]);
flushSync(() => {
btn?.click();
});
assert.deepEqual(logs, [0, 'class trigger false', 'local trigger false', 1, 2, 3]);
flushSync(() => {
btn?.click();
});
assert.deepEqual(logs, [
0,
'class trigger false',
'local trigger false',
1,
2,
3,
4,
'class trigger true',
'local trigger true'
]);
}
});

@ -0,0 +1,37 @@
<script module>
class SomeLogic {
trigger() {
this.someValue++;
}
constructor() {
this.someValue = $state(0);
this.isAboveThree = $derived(this.someValue > 3);
}
}
const someLogic = new SomeLogic();
</script>
<script>
function increment() {
someLogic.trigger();
}
let localDerived = $derived(someLogic.someValue > 3);
$effect(() => {
console.log(someLogic.someValue);
});
$effect(() => {
console.log('class trigger ' + someLogic.isAboveThree)
});
$effect(() => {
console.log('local trigger ' + localDerived)
});
</script>
<button on:click={increment}>
clicks: {someLogic.someValue}
</button>

@ -0,0 +1,20 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>10: 20</button>`,
test({ assert, target }) {
const btn = target.querySelector('button');
flushSync(() => {
btn?.click();
});
assert.htmlEqual(target.innerHTML, `<button>11: 22</button>`);
flushSync(() => {
btn?.click();
});
assert.htmlEqual(target.innerHTML, `<button>12: 24</button>`);
}
});

@ -0,0 +1,22 @@
<script>
class Counter {
constructor(initial) {
this.count = $state(initial);
}
increment = () => {
this.count++;
}
}
class PluggableCounter extends Counter {
constructor(initial, plugin) {
super(initial)
this.custom = $derived(plugin(this.count));
}
}
const counter = new PluggableCounter(10, (count) => count * 2);
</script>
<button onclick={counter.increment}>{counter.count}: {counter.custom}</button>

@ -0,0 +1,20 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>20</button>`,
test({ assert, target }) {
const btn = target.querySelector('button');
flushSync(() => {
btn?.click();
});
assert.htmlEqual(target.innerHTML, `<button>22</button>`);
flushSync(() => {
btn?.click();
});
assert.htmlEqual(target.innerHTML, `<button>24</button>`);
}
});

@ -0,0 +1,18 @@
<script>
class Counter {
/** @type {number} */
#count;
constructor(initial) {
this.#count = $state(initial);
this.doubled = $derived(this.#count * 2);
}
increment = () => {
this.#count++;
}
}
const counter = new Counter(10);
</script>
<button onclick={counter.increment}>{counter.doubled}</button>

@ -0,0 +1,14 @@
[
{
"code": "constructor_state_reassignment",
"message": "Cannot redeclare stateful field `count` in the constructor. The field was originally declared here: `(unknown):2:1`",
"start": {
"line": 5,
"column": 2
},
"end": {
"line": 5,
"column": 24
}
}
]

@ -0,0 +1,7 @@
export class Counter {
count = $state(0);
constructor() {
this.count = $state(0);
}
}

@ -0,0 +1,14 @@
[
{
"code": "constructor_state_reassignment",
"message": "Cannot redeclare stateful field `count` in the constructor. The field was originally declared here: `(unknown):3:2`",
"start": {
"line": 5,
"column": 2
},
"end": {
"line": 5,
"column": 24
}
}
]

@ -0,0 +1,7 @@
export class Counter {
constructor() {
this.count = $state(0);
this.count = 1;
this.count = $state(0);
}
}

@ -0,0 +1,14 @@
[
{
"code": "constructor_state_reassignment",
"message": "Cannot redeclare stateful field `count` in the constructor. The field was originally declared here: `(unknown):3:2`",
"start": {
"line": 5,
"column": 2
},
"end": {
"line": 5,
"column": 28
}
}
]

@ -0,0 +1,7 @@
export class Counter {
constructor() {
this.count = $state(0);
this.count = 1;
this.count = $state.raw(0);
}
}

@ -0,0 +1,14 @@
[
{
"code": "state_invalid_placement",
"message": "`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.",
"start": {
"line": 4,
"column": 16
},
"end": {
"line": 4,
"column": 25
}
}
]

@ -0,0 +1,7 @@
export class Counter {
constructor() {
if (true) {
this.count = $state(0);
}
}
}
Loading…
Cancel
Save