change to $state.readonly

pull/9851/head
Dominic Gannaway 3 years ago
parent 573cd09e42
commit b689c462c3

@ -2,4 +2,4 @@
'svelte': patch
---
feat: add $state.raw rune
feat: add $state.readonly rune

@ -585,7 +585,7 @@ const legacy_scope_tweaker = {
);
if (
binding.kind === 'state' ||
binding.kind === 'raw_state' ||
binding.kind === 'readonly_state' ||
(binding.kind === 'normal' && binding.declaration_kind === 'let')
) {
binding.kind = 'prop';
@ -643,12 +643,13 @@ const runes_scope_js_tweaker = {
const callee = node.init.callee;
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return;
if (rune !== '$state' && rune !== '$state.raw' && rune !== '$derived') return;
if (rune !== '$state' && rune !== '$state.readonly' && rune !== '$derived') return;
for (const path of extract_paths(node.id)) {
// @ts-ignore this fails in CI for some insane reason
const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(path.node.name));
binding.kind = rune === '$state' ? 'state' : rune === '$state.raw' ? 'raw_state' : 'derived';
binding.kind =
rune === '$state' ? 'state' : rune === '$state.readonly' ? 'readonly_state' : 'derived';
}
}
};
@ -672,7 +673,7 @@ const runes_scope_tweaker = {
const callee = init.callee;
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return;
if (rune !== '$state' && rune !== '$state.raw' && rune !== '$derived' && rune !== '$props')
if (rune !== '$state' && rune !== '$state.readonly' && rune !== '$derived' && rune !== '$props')
return;
for (const path of extract_paths(node.id)) {
@ -681,8 +682,8 @@ const runes_scope_tweaker = {
binding.kind =
rune === '$state'
? 'state'
: rune === '$state.raw'
? 'raw_state'
: rune === '$state.readonly'
? 'readonly_state'
: rune === '$derived'
? 'derived'
: path.is_rest
@ -902,7 +903,9 @@ const common_visitors = {
if (
node !== binding.node &&
(binding.kind === 'state' || binding.kind === 'raw_state' || binding.kind === 'derived') &&
(binding.kind === 'state' ||
binding.kind === 'readonly_state' ||
binding.kind === 'derived') &&
context.state.function_depth === binding.scope.function_depth
) {
warn(context.state.analysis.warnings, node, context.path, 'static-state-reference');

@ -59,7 +59,7 @@ export interface ComponentClientTransformState extends ClientTransformState {
}
export interface StateField {
kind: 'state' | 'raw_state' | 'derived';
kind: 'state' | 'readonly_state' | 'derived';
id: PrivateIdentifier;
}

@ -92,7 +92,7 @@ export function serialize_get_binding(node, state) {
}
if (
((binding.kind === 'state' || binding.kind === 'raw_state') &&
((binding.kind === 'state' || binding.kind === 'readonly_state') &&
(!state.analysis.immutable || state.analysis.accessors || binding.reassigned)) ||
binding.kind === 'derived' ||
binding.kind === 'legacy_reactive'
@ -162,40 +162,53 @@ export function serialize_set_binding(node, context, fallback) {
// Handle class private/public state assignment cases
while (left.type === 'MemberExpression') {
if (
left.object.type === 'ThisExpression' &&
left.property.type === 'PrivateIdentifier' &&
context.state.private_state.has(left.property.name)
) {
if (left.object.type === 'ThisExpression' && left.property.type === 'PrivateIdentifier') {
const private_state = context.state.private_state.get(left.property.name);
const value = get_assignment_value(node, context);
if (state.in_constructor) {
// See if we should wrap value in $.proxy
if (context.state.analysis.runes && should_proxy(value)) {
const assignment = fallback();
if (assignment.type === 'AssignmentExpression') {
assignment.right = b.call('$.proxy', value);
return assignment;
if (private_state !== undefined) {
if (state.in_constructor) {
// See if we should wrap value in $.proxy
if (context.state.analysis.runes && should_proxy_or_freeze(value)) {
const assignment = fallback();
if (assignment.type === 'AssignmentExpression') {
assignment.right =
private_state.kind === 'readonly_state'
? b.call('$.freeze', value)
: b.call('$.proxy', value);
return assignment;
}
}
} else {
return b.call(
'$.set',
left,
context.state.analysis.runes && should_proxy_or_freeze(value)
? private_state.kind === 'readonly_state'
? b.call('$.freeze', value)
: b.call('$.proxy', value)
: value
);
}
} else {
return b.call(
'$.set',
left,
context.state.analysis.runes && should_proxy(value) ? b.call('$.proxy', value) : value
);
}
} else if (
left.object.type === 'ThisExpression' &&
left.property.type === 'Identifier' &&
context.state.public_state.has(left.property.name) &&
state.in_constructor
) {
const public_state = context.state.public_state.get(left.property.name);
const value = get_assignment_value(node, context);
// See if we should wrap value in $.proxy
if (context.state.analysis.runes && should_proxy(value)) {
if (
context.state.analysis.runes &&
public_state !== undefined &&
should_proxy_or_freeze(value)
) {
const assignment = fallback();
if (assignment.type === 'AssignmentExpression') {
assignment.right = b.call('$.proxy', value);
assignment.right =
public_state.kind === 'readonly_state'
? b.call('$.freeze', value)
: b.call('$.proxy', value);
return assignment;
}
}
@ -232,7 +245,7 @@ export function serialize_set_binding(node, context, fallback) {
if (
binding.kind !== 'state' &&
binding.kind !== 'raw_state' &&
binding.kind !== 'readonly_state' &&
binding.kind !== 'prop' &&
binding.kind !== 'each' &&
binding.kind !== 'legacy_reactive' &&
@ -254,7 +267,17 @@ export function serialize_set_binding(node, context, fallback) {
return b.call(
'$.set',
b.id(left_name),
context.state.analysis.runes && should_proxy(value) ? b.call('$.proxy', value) : value
context.state.analysis.runes && should_proxy_or_freeze(value)
? b.call('$.proxy', value)
: value
);
} else if (binding.kind === 'readonly_state') {
return b.call(
'$.set',
b.id(left_name),
context.state.analysis.runes && should_proxy_or_freeze(value)
? b.call('$.freeze', value)
: value
);
} else {
return b.call('$.set', b.id(left_name), value);
@ -496,7 +519,7 @@ export function create_state_declarators(declarator, scope, value) {
}
/** @param {import('estree').Expression} node */
export function should_proxy(node) {
export function should_proxy_or_freeze(node) {
if (
!node ||
node.type === 'Literal' ||

@ -49,7 +49,7 @@ export const global_visitors = {
// use runtime functions for smaller output
if (
binding?.kind === 'state' ||
binding?.kind === 'raw_state' ||
binding?.kind === 'readonly_state' ||
binding?.kind === 'each' ||
binding?.kind === 'legacy_reactive' ||
binding?.kind === 'prop' ||

@ -2,7 +2,7 @@ import { get_rune } from '../../../scope.js';
import { is_hoistable_function, transform_inspect_rune } from '../../utils.js';
import * as b from '../../../../utils/builders.js';
import * as assert from '../../../../utils/assert.js';
import { create_state_declarators, get_prop_source, should_proxy } from '../utils.js';
import { create_state_declarators, get_prop_source, should_proxy_or_freeze } from '../utils.js';
import { unwrap_ts_expression } from '../../../../utils/ast.js';
/** @type {import('../types.js').ComponentVisitors} */
@ -29,10 +29,15 @@ export const javascript_visitors_runes = {
if (definition.value?.type === 'CallExpression') {
const rune = get_rune(definition.value, state.scope);
if (rune === '$state' || rune === '$state.raw' || rune === '$derived') {
if (rune === '$state' || rune === '$state.readonly' || rune === '$derived') {
/** @type {import('../types.js').StateField} */
const field = {
kind: rune === '$state' ? 'state' : rune === '$state.raw' ? 'raw_state' : 'derived',
kind:
rune === '$state'
? 'state'
: rune === '$state.readonly'
? 'readonly_state'
: 'derived',
// @ts-expect-error this is set in the next pass
id: is_private ? definition.key : null
};
@ -84,9 +89,9 @@ export const javascript_visitors_runes = {
value =
field.kind === 'state'
? b.call('$.source', should_proxy(init) ? b.call('$.proxy', init) : init)
: field.kind === 'raw_state'
? b.call('$.source', init)
? b.call('$.source', should_proxy_or_freeze(init) ? b.call('$.proxy', init) : init)
: field.kind === 'readonly_state'
? b.call('$.source', should_proxy_or_freeze(init) ? b.call('$.freeze', init) : init)
: b.call('$.derived', b.thunk(init));
} else {
// if no arguments, we know it's state as `$derived()` is a compile error
@ -116,11 +121,16 @@ export const javascript_visitors_runes = {
);
}
if (field.kind === 'raw_state') {
if (field.kind === 'readonly_state') {
// set foo(value) { this.#foo = value; }
const value = b.id('value');
body.push(
b.method('set', definition.key, [value], [b.stmt(b.call('$.set', member, value))])
b.method(
'set',
definition.key,
[value],
[b.stmt(b.call('$.set', member, b.call('$.freeze', value)))]
)
);
}
@ -227,17 +237,21 @@ export const javascript_visitors_runes = {
const binding = /** @type {import('#compiler').Binding} */ (
state.scope.get(declarator.id.name)
);
if (should_proxy(value)) {
if (should_proxy_or_freeze(value)) {
value = b.call('$.proxy', value);
}
if (!state.analysis.immutable || state.analysis.accessors || binding.reassigned) {
value = b.call('$.source', value);
}
} else if (rune === '$state.raw') {
} else if (rune === '$state.readonly') {
const binding = /** @type {import('#compiler').Binding} */ (
state.scope.get(declarator.id.name)
);
if (should_proxy_or_freeze(value)) {
value = b.call('$.freeze', value);
}
if (binding.reassigned) {
value = b.call('$.source', value);
}

@ -1227,7 +1227,7 @@ function serialize_event_handler(node, { state, visit }) {
if (
binding !== null &&
(binding.kind === 'state' ||
binding.kind === 'raw_state' ||
binding.kind === 'readonly_state' ||
binding.kind === 'legacy_reactive' ||
binding.kind === 'derived' ||
binding.kind === 'prop' ||

@ -446,7 +446,7 @@ function serialize_set_binding(node, context, fallback) {
if (
binding.kind !== 'state' &&
binding.kind !== 'raw_state' &&
binding.kind !== 'readonly_state' &&
binding.kind !== 'prop' &&
binding.kind !== 'each' &&
binding.kind !== 'legacy_reactive' &&
@ -559,7 +559,7 @@ const javascript_visitors_runes = {
if (node.value != null && node.value.type === 'CallExpression') {
const rune = get_rune(node.value, state.scope);
if (rune === '$state' || rune === '$state.raw' || rune === '$derived') {
if (rune === '$state' || rune === '$state.readonly' || rune === '$derived') {
return {
...node,
value:

@ -72,7 +72,7 @@ export const ElementBindings = [
export const Runes = /** @type {const} */ ([
'$state',
'$state.raw',
'$state.readonly',
'$props',
'$derived',
'$effect',

@ -258,7 +258,7 @@ export interface Binding {
| 'prop'
| 'rest_prop'
| 'state'
| 'raw_state'
| 'readonly_state'
| 'derived'
| 'each'
| 'store_sub'

@ -1,4 +1,4 @@
import { define_property } from '../utils.js';
import { define_property, is_frozen } from '../utils.js';
import { READONLY_SYMBOL, STATE_SYMBOL } from './proxy.js';
/**
@ -6,8 +6,6 @@ import { READONLY_SYMBOL, STATE_SYMBOL } from './proxy.js';
* @typedef {T & { [READONLY_SYMBOL]: Proxy<T> }} StateObject
*/
const is_frozen = Object.isFrozen;
/**
* Expects a value that was wrapped with `proxy` and makes it readonly.
*

@ -1,7 +1,7 @@
import { DEV } from 'esm-env';
import { subscribe_to_store } from '../../store/utils.js';
import { EMPTY_FUNC, run_all } from '../common.js';
import { get_descriptor, get_descriptors, is_array } from './utils.js';
import { get_descriptor, get_descriptors, is_array, is_frozen, object_freeze } from './utils.js';
import {
PROPS_IS_LAZY_INITIAL,
PROPS_IS_IMMUTABLE,
@ -9,7 +9,7 @@ import {
PROPS_IS_UPDATED
} from '../../constants.js';
import { readonly } from './proxy/readonly.js';
import { proxy, unstate } from './proxy/proxy.js';
import { READONLY_SYMBOL, STATE_SYMBOL, proxy, unstate } from './proxy/proxy.js';
export const SOURCE = 1;
export const DERIVED = 1 << 1;
@ -1899,3 +1899,26 @@ if (DEV) {
throw_rune_error('$inspect');
throw_rune_error('$props');
}
/**
* Expects a value that was wrapped with `freeze` and makes it frozen.
*
* @template {import('./proxy/proxy.js').StateObject} T
* @param {T} value
* @returns {Readonly<Record<string | symbol, any>>}
*/
export function freeze(value) {
if (typeof value === 'object' && value != null && !is_frozen(value)) {
// If the object is already proxified, then unstate the value
if (STATE_SYMBOL in value) {
return object_freeze(unstate(value));
}
// If the value isn't already read-only then just use that
if (DEV && READONLY_SYMBOL in value) {
return value;
}
// Otherwise freeze the object
object_freeze(value);
}
return value;
}

@ -5,6 +5,8 @@ export var array_from = Array.from;
export var object_keys = Object.keys;
export var object_entries = Object.entries;
export var object_assign = Object.assign;
export var is_frozen = Object.isFrozen;
export var object_freeze = Object.freeze;
export var define_property = Object.defineProperty;
export var get_descriptor = Object.getOwnPropertyDescriptor;
export var get_descriptors = Object.getOwnPropertyDescriptors;

@ -36,7 +36,8 @@ export {
effect_active,
user_root_effect,
inspect,
unwrap
unwrap,
freeze
} from './client/runtime.js';
export * from './client/each.js';

@ -19,12 +19,12 @@ declare function $state<T>(): T | undefined;
declare namespace $state {
/**
* Declares reactive state without applying reactivity to nested properties.
* Declares reactive read-only state.
*
* Example:
* ```ts
* <script>
* let items = $state.raw([0]);
* let items = $state.readonly([0]);
*
* const addItem = () => {
* items = [...items, items.length];
@ -40,8 +40,8 @@ declare namespace $state {
*
* @param initial The initial value
*/
export function $raw<T>(initial: T): T;
export function $raw<T>(): T | undefined;
export function readonly<T>(initial: T): T;
export function readonly<T>(): T | undefined;
}
/**

@ -1,8 +1,13 @@
import { test } from '../../test';
import { log } from './log.js';
export default test({
html: `<button>0</button>`,
before_test() {
log.length = 0;
},
async test({ assert, target }) {
const btn = target.querySelector('button');
@ -11,5 +16,7 @@ export default test({
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>0</button>`);
assert.deepEqual(log, ['read only', 'read only']);
}
});

@ -1,6 +1,8 @@
<script>
import { log } from './log.js';
class Counter {
#count = $state.raw();
#count = $state.readonly();
constructor(initial_count) {
this.#count = { a: initial_count };
@ -16,4 +18,10 @@
const counter = new Counter(0);
</script>
<button on:click={() => counter.count.a++}>{counter.count.a}</button>
<button on:click={() => {
try {
counter.count.a++
} catch (e) {
log.push('read only')
}
}}>{counter.count.a}</button>

@ -1,6 +1,6 @@
<script>
class Counter {
#count = $state.raw(0);
#count = $state.readonly(0);
constructor(initial_count) {
this.#count = initial_count;

@ -1,8 +0,0 @@
<script>
class Counter {
count = $state.raw({ a: 0 });
}
const counter = new Counter();
</script>
<button on:click={() => counter.count.a++}>{counter.count.a}</button>

@ -1,8 +1,13 @@
import { test } from '../../test';
import { log } from './log.js';
export default test({
html: `<button>0</button>`,
before_test() {
log.length = 0;
},
async test({ assert, target }) {
const btn = target.querySelector('button');
@ -11,5 +16,7 @@ export default test({
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>0</button>`);
assert.deepEqual(log, ['read only', 'read only']);
}
});

@ -0,0 +1,16 @@
<script>
import { log } from './log.js';
class Counter {
count = $state.readonly({ a: 0 });
}
const counter = new Counter();
</script>
<button on:click={() => {
try {
counter.count.a++
} catch (e) {
log.push('read only')
}
}}>{counter.count.a}</button>

@ -1,6 +1,6 @@
<script>
class Counter {
count = $state.raw(0);
count = $state.readonly(0);
}
const counter = new Counter();
</script>

@ -1,5 +1,5 @@
<script>
let items = $state.raw([0]);
let items = $state.readonly([0]);
const addItem = () => {
items = [...items, items.length];

@ -0,0 +1,2 @@
/** @type {any[]} */
export const log = [];

@ -1,8 +1,8 @@
<script>
import { log } from './log.js';
let x = $state.raw(0);
let y = $state.raw(0);
let x = $state.readonly(0);
let y = $state.readonly(0);
$effect(() => {
log.push(x);

@ -64,15 +64,15 @@ Objects and arrays [are made reactive](/#H4sIAAAAAAAAE42QwWrDMBBEf2URhUhUNEl7c21
In non-runes mode, a `let` declaration is treated as reactive state if it is updated at some point. Unlike `$state(...)`, which works anywhere in your app, `let` only behaves this way at the top level of a component.
## `$state.raw`
## `$state.readonly`
Similar to `$state`, `$state.raw` is also declared and can be used in many of the same ways (including on classes). However, reactivity is _not_ applied deeply to properties of any objects or arrays used with `$state.raw`. So if you intend to use objects as state and you want to mutate their properties and have reactivity work by default, it's recommended you use `$state` instead.
Similar to `$state`, `$state.readonly` is also declared and can be used in many of the same ways (including on classes). However, the properties of any object and arrays are treated as read-only, and cannot be mutated. So if you intend to use objects as state and you want to mutate their properties and have reactivity work by default, it's recommended you use `$state` instead.
For the cases where you don't want Svelte's reactivity to apply deeply to state, and for those who might want to have more control over their data structures, you might find `$state.raw` useful. Furthermore, `$state.raw` is ideal for those who want to work with data using immutable patterns rather than mutable patterns.
For the cases where you don't want Svelte's reactivity to apply deeply to state, and for those who might want to have more control over their data structures, you might find `$state.readonly` useful. Furthermore, `$state.readonly` is ideal for those who want to work with data using immutable patterns rather than mutable patterns.
```svelte
<script>
let items = $state.raw([0]);
let items = $state.readonly([0]);
const addItem = () => {
items = [...items, items.length];
@ -84,6 +84,8 @@ For the cases where you don't want Svelte's reactivity to apply deeply to state,
</button>
```
> Plain objects and arrays passed to `$state.readonly` will be shallowly frozen using `Object.freeze()`.
## `$derived`
Derived state is declared with the `$derived` rune:

Loading…
Cancel
Save