Skip to content

Commit 75cd1e8

Browse files
trueadmRich-Harrisbenmccann
authored
feat: add $state.frozen rune (#9851)
* feat: add $state.raw rune fix typo fix typo * add more tests, fix example * add other test * change to $state.readonly * fix readme * fix validation * fix more * improve types * improve REPL * switch to $state.frozen * update docs * update docs * update docs * Update .changeset/dry-clocks-grow.md Co-authored-by: Ben McCann <[email protected]> * Update packages/svelte/src/internal/client/runtime.js Co-authored-by: Ben McCann <[email protected]> * Update packages/svelte/src/internal/client/runtime.js * docs * Update sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md Co-authored-by: Ben McCann <[email protected]> --------- Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Ben McCann <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent eab690d commit 75cd1e8

File tree

34 files changed

+425
-62
lines changed

34 files changed

+425
-62
lines changed

.changeset/dry-clocks-grow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
feat: add `$state.frozen` rune

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

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ const legacy_scope_tweaker = {
596596
);
597597
if (
598598
binding.kind === 'state' ||
599+
binding.kind === 'frozen_state' ||
599600
(binding.kind === 'normal' && binding.declaration_kind === 'let')
600601
) {
601602
binding.kind = 'prop';
@@ -647,18 +648,19 @@ const legacy_scope_tweaker = {
647648
const runes_scope_js_tweaker = {
648649
VariableDeclarator(node, { state }) {
649650
if (node.init?.type !== 'CallExpression') return;
650-
if (get_rune(node.init, state.scope) === null) return;
651+
const rune = get_rune(node.init, state.scope);
652+
if (rune === null) return;
651653

652654
const callee = node.init.callee;
653-
if (callee.type !== 'Identifier') return;
655+
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return;
654656

655-
const name = callee.name;
656-
if (name !== '$state' && name !== '$derived') return;
657+
if (rune !== '$state' && rune !== '$state.frozen' && rune !== '$derived') return;
657658

658659
for (const path of extract_paths(node.id)) {
659660
// @ts-ignore this fails in CI for some insane reason
660661
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(path.node.name));
661-
binding.kind = name === '$state' ? 'state' : 'derived';
662+
binding.kind =
663+
rune === '$state' ? 'state' : rune === '$state.frozen' ? 'frozen_state' : 'derived';
662664
}
663665
}
664666
};
@@ -676,28 +678,31 @@ const runes_scope_tweaker = {
676678
VariableDeclarator(node, { state }) {
677679
const init = unwrap_ts_expression(node.init);
678680
if (!init || init.type !== 'CallExpression') return;
679-
if (get_rune(init, state.scope) === null) return;
681+
const rune = get_rune(init, state.scope);
682+
if (rune === null) return;
680683

681684
const callee = init.callee;
682-
if (callee.type !== 'Identifier') return;
685+
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return;
683686

684-
const name = callee.name;
685-
if (name !== '$state' && name !== '$derived' && name !== '$props') return;
687+
if (rune !== '$state' && rune !== '$state.frozen' && rune !== '$derived' && rune !== '$props')
688+
return;
686689

687690
for (const path of extract_paths(node.id)) {
688691
// @ts-ignore this fails in CI for some insane reason
689692
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(path.node.name));
690693
binding.kind =
691-
name === '$state'
694+
rune === '$state'
692695
? 'state'
693-
: name === '$derived'
696+
: rune === '$state.frozen'
697+
? 'frozen_state'
698+
: rune === '$derived'
694699
? 'derived'
695700
: path.is_rest
696701
? 'rest_prop'
697702
: 'prop';
698703
}
699704

700-
if (name === '$props') {
705+
if (rune === '$props') {
701706
for (const property of /** @type {import('estree').ObjectPattern} */ (node.id).properties) {
702707
if (property.type !== 'Property') continue;
703708

@@ -909,7 +914,9 @@ const common_visitors = {
909914

910915
if (
911916
node !== binding.node &&
912-
(binding.kind === 'state' || binding.kind === 'derived') &&
917+
(binding.kind === 'state' ||
918+
binding.kind === 'frozen_state' ||
919+
binding.kind === 'derived') &&
913920
context.state.function_depth === binding.scope.function_depth
914921
) {
915922
warn(context.state.analysis.warnings, node, context.path, 'static-state-reference');

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ export const validation = {
349349
if (
350350
!binding ||
351351
(binding.kind !== 'state' &&
352+
binding.kind !== 'frozen_state' &&
352353
binding.kind !== 'prop' &&
353354
binding.kind !== 'each' &&
354355
binding.kind !== 'store_sub' &&
@@ -661,7 +662,7 @@ function validate_export(node, scope, name) {
661662
error(node, 'invalid-derived-export');
662663
}
663664

664-
if (binding.kind === 'state' && binding.reassigned) {
665+
if ((binding.kind === 'state' || binding.kind === 'frozen_state') && binding.reassigned) {
665666
error(node, 'invalid-state-export');
666667
}
667668
}
@@ -835,7 +836,9 @@ function validate_no_const_assignment(node, argument, scope, is_binding) {
835836
is_binding,
836837
// This takes advantage of the fact that we don't assign initial for let directives and then/catch variables.
837838
// If we start doing that, we need another property on the binding to differentiate, or give up on the more precise error message.
838-
binding.kind !== 'state' && (binding.kind !== 'normal' || !binding.initial)
839+
binding.kind !== 'state' &&
840+
binding.kind !== 'frozen_state' &&
841+
(binding.kind !== 'normal' || !binding.initial)
839842
);
840843
}
841844
}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,15 +233,18 @@ export function client_component(source, analysis, options) {
233233
'$.bind_prop',
234234
b.id('$$props'),
235235
b.literal(alias ?? name),
236-
binding?.kind === 'state' ? b.call('$.get', b.id(name)) : b.id(name)
236+
binding?.kind === 'state' || binding?.kind === 'frozen_state'
237+
? b.call('$.get', b.id(name))
238+
: b.id(name)
237239
)
238240
);
239241
});
240242

241243
const properties = analysis.exports.map(({ name, alias }) => {
242244
const binding = analysis.instance.scope.get(name);
243245
const is_source =
244-
binding?.kind === 'state' && (!state.analysis.immutable || binding.reassigned);
246+
(binding?.kind === 'state' || binding?.kind === 'frozen_state') &&
247+
(!state.analysis.immutable || binding.reassigned);
245248

246249
// TODO This is always a getter because the `renamed-instance-exports` test wants it that way.
247250
// Should we for code size reasons make it an init in runes mode and/or non-dev mode?

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export interface ComponentClientTransformState extends ClientTransformState {
5959
}
6060

6161
export interface StateField {
62-
kind: 'state' | 'derived';
62+
kind: 'state' | 'frozen_state' | 'derived';
6363
id: PrivateIdentifier;
6464
}
6565

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

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export function serialize_get_binding(node, state) {
9292
}
9393

9494
if (
95-
(binding.kind === 'state' &&
95+
((binding.kind === 'state' || binding.kind === 'frozen_state') &&
9696
(!state.analysis.immutable || state.analysis.accessors || binding.reassigned)) ||
9797
binding.kind === 'derived' ||
9898
binding.kind === 'legacy_reactive'
@@ -162,40 +162,53 @@ export function serialize_set_binding(node, context, fallback) {
162162

163163
// Handle class private/public state assignment cases
164164
while (left.type === 'MemberExpression') {
165-
if (
166-
left.object.type === 'ThisExpression' &&
167-
left.property.type === 'PrivateIdentifier' &&
168-
context.state.private_state.has(left.property.name)
169-
) {
165+
if (left.object.type === 'ThisExpression' && left.property.type === 'PrivateIdentifier') {
166+
const private_state = context.state.private_state.get(left.property.name);
170167
const value = get_assignment_value(node, context);
171-
if (state.in_constructor) {
172-
// See if we should wrap value in $.proxy
173-
if (context.state.analysis.runes && should_proxy(value)) {
174-
const assignment = fallback();
175-
if (assignment.type === 'AssignmentExpression') {
176-
assignment.right = b.call('$.proxy', value);
177-
return assignment;
168+
if (private_state !== undefined) {
169+
if (state.in_constructor) {
170+
// See if we should wrap value in $.proxy
171+
if (context.state.analysis.runes && should_proxy_or_freeze(value)) {
172+
const assignment = fallback();
173+
if (assignment.type === 'AssignmentExpression') {
174+
assignment.right =
175+
private_state.kind === 'frozen_state'
176+
? b.call('$.freeze', value)
177+
: b.call('$.proxy', value);
178+
return assignment;
179+
}
178180
}
181+
} else {
182+
return b.call(
183+
'$.set',
184+
left,
185+
context.state.analysis.runes && should_proxy_or_freeze(value)
186+
? private_state.kind === 'frozen_state'
187+
? b.call('$.freeze', value)
188+
: b.call('$.proxy', value)
189+
: value
190+
);
179191
}
180-
} else {
181-
return b.call(
182-
'$.set',
183-
left,
184-
context.state.analysis.runes && should_proxy(value) ? b.call('$.proxy', value) : value
185-
);
186192
}
187193
} else if (
188194
left.object.type === 'ThisExpression' &&
189195
left.property.type === 'Identifier' &&
190-
context.state.public_state.has(left.property.name) &&
191196
state.in_constructor
192197
) {
198+
const public_state = context.state.public_state.get(left.property.name);
193199
const value = get_assignment_value(node, context);
194200
// See if we should wrap value in $.proxy
195-
if (context.state.analysis.runes && should_proxy(value)) {
201+
if (
202+
context.state.analysis.runes &&
203+
public_state !== undefined &&
204+
should_proxy_or_freeze(value)
205+
) {
196206
const assignment = fallback();
197207
if (assignment.type === 'AssignmentExpression') {
198-
assignment.right = b.call('$.proxy', value);
208+
assignment.right =
209+
public_state.kind === 'frozen_state'
210+
? b.call('$.freeze', value)
211+
: b.call('$.proxy', value);
199212
return assignment;
200213
}
201214
}
@@ -232,6 +245,7 @@ export function serialize_set_binding(node, context, fallback) {
232245

233246
if (
234247
binding.kind !== 'state' &&
248+
binding.kind !== 'frozen_state' &&
235249
binding.kind !== 'prop' &&
236250
binding.kind !== 'each' &&
237251
binding.kind !== 'legacy_reactive' &&
@@ -249,12 +263,24 @@ export function serialize_set_binding(node, context, fallback) {
249263
return b.call(left, value);
250264
} else if (is_store) {
251265
return b.call('$.store_set', serialize_get_binding(b.id(left_name), state), value);
252-
} else {
266+
} else if (binding.kind === 'state') {
253267
return b.call(
254268
'$.set',
255269
b.id(left_name),
256-
context.state.analysis.runes && should_proxy(value) ? b.call('$.proxy', value) : value
270+
context.state.analysis.runes && should_proxy_or_freeze(value)
271+
? b.call('$.proxy', value)
272+
: value
257273
);
274+
} else if (binding.kind === 'frozen_state') {
275+
return b.call(
276+
'$.set',
277+
b.id(left_name),
278+
context.state.analysis.runes && should_proxy_or_freeze(value)
279+
? b.call('$.freeze', value)
280+
: value
281+
);
282+
} else {
283+
return b.call('$.set', b.id(left_name), value);
258284
}
259285
} else {
260286
if (is_store) {
@@ -492,7 +518,7 @@ export function create_state_declarators(declarator, scope, value) {
492518
}
493519

494520
/** @param {import('estree').Expression} node */
495-
export function should_proxy(node) {
521+
export function should_proxy_or_freeze(node) {
496522
if (
497523
!node ||
498524
node.type === 'Literal' ||

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const global_visitors = {
4949
// use runtime functions for smaller output
5050
if (
5151
binding?.kind === 'state' ||
52+
binding?.kind === 'frozen_state' ||
5253
binding?.kind === 'each' ||
5354
binding?.kind === 'legacy_reactive' ||
5455
binding?.kind === 'prop' ||

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { get_rune } from '../../../scope.js';
22
import { is_hoistable_function, transform_inspect_rune } from '../../utils.js';
33
import * as b from '../../../../utils/builders.js';
44
import * as assert from '../../../../utils/assert.js';
5-
import { create_state_declarators, get_prop_source, should_proxy } from '../utils.js';
5+
import { create_state_declarators, get_prop_source, should_proxy_or_freeze } from '../utils.js';
66
import { unwrap_ts_expression } from '../../../../utils/ast.js';
77

88
/** @type {import('../types.js').ComponentVisitors} */
@@ -29,10 +29,11 @@ export const javascript_visitors_runes = {
2929

3030
if (definition.value?.type === 'CallExpression') {
3131
const rune = get_rune(definition.value, state.scope);
32-
if (rune === '$state' || rune === '$derived') {
32+
if (rune === '$state' || rune === '$state.frozen' || rune === '$derived') {
3333
/** @type {import('../types.js').StateField} */
3434
const field = {
35-
kind: rune === '$state' ? 'state' : 'derived',
35+
kind:
36+
rune === '$state' ? 'state' : rune === '$state.frozen' ? 'frozen_state' : 'derived',
3637
// @ts-expect-error this is set in the next pass
3738
id: is_private ? definition.key : null
3839
};
@@ -84,7 +85,9 @@ export const javascript_visitors_runes = {
8485

8586
value =
8687
field.kind === 'state'
87-
? b.call('$.source', should_proxy(init) ? b.call('$.proxy', init) : init)
88+
? b.call('$.source', should_proxy_or_freeze(init) ? b.call('$.proxy', init) : init)
89+
: field.kind === 'frozen_state'
90+
? b.call('$.source', should_proxy_or_freeze(init) ? b.call('$.freeze', init) : init)
8891
: b.call('$.derived', b.thunk(init));
8992
} else {
9093
// if no arguments, we know it's state as `$derived()` is a compile error
@@ -114,6 +117,19 @@ export const javascript_visitors_runes = {
114117
);
115118
}
116119

120+
if (field.kind === 'frozen_state') {
121+
// set foo(value) { this.#foo = value; }
122+
const value = b.id('value');
123+
body.push(
124+
b.method(
125+
'set',
126+
definition.key,
127+
[value],
128+
[b.stmt(b.call('$.set', member, b.call('$.freeze', value)))]
129+
)
130+
);
131+
}
132+
117133
if (field.kind === 'derived' && state.options.dev) {
118134
body.push(
119135
b.method(
@@ -217,13 +233,24 @@ export const javascript_visitors_runes = {
217233
const binding = /** @type {import('#compiler').Binding} */ (
218234
state.scope.get(declarator.id.name)
219235
);
220-
if (should_proxy(value)) {
236+
if (should_proxy_or_freeze(value)) {
221237
value = b.call('$.proxy', value);
222238
}
223239

224240
if (!state.analysis.immutable || state.analysis.accessors || binding.reassigned) {
225241
value = b.call('$.source', value);
226242
}
243+
} else if (rune === '$state.frozen') {
244+
const binding = /** @type {import('#compiler').Binding} */ (
245+
state.scope.get(declarator.id.name)
246+
);
247+
if (should_proxy_or_freeze(value)) {
248+
value = b.call('$.freeze', value);
249+
}
250+
251+
if (binding.reassigned) {
252+
value = b.call('$.source', value);
253+
}
227254
} else {
228255
value = b.call('$.derived', b.thunk(value));
229256
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,6 +1245,7 @@ function serialize_event_handler(node, { state, visit }) {
12451245
if (
12461246
binding !== null &&
12471247
(binding.kind === 'state' ||
1248+
binding.kind === 'frozen_state' ||
12481249
binding.kind === 'legacy_reactive' ||
12491250
binding.kind === 'derived' ||
12501251
binding.kind === 'prop' ||

packages/svelte/src/compiler/phases/3-transform/server/transform-server.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ function serialize_set_binding(node, context, fallback) {
446446

447447
if (
448448
binding.kind !== 'state' &&
449+
binding.kind !== 'frozen_state' &&
449450
binding.kind !== 'prop' &&
450451
binding.kind !== 'each' &&
451452
binding.kind !== 'legacy_reactive' &&
@@ -558,7 +559,7 @@ const javascript_visitors_runes = {
558559
if (node.value != null && node.value.type === 'CallExpression') {
559560
const rune = get_rune(node.value, state.scope);
560561

561-
if (rune === '$state' || rune === '$derived') {
562+
if (rune === '$state' || rune === '$state.frozen' || rune === '$derived') {
562563
return {
563564
...node,
564565
value:

packages/svelte/src/compiler/phases/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const ElementBindings = [
7272

7373
export const Runes = /** @type {const} */ ([
7474
'$state',
75+
'$state.frozen',
7576
'$props',
7677
'$derived',
7778
'$effect',

0 commit comments

Comments
 (0)