mirror of
https://github.com/sveltejs/svelte.git
synced 2024-11-21 19:38:58 +01:00
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 <322311+benmccann@users.noreply.github.com> * Update packages/svelte/src/internal/client/runtime.js Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> * 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 <322311+benmccann@users.noreply.github.com> --------- Co-authored-by: Rich Harris <richard.a.harris@gmail.com> Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> Co-authored-by: Rich Harris <rich.harris@vercel.com>
This commit is contained in:
parent
eab690d31a
commit
75cd1e825c
5
.changeset/dry-clocks-grow.md
Normal file
5
.changeset/dry-clocks-grow.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
feat: add `$state.frozen` rune
|
@ -596,6 +596,7 @@ const legacy_scope_tweaker = {
|
||||
);
|
||||
if (
|
||||
binding.kind === 'state' ||
|
||||
binding.kind === 'frozen_state' ||
|
||||
(binding.kind === 'normal' && binding.declaration_kind === 'let')
|
||||
) {
|
||||
binding.kind = 'prop';
|
||||
@ -647,18 +648,19 @@ const legacy_scope_tweaker = {
|
||||
const runes_scope_js_tweaker = {
|
||||
VariableDeclarator(node, { state }) {
|
||||
if (node.init?.type !== 'CallExpression') return;
|
||||
if (get_rune(node.init, state.scope) === null) return;
|
||||
const rune = get_rune(node.init, state.scope);
|
||||
if (rune === null) return;
|
||||
|
||||
const callee = node.init.callee;
|
||||
if (callee.type !== 'Identifier') return;
|
||||
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return;
|
||||
|
||||
const name = callee.name;
|
||||
if (name !== '$state' && name !== '$derived') return;
|
||||
if (rune !== '$state' && rune !== '$state.frozen' && 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 = name === '$state' ? 'state' : 'derived';
|
||||
binding.kind =
|
||||
rune === '$state' ? 'state' : rune === '$state.frozen' ? 'frozen_state' : 'derived';
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -676,28 +678,31 @@ const runes_scope_tweaker = {
|
||||
VariableDeclarator(node, { state }) {
|
||||
const init = unwrap_ts_expression(node.init);
|
||||
if (!init || init.type !== 'CallExpression') return;
|
||||
if (get_rune(init, state.scope) === null) return;
|
||||
const rune = get_rune(init, state.scope);
|
||||
if (rune === null) return;
|
||||
|
||||
const callee = init.callee;
|
||||
if (callee.type !== 'Identifier') return;
|
||||
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return;
|
||||
|
||||
const name = callee.name;
|
||||
if (name !== '$state' && name !== '$derived' && name !== '$props') return;
|
||||
if (rune !== '$state' && rune !== '$state.frozen' && rune !== '$derived' && rune !== '$props')
|
||||
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 =
|
||||
name === '$state'
|
||||
rune === '$state'
|
||||
? 'state'
|
||||
: name === '$derived'
|
||||
: rune === '$state.frozen'
|
||||
? 'frozen_state'
|
||||
: rune === '$derived'
|
||||
? 'derived'
|
||||
: path.is_rest
|
||||
? 'rest_prop'
|
||||
: 'prop';
|
||||
}
|
||||
|
||||
if (name === '$props') {
|
||||
if (rune === '$props') {
|
||||
for (const property of /** @type {import('estree').ObjectPattern} */ (node.id).properties) {
|
||||
if (property.type !== 'Property') continue;
|
||||
|
||||
@ -909,7 +914,9 @@ const common_visitors = {
|
||||
|
||||
if (
|
||||
node !== binding.node &&
|
||||
(binding.kind === 'state' || binding.kind === 'derived') &&
|
||||
(binding.kind === 'state' ||
|
||||
binding.kind === 'frozen_state' ||
|
||||
binding.kind === 'derived') &&
|
||||
context.state.function_depth === binding.scope.function_depth
|
||||
) {
|
||||
warn(context.state.analysis.warnings, node, context.path, 'static-state-reference');
|
||||
|
@ -349,6 +349,7 @@ export const validation = {
|
||||
if (
|
||||
!binding ||
|
||||
(binding.kind !== 'state' &&
|
||||
binding.kind !== 'frozen_state' &&
|
||||
binding.kind !== 'prop' &&
|
||||
binding.kind !== 'each' &&
|
||||
binding.kind !== 'store_sub' &&
|
||||
@ -661,7 +662,7 @@ function validate_export(node, scope, name) {
|
||||
error(node, 'invalid-derived-export');
|
||||
}
|
||||
|
||||
if (binding.kind === 'state' && binding.reassigned) {
|
||||
if ((binding.kind === 'state' || binding.kind === 'frozen_state') && binding.reassigned) {
|
||||
error(node, 'invalid-state-export');
|
||||
}
|
||||
}
|
||||
@ -835,7 +836,9 @@ function validate_no_const_assignment(node, argument, scope, is_binding) {
|
||||
is_binding,
|
||||
// This takes advantage of the fact that we don't assign initial for let directives and then/catch variables.
|
||||
// If we start doing that, we need another property on the binding to differentiate, or give up on the more precise error message.
|
||||
binding.kind !== 'state' && (binding.kind !== 'normal' || !binding.initial)
|
||||
binding.kind !== 'state' &&
|
||||
binding.kind !== 'frozen_state' &&
|
||||
(binding.kind !== 'normal' || !binding.initial)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -233,7 +233,9 @@ export function client_component(source, analysis, options) {
|
||||
'$.bind_prop',
|
||||
b.id('$$props'),
|
||||
b.literal(alias ?? name),
|
||||
binding?.kind === 'state' ? b.call('$.get', b.id(name)) : b.id(name)
|
||||
binding?.kind === 'state' || binding?.kind === 'frozen_state'
|
||||
? b.call('$.get', b.id(name))
|
||||
: b.id(name)
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -241,7 +243,8 @@ export function client_component(source, analysis, options) {
|
||||
const properties = analysis.exports.map(({ name, alias }) => {
|
||||
const binding = analysis.instance.scope.get(name);
|
||||
const is_source =
|
||||
binding?.kind === 'state' && (!state.analysis.immutable || binding.reassigned);
|
||||
(binding?.kind === 'state' || binding?.kind === 'frozen_state') &&
|
||||
(!state.analysis.immutable || binding.reassigned);
|
||||
|
||||
// TODO This is always a getter because the `renamed-instance-exports` test wants it that way.
|
||||
// Should we for code size reasons make it an init in runes mode and/or non-dev mode?
|
||||
|
@ -59,7 +59,7 @@ export interface ComponentClientTransformState extends ClientTransformState {
|
||||
}
|
||||
|
||||
export interface StateField {
|
||||
kind: 'state' | 'derived';
|
||||
kind: 'state' | 'frozen_state' | 'derived';
|
||||
id: PrivateIdentifier;
|
||||
}
|
||||
|
||||
|
@ -92,7 +92,7 @@ export function serialize_get_binding(node, state) {
|
||||
}
|
||||
|
||||
if (
|
||||
(binding.kind === 'state' &&
|
||||
((binding.kind === 'state' || binding.kind === 'frozen_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 === 'frozen_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 === 'frozen_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 === 'frozen_state'
|
||||
? b.call('$.freeze', value)
|
||||
: b.call('$.proxy', value);
|
||||
return assignment;
|
||||
}
|
||||
}
|
||||
@ -232,6 +245,7 @@ export function serialize_set_binding(node, context, fallback) {
|
||||
|
||||
if (
|
||||
binding.kind !== 'state' &&
|
||||
binding.kind !== 'frozen_state' &&
|
||||
binding.kind !== 'prop' &&
|
||||
binding.kind !== 'each' &&
|
||||
binding.kind !== 'legacy_reactive' &&
|
||||
@ -249,12 +263,24 @@ export function serialize_set_binding(node, context, fallback) {
|
||||
return b.call(left, value);
|
||||
} else if (is_store) {
|
||||
return b.call('$.store_set', serialize_get_binding(b.id(left_name), state), value);
|
||||
} else {
|
||||
} else if (binding.kind === 'state') {
|
||||
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 === 'frozen_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);
|
||||
}
|
||||
} else {
|
||||
if (is_store) {
|
||||
@ -492,7 +518,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,6 +49,7 @@ export const global_visitors = {
|
||||
// use runtime functions for smaller output
|
||||
if (
|
||||
binding?.kind === 'state' ||
|
||||
binding?.kind === 'frozen_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,11 @@ export const javascript_visitors_runes = {
|
||||
|
||||
if (definition.value?.type === 'CallExpression') {
|
||||
const rune = get_rune(definition.value, state.scope);
|
||||
if (rune === '$state' || rune === '$derived') {
|
||||
if (rune === '$state' || rune === '$state.frozen' || rune === '$derived') {
|
||||
/** @type {import('../types.js').StateField} */
|
||||
const field = {
|
||||
kind: rune === '$state' ? 'state' : 'derived',
|
||||
kind:
|
||||
rune === '$state' ? 'state' : rune === '$state.frozen' ? 'frozen_state' : 'derived',
|
||||
// @ts-expect-error this is set in the next pass
|
||||
id: is_private ? definition.key : null
|
||||
};
|
||||
@ -84,7 +85,9 @@ export const javascript_visitors_runes = {
|
||||
|
||||
value =
|
||||
field.kind === 'state'
|
||||
? b.call('$.source', should_proxy(init) ? b.call('$.proxy', init) : init)
|
||||
? b.call('$.source', should_proxy_or_freeze(init) ? b.call('$.proxy', init) : init)
|
||||
: field.kind === 'frozen_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
|
||||
@ -114,6 +117,19 @@ export const javascript_visitors_runes = {
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'frozen_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, b.call('$.freeze', value)))]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === 'derived' && state.options.dev) {
|
||||
body.push(
|
||||
b.method(
|
||||
@ -217,13 +233,24 @@ 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.frozen') {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
value = b.call('$.derived', b.thunk(value));
|
||||
}
|
||||
|
@ -1245,6 +1245,7 @@ function serialize_event_handler(node, { state, visit }) {
|
||||
if (
|
||||
binding !== null &&
|
||||
(binding.kind === 'state' ||
|
||||
binding.kind === 'frozen_state' ||
|
||||
binding.kind === 'legacy_reactive' ||
|
||||
binding.kind === 'derived' ||
|
||||
binding.kind === 'prop' ||
|
||||
|
@ -446,6 +446,7 @@ function serialize_set_binding(node, context, fallback) {
|
||||
|
||||
if (
|
||||
binding.kind !== 'state' &&
|
||||
binding.kind !== 'frozen_state' &&
|
||||
binding.kind !== 'prop' &&
|
||||
binding.kind !== 'each' &&
|
||||
binding.kind !== 'legacy_reactive' &&
|
||||
@ -558,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 === '$derived') {
|
||||
if (rune === '$state' || rune === '$state.frozen' || rune === '$derived') {
|
||||
return {
|
||||
...node,
|
||||
value:
|
||||
|
@ -72,6 +72,7 @@ export const ElementBindings = [
|
||||
|
||||
export const Runes = /** @type {const} */ ([
|
||||
'$state',
|
||||
'$state.frozen',
|
||||
'$props',
|
||||
'$derived',
|
||||
'$effect',
|
||||
|
@ -258,6 +258,7 @@ export interface Binding {
|
||||
| 'prop'
|
||||
| 'rest_prop'
|
||||
| 'state'
|
||||
| 'frozen_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,25 @@ 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 is 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';
|
||||
|
27
packages/svelte/src/main/ambient.d.ts
vendored
27
packages/svelte/src/main/ambient.d.ts
vendored
@ -17,6 +17,33 @@ declare module '*.svelte' {
|
||||
declare function $state<T>(initial: T): T;
|
||||
declare function $state<T>(): T | undefined;
|
||||
|
||||
declare namespace $state {
|
||||
/**
|
||||
* Declares reactive read-only state that is shallowly immutable.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* <script>
|
||||
* let items = $state.frozen([0]);
|
||||
*
|
||||
* const addItem = () => {
|
||||
* items = [...items, items.length];
|
||||
* };
|
||||
* </script>
|
||||
*
|
||||
* <button on:click={addItem}>
|
||||
* {items.join(', ')}
|
||||
* </button>
|
||||
* ```
|
||||
*
|
||||
* https://svelte-5-preview.vercel.app/docs/runes#$state-raw
|
||||
*
|
||||
* @param initial The initial value
|
||||
*/
|
||||
export function frozen<T>(initial: T): Readonly<T>;
|
||||
export function frozen<T>(): Readonly<T> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares derived state, i.e. one that depends on other state variables.
|
||||
* The expression inside `$derived(...)` should be free of side-effects.
|
||||
|
@ -0,0 +1,22 @@
|
||||
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');
|
||||
|
||||
await btn?.click();
|
||||
assert.htmlEqual(target.innerHTML, `<button>0</button>`);
|
||||
|
||||
await btn?.click();
|
||||
assert.htmlEqual(target.innerHTML, `<button>0</button>`);
|
||||
|
||||
assert.deepEqual(log, ['read only', 'read only']);
|
||||
}
|
||||
});
|
@ -0,0 +1,2 @@
|
||||
/** @type {any[]} */
|
||||
export const log = [];
|
@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { log } from './log.js';
|
||||
|
||||
class Counter {
|
||||
count = $state.frozen({ a: 0 });
|
||||
}
|
||||
const counter = new Counter();
|
||||
</script>
|
||||
|
||||
<button on:click={() => {
|
||||
try {
|
||||
counter.count.a++
|
||||
} catch (e) {
|
||||
log.push('read only')
|
||||
}
|
||||
}}>{counter.count.a}</button>
|
@ -0,0 +1,15 @@
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
html: `<button>0</button>`,
|
||||
|
||||
async test({ assert, target }) {
|
||||
const btn = target.querySelector('button');
|
||||
|
||||
await btn?.click();
|
||||
assert.htmlEqual(target.innerHTML, `<button>1</button>`);
|
||||
|
||||
await btn?.click();
|
||||
assert.htmlEqual(target.innerHTML, `<button>2</button>`);
|
||||
}
|
||||
});
|
@ -0,0 +1,8 @@
|
||||
<script>
|
||||
class Counter {
|
||||
count = $state.frozen(0);
|
||||
}
|
||||
const counter = new Counter();
|
||||
</script>
|
||||
|
||||
<button on:click={() => counter.count++}>{counter.count}</button>
|
@ -0,0 +1,22 @@
|
||||
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');
|
||||
|
||||
await btn?.click();
|
||||
assert.htmlEqual(target.innerHTML, `<button>0</button>`);
|
||||
|
||||
await btn?.click();
|
||||
assert.htmlEqual(target.innerHTML, `<button>0</button>`);
|
||||
|
||||
assert.deepEqual(log, ['read only', 'read only']);
|
||||
}
|
||||
});
|
@ -0,0 +1,2 @@
|
||||
/** @type {any[]} */
|
||||
export const log = [];
|
@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import { log } from './log.js';
|
||||
|
||||
class Counter {
|
||||
#count = $state.frozen();
|
||||
|
||||
constructor(initial_count) {
|
||||
this.#count = { a: initial_count };
|
||||
}
|
||||
|
||||
get count() {
|
||||
return this.#count;
|
||||
}
|
||||
set count(val) {
|
||||
this.#count = val;
|
||||
}
|
||||
}
|
||||
const counter = new Counter(0);
|
||||
</script>
|
||||
|
||||
<button on:click={() => {
|
||||
try {
|
||||
counter.count.a++
|
||||
} catch (e) {
|
||||
log.push('read only')
|
||||
}
|
||||
}}>{counter.count.a}</button>
|
@ -0,0 +1,15 @@
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
html: `<button>0</button>`,
|
||||
|
||||
async test({ assert, target }) {
|
||||
const btn = target.querySelector('button');
|
||||
|
||||
await btn?.click();
|
||||
assert.htmlEqual(target.innerHTML, `<button>1</button>`);
|
||||
|
||||
await btn?.click();
|
||||
assert.htmlEqual(target.innerHTML, `<button>2</button>`);
|
||||
}
|
||||
});
|
@ -0,0 +1,19 @@
|
||||
<script>
|
||||
class Counter {
|
||||
#count = $state.frozen(0);
|
||||
|
||||
constructor(initial_count) {
|
||||
this.#count = initial_count;
|
||||
}
|
||||
|
||||
get count() {
|
||||
return this.#count;
|
||||
}
|
||||
set count(val) {
|
||||
this.#count = val;
|
||||
}
|
||||
}
|
||||
const counter = new Counter(0);
|
||||
</script>
|
||||
|
||||
<button on:click={() => counter.count++}>{counter.count}</button>
|
@ -0,0 +1,11 @@
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
async test({ assert, target }) {
|
||||
const [b1] = target.querySelectorAll('button');
|
||||
b1.click();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.htmlEqual(target.innerHTML, `<button>0, 1</button>`);
|
||||
}
|
||||
});
|
@ -0,0 +1,11 @@
|
||||
<script>
|
||||
let items = $state.frozen([0]);
|
||||
|
||||
const addItem = () => {
|
||||
items = [...items, items.length];
|
||||
};
|
||||
</script>
|
||||
|
||||
<button on:click={addItem}>
|
||||
{items.join(', ')}
|
||||
</button>
|
@ -0,0 +1,17 @@
|
||||
import { test } from '../../test';
|
||||
import { log } from './log.js';
|
||||
|
||||
export default test({
|
||||
before_test() {
|
||||
log.length = 0;
|
||||
},
|
||||
|
||||
async test({ assert, target }) {
|
||||
const [b1, b2] = target.querySelectorAll('button');
|
||||
b1.click();
|
||||
b2.click();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(log, [0, 1]);
|
||||
}
|
||||
});
|
@ -0,0 +1,2 @@
|
||||
/** @type {any[]} */
|
||||
export const log = [];
|
@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { log } from './log.js';
|
||||
|
||||
let x = $state.frozen(0);
|
||||
let y = $state.frozen(0);
|
||||
|
||||
$effect(() => {
|
||||
log.push(x);
|
||||
});
|
||||
</script>
|
||||
|
||||
<button on:click={() => x++}>{x}</button>
|
||||
<button on:click={() => y++}>{y}</button>
|
@ -205,17 +205,23 @@
|
||||
return {
|
||||
from: word.from - 1,
|
||||
options: [
|
||||
{ label: '$state', type: 'keyword', boost: 10 },
|
||||
{ label: '$props', type: 'keyword', boost: 9 },
|
||||
{ label: '$derived', type: 'keyword', boost: 8 },
|
||||
snip('$effect(() => {\n\t${}\n});', { label: '$effect', type: 'keyword', boost: 7 }),
|
||||
{ label: '$state', type: 'keyword', boost: 9 },
|
||||
{ label: '$props', type: 'keyword', boost: 8 },
|
||||
{ label: '$derived', type: 'keyword', boost: 7 },
|
||||
snip('$effect(() => {\n\t${}\n});', { label: '$effect', type: 'keyword', boost: 6 }),
|
||||
snip('$effect.pre(() => {\n\t${}\n});', {
|
||||
label: '$effect.pre',
|
||||
type: 'keyword',
|
||||
boost: 6
|
||||
boost: 5
|
||||
}),
|
||||
{ label: '$effect.active', type: 'keyword', boost: 5 },
|
||||
{ label: '$inspect', type: 'keyword', boost: 4 }
|
||||
{ label: '$state.frozen', type: 'keyword', boost: 4 },
|
||||
snip('$effect.root(() => {\n\t${}\n});', {
|
||||
label: '$effect.root',
|
||||
type: 'keyword',
|
||||
boost: 3
|
||||
}),
|
||||
{ label: '$effect.active', type: 'keyword', boost: 2 },
|
||||
{ label: '$inspect', type: 'keyword', boost: 1 }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
@ -64,6 +64,35 @@ 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.frozen`
|
||||
|
||||
State declared with `$state.frozen` cannot be mutated; it can only be _reassigned_. In other words, rather than assigning to a property of an object, or using an array method like `push`, replace the object or array altogether if you'd like to update it:
|
||||
|
||||
```diff
|
||||
<script>
|
||||
- let numbers = $state([1, 2, 3]);
|
||||
+ let numbers = $state.frozen([1, 2, 3]);
|
||||
</script>
|
||||
|
||||
-<button onclick={() => numbers.push(numbers.length + 1)}>
|
||||
+<button onclick={() => numbers = [...numbers, numbers.length + 1]}>
|
||||
push
|
||||
</button>
|
||||
|
||||
-<button onclick={() => numbers.pop()}> pop </button>
|
||||
+<button onclick={() => numbers = numbers.slice(0, -1)}> pop </button>
|
||||
|
||||
<p>
|
||||
{numbers.join(' + ') || 0}
|
||||
=
|
||||
{numbers.reduce((a, b) => a + b, 0)}
|
||||
</p>
|
||||
```
|
||||
|
||||
This can improve performance with large arrays and objects that you weren't planning to mutate anyway, since it avoids the cost of making them reactive. Note that frozen state can _contain_ reactive state (for example, a frozen array of reactive objects).
|
||||
|
||||
> Objects and arrays passed to `$state.frozen` will be shallowly frozen using `Object.freeze()`. If you don't want this, pass in a clone of the object or array instead.
|
||||
|
||||
## `$derived`
|
||||
|
||||
Derived state is declared with the `$derived` rune:
|
||||
|
Loading…
Reference in New Issue
Block a user