diff --git a/.changeset/friendly-rice-confess.md b/.changeset/friendly-rice-confess.md new file mode 100644 index 0000000000..c3fd4f1f58 --- /dev/null +++ b/.changeset/friendly-rice-confess.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: adds $state.link rune diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index 63a3873d3f..d894900334 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -125,6 +125,27 @@ declare namespace $state { */ export function frozen(initial: T): Readonly; export function frozen(): Readonly | undefined; + + /** + * Declares reactive state that is linked to another value. Local mutations will override the linked value + * until the linked value changes. + * + * Example: + * ```ts + * + * + * + * ``` + * + * https://svelte-5-preview.vercel.app/docs/runes#$state-link + * + * @param value The linked value + */ + export function link(value: T): T; + /** * To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`: * diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index a064a457d0..3548fc3ff5 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -911,6 +911,7 @@ const runes_scope_js_tweaker = { if ( rune !== '$state' && + rune !== '$state.link' && rune !== '$state.frozen' && rune !== '$derived' && rune !== '$derived.by' @@ -921,7 +922,13 @@ const runes_scope_js_tweaker = { // @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.frozen' ? 'frozen_state' : 'derived'; + rune === '$state' + ? 'state' + : rune === '$state.link' + ? 'link_state' + : rune === '$state.frozen' + ? 'frozen_state' + : 'derived'; } } }; @@ -948,6 +955,7 @@ const runes_scope_tweaker = { if ( rune !== '$state' && rune !== '$state.frozen' && + rune !== '$state.link' && rune !== '$derived' && rune !== '$derived.by' && rune !== '$props' @@ -962,11 +970,13 @@ const runes_scope_tweaker = { ? 'state' : rune === '$state.frozen' ? 'frozen_state' - : rune === '$derived' || rune === '$derived.by' - ? 'derived' - : path.is_rest - ? 'rest_prop' - : 'prop'; + : rune === '$state.link' + ? 'link_state' + : rune === '$derived' || rune === '$derived.by' + ? 'derived' + : path.is_rest + ? 'rest_prop' + : 'prop'; } if (rune === '$props') { @@ -1279,7 +1289,7 @@ const common_visitors = { context.state.function_depth === binding.scope.function_depth && // If we have $state that can be proxied or frozen and isn't re-assigned, then that means // it's likely not using a primitive value and thus this warning isn't that helpful. - ((binding.kind === 'state' && + (((binding.kind === 'state' || binding.kind === 'link_state') && (binding.reassigned || (binding.initial?.type === 'CallExpression' && binding.initial.arguments.length === 1 && @@ -1297,6 +1307,7 @@ const common_visitors = { } }, CallExpression(node, context) { + debugger; const { expression, render_tag } = context.state; if ( (expression?.type === 'ExpressionTag' || expression?.type === 'SpreadAttribute') && @@ -1339,6 +1350,16 @@ const common_visitors = { } } + if (rune === '$state.link') { + // Similar to $derived above, $state is treated as `$state(() => foo)` + context.next({ + ...context.state, + function_depth: context.state.function_depth + 1 + }); + + return; + } + if (rune === '$effect' || rune === '$effect.pre') { // `$effect` needs context because Svelte needs to know whether it should re-run // effects that invalidate themselves, and that's determined by whether we're in runes mode diff --git a/packages/svelte/src/compiler/phases/2-analyze/validation.js b/packages/svelte/src/compiler/phases/2-analyze/validation.js index 29758b6b2f..5650b665b0 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/validation.js +++ b/packages/svelte/src/compiler/phases/2-analyze/validation.js @@ -391,6 +391,7 @@ const validation = { !binding || (binding.kind !== 'state' && binding.kind !== 'frozen_state' && + binding.kind !== 'link_state' && binding.kind !== 'prop' && binding.kind !== 'bindable_prop' && binding.kind !== 'each' && @@ -868,7 +869,12 @@ function validate_export(node, scope, name) { e.derived_invalid_export(node); } - if ((binding.kind === 'state' || binding.kind === 'frozen_state') && binding.reassigned) { + if ( + (binding.kind === 'state' || + binding.kind === 'frozen_state' || + binding.kind === 'link_state') && + binding.reassigned + ) { e.state_invalid_export(node); } } 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 3dbf5dc292..e0aae8f3cb 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 @@ -67,7 +67,7 @@ export interface ComponentClientTransformState extends ClientTransformState { } export interface StateField { - kind: 'state' | 'frozen_state' | 'derived' | 'derived_call'; + kind: 'state' | 'frozen_state' | 'link_state' | 'derived' | 'derived_call'; 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 5cc0f5c91b..7e450d8ea1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -100,6 +100,10 @@ export function serialize_get_binding(node, state) { return b.member(b.id('$$props'), node); } + if (binding.kind === 'link_state') { + return b.call(node); + } + if (binding.kind === 'legacy_reactive_import') { return b.call('$$_import_' + node.name); } @@ -282,6 +286,7 @@ export function serialize_set_binding(node, context, fallback, prefix, options) if ( binding.kind !== 'state' && binding.kind !== 'frozen_state' && + binding.kind !== 'link_state' && binding.kind !== 'prop' && binding.kind !== 'bindable_prop' && binding.kind !== 'each' && @@ -302,7 +307,12 @@ export function serialize_set_binding(node, context, fallback, prefix, options) /**@type {import("estree").Expression}*/ (binding.initial), context.state.scope ); - if ((binding.kind === 'prop' || binding.kind === 'bindable_prop') && !is_initial_proxy) { + if ( + (binding.kind === 'prop' || + binding.kind === 'bindable_prop' || + binding.kind === 'link_state') && + !is_initial_proxy + ) { return b.call(left, value); } else if (is_store) { return b.call('$.store_set', serialize_get_binding(b.id(left_name), state), value); @@ -329,7 +339,9 @@ export function serialize_set_binding(node, context, fallback, prefix, options) : value ); } else if ( - (binding.kind === 'prop' || binding.kind === 'bindable_prop') && + (binding.kind === 'prop' || + binding.kind === 'bindable_prop' || + binding.kind === 'link_state') && is_initial_proxy ) { call = b.call( @@ -337,7 +349,7 @@ export function serialize_set_binding(node, context, fallback, prefix, options) context.state.analysis.runes && !options?.skip_proxy_and_freeze && should_proxy_or_freeze(value, context.state.scope) && - binding.kind === 'bindable_prop' + (binding.kind === 'bindable_prop' || binding.kind === 'link_state') ? serialize_proxy_reassignment(value, left_name, state) : value ); 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 a640fb01b0..30a315e91d 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 @@ -68,6 +68,7 @@ export const global_visitors = { if ( binding?.kind === 'state' || binding?.kind === 'frozen_state' || + binding?.kind === 'link_state' || binding?.kind === 'each' || binding?.kind === 'legacy_reactive' || binding?.kind === 'prop' || @@ -85,6 +86,7 @@ export const global_visitors = { args.push(serialize_get_binding(b.id(name), state), b.call('$' + name)); } else { if (binding.kind === 'prop' || binding.kind === 'bindable_prop') fn += '_prop'; + if (binding.kind === 'link_state') fn += '_link'; args.push(b.id(name)); } 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 71524b539a..298c95b6de 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 @@ -43,6 +43,7 @@ export const javascript_visitors_runes = { if ( rune === '$state' || rune === '$state.frozen' || + rune === '$state.link' || rune === '$derived' || rune === '$derived.by' ) { @@ -53,9 +54,11 @@ export const javascript_visitors_runes = { ? 'state' : rune === '$state.frozen' ? 'frozen_state' - : rune === '$derived.by' - ? 'derived_call' - : 'derived', + : rune === '$state.link' + ? 'link_state' + : rune === '$derived.by' + ? 'derived_call' + : 'derived', // @ts-expect-error this is set in the next pass id: is_private ? definition.key : null }; @@ -114,14 +117,21 @@ export const javascript_visitors_runes = { '$.source', should_proxy_or_freeze(init, state.scope) ? b.call('$.proxy', init) : init ) - : field.kind === 'frozen_state' + : field.kind === 'link_state' ? b.call( - '$.source', - should_proxy_or_freeze(init, state.scope) ? b.call('$.freeze', init) : init + '$.source_link', + should_proxy_or_freeze(init, state.scope) + ? b.thunk(b.call('$.proxy', init)) + : b.thunk(init) ) - : field.kind === 'derived_call' - ? b.call('$.derived', init) - : b.call('$.derived', b.thunk(init)); + : field.kind === 'frozen_state' + ? b.call( + '$.source', + should_proxy_or_freeze(init, state.scope) ? b.call('$.freeze', init) : init + ) + : field.kind === 'derived_call' + ? b.call('$.derived', init) + : b.call('$.derived', b.thunk(init)); } else { // if no arguments, we know it's state as `$derived()` is a compile error value = b.call('$.source'); @@ -134,8 +144,24 @@ export const javascript_visitors_runes = { const member = b.member(b.this, field.id); body.push(b.prop_def(field.id, value)); - // get foo() { return this.#foo; } - body.push(b.method('get', definition.key, [], [b.return(b.call('$.get', member))])); + if (field.kind === 'link_state') { + // get foo() { return this.#foo; } + body.push(b.method('get', definition.key, [], [b.return(b.call(member))])); + + // set foo(value) { this.#foo = value; } + const value = b.id('value'); + body.push( + b.method( + 'set', + definition.key, + [value], + [b.stmt(b.call(member, serialize_proxy_reassignment(value, field.id, state)))] + ) + ); + } else { + // get foo() { return this.#foo; } + body.push(b.method('get', definition.key, [], [b.return(b.call('$.get', member))])); + } if (field.kind === 'state') { // set foo(value) { this.#foo = value; } @@ -314,7 +340,7 @@ export const javascript_visitors_runes = { ? b.id('undefined') : /** @type {import('estree').Expression} */ (visit(args[0])); - if (rune === '$state' || rune === '$state.frozen') { + if (rune === '$state' || rune === '$state.frozen' || rune === '$state.link') { /** * @param {import('estree').Identifier} id * @param {import('estree').Expression} value @@ -322,7 +348,13 @@ export const javascript_visitors_runes = { const create_state_declarator = (id, value) => { const binding = /** @type {import('#compiler').Binding} */ (state.scope.get(id.name)); if (should_proxy_or_freeze(value, state.scope)) { - value = b.call(rune === '$state' ? '$.proxy' : '$.freeze', value); + value = b.call( + rune === '$state' || rune === '$state.link' ? '$.proxy' : '$.freeze', + value + ); + } + if (binding.kind === 'link_state') { + return b.call('$.source_link', b.thunk(value)); } if (is_state_source(binding, state)) { value = b.call('$.source', value); 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 1d0695b5f0..5f7c3aebb5 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 @@ -559,7 +559,12 @@ 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.frozen' || rune === '$derived') { + if ( + rune === '$state' || + rune === '$state.frozen' || + rune === '$state.link' || + rune === '$derived' + ) { return { ...node, value: diff --git a/packages/svelte/src/compiler/phases/constants.js b/packages/svelte/src/compiler/phases/constants.js index 898a77c4bf..8ac0e8118c 100644 --- a/packages/svelte/src/compiler/phases/constants.js +++ b/packages/svelte/src/compiler/phases/constants.js @@ -31,6 +31,7 @@ export const PassiveEvents = ['wheel', 'touchstart', 'touchmove', 'touchend', 't export const Runes = /** @type {const} */ ([ '$state', + '$state.link', '$state.frozen', '$state.snapshot', '$state.is', diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index 47616f9905..576fad72d6 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -278,6 +278,7 @@ export interface Binding { | 'rest_prop' | 'state' | 'frozen_state' + | 'link_state' | 'derived' | 'each' | 'snippet' diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 859e85f22c..1489d30df0 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -104,7 +104,14 @@ export { user_effect, user_pre_effect } from './reactivity/effects.js'; -export { mutable_source, mutate, source, set } from './reactivity/sources.js'; +export { + mutable_source, + mutate, + source, + source_link, + set, + update_link +} from './reactivity/sources.js'; export { prop, rest_props, diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 8fa229d56f..f429cc295b 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -25,6 +25,7 @@ import { MAYBE_DIRTY } from '../constants.js'; import * as e from '../errors.js'; +import { derived } from './deriveds.js'; let inspect_effects = new Set(); @@ -44,6 +45,47 @@ export function source(v) { }; } +/** + * @param {() => any} get_value + */ +export function source_link(get_value) { + var was_local = false; + var local_source = source(undefined); + var linked_derived = derived(() => { + var local_value = get(local_source); + var linked_value = get_value(); + + if (was_local) { + was_local = false; + return local_value; + } + + return linked_value; + }); + + return function (/** @type {any} */ value) { + var linked_value = get(linked_derived); + if (arguments.length > 0) { + was_local = true; + set(local_source, value); + get(linked_derived); + return value; + } + return linked_value; + }; +} + +/** + * @param {((value?: number) => number)} fn + * @param {1 | -1} [d] + * @returns {number} + */ +export function update_link(fn, d = 1) { + const value = fn(); + fn(value + d); + return value; +} + /** * @template V * @param {V} initial_value diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 86f33588a6..b483c3dab0 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -918,6 +918,7 @@ declare module 'svelte/compiler' { | 'rest_prop' | 'state' | 'frozen_state' + | 'link_state' | 'derived' | 'each' | 'snippet' @@ -2855,6 +2856,27 @@ declare namespace $state { */ export function frozen(initial: T): Readonly; export function frozen(): Readonly | undefined; + + /** + * Declares reactive state that is linked to another value. Local mutations will override the linked value + * until the linked value changes. + * + * Example: + * ```ts + * + * + * + * ``` + * + * https://svelte-5-preview.vercel.app/docs/runes#$state-link + * + * @param value The linked value + */ + export function link(value: T): T; + /** * To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`: * diff --git a/sites/svelte-5-preview/src/lib/autocomplete.js b/sites/svelte-5-preview/src/lib/autocomplete.js index 750b9f2e7c..edb8be6f84 100644 --- a/sites/svelte-5-preview/src/lib/autocomplete.js +++ b/sites/svelte-5-preview/src/lib/autocomplete.js @@ -118,6 +118,7 @@ const runes = [ { snippet: '$bindable()', test: is_bindable }, { snippet: '$effect.root(() => {\n\t${}\n})' }, { snippet: '$state.snapshot(${})' }, + { snippet: '$state.link(${})' }, { snippet: '$state.is(${})' }, { snippet: '$effect.tracking()' }, { snippet: '$inspect(${});', test: is_statement } 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 5f0ee7c0ea..76007ae96c 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,10 @@ Only plain objects and arrays [are made deeply reactive](/#H4sIAAAAAAAAE42QwWrDM 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.link` + +TODO + ## `$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: