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 741e24fde01e..3fbcda724378 100644 --- a/documentation/docs/02-runes/02-$state.md +++ b/documentation/docs/02-runes/02-$state.md @@ -147,6 +147,22 @@ 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` accept an optional second argument that includes an `onchange` function. + +This function is called synchronously whenever the value is reassigned or (for `$state`) mutated, allowing you to respond to changes before [effects]($effect) run. It's useful for — for example — persisting data, or validating it: + +```js +let count = $state(0, { + onchange() { + count = Math.min(count, 10); + } +}); +``` + +> The `onchange` function is [untracked](svelte#untrack). + As with `$state`, you can declare class fields using `$state.raw`. ## `$state.snapshot` diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index ad32eaa56f5e..e06868007020 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/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 9b6337b9ed9a..612e41eeb989 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -127,8 +127,10 @@ 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 (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'); + } } 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 c42d1b95d88d..38c0ac60b9e5 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 @@ -294,8 +294,8 @@ 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'); - return [getter, b.set(alias ?? name, [b.stmt(b.call('$.set', b.id(name), value))])]; + const call = b.call('$.set', b.id(name), b.id('$$value'), binding.kind === 'state' && b.true); + return [getter, b.set(alias ?? name, [b.stmt(call)])]; } return getter; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js index 3e2f1414e63b..0ddca0c14880 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js @@ -5,6 +5,7 @@ import * as b from '#compiler/builders'; import { get_rune } from '../../../scope.js'; import { transform_inspect_rune } from '../../utils.js'; import { should_proxy } from '../utils.js'; +import { get_onchange } from './shared/state.js'; /** * @param {CallExpression} node @@ -24,6 +25,7 @@ export function CallExpression(node, context) { case '$state': case '$state.raw': { let arg = node.arguments[0]; + let onchange = get_onchange(/** @type {Expression} */ (node.arguments[1]), context); /** @type {Expression | undefined} */ let value = undefined; @@ -35,11 +37,11 @@ export function CallExpression(node, context) { rune === '$state' && should_proxy(/** @type {Expression} */ (arg), context.state.scope) ) { - value = b.call('$.proxy', value); + return b.call('$.assignable_proxy', value, onchange); } } - return b.call('$.state', value); + return b.call('$.state', value, onchange); } case '$derived': 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 5bd9add2a59e..533fbb2d9e37 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,6 +5,7 @@ import * as b from '#compiler/builders'; import { dev } from '../../../../state.js'; import { get_parent } from '../../../../utils/ast.js'; import { get_name } from '../../../nodes.js'; +import { get_onchange } from './shared/state.js'; /** * @param {ClassBody} node 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 19a7de57159d..d1390cbae903 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 @@ -9,6 +9,7 @@ import { get_rune } from '../../../scope.js'; import { get_prop_source, is_prop_source, is_state_source, should_proxy } from '../utils.js'; import { is_hoisted_function } from '../../utils.js'; import { get_value } from './shared/declarations.js'; +import { get_onchange } from './shared/state.js'; /** * @param {VariableDeclaration} node @@ -123,12 +124,15 @@ export function VariableDeclaration(node, context) { const args = /** @type {CallExpression} */ (init).arguments; const value = /** @type {Expression} */ (args[0]) ?? b.void0; // TODO do we need the void 0? can we just omit it altogether? + const onchange = get_onchange(/** @type {Expression} */ (args[1]), context); + if (rune === '$state' || rune === '$state.raw') { /** * @param {Identifier} id * @param {Expression} value + * @param {Expression} [onchange] */ - const create_state_declarator = (id, value) => { + const create_state_declarator = (id, value, onchange) => { const binding = /** @type {import('#compiler').Binding} */ ( context.state.scope.get(id.name) ); @@ -136,7 +140,7 @@ export function VariableDeclaration(node, context) { const is_proxy = should_proxy(value, context.state.scope); if (rune === '$state' && is_proxy) { - value = b.call('$.proxy', value); + value = b.call(is_state ? '$.assignable_proxy' : '$.proxy', value, onchange); if (dev && !is_state) { value = b.call('$.tag_proxy', value, b.literal(id.name)); @@ -144,7 +148,9 @@ export function VariableDeclaration(node, context) { } if (is_state) { - value = b.call('$.state', value); + if (!(rune === '$state' && is_proxy)) { + value = b.call('$.state', value, onchange); + } if (dev) { value = b.call('$.tag', value, b.literal(id.name)); @@ -158,7 +164,10 @@ export function VariableDeclaration(node, context) { const expression = /** @type {Expression} */ (context.visit(value)); declarations.push( - b.declarator(declarator.id, create_state_declarator(declarator.id, expression)) + b.declarator( + declarator.id, + create_state_declarator(declarator.id, expression, onchange) + ) ); } else { const tmp = b.id(context.state.scope.generate('tmp')); @@ -183,7 +192,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, onchange) : value ); }) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/state.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/state.js new file mode 100644 index 000000000000..cba0789ee808 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/state.js @@ -0,0 +1,31 @@ +/** @import { Expression, Property } from 'estree' */ +/** @import { ComponentContext, Context } from '../../types' */ +import * as b from '../../../../../utils/builders.js'; + +/** + * Extract the `onchange` callback from the options passed to `$state` + * @param {Expression} options + * @param {ComponentContext | Context} context + * @returns {Expression | undefined} + */ +export function get_onchange(options, context) { + if (!options) return; + + if (options.type === 'ObjectExpression') { + const onchange = /** @type {Property | undefined} */ ( + options.properties.find( + (property) => + property.type === 'Property' && + !property.computed && + property.key.type === 'Identifier' && + property.key.name === 'onchange' + ) + ); + + if (!onchange) return; + + return /** @type {Expression} */ (context.visit(onchange.value)); + } + + return b.member(/** @type {Expression} */ (context.visit(options)), 'onchange'); +} diff --git a/packages/svelte/src/index.d.ts b/packages/svelte/src/index.d.ts index 38e60866898f..ca4accc26ff3 100644 --- a/packages/svelte/src/index.d.ts +++ b/packages/svelte/src/index.d.ts @@ -352,4 +352,6 @@ export type MountOptions = Record props: Props; }); +export { 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 50a7a21ae80f..6e863de31c0f 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -27,6 +27,7 @@ export const ASYNC = 1 << 22; export const ERROR_VALUE = 1 << 23; export const STATE_SYMBOL = Symbol('$state'); +export const PROXY_ONCHANGE_SYMBOL = Symbol('proxy onchange'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); export const PROXY_PATH_SYMBOL = Symbol('proxy path'); diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index cddb432a982b..962435daa831 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -151,7 +151,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 3ae4b87ed5d6..a2ed8ca50f71 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -20,9 +20,12 @@ import { set, increment, flush_inspect_effects, - set_inspect_effects_deferred + set_inspect_effects_deferred, + batch_onchange, + state, + onchange_batch } from './reactivity/sources.js'; -import { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; +import { PROXY_PATH_SYMBOL, STATE_SYMBOL, PROXY_ONCHANGE_SYMBOL } from '#client/constants'; import { UNINITIALIZED } from '../../constants.js'; import * as e from './errors.js'; import { get_stack, tag } from './dev/tracing.js'; @@ -31,17 +34,47 @@ import { tracing_mode_flag } from '../flags/index.js'; // TODO move all regexes into shared module? const regex_is_valid_identifier = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/; +/** + * Used to prevent batching in case we are not setting the length of an array + * @param {any} fn + * @returns + */ +function identity(fn) { + return fn; +} + /** * @template T * @param {T} value + * @param {() => void} [onchange] * @returns {T} */ -export function proxy(value) { +export function proxy(value, onchange) { // 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) { + if (onchange) { + // @ts-ignore + value[PROXY_ONCHANGE_SYMBOL](onchange); + } + return value; } + if (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([onchange]); + onchange = () => { + for (let onchange of onchanges) { + onchange(); + } + }; + } + const prototype = get_prototype_of(value); if (prototype !== object_prototype && prototype !== array_prototype) { @@ -85,7 +118,7 @@ export function proxy(value) { 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, onchange, stack)); if (DEV) { value = /** @type {any} */ (inspectable_array(/** @type {any[]} */ (value))); } @@ -123,7 +156,7 @@ export function proxy(value) { var s = sources.get(prop); if (s === undefined) { s = with_parent(() => { - var s = source(descriptor.value, stack); + var s = source(descriptor.value, onchange, stack); sources.set(prop, s); if (DEV && typeof prop === 'string') { tag(s, get_label(path, prop)); @@ -142,7 +175,7 @@ export function proxy(value) { if (s === undefined) { if (prop in target) { - const s = with_parent(() => source(UNINITIALIZED, stack)); + const s = with_parent(() => source(UNINITIALIZED, onchange, stack)); sources.set(prop, s); increment(version); @@ -151,6 +184,12 @@ export function proxy(value) { } } } else { + // 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 (onchange && typeof s.v === 'object' && s.v !== null && STATE_SYMBOL in s.v) { + s.v[PROXY_ONCHANGE_SYMBOL](onchange, true); + } + set(s, UNINITIALIZED); increment(version); } @@ -167,14 +206,30 @@ export function proxy(value) { return update_path; } + if (prop === PROXY_ONCHANGE_SYMBOL) { + return (/** @type {(() => unknown)} */ 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 (onchange != null && value) { + if (remove) { + onchanges?.delete(value); + } else { + onchanges?.add(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)) { + let opt = onchange; + s = with_parent(() => { - var p = proxy(exists ? target[prop] : UNINITIALIZED); - var s = source(p, stack); + var p = proxy(exists ? target[prop] : UNINITIALIZED, opt); + var s = source(p, opt, stack); if (DEV) { tag(s, get_label(path, prop)); @@ -191,7 +246,17 @@ export function proxy(value) { return v === UNINITIALIZED ? undefined : v; } - return Reflect.get(target, prop, receiver); + v = Reflect.get(target, prop, receiver); + + if ( + is_proxied_array && + onchange != null && + ARRAY_MUTATING_METHODS.has(/** @type {string} */ (prop)) + ) { + return batch_onchange(v); + } + + return v; }, getOwnPropertyDescriptor(target, prop) { @@ -230,9 +295,10 @@ export function proxy(value) { (active_effect !== null && (!has || get_descriptor(target, prop)?.writable)) ) { if (s === undefined) { + let opt = onchange; s = with_parent(() => { - var p = has ? proxy(target[prop]) : UNINITIALIZED; - var s = source(p, stack); + var p = has ? proxy(target[prop], opt) : UNINITIALIZED; + var s = source(p, opt, stack); if (DEV) { tag(s, get_label(path, prop)); @@ -257,47 +323,65 @@ export function proxy(value) { var s = sources.get(prop); var has = prop in target; - // variable.length = value -> clear all signals with index >= value - if (is_proxied_array && prop === 'length') { - for (var i = value; i < /** @type {Source} */ (s).v; i += 1) { - var other_s = sources.get(i + ''); - if (other_s !== undefined) { - 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 = with_parent(() => source(UNINITIALIZED, stack)); - sources.set(i + '', other_s); - - if (DEV) { - tag(other_s, get_label(path, i)); + // if we are changing the length of the array we batch all the changes + // to the sources and the original value by calling batch_onchange and immediately + // invoking it...otherwise we just invoke an identity function + (is_proxied_array && prop === 'length' && !onchange_batch ? batch_onchange : identity)(() => { + // variable.length = value -> clear all signals with index >= value + if (is_proxied_array && prop === 'length') { + for (var i = value; i < /** @type {Source} */ (s).v; i += 1) { + var other_s = sources.get(i + ''); + if (other_s !== undefined) { + if ( + onchange && + typeof other_s.v === 'object' && + other_s.v !== null && + STATE_SYMBOL in other_s.v + ) { + other_s.v[PROXY_ONCHANGE_SYMBOL](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 = with_parent(() => source(UNINITIALIZED, onchange, stack)); + sources.set(i + '', other_s); + + if (DEV) { + tag(other_s, get_label(path, i)); + } } } } - } - // If we haven't yet created a source for this property, we need to ensure - // we do so otherwise if we read it later, then the write won't be tracked and - // the heuristics of effects will be different vs if we had read the proxied - // object property before writing to that property. - if (s === undefined) { - if (!has || get_descriptor(target, prop)?.writable) { - s = with_parent(() => source(undefined, stack)); - set(s, proxy(value)); - - sources.set(prop, s); + // If we haven't yet created a source for this property, we need to ensure + // we do so otherwise if we read it later, then the write won't be tracked and + // the heuristics of effects will be different vs if we had read the proxied + // object property before writing to that property. + if (s === undefined) { + if (!has || get_descriptor(target, prop)?.writable) { + s = with_parent(() => source(undefined, onchange, stack)); + sources.set(prop, s); + set(s, proxy(value, onchange)); - if (DEV) { - tag(s, get_label(path, prop)); + if (DEV) { + tag(s, get_label(path, prop)); + } + } + } else { + has = s.v !== UNINITIALIZED; + + var p = with_parent(() => proxy(value, onchange)); + // 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 (onchange && typeof s.v === 'object' && s.v !== null && STATE_SYMBOL in s.v) { + s.v[PROXY_ONCHANGE_SYMBOL](onchange, true); } - } - } else { - has = s.v !== UNINITIALIZED; - var p = with_parent(() => proxy(value)); - set(s, p); - } + set(s, p); + } + })(); var descriptor = Reflect.getOwnPropertyDescriptor(target, prop); @@ -349,6 +433,16 @@ export function proxy(value) { }); } +/** + * @template T + * @param {T} value + * @param {() => void} [onchange] + * @returns {Source} + */ +export function assignable_proxy(value, onchange) { + return state(proxy(value, onchange), onchange); +} + /** * @param {string} path * @param {string | symbol} prop diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 9b534d2d7190..2b18d3a285b6 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -27,14 +27,15 @@ import { MAYBE_DIRTY, BLOCK_EFFECT, ROOT_EFFECT, - ASYNC + ASYNC, + PROXY_ONCHANGE_SYMBOL } from '#client/constants'; import * as e from '../errors.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { get_stack, tag_proxy } from '../dev/tracing.js'; +import { proxy } from '../proxy.js'; import { component_context, is_runes } from '../context.js'; import { Batch, schedule_effect } from './batch.js'; -import { proxy } from '../proxy.js'; import { execute_derived } from './deriveds.js'; export let inspect_effects = new Set(); @@ -55,14 +56,41 @@ export function set_inspect_effects_deferred() { inspect_effects_deferred = true; } +/** @type {null | Set<() => void>} */ +export 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 {() => void} [o] * @param {Error | null} [stack] * @returns {Source} */ // TODO rename this to `state` throughout the codebase -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 @@ -70,7 +98,8 @@ export function source(v, stack) { reactions: null, equals, rv: 0, - wv: 0 + wv: 0, + o }; if (DEV && tracing_mode_flag) { @@ -86,11 +115,12 @@ export function source(v, stack) { /** * @template V * @param {V} v + * @param {() => void} [o] * @param {Error | null} [stack] */ /*#__NO_SIDE_EFFECTS__*/ -export function state(v, stack) { - const s = source(v, stack); +export function state(v, o, stack) { + const s = source(v, o, stack); push_reaction_value(s); @@ -152,7 +182,7 @@ export function set(source, value, should_proxy = false) { e.state_unsafe_mutation(); } - let new_value = should_proxy ? proxy(value) : value; + let new_value = should_proxy ? proxy(value, source.o) : value; if (DEV) { tag_proxy(new_value, /** @type {string} */ (source.label)); @@ -171,6 +201,11 @@ export function internal_set(source, value) { if (!source.equals(value)) { var old_value = source.v; + if (typeof old_value === 'object' && old_value != null && source.o) { + // @ts-ignore + old_value[PROXY_ONCHANGE_SYMBOL]?.(source.o, true); + } + if (is_destroying_effect) { old_values.set(source, value); } else { @@ -236,6 +271,15 @@ export function internal_set(source, value) { if (DEV && inspect_effects.size > 0 && !inspect_effects_deferred) { flush_inspect_effects(); } + + var onchange = source.o; + 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 72187e84a720..bb9facce25ce 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -14,6 +14,10 @@ export interface Signal { wv: number; } +export interface StateOptions { + onchange?: () => unknown; +} + export interface Value extends Signal { /** Equality function */ equals: Equals; @@ -23,6 +27,9 @@ export interface Value extends Signal { rv: number; /** The latest value for this signal */ v: V; + /** onchange callback */ + o?: () => void; + /** Dev only */ // dev-only /** A label (e.g. the `foo` in `let foo = $state(...)`) used for `$inspect.trace()` */ 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/compiler-errors/samples/runes-wrong-state-raw-args/_config.js b/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-raw-args/_config.js index af226559d11b..59efc6af3942 100644 --- a/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-raw-args/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-raw-args/_config.js @@ -3,6 +3,6 @@ import { test } from '../../test'; export default test({ error: { code: 'rune_invalid_arguments_length', - message: '`$state.raw` must be called with zero or one arguments' + message: '`$state.raw` 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..619fab8a2caa --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-accumulated/_config.js @@ -0,0 +1,14 @@ +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-after-mutate/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange-after-mutate/_config.js new file mode 100644 index 000000000000..3985156f0379 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-after-mutate/_config.js @@ -0,0 +1,11 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const btn = target.querySelector('button'); + + flushSync(() => btn?.click()); + assert.deepEqual(logs, [{ message: 'hello' }, { message: 'goodbye' }]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-after-mutate/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange-after-mutate/main.svelte new file mode 100644 index 000000000000..1a1fc4089130 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-after-mutate/main.svelte @@ -0,0 +1,14 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-array-length-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange-array-length-batch/_config.js new file mode 100644 index 000000000000..3ad5b749ee74 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-array-length-batch/_config.js @@ -0,0 +1,14 @@ +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, [[{}, {}, {}, {}, {}, {}, {}, {}]]); + + flushSync(() => btn2.click()); + assert.deepEqual(logs, [[{}, {}, {}, {}, {}, {}, {}, {}], []]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-array-length-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange-array-length-batch/main.svelte new file mode 100644 index 000000000000..dcea39d2c3ca --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-array-length-batch/main.svelte @@ -0,0 +1,14 @@ + + + + + + + +
{JSON.stringify(array)}
\ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-arrays/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange-arrays/_config.js new file mode 100644 index 000000000000..d77d3f9aa707 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-arrays/_config.js @@ -0,0 +1,17 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [btn, btn2, btn3] = target.querySelectorAll('button'); + + flushSync(() => btn.click()); + assert.deepEqual(logs, ['arr']); + + flushSync(() => btn2.click()); + assert.deepEqual(logs, ['arr', 'arr']); + + flushSync(() => btn3.click()); + assert.deepEqual(logs, ['arr', 'arr', 'arr']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-arrays/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange-arrays/main.svelte new file mode 100644 index 000000000000..41f8c7a948d8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-arrays/main.svelte @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-child/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange-child/_config.js new file mode 100644 index 000000000000..76380eddc96c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-child/_config.js @@ -0,0 +1,11 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const btn = target.querySelector('button'); + + flushSync(() => btn?.click()); + assert.deepEqual(logs, ['b changed']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-child/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange-child/main.svelte new file mode 100644 index 000000000000..e1f60934220b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-child/main.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-classes/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange-classes/_config.js new file mode 100644 index 000000000000..9ed80bd66055 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-classes/_config.js @@ -0,0 +1,64 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [btn, btn2, btn3, btn4, btn5, btn6, btn7] = target.querySelectorAll('button'); + + assert.deepEqual(logs, [ + 'constructor count', + 'constructor proxy', + 'assign in constructor', + 'assign in constructor proxy' + ]); + + logs.length = 0; + + flushSync(() => btn.click()); + assert.deepEqual(logs, ['class count']); + + flushSync(() => btn2.click()); + assert.deepEqual(logs, ['class count', 'class proxy']); + + flushSync(() => btn3.click()); + assert.deepEqual(logs, ['class count', 'class proxy', 'class proxy']); + + flushSync(() => btn4.click()); + assert.deepEqual(logs, [ + 'class count', + 'class proxy', + 'class proxy', + 'declared in constructor' + ]); + + flushSync(() => btn5.click()); + assert.deepEqual(logs, [ + 'class count', + 'class proxy', + 'class proxy', + 'declared in constructor', + 'declared in constructor' + ]); + + flushSync(() => btn6.click()); + assert.deepEqual(logs, [ + 'class count', + 'class proxy', + 'class proxy', + 'declared in constructor', + 'declared in constructor', + 'declared in constructor proxy' + ]); + + flushSync(() => btn7.click()); + assert.deepEqual(logs, [ + 'class count', + 'class proxy', + 'class proxy', + 'declared in constructor', + 'declared in constructor', + 'declared in constructor proxy', + 'declared in constructor proxy' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-classes/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange-classes/main.svelte new file mode 100644 index 000000000000..8f49d5294b8a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-classes/main.svelte @@ -0,0 +1,68 @@ + + + + + + + + + \ No newline at end of file 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..ad79aa009243 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-extrapolated-reference/_config.js @@ -0,0 +1,20 @@ +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-proxies/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange-proxies/_config.js new file mode 100644 index 000000000000..42cbcef00535 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-proxies/_config.js @@ -0,0 +1,14 @@ +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, ['proxy']); + + flushSync(() => btn2.click()); + assert.deepEqual(logs, ['proxy', 'proxy']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-proxies/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange-proxies/main.svelte new file mode 100644 index 000000000000..5340b231592d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-proxies/main.svelte @@ -0,0 +1,10 @@ + + + + diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-reassign-proxy/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange-reassign-proxy/_config.js new file mode 100644 index 000000000000..d9e2a1fdadae --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-reassign-proxy/_config.js @@ -0,0 +1,20 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [btn, btn2, btn3, btn4] = target.querySelectorAll('button'); + + flushSync(() => btn.click()); + assert.deepEqual(logs, ['a']); + + flushSync(() => btn2.click()); + assert.deepEqual(logs, ['a', 'b', 'c']); + flushSync(() => btn3.click()); + assert.deepEqual(logs, ['a', 'b', 'c', 'b', 'c']); + flushSync(() => btn4.click()); + assert.deepEqual(logs, ['a', 'b', 'c', 'b', 'c', 'c']); + flushSync(() => btn2.click()); + assert.deepEqual(logs, ['a', 'b', 'c', 'b', 'c', 'c', 'b']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-reassign-proxy/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange-reassign-proxy/main.svelte new file mode 100644 index 000000000000..8661a3de262c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-reassign-proxy/main.svelte @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file 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..ecade967c2a9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange/_config.js @@ -0,0 +1,11 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const btn = target.querySelector('button'); + + flushSync(() => btn?.click()); + assert.deepEqual(logs, ['count']); + } +}); 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..8dc265b1df72 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange/main.svelte @@ -0,0 +1,9 @@ + + + \ 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..160da4728340 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-raw-onchange/_config.js @@ -0,0 +1,106 @@ +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, btn11, btn12, btn13] = + target.querySelectorAll('button'); + + assert.deepEqual(logs, [ + 'constructor count', + 'constructor object', + 'assign in constructor', + 'assign in constructor object' + ]); + + logs.length = 0; + + flushSync(() => btn.click()); + assert.deepEqual(logs, ['count']); + + flushSync(() => btn2.click()); + assert.deepEqual(logs, ['count']); + + flushSync(() => btn3.click()); + assert.deepEqual(logs, ['count', 'object']); + + flushSync(() => btn4.click()); + assert.deepEqual(logs, ['count', 'object', 'class count']); + + flushSync(() => btn5.click()); + assert.deepEqual(logs, ['count', 'object', 'class count']); + + flushSync(() => btn6.click()); + assert.deepEqual(logs, ['count', 'object', 'class count', 'class object']); + + flushSync(() => btn7.click()); + assert.deepEqual(logs, [ + 'count', + 'object', + 'class count', + 'class object', + 'declared in constructor' + ]); + + flushSync(() => btn8.click()); + assert.deepEqual(logs, [ + 'count', + 'object', + 'class count', + 'class object', + 'declared in constructor', + 'declared in constructor object' + ]); + + flushSync(() => btn9.click()); + assert.deepEqual(logs, [ + 'count', + 'object', + 'class count', + 'class object', + 'declared in constructor', + 'declared in constructor object' + ]); + + flushSync(() => btn10.click()); + assert.deepEqual(logs, [ + 'count', + 'object', + 'class count', + 'class object', + 'declared in constructor', + 'declared in constructor object' + ]); + + flushSync(() => btn11.click()); + assert.deepEqual(logs, [ + 'count', + 'object', + 'class count', + 'class object', + 'declared in constructor', + 'declared in constructor object' + ]); + + flushSync(() => btn12.click()); + assert.deepEqual(logs, [ + 'count', + 'object', + 'class count', + 'class object', + 'declared in constructor', + 'declared in constructor object' + ]); + + flushSync(() => btn13.click()); + assert.deepEqual(logs, [ + 'count', + 'object', + 'class count', + 'class object', + 'declared in constructor', + 'declared in constructor object', + '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..2fc9f4e18744 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-raw-onchange/main.svelte @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 9ea45af7e6b7..5182cf9b1595 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -434,6 +434,12 @@ declare module 'svelte' { * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * */ export function afterUpdate(fn: () => void): void; + type Getters = { + [K in keyof T]: () => T[K]; + }; + export interface StateOptions { + onchange?: () => unknown; + } /** * Synchronously flush any pending updates. * Returns void if no callback is provided, otherwise returns the result of calling the callback. @@ -545,9 +551,6 @@ declare module 'svelte' { * ``` * */ export function untrack(fn: () => T): T; - type Getters = { - [K in keyof T]: () => T[K]; - }; export {}; } @@ -3094,6 +3097,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; @@ -3190,6 +3198,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; /**