diff --git a/.changeset/red-rules-share.md b/.changeset/red-rules-share.md new file mode 100644 index 000000000000..2a4d29b7985e --- /dev/null +++ b/.changeset/red-rules-share.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add `onchange` option to `$state` diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md index 49e17cd08ff3..1b094d375bd6 100644 --- a/documentation/docs/02-runes/02-$state.md +++ b/documentation/docs/02-runes/02-$state.md @@ -147,6 +147,35 @@ person = { 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 raw state can _contain_ reactive state (for example, a raw array of reactive objects). +## State options + +Both `$state` and `$state.raw` can optionally accept a second argument. This allows you to specify an `onchange` function that will be called synchronously whenever the state value changes (for `$state` it will also be called for deep mutations). + +The `onchange` function is untracked so even if you assign within an `$effect` it will not cause unwanted dependencies. + +```js +let count = $state(0, { + onchange(){ + console.log("count is now", count); + } +}); + +// this will log "count is now 1" +count++; +``` + +this could be especially useful if you want to sync some stateful variable that could be mutated without using an effect. + +```js +let array = $state([], { + onchange(){ + localStorage.setItem('array', JSON.stringify(array)); + } +}); + +array.push(array.length); +``` + ## `$state.snapshot` To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`: diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index 2fef3bd45d50..58aabe20cbb0 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -648,6 +648,12 @@ Cannot access a computed property of a rune `%name%` is not a valid rune ``` +### rune_invalid_options + +``` +Options for `%rune%` needs to be declared inline +``` + ### rune_invalid_usage ``` diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md index 0aa6fbed90d8..7e56490a2efe 100644 --- a/packages/svelte/messages/compile-errors/script.md +++ b/packages/svelte/messages/compile-errors/script.md @@ -158,6 +158,10 @@ This turned out to be buggy and unpredictable, particularly when working with de > `%name%` is not a valid rune +## rune_invalid_options + +> Options for `%rune%` needs to be declared inline + ## rune_invalid_usage > Cannot use `%rune%` rune in non-runes mode diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index fbcecba8e47c..0d6d45dfc20b 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -20,6 +20,11 @@ declare module '*.svelte' { * * @param initial The initial value */ +declare function $state( + initial: undefined, + options?: import('svelte').StateOptions +): T | undefined; +declare function $state(initial: T, options?: import('svelte').StateOptions): T; declare function $state(initial: T): T; declare function $state(): T | undefined; @@ -116,6 +121,11 @@ declare namespace $state { * * @param initial The initial value */ + export function raw( + initial: undefined, + options?: import('svelte').StateOptions + ): T | undefined; + export function raw(initial?: T, options?: import('svelte').StateOptions): T; export function raw(initial: T): T; export function raw(): T | undefined; /** diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 53a6ac6849ec..e0d1189e2950 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -373,6 +373,16 @@ export function rune_invalid_name(node, name) { e(node, 'rune_invalid_name', `\`${name}\` is not a valid rune\nhttps://svelte.dev/e/rune_invalid_name`); } +/** + * Options for `%rune%` needs to be declared inline + * @param {null | number | NodeLike} node + * @param {string} rune + * @returns {never} + */ +export function rune_invalid_options(node, rune) { + e(node, 'rune_invalid_options', `Options for \`${rune}\` needs to be declared inline\nhttps://svelte.dev/e/rune_invalid_options`); +} + /** * Cannot use `%rune%` rune in non-runes mode * @param {null | number | NodeLike} node diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 9f51cd61de6d..e9adebd81af4 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -87,8 +87,13 @@ export function CallExpression(node, context) { if ((rune === '$derived' || rune === '$derived.by') && node.arguments.length !== 1) { e.rune_invalid_arguments_length(node, rune, 'exactly one argument'); - } else if (rune === '$state' && node.arguments.length > 1) { - e.rune_invalid_arguments_length(node, rune, 'zero or one arguments'); + } else if (rune === '$state' || rune === '$state.raw') { + if (node.arguments.length > 2) { + e.rune_invalid_arguments_length(node, rune, 'at most two arguments'); + } + if (node.arguments.length === 2 && node.arguments[1].type !== 'ObjectExpression') { + e.rune_invalid_options(node.arguments[1], rune); + } } break; 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 f4a6c9a4147b..e7ac6a9653c9 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 @@ -292,7 +292,10 @@ export function client_component(analysis, options) { } if (binding?.kind === 'state' || binding?.kind === 'raw_state') { - const value = binding.kind === 'state' ? b.call('$.proxy', b.id('$$value')) : b.id('$$value'); + const value = + binding.kind === 'state' + ? b.call('$.proxy', b.id('$$value'), b.call('$.get_options', b.id(name))) + : b.id('$$value'); return [getter, b.set(alias ?? name, [b.stmt(b.call('$.set', b.id(name), value))])]; } 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 db4adf451c2e..f4ac6c1bb400 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 @@ -30,7 +30,7 @@ export interface ClientTransformState extends TransformState { /** turn `foo` into e.g. `$.get(foo)` */ read: (id: Identifier) => Expression; /** turn `foo = bar` into e.g. `$.set(foo, bar)` */ - assign?: (node: Identifier, value: Expression) => Expression; + assign?: (node: Identifier, value: Expression, proxy?: boolean) => Expression; /** turn `foo.bar = baz` into e.g. `$.mutate(foo, $.get(foo).bar = baz);` */ mutate?: (node: Identifier, mutation: AssignmentExpression | UpdateExpression) => Expression; /** turn `foo++` into e.g. `$.update(foo)` */ 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 c59a5544dfb2..664b909a09af 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -45,14 +45,6 @@ export function build_getter(node, state) { return node; } -/** - * @param {Expression} value - * @param {Expression} previous - */ -export function build_proxy_reassignment(value, previous) { - return dev ? b.call('$.proxy', value, b.null, previous) : b.call('$.proxy', value); -} - /** * @param {FunctionDeclaration | FunctionExpression | ArrowFunctionExpression} node * @param {ComponentContext} context diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js index 0c70f7e00cda..4d34f2c4681a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js @@ -9,7 +9,7 @@ import { is_event_attribute } from '../../../../utils/ast.js'; import { dev, filename, is_ignored, locate_node, locator } from '../../../../state.js'; -import { build_proxy_reassignment, should_proxy } from '../utils.js'; +import { should_proxy } from '../utils.js'; import { visit_assignment_expression } from '../../shared/assignments.js'; /** @@ -65,21 +65,20 @@ function build_assignment(operator, left, right, context) { context.visit(build_assignment_value(operator, left, right)) ); - if ( + const needs_proxy = private_state.kind === 'state' && is_non_coercive_operator(operator) && - should_proxy(value, context.state.scope) - ) { - value = build_proxy_reassignment(value, b.member(b.this, private_state.id)); - } - - if (context.state.in_constructor) { - // inside the constructor, we can assign to `this.#foo.v` rather than using `$.set`, - // since nothing is tracking the signal at this point - return b.assignment(operator, /** @type {Pattern} */ (context.visit(left)), value); - } - - return b.call('$.set', left, value); + should_proxy(value, context.state.scope); + + return b.call( + // inside the constructor, we use `$.simple_set` rather than using `$.set`, + // that only assign the value and eventually call onchange since nothing is tracking the signal at this point + context.state.in_constructor ? '$.simple_set' : '$.set', + left, + value, + needs_proxy && b.true, + dev && needs_proxy && b.true + ); } } @@ -113,19 +112,17 @@ function build_assignment(operator, left, right, context) { context.visit(build_assignment_value(operator, left, right)) ); - if ( + return transform.assign( + object, + value, !is_primitive && - binding.kind !== 'prop' && - binding.kind !== 'bindable_prop' && - binding.kind !== 'raw_state' && - context.state.analysis.runes && - should_proxy(right, context.state.scope) && - is_non_coercive_operator(operator) - ) { - value = build_proxy_reassignment(value, object); - } - - return transform.assign(object, value); + binding.kind !== 'prop' && + binding.kind !== 'bindable_prop' && + binding.kind !== 'raw_state' && + context.state.analysis.runes && + should_proxy(right, context.state.scope) && + is_non_coercive_operator(operator) + ); } // mutation diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js index 7b3a9a4d0e29..e9a1fec0c657 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js @@ -5,7 +5,7 @@ import { dev, is_ignored } from '../../../../state.js'; import * as b from '../../../../utils/builders.js'; import { regex_invalid_identifier_chars } from '../../../patterns.js'; import { get_rune } from '../../../scope.js'; -import { build_proxy_reassignment, should_proxy } from '../utils.js'; +import { should_proxy } from '../utils.js'; /** * @param {ClassBody} node @@ -116,14 +116,22 @@ export function ClassBody(node, context) { context.visit(definition.value.arguments[0], child_state) ); + let options = + definition.value.arguments.length === 2 + ? /** @type {Expression} **/ ( + context.visit(definition.value.arguments[1], child_state) + ) + : undefined; + + let proxied = should_proxy(init, context.state.scope); + value = field.kind === 'state' - ? b.call( - '$.state', - should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init - ) + ? should_proxy(init, context.state.scope) + ? b.call('$.assignable_proxy', init, options) + : b.call('$.state', init, options) : field.kind === 'raw_state' - ? b.call('$.state', init) + ? b.call('$.state', init, options) : field.kind === 'derived_by' ? b.call('$.derived', init) : b.call('$.derived', b.thunk(init)); @@ -152,7 +160,7 @@ export function ClassBody(node, context) { 'set', definition.key, [value], - [b.stmt(b.call('$.set', member, build_proxy_reassignment(value, prev)))] + [b.stmt(b.call('$.set', member, value, b.true, dev && b.true))] ) ); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js index afb90bbec7f9..6897f554e2c4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js @@ -113,28 +113,34 @@ export function VariableDeclaration(node, context) { const args = /** @type {CallExpression} */ (init).arguments; const value = args.length === 0 ? b.id('undefined') : /** @type {Expression} */ (context.visit(args[0])); + let options = + args.length === 2 ? /** @type {Expression} */ (context.visit(args[1])) : undefined; if (rune === '$state' || rune === '$state.raw') { /** * @param {Identifier} id * @param {Expression} value + * @param {Expression} [options] */ - const create_state_declarator = (id, value) => { + const create_state_declarator = (id, value, options) => { const binding = /** @type {import('#compiler').Binding} */ ( context.state.scope.get(id.name) ); - if (rune === '$state' && should_proxy(value, context.state.scope)) { - value = b.call('$.proxy', value); - } - if (is_state_source(binding, context.state.analysis)) { - value = b.call('$.state', value); + const proxied = rune === '$state' && should_proxy(value, context.state.scope); + const is_state = is_state_source(binding, context.state.analysis); + if (proxied && is_state) { + value = b.call('$.assignable_proxy', value, options); + } else if (proxied) { + value = b.call('$.proxy', value, options); + } else if (is_state) { + value = b.call('$.state', value, options); } return value; }; if (declarator.id.type === 'Identifier') { declarations.push( - b.declarator(declarator.id, create_state_declarator(declarator.id, value)) + b.declarator(declarator.id, create_state_declarator(declarator.id, value, options)) ); } else { const tmp = context.state.scope.generate('tmp'); @@ -147,7 +153,7 @@ export function VariableDeclaration(node, context) { return b.declarator( path.node, binding?.kind === 'state' || binding?.kind === 'raw_state' - ? create_state_declarator(binding.node, value) + ? create_state_declarator(binding.node, value, options) : value ); }) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js index 0bd8c352f6a9..57e40a9536e3 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js @@ -1,7 +1,8 @@ /** @import { Identifier } from 'estree' */ /** @import { ComponentContext, Context } from '../../types' */ -import { is_state_source } from '../../utils.js'; +import { is_state_source, should_proxy } from '../../utils.js'; import * as b from '../../../../../utils/builders.js'; +import { dev } from '../../../../../state.js'; /** * Turns `foo` into `$.get(foo)` @@ -24,8 +25,8 @@ export function add_state_transformers(context) { ) { context.state.transform[name] = { read: binding.declaration_kind === 'var' ? (node) => b.call('$.safe_get', node) : get_value, - assign: (node, value) => { - let call = b.call('$.set', node, value); + assign: (node, value, proxy = false) => { + let call = b.call('$.set', node, value, proxy && b.true, dev && proxy && b.true); if (context.state.scope.get(`$${node.name}`)?.kind === 'store_sub') { call = b.call('$.store_unsub', call, b.literal(`$${node.name}`), b.id('$$stores')); diff --git a/packages/svelte/src/index.d.ts b/packages/svelte/src/index.d.ts index 554510542e2e..f94e6b5425b5 100644 --- a/packages/svelte/src/index.d.ts +++ b/packages/svelte/src/index.d.ts @@ -351,4 +351,6 @@ export type MountOptions = Record props: Props; }); +export { ValueOptions as StateOptions } from './internal/client/types.js'; + export * from './index-client.js'; diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index a4840ce4ebd0..5f24792eaed6 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -22,6 +22,7 @@ export const HEAD_EFFECT = 1 << 19; export const EFFECT_HAS_DERIVED = 1 << 20; export const STATE_SYMBOL = Symbol('$state'); +export const PROXY_ONCHANGE_SYMBOL = Symbol('proxy onchange'); export const STATE_SYMBOL_METADATA = Symbol('$state metadata'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 2bf58c51f75d..5b8969d5ee11 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -109,7 +109,14 @@ export { user_effect, user_pre_effect } from './reactivity/effects.js'; -export { mutable_state, mutate, set, state } from './reactivity/sources.js'; +export { + mutable_state, + mutate, + set, + simple_set, + state, + get_options +} from './reactivity/sources.js'; export { prop, rest_props, @@ -152,7 +159,7 @@ export { } from './runtime.js'; export { validate_binding, validate_each_keys } from './validate.js'; export { raf } from './timing.js'; -export { proxy } from './proxy.js'; +export { proxy, assignable_proxy } from './proxy.js'; export { create_custom_element } from './dom/elements/custom-element.js'; export { child, diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index 6cbd6394df3a..ae3fb3598db1 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -1,6 +1,7 @@ -/** @import { ProxyMetadata, ProxyStateObject, Source } from '#client' */ +/** @import { ProxyMetadata, ProxyStateObject, Source, ValueOptions } from '#client' */ import { DEV } from 'esm-env'; -import { get, component_context, active_effect } from './runtime.js'; +import { UNINITIALIZED } from '../../constants.js'; +import { tracing_mode_flag } from '../flags/index.js'; import { array_prototype, get_descriptor, @@ -8,32 +9,64 @@ import { is_array, object_prototype } from '../shared/utils.js'; +import { PROXY_ONCHANGE_SYMBOL, STATE_SYMBOL, STATE_SYMBOL_METADATA } from './constants.js'; import { check_ownership, widen_ownership } from './dev/ownership.js'; -import { source, set } from './reactivity/sources.js'; -import { STATE_SYMBOL, STATE_SYMBOL_METADATA } from './constants.js'; -import { UNINITIALIZED } from '../../constants.js'; -import * as e from './errors.js'; import { get_stack } from './dev/tracing.js'; -import { tracing_mode_flag } from '../flags/index.js'; +import * as e from './errors.js'; +import { batch_onchange, set, source, state } from './reactivity/sources.js'; +import { active_effect, component_context, get } from './runtime.js'; + +const array_methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'reverse', 'sort']; + +/** + * @param {ValueOptions | undefined} options + * @returns {ValueOptions | undefined} + */ +function clone_options(options) { + return options != null + ? { + onchange: options.onchange + } + : undefined; +} /** * @template T * @param {T} value + * @param {ValueOptions} [_options] * @param {ProxyMetadata | null} [parent] * @param {Source} [prev] dev mode only * @returns {T} */ -export function proxy(value, parent = null, prev) { +export function proxy(value, _options, parent = null, prev) { + let options = clone_options(_options); /** @type {Error | null} */ var stack = null; if (DEV && tracing_mode_flag) { stack = get_stack('CreatedAt'); } // if non-proxyable, or is already a proxy, return `value` - if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) { + if (typeof value !== 'object' || value === null) { return value; } + if (STATE_SYMBOL in value) { + // @ts-ignore + value[PROXY_ONCHANGE_SYMBOL](options?.onchange); + return value; + } + + if (options?.onchange) { + // if there's an onchange we actually store that but override the value + // to store every other onchange that new proxies might add + var onchanges = new Set([options.onchange]); + options.onchange = () => { + for (let onchange of onchanges) { + onchange(); + } + }; + } + const prototype = get_prototype_of(value); if (prototype !== object_prototype && prototype !== array_prototype) { @@ -48,7 +81,10 @@ export function proxy(value, parent = null, prev) { if (is_proxied_array) { // We need to create the length source eagerly to ensure that // mutations to the array are properly synced with our proxy - sources.set('length', source(/** @type {any[]} */ (value).length, stack)); + sources.set( + 'length', + source(/** @type {any[]} */ (value).length, clone_options(options), stack) + ); } /** @type {ProxyMetadata} */ @@ -94,10 +130,10 @@ export function proxy(value, parent = null, prev) { var s = sources.get(prop); if (s === undefined) { - s = source(descriptor.value, stack); + s = source(descriptor.value, clone_options(options), stack); sources.set(prop, s); } else { - set(s, proxy(descriptor.value, metadata)); + set(s, proxy(descriptor.value, options, metadata)); } return true; @@ -108,7 +144,7 @@ export function proxy(value, parent = null, prev) { if (s === undefined) { if (prop in target) { - sources.set(prop, source(UNINITIALIZED, stack)); + sources.set(prop, source(UNINITIALIZED, clone_options(options), stack)); } } else { // When working with arrays, we need to also ensure we update the length when removing @@ -121,6 +157,11 @@ export function proxy(value, parent = null, prev) { set(ls, n); } } + // when we delete a property if the source is a proxy we remove the current onchange from + // the proxy `onchanges` so that it doesn't trigger it anymore + if (typeof s.v === 'object' && s.v !== null && STATE_SYMBOL in s.v) { + s.v[PROXY_ONCHANGE_SYMBOL](options?.onchange, true); + } set(s, UNINITIALIZED); update_version(version); } @@ -137,12 +178,38 @@ export function proxy(value, parent = null, prev) { return value; } + if (prop === PROXY_ONCHANGE_SYMBOL) { + return ( + /** @type {(() => unknown) | undefined} */ value, + /** @type {boolean} */ remove + ) => { + // we either add or remove the passed in value + // to the onchanges array or we set every source onchange + // to the passed in value (if it's undefined it will make the chain stop) + if (options?.onchange != null && value && !remove) { + onchanges?.add?.(value); + } else if (options?.onchange != null && value) { + onchanges?.delete?.(value); + } else { + options = { + onchange: value + }; + for (let [, s] of sources) { + if (s.o) { + s.o.onchange = value; + } + } + } + }; + } + var s = sources.get(prop); var exists = prop in target; // create a source, but only if it's an own property and not a prototype property if (s === undefined && (!exists || get_descriptor(target, prop)?.writable)) { - s = source(proxy(exists ? target[prop] : UNINITIALIZED, metadata), stack); + let opt = clone_options(options); + s = source(proxy(exists ? target[prop] : UNINITIALIZED, opt, metadata), opt, stack); sources.set(prop, s); } @@ -167,7 +234,17 @@ export function proxy(value, parent = null, prev) { return v === UNINITIALIZED ? undefined : v; } - return Reflect.get(target, prop, receiver); + v = Reflect.get(target, prop, receiver); + + if ( + is_proxied_array && + options?.onchange != null && + array_methods.includes(/** @type {string} */ (prop)) + ) { + return batch_onchange(v); + } + + return v; }, getOwnPropertyDescriptor(target, prop) { @@ -210,7 +287,8 @@ export function proxy(value, parent = null, prev) { (active_effect !== null && (!has || get_descriptor(target, prop)?.writable)) ) { if (s === undefined) { - s = source(has ? proxy(target[prop], metadata) : UNINITIALIZED, stack); + let opt = clone_options(options); + s = source(has ? proxy(target[prop], opt, metadata) : UNINITIALIZED, opt, stack); sources.set(prop, s); } @@ -232,12 +310,15 @@ export function proxy(value, parent = null, prev) { for (var i = value; i < /** @type {Source} */ (s).v; i += 1) { var other_s = sources.get(i + ''); if (other_s !== undefined) { + if (typeof other_s.v === 'object' && other_s.v !== null && STATE_SYMBOL in other_s.v) { + other_s.v[PROXY_ONCHANGE_SYMBOL](options?.onchange, true); + } set(other_s, UNINITIALIZED); } else if (i in target) { // If the item exists in the original, we need to create a uninitialized source, // else a later read of the property would result in a source being created with // the value of the original item at that index. - other_s = source(UNINITIALIZED, stack); + other_s = source(UNINITIALIZED, clone_options(options), stack); sources.set(i + '', other_s); } } @@ -249,13 +330,19 @@ export function proxy(value, parent = null, prev) { // object property before writing to that property. if (s === undefined) { if (!has || get_descriptor(target, prop)?.writable) { - s = source(undefined, stack); - set(s, proxy(value, metadata)); + const opt = clone_options(options); + s = source(undefined, opt, stack); + set(s, proxy(value, opt, metadata)); sources.set(prop, s); } } else { has = s.v !== UNINITIALIZED; - set(s, proxy(value, metadata)); + // when we set a property if the source is a proxy we remove the current onchange from + // the proxy `onchanges` so that it doesn't trigger it anymore + if (typeof s.v === 'object' && s.v !== null && STATE_SYMBOL in s.v) { + s.v[PROXY_ONCHANGE_SYMBOL](options?.onchange, true); + } + set(s, proxy(value, clone_options(options), metadata)); } if (DEV) { @@ -317,6 +404,17 @@ export function proxy(value, parent = null, prev) { }); } +/** + * @template T + * @param {T} value + * @param {ValueOptions} [options] + * @returns {Source} + */ + +export function assignable_proxy(value, options) { + return state(proxy(value, options), options); +} + /** * @param {Source} signal * @param {1 | -1} [d] diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index d10008dae28b..7495b7ef6e49 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -1,4 +1,4 @@ -/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */ +/** @import { Derived, Effect, Reaction, Source, Value, ValueOptions } from '#client' */ import { DEV } from 'esm-env'; import { component_context, @@ -35,6 +35,7 @@ import { import * as e from '../errors.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { get_stack } from '../dev/tracing.js'; +import { proxy } from '../proxy.js'; export let inspect_effects = new Set(); @@ -45,13 +46,40 @@ export function set_inspect_effects(v) { inspect_effects = v; } +/** @type {null | Set<() => void>} */ +let onchange_batch = null; + +/** + * @param {Function} fn + */ +export function batch_onchange(fn) { + // @ts-expect-error + return function (...args) { + let previous_onchange_batch = onchange_batch; + + try { + onchange_batch = new Set(); + + // @ts-expect-error + return fn.apply(this, args); + } finally { + for (const onchange of /** @type {Set<() => void>} */ (onchange_batch)) { + onchange(); + } + + onchange_batch = previous_onchange_batch; + } + }; +} + /** * @template V * @param {V} v + * @param {ValueOptions} [o] * @param {Error | null} [stack] * @returns {Source} */ -export function source(v, stack) { +export function source(v, o, stack) { /** @type {Value} */ var signal = { f: 0, // TODO ideally we could skip this altogether, but it causes type errors @@ -59,7 +87,8 @@ export function source(v, stack) { reactions: null, equals, rv: 0, - wv: 0 + wv: 0, + o }; if (DEV && tracing_mode_flag) { @@ -73,9 +102,18 @@ export function source(v, stack) { /** * @template V * @param {V} v + * @param {ValueOptions} [o] + */ +export function state(v, o) { + return push_derived_source(source(v, o)); +} + +/** + * @param {Source} source + * @returns {ValueOptions | undefined} */ -export function state(v) { - return push_derived_source(source(v)); +export function get_options(source) { + return source.o; } /** @@ -144,9 +182,11 @@ export function mutate(source, value) { * @template V * @param {Source} source * @param {V} value + * @param {boolean} [should_proxy] + * @param {boolean} [needs_previous] * @returns {V} */ -export function set(source, value) { +export function set(source, value, should_proxy = false, needs_previous = false) { if ( active_reaction !== null && !untracking && @@ -159,7 +199,35 @@ export function set(source, value) { e.state_unsafe_mutation(); } - return internal_set(source, value); + let new_value = should_proxy + ? needs_previous + ? proxy(value, source.o, null, source) + : proxy(value, source.o) + : value; + + return internal_set(source, new_value); +} + +/** + * @template V + * @param {Source} source + * @param {V} value + * @param {boolean} [should_proxy] + * @param {boolean} [needs_previous] + * @returns {V} + */ +export function simple_set(source, value, should_proxy = false, needs_previous = false) { + let new_value = should_proxy + ? needs_previous + ? proxy(value, source.o, null, source) + : proxy(value, source.o) + : value; + + source.v = new_value; + + source.o?.onchange?.(); + + return new_value; } /** @@ -221,6 +289,15 @@ export function internal_set(source, value) { } inspect_effects.clear(); } + + var onchange = source.o?.onchange; + if (onchange) { + if (onchange_batch) { + onchange_batch.add(onchange); + } else { + onchange(); + } + } } return value; diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 3a76a3ff836c..0b1492e4dd9b 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -7,6 +7,10 @@ export interface Signal { wv: number; } +export interface ValueOptions { + onchange?: () => unknown; +} + export interface Value extends Signal { /** Equality function */ equals: Equals; @@ -16,6 +20,8 @@ export interface Value extends Signal { rv: number; /** The latest value for this signal */ v: V; + /** Options for the source */ + o?: ValueOptions; /** Dev only */ created?: Error | null; updated?: Error | null; diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 7208ed77837e..e7ce4137fb3e 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -1,6 +1,6 @@ import type { Store } from '#shared'; import { STATE_SYMBOL } from './constants.js'; -import type { Effect, Source, Value, Reaction } from './reactivity/types.js'; +import type { Effect, Source, Value, Reaction, ValueOptions } from './reactivity/types.js'; type EventCallback = (event: Event) => boolean; export type EventCallbackMap = Record; diff --git a/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-args/_config.js b/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-args/_config.js index 993ca18f4765..d2c92ca6814e 100644 --- a/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-args/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-args/_config.js @@ -3,6 +3,6 @@ import { test } from '../../test'; export default test({ error: { code: 'rune_invalid_arguments_length', - message: '`$state` must be called with zero or one arguments' + message: '`$state` must be called with at most two arguments' } }); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-accumulated/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange-accumulated/_config.js new file mode 100644 index 000000000000..f99a1c856b75 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-accumulated/_config.js @@ -0,0 +1,18 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [btn, btn2] = target.querySelectorAll('button'); + + flushSync(() => { + btn.click(); + }); + assert.deepEqual(logs, ['foo', 'baz']); + + flushSync(() => { + btn2.click(); + }); + assert.deepEqual(logs, ['foo', 'baz', 'foo', 'baz']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-accumulated/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange-accumulated/main.svelte new file mode 100644 index 000000000000..1a299533a46c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-accumulated/main.svelte @@ -0,0 +1,15 @@ + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-extrapolated-reference/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange-extrapolated-reference/_config.js new file mode 100644 index 000000000000..cc0bfbb1e382 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-extrapolated-reference/_config.js @@ -0,0 +1,33 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [btn, btn2, btn3, btn4, btn5, btn6] = target.querySelectorAll('button'); + logs.length = 0; + + flushSync(() => { + btn.click(); + }); + flushSync(() => { + btn2.click(); + }); + flushSync(() => { + btn3.click(); + }); + flushSync(() => { + btn4.click(); + }); + flushSync(() => { + btn5.click(); + }); + assert.deepEqual(logs, []); + flushSync(() => { + btn6.click(); + }); + flushSync(() => { + btn.click(); + }); + assert.deepEqual(logs, ['arr', 'arr']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-extrapolated-reference/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange-extrapolated-reference/main.svelte new file mode 100644 index 000000000000..4d586c7707cb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-extrapolated-reference/main.svelte @@ -0,0 +1,43 @@ + + + + + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange/_config.js new file mode 100644 index 000000000000..82625ded0acc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange/_config.js @@ -0,0 +1,91 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [btn, btn2, btn3, btn4, btn5, btn6, btn7, btn8, btn9] = target.querySelectorAll('button'); + + assert.deepEqual(logs, ['constructor count', 'constructor proxy']); + + logs.length = 0; + + flushSync(() => { + btn.click(); + }); + assert.deepEqual(logs, ['count']); + + flushSync(() => { + btn2.click(); + }); + assert.deepEqual(logs, ['count', 'proxy']); + + flushSync(() => { + btn3.click(); + }); + assert.deepEqual(logs, ['count', 'proxy', 'proxy']); + + flushSync(() => { + btn4.click(); + }); + assert.deepEqual(logs, ['count', 'proxy', 'proxy', 'class count']); + + flushSync(() => { + btn5.click(); + }); + assert.deepEqual(logs, ['count', 'proxy', 'proxy', 'class count', 'class proxy']); + + flushSync(() => { + btn6.click(); + }); + assert.deepEqual(logs, [ + 'count', + 'proxy', + 'proxy', + 'class count', + 'class proxy', + 'class proxy' + ]); + + flushSync(() => { + btn7.click(); + }); + assert.deepEqual(logs, [ + 'count', + 'proxy', + 'proxy', + 'class count', + 'class proxy', + 'class proxy', + 'arr' + ]); + + flushSync(() => { + btn8.click(); + }); + assert.deepEqual(logs, [ + 'count', + 'proxy', + 'proxy', + 'class count', + 'class proxy', + 'class proxy', + 'arr', + 'arr' + ]); + + flushSync(() => { + btn9.click(); + }); + assert.deepEqual(logs, [ + 'count', + 'proxy', + 'proxy', + 'class count', + 'class proxy', + 'class proxy', + 'arr', + 'arr', + 'arr' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange/main.svelte new file mode 100644 index 000000000000..565e8477dc26 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange/main.svelte @@ -0,0 +1,64 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/state-raw-onchange/_config.js b/packages/svelte/tests/runtime-runes/samples/state-raw-onchange/_config.js new file mode 100644 index 000000000000..ab2a125c1213 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-raw-onchange/_config.js @@ -0,0 +1,63 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [btn, btn2, btn3, btn4, btn5, btn6, btn7, btn8, btn9, btn10] = + target.querySelectorAll('button'); + + assert.deepEqual(logs, ['constructor count', 'constructor proxy']); + + logs.length = 0; + + flushSync(() => { + btn.click(); + }); + assert.deepEqual(logs, ['count']); + + flushSync(() => { + btn2.click(); + }); + assert.deepEqual(logs, ['count']); + + flushSync(() => { + btn3.click(); + }); + assert.deepEqual(logs, ['count', 'proxy']); + + flushSync(() => { + btn4.click(); + }); + assert.deepEqual(logs, ['count', 'proxy', 'class count']); + + flushSync(() => { + btn5.click(); + }); + assert.deepEqual(logs, ['count', 'proxy', 'class count']); + + flushSync(() => { + btn6.click(); + }); + assert.deepEqual(logs, ['count', 'proxy', 'class count', 'class proxy']); + + flushSync(() => { + btn7.click(); + }); + assert.deepEqual(logs, ['count', 'proxy', 'class count', 'class proxy']); + + flushSync(() => { + btn8.click(); + }); + assert.deepEqual(logs, ['count', 'proxy', 'class count', 'class proxy']); + + flushSync(() => { + btn9.click(); + }); + assert.deepEqual(logs, ['count', 'proxy', 'class count', 'class proxy']); + + flushSync(() => { + btn10.click(); + }); + assert.deepEqual(logs, ['count', 'proxy', 'class count', 'class proxy', 'arr']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-raw-onchange/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-raw-onchange/main.svelte new file mode 100644 index 000000000000..3879e32621a0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-raw-onchange/main.svelte @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js index fa990b33ee56..390e86a3510a 100644 --- a/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/bind-component-snippet/_expected/client/index.svelte.js @@ -23,7 +23,7 @@ export default function Bind_component_snippet($$anchor) { return $.get(value); }, set value($$value) { - $.set(value, $.proxy($$value)); + $.set(value, $$value, true); } }); diff --git a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js index 2898f31a6fb5..bc451686422a 100644 --- a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js @@ -12,14 +12,14 @@ export default function Class_state_field_constructor_assignment($$anchor, $$pro } set a(value) { - $.set(this.#a, $.proxy(value)); + $.set(this.#a, value, true); } #b = $.state(); constructor() { this.a = 1; - this.#b.v = 2; + $.simple_set(this.#b, 2); } } diff --git a/packages/svelte/tests/snapshot/samples/destructured-assignments/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/destructured-assignments/_expected/client/index.svelte.js index 9651713c52f5..47f297bce9c7 100644 --- a/packages/svelte/tests/snapshot/samples/destructured-assignments/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/destructured-assignments/_expected/client/index.svelte.js @@ -8,8 +8,8 @@ let d = 4; export function update(array) { ( - $.set(a, $.proxy(array[0])), - $.set(b, $.proxy(array[1])) + $.set(a, array[0], true), + $.set(b, array[1], true) ); [c, d] = array; diff --git a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js index c545608bcacf..762a23754c9b 100644 --- a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js @@ -13,7 +13,7 @@ export default function Function_prop_no_getter($$anchor) { Button($$anchor, { onmousedown: () => $.set(count, $.get(count) + 1), onmouseup, - onmouseenter: () => $.set(count, $.proxy(plusOne($.get(count)))), + onmouseenter: () => $.set(count, plusOne($.get(count)), true), children: ($$anchor, $$slotProps) => { $.next(); diff --git a/packages/svelte/tests/validator/samples/rune-invalid-options/errors.json b/packages/svelte/tests/validator/samples/rune-invalid-options/errors.json new file mode 100644 index 000000000000..0403da6a2812 --- /dev/null +++ b/packages/svelte/tests/validator/samples/rune-invalid-options/errors.json @@ -0,0 +1,14 @@ +[ + { + "code": "rune_invalid_options", + "end": { + "column": 29, + "line": 3 + }, + "start": { + "column": 22, + "line": 3 + }, + "message": "Options for `$state` needs to be declared inline" + } +] diff --git a/packages/svelte/tests/validator/samples/rune-invalid-options/input.svelte b/packages/svelte/tests/validator/samples/rune-invalid-options/input.svelte new file mode 100644 index 000000000000..252737e26cef --- /dev/null +++ b/packages/svelte/tests/validator/samples/rune-invalid-options/input.svelte @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index d00b2b01ed18..176de125a6a1 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -412,6 +412,12 @@ declare module 'svelte' { * Synchronously flushes any pending state changes and those that result from it. * */ export function flushSync(fn?: (() => void) | undefined): void; + type Getters = { + [K in keyof T]: () => T[K]; + }; + export interface StateOptions { + onchange?: () => unknown; + } /** * Create a snippet programmatically * */ @@ -512,9 +518,6 @@ declare module 'svelte' { * * */ export function getAllContexts = Map>(): T; - type Getters = { - [K in keyof T]: () => T[K]; - }; export {}; } @@ -2676,6 +2679,11 @@ declare module 'svelte/types/compiler/interfaces' { * * @param initial The initial value */ +declare function $state( + initial: undefined, + options?: import('svelte').StateOptions +): T | undefined; +declare function $state(initial: T, options?: import('svelte').StateOptions): T; declare function $state(initial: T): T; declare function $state(): T | undefined; @@ -2772,6 +2780,11 @@ declare namespace $state { * * @param initial The initial value */ + export function raw( + initial: undefined, + options?: import('svelte').StateOptions + ): T | undefined; + export function raw(initial?: T, options?: import('svelte').StateOptions): T; export function raw(initial: T): T; export function raw(): T | undefined; /**