diff --git a/.changeset/dry-clocks-grow.md b/.changeset/dry-clocks-grow.md new file mode 100644 index 0000000000..1975d9785c --- /dev/null +++ b/.changeset/dry-clocks-grow.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: add `$state.frozen` rune diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 35b7bbc170..e7f9e1a727 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -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'); diff --git a/packages/svelte/src/compiler/phases/2-analyze/validation.js b/packages/svelte/src/compiler/phases/2-analyze/validation.js index 08402009ab..40244f97d0 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/validation.js +++ b/packages/svelte/src/compiler/phases/2-analyze/validation.js @@ -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) ); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index d82cdd68f2..dad08406f0 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -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? diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index ef9b466bad..d94e1f9fe6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -59,7 +59,7 @@ export interface ComponentClientTransformState extends ClientTransformState { } export interface StateField { - kind: 'state' | 'derived'; + kind: 'state' | 'frozen_state' | 'derived'; id: PrivateIdentifier; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/utils.js index 7a0f8af075..40a5a65cab 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -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' || diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/global.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/global.js index 136bb020a9..683bb816af 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/global.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/global.js @@ -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' || diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js index 0526c9c4bf..41ce98de49 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js @@ -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)); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js index 6ad1026cc7..4fd6b63146 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js @@ -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' || diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 79c62e396d..c26fa0b76e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -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: diff --git a/packages/svelte/src/compiler/phases/constants.js b/packages/svelte/src/compiler/phases/constants.js index ec40c50c83..b8663e97f0 100644 --- a/packages/svelte/src/compiler/phases/constants.js +++ b/packages/svelte/src/compiler/phases/constants.js @@ -72,6 +72,7 @@ export const ElementBindings = [ export const Runes = /** @type {const} */ ([ '$state', + '$state.frozen', '$props', '$derived', '$effect', diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index 6435c20207..fcb960847e 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -258,6 +258,7 @@ export interface Binding { | 'prop' | 'rest_prop' | 'state' + | 'frozen_state' | 'derived' | 'each' | 'store_sub' diff --git a/packages/svelte/src/internal/client/proxy/readonly.js b/packages/svelte/src/internal/client/proxy/readonly.js index abc2163d97..c2123a9cce 100644 --- a/packages/svelte/src/internal/client/proxy/readonly.js +++ b/packages/svelte/src/internal/client/proxy/readonly.js @@ -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 }} StateObject */ -const is_frozen = Object.isFrozen; - /** * Expects a value that was wrapped with `proxy` and makes it readonly. * diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 6fb4cc927a..15e25d278c 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -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>} + */ +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; +} diff --git a/packages/svelte/src/internal/client/utils.js b/packages/svelte/src/internal/client/utils.js index 627d2b34fb..7c1b01515e 100644 --- a/packages/svelte/src/internal/client/utils.js +++ b/packages/svelte/src/internal/client/utils.js @@ -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; diff --git a/packages/svelte/src/internal/index.js b/packages/svelte/src/internal/index.js index 048105d0f9..709ec9dee9 100644 --- a/packages/svelte/src/internal/index.js +++ b/packages/svelte/src/internal/index.js @@ -36,7 +36,8 @@ export { effect_active, user_root_effect, inspect, - unwrap + unwrap, + freeze } from './client/runtime.js'; export * from './client/each.js'; diff --git a/packages/svelte/src/main/ambient.d.ts b/packages/svelte/src/main/ambient.d.ts index 9ae68610c2..8b8eadf048 100644 --- a/packages/svelte/src/main/ambient.d.ts +++ b/packages/svelte/src/main/ambient.d.ts @@ -17,6 +17,33 @@ declare module '*.svelte' { declare function $state(initial: T): T; declare function $state(): T | undefined; +declare namespace $state { + /** + * Declares reactive read-only state that is shallowly immutable. + * + * Example: + * ```ts + * + * + * + * ``` + * + * https://svelte-5-preview.vercel.app/docs/runes#$state-raw + * + * @param initial The initial value + */ + export function frozen(initial: T): Readonly; + export function frozen(): Readonly | undefined; +} + /** * Declares derived state, i.e. one that depends on other state variables. * The expression inside `$derived(...)` should be free of side-effects. diff --git a/packages/svelte/tests/runtime-runes/samples/class-frozen-state-object/_config.js b/packages/svelte/tests/runtime-runes/samples/class-frozen-state-object/_config.js new file mode 100644 index 0000000000..6ad9dff351 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-frozen-state-object/_config.js @@ -0,0 +1,22 @@ +import { test } from '../../test'; +import { log } from './log.js'; + +export default test({ + html: ``, + + before_test() { + log.length = 0; + }, + + async test({ assert, target }) { + const btn = target.querySelector('button'); + + await btn?.click(); + assert.htmlEqual(target.innerHTML, ``); + + await btn?.click(); + assert.htmlEqual(target.innerHTML, ``); + + assert.deepEqual(log, ['read only', 'read only']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-frozen-state-object/log.js b/packages/svelte/tests/runtime-runes/samples/class-frozen-state-object/log.js new file mode 100644 index 0000000000..d3df521f4d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-frozen-state-object/log.js @@ -0,0 +1,2 @@ +/** @type {any[]} */ +export const log = []; diff --git a/packages/svelte/tests/runtime-runes/samples/class-frozen-state-object/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-frozen-state-object/main.svelte new file mode 100644 index 0000000000..e381961fe9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-frozen-state-object/main.svelte @@ -0,0 +1,16 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/class-frozen-state/_config.js b/packages/svelte/tests/runtime-runes/samples/class-frozen-state/_config.js new file mode 100644 index 0000000000..436ce99798 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-frozen-state/_config.js @@ -0,0 +1,15 @@ +import { test } from '../../test'; + +export default test({ + html: ``, + + async test({ assert, target }) { + const btn = target.querySelector('button'); + + await btn?.click(); + assert.htmlEqual(target.innerHTML, ``); + + await btn?.click(); + assert.htmlEqual(target.innerHTML, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-frozen-state/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-frozen-state/main.svelte new file mode 100644 index 0000000000..a723976ea9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-frozen-state/main.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state-object/_config.js b/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state-object/_config.js new file mode 100644 index 0000000000..6ad9dff351 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state-object/_config.js @@ -0,0 +1,22 @@ +import { test } from '../../test'; +import { log } from './log.js'; + +export default test({ + html: ``, + + before_test() { + log.length = 0; + }, + + async test({ assert, target }) { + const btn = target.querySelector('button'); + + await btn?.click(); + assert.htmlEqual(target.innerHTML, ``); + + await btn?.click(); + assert.htmlEqual(target.innerHTML, ``); + + assert.deepEqual(log, ['read only', 'read only']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state-object/log.js b/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state-object/log.js new file mode 100644 index 0000000000..d3df521f4d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state-object/log.js @@ -0,0 +1,2 @@ +/** @type {any[]} */ +export const log = []; diff --git a/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state-object/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state-object/main.svelte new file mode 100644 index 0000000000..8899e2fdb4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state-object/main.svelte @@ -0,0 +1,27 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state/_config.js b/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state/_config.js new file mode 100644 index 0000000000..436ce99798 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state/_config.js @@ -0,0 +1,15 @@ +import { test } from '../../test'; + +export default test({ + html: ``, + + async test({ assert, target }) { + const btn = target.querySelector('button'); + + await btn?.click(); + assert.htmlEqual(target.innerHTML, ``); + + await btn?.click(); + assert.htmlEqual(target.innerHTML, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state/main.svelte new file mode 100644 index 0000000000..f509f351bb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-private-frozen-state/main.svelte @@ -0,0 +1,19 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/readonly-state-replace/_config.js b/packages/svelte/tests/runtime-runes/samples/readonly-state-replace/_config.js new file mode 100644 index 0000000000..41d9e4061a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/readonly-state-replace/_config.js @@ -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, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/readonly-state-replace/main.svelte b/packages/svelte/tests/runtime-runes/samples/readonly-state-replace/main.svelte new file mode 100644 index 0000000000..4a53807030 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/readonly-state-replace/main.svelte @@ -0,0 +1,11 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/readonly-state/_config.js b/packages/svelte/tests/runtime-runes/samples/readonly-state/_config.js new file mode 100644 index 0000000000..1e6333bdeb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/readonly-state/_config.js @@ -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]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/readonly-state/log.js b/packages/svelte/tests/runtime-runes/samples/readonly-state/log.js new file mode 100644 index 0000000000..d3df521f4d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/readonly-state/log.js @@ -0,0 +1,2 @@ +/** @type {any[]} */ +export const log = []; diff --git a/packages/svelte/tests/runtime-runes/samples/readonly-state/main.svelte b/packages/svelte/tests/runtime-runes/samples/readonly-state/main.svelte new file mode 100644 index 0000000000..1dde3104be --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/readonly-state/main.svelte @@ -0,0 +1,13 @@ + + + + diff --git a/sites/svelte-5-preview/src/lib/CodeMirror.svelte b/sites/svelte-5-preview/src/lib/CodeMirror.svelte index ebeeef29dd..e8997aa9f2 100644 --- a/sites/svelte-5-preview/src/lib/CodeMirror.svelte +++ b/sites/svelte-5-preview/src/lib/CodeMirror.svelte @@ -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 } ] }; } diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md index 2cd3392e53..70a1cbb288 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md @@ -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 + + +- + +- ++ + +

+ {numbers.join(' + ') || 0} + = + {numbers.reduce((a, b) => a + b, 0)} +

+``` + +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: