Skip to content

Commit d45c8f0

Browse files
committed
feat: allow state and derived declarations inside class constructors
closes #11116 closes #11339
1 parent f2f71ae commit d45c8f0

File tree

8 files changed

+211
-54
lines changed

8 files changed

+211
-54
lines changed

.changeset/funny-icons-flash.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
feat: allow state and derived declarations inside class constructors

packages/svelte/src/compiler/phases/2-analyze/validation.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,8 @@ function validate_call_expression(node, scope, path) {
821821
) {
822822
if (parent.type === 'VariableDeclarator') return;
823823
if (parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) return;
824-
e.state_invalid_placement(node, rune);
824+
// TODO
825+
// e.state_invalid_placement(node, rune);
825826
}
826827

827828
if (rune === '$effect' || rune === '$effect.pre') {

packages/svelte/src/compiler/phases/3-transform/client/types.d.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import type {
33
Statement,
44
LabeledStatement,
55
Identifier,
6-
PrivateIdentifier
6+
PrivateIdentifier,
7+
Literal
78
} from 'estree';
89
import type { Namespace, SvelteNode, ValidatedCompileOptions } from '#compiler';
910
import type { TransformState } from '../types.js';
@@ -66,6 +67,8 @@ export interface ComponentClientTransformState extends ClientTransformState {
6667

6768
export interface StateField {
6869
kind: 'state' | 'frozen_state' | 'derived' | 'derived_call';
70+
public_id: Identifier | Literal | null;
71+
declared_in_constructor: boolean;
6972
id: PrivateIdentifier;
7073
}
7174

packages/svelte/src/compiler/phases/3-transform/client/utils.js

+26
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
PROPS_IS_RUNES,
1212
PROPS_IS_UPDATED
1313
} from '../../../../constants.js';
14+
import { get_rune } from '../../scope.js';
1415

1516
/**
1617
* @template {import('./types').ClientTransformState} State
@@ -231,6 +232,31 @@ export function serialize_set_binding(node, context, fallback, options) {
231232
state.in_constructor
232233
) {
233234
const public_state = context.state.public_state.get(assignee.property.name);
235+
const rune = get_rune(node.right, context.state.scope);
236+
237+
if (
238+
public_state &&
239+
(rune === '$state' ||
240+
rune === '$state.frozen' ||
241+
rune === '$derived' ||
242+
rune === '$derived.by')
243+
) {
244+
const args = /** @type {import('estree').CallExpression} */ (node.right).arguments;
245+
let value =
246+
args.length === 0
247+
? b.id('undefined')
248+
: /** @type {import('estree').Expression} */ (visit(args[0]));
249+
if (rune === '$state' || rune === '$state.frozen') {
250+
if (should_proxy_or_freeze(value, state.scope)) {
251+
value = b.call(rune === '$state' ? '$.proxy' : '$.freeze', value);
252+
}
253+
value = b.call('$.source', value);
254+
} else {
255+
value = b.call('$.derived', rune === '$derived.by' ? value : b.thunk(value));
256+
}
257+
return b.assignment(node.operator, b.member(b.this, public_state.id), value);
258+
}
259+
234260
const value = get_assignment_value(node, context);
235261
// See if we should wrap value in $.proxy
236262
if (

packages/svelte/src/compiler/phases/3-transform/client/visitors/global.js

+13-5
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,26 @@ export const global_visitors = {
1212
return serialize_get_binding(node, state);
1313
}
1414
},
15-
MemberExpression(node, { state, next }) {
15+
MemberExpression(node, { state, next, path }) {
1616
if (node.object.type === 'ThisExpression') {
17-
// rewrite `this.#foo` as `this.#foo.v` inside a constructor
17+
const parent = path.at(-1);
18+
const is_constructor_assignment =
19+
parent?.type === 'AssignmentExpression' && parent.left === node;
20+
21+
// rewrite `this.#foo = ...` as `this.#foo.v = ...` inside a constructor
1822
if (node.property.type === 'PrivateIdentifier') {
1923
const field = state.private_state.get(node.property.name);
2024
if (field) {
21-
return state.in_constructor ? b.member(node, b.id('v')) : b.call('$.get', node);
25+
if (state.in_constructor && is_constructor_assignment) {
26+
return b.member(node, b.id('v'));
27+
} else {
28+
return b.call('$.get', node);
29+
}
2230
}
2331
}
2432

25-
// rewrite `this.foo` as `this.#foo.v` inside a constructor
26-
if (node.property.type === 'Identifier' && !node.computed) {
33+
// rewrite `this.foo = ...` as `this.#foo.v = ...` inside a constructor
34+
if (node.property.type === 'Identifier' && !node.computed && is_constructor_assignment) {
2735
const field = state.public_state.get(node.property.name);
2836

2937
if (field && state.in_constructor) {

packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js

+106-47
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export const javascript_visitors_runes = {
5050
: rune === '$derived.by'
5151
? 'derived_call'
5252
: 'derived',
53+
public_id: definition.key.type === 'PrivateIdentifier' ? null : definition.key,
54+
declared_in_constructor: false,
5355
// @ts-expect-error this is set in the next pass
5456
id: is_private ? definition.key : null
5557
};
@@ -61,6 +63,61 @@ export const javascript_visitors_runes = {
6163
}
6264
}
6365
}
66+
} else if (definition.type === 'MethodDefinition' && definition.kind === 'constructor') {
67+
for (const entry of definition.value.body.body) {
68+
if (
69+
entry.type === 'ExpressionStatement' &&
70+
entry.expression.type === 'AssignmentExpression'
71+
) {
72+
let { left, right } = entry.expression;
73+
if (
74+
left.type !== 'MemberExpression' ||
75+
left.object.type !== 'ThisExpression' ||
76+
(left.property.type !== 'Identifier' && left.property.type !== 'PrivateIdentifier')
77+
) {
78+
continue;
79+
}
80+
81+
const id = left.property;
82+
const name = get_name(id);
83+
if (!name) continue;
84+
85+
const is_private = id.type === 'PrivateIdentifier';
86+
if (is_private) private_ids.push(name);
87+
88+
if (right.type === 'CallExpression') {
89+
const rune = get_rune(right, state.scope);
90+
if (
91+
rune === '$state' ||
92+
rune === '$state.frozen' ||
93+
rune === '$derived' ||
94+
rune === '$derived.by'
95+
) {
96+
/** @type {import('../types.js').StateField} */
97+
const field = {
98+
kind:
99+
rune === '$state'
100+
? 'state'
101+
: rune === '$state.frozen'
102+
? 'frozen_state'
103+
: rune === '$derived.by'
104+
? 'derived_call'
105+
: 'derived',
106+
public_id: id.type === 'PrivateIdentifier' ? null : id,
107+
declared_in_constructor: true,
108+
// @ts-expect-error this is set in the next pass
109+
id: is_private ? id : null
110+
};
111+
112+
if (is_private) {
113+
private_state.set(name, field);
114+
} else {
115+
public_state.set(name, field);
116+
}
117+
}
118+
}
119+
}
120+
}
64121
}
65122
}
66123

@@ -78,6 +135,53 @@ export const javascript_visitors_runes = {
78135
/** @type {Array<import('estree').MethodDefinition | import('estree').PropertyDefinition>} */
79136
const body = [];
80137

138+
// create getters and setters for public fields
139+
for (const [name, field] of public_state) {
140+
const public_id = /** @type {import('estree').Identifier | import('estree').Literal} */ (
141+
field.public_id
142+
);
143+
const member = b.member(b.this, field.id);
144+
145+
if (field.declared_in_constructor) {
146+
// #foo;
147+
body.push(b.prop_def(field.id, undefined));
148+
}
149+
150+
// get foo() { return this.#foo; }
151+
body.push(b.method('get', public_id, [], [b.return(b.call('$.get', member))]));
152+
153+
if (field.kind === 'state' || field.kind === 'frozen_state') {
154+
// set foo(value) { this.#foo = value; }
155+
const value = b.id('value');
156+
body.push(
157+
b.method(
158+
'set',
159+
public_id,
160+
[value],
161+
[
162+
b.stmt(
163+
b.call(
164+
'$.set',
165+
member,
166+
b.call(field.kind === 'state' ? '$.proxy' : '$.freeze', value)
167+
)
168+
)
169+
]
170+
)
171+
);
172+
} else if (state.options.dev) {
173+
body.push(
174+
b.method(
175+
'set',
176+
public_id,
177+
[b.id('_')],
178+
[b.throw_error(`Cannot update a derived property ('${name}')`)]
179+
)
180+
);
181+
}
182+
}
183+
184+
/** @type {import('../types.js').ComponentClientTransformState} */
81185
const child_state = { ...state, public_state, private_state };
82186

83187
// Replace parts of the class body
@@ -121,53 +225,8 @@ export const javascript_visitors_runes = {
121225
value = b.call('$.source');
122226
}
123227

124-
if (is_private) {
125-
body.push(b.prop_def(field.id, value));
126-
} else {
127-
// #foo;
128-
const member = b.member(b.this, field.id);
129-
body.push(b.prop_def(field.id, value));
130-
131-
// get foo() { return this.#foo; }
132-
body.push(b.method('get', definition.key, [], [b.return(b.call('$.get', member))]));
133-
134-
if (field.kind === 'state') {
135-
// set foo(value) { this.#foo = value; }
136-
const value = b.id('value');
137-
body.push(
138-
b.method(
139-
'set',
140-
definition.key,
141-
[value],
142-
[b.stmt(b.call('$.set', member, b.call('$.proxy', value)))]
143-
)
144-
);
145-
}
146-
147-
if (field.kind === 'frozen_state') {
148-
// set foo(value) { this.#foo = value; }
149-
const value = b.id('value');
150-
body.push(
151-
b.method(
152-
'set',
153-
definition.key,
154-
[value],
155-
[b.stmt(b.call('$.set', member, b.call('$.freeze', value)))]
156-
)
157-
);
158-
}
159-
160-
if ((field.kind === 'derived' || field.kind === 'derived_call') && state.options.dev) {
161-
body.push(
162-
b.method(
163-
'set',
164-
definition.key,
165-
[b.id('_')],
166-
[b.throw_error(`Cannot update a derived property ('${name}')`)]
167-
)
168-
);
169-
}
170-
}
228+
// #foo = $state/$derived();
229+
body.push(b.prop_def(field.id, value));
171230
continue;
172231
}
173232
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
solo: true,
5+
html: `
6+
<button>0</button>
7+
<button>0</button>
8+
`,
9+
10+
async test({ assert, target }) {
11+
const [btn1, btn2] = target.querySelectorAll('button');
12+
13+
await btn1?.click();
14+
assert.htmlEqual(
15+
target.innerHTML,
16+
`
17+
<button>1</button>
18+
<button>0</button>
19+
`
20+
);
21+
22+
await btn2?.click();
23+
assert.htmlEqual(
24+
target.innerHTML,
25+
`
26+
<button>2</button>
27+
<button>1</button>
28+
`
29+
);
30+
}
31+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script>
2+
class Counter {
3+
#count;
4+
get secretCount() {
5+
return this.#count;
6+
}
7+
8+
constructor() {
9+
this.#count = $state(0); // TODO
10+
this.count = $state(0);
11+
this.double = $derived(this.count * 2);
12+
}
13+
14+
increment() {
15+
this.#count++;
16+
this.count++;
17+
}
18+
}
19+
20+
const counter = new Counter();
21+
</script>
22+
23+
<button on:click={() => counter.count++}>{counter.count}</button>
24+
<button on:click={counter.increment}>{counter.secretCount}</button>

0 commit comments

Comments
 (0)