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 16630a977b62..fc96e6517e7e 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). + ## `$state.snapshot` To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`: diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index a1484718cc77..2e402b4d6472 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 904817b014e4..979fb62316c3 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -125,8 +125,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 f0da5a491887..b39ffc63b3a4 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 @@ -295,8 +295,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/ClassBody.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js index f3ebd940699c..44ba7837b8fb 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 @@ -4,6 +4,7 @@ import * as b from '#compiler/builders'; import { regex_invalid_identifier_chars } from '../../../patterns.js'; import { get_rune } from '../../../scope.js'; import { should_proxy } from '../utils.js'; +import { get_onchange } from './shared/state.js'; /** * @param {ClassBody} node @@ -114,17 +115,20 @@ export function ClassBody(node, context) { context.visit(definition.value.arguments[0], child_state) ); - value = - field.kind === 'state' - ? b.call( - '$.state', - should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init - ) - : field.kind === 'raw_state' - ? b.call('$.state', init) - : field.kind === 'derived_by' - ? b.call('$.derived', init) - : b.call('$.derived', b.thunk(init)); + if (field.kind === 'state' || field.kind === 'raw_state') { + const onchange = get_onchange( + /** @type {Expression} */ (definition.value.arguments[1]), + // @ts-ignore mismatch between Context and ComponentContext. TODO look into + context + ); + + value = + field.kind === 'state' && should_proxy(init, context.state.scope) + ? b.call('$.assignable_proxy', init, onchange) + : b.call('$.state', init, onchange); + } else { + value = b.call('$.derived', field.kind === 'derived_by' ? init : b.thunk(init)); + } } else { // if no arguments, we know it's state as `$derived()` is a compile error value = b.call('$.state'); 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 84044e4dedca..0dfd9c211389 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 @@ -8,6 +8,7 @@ import * as assert from '../../../../utils/assert.js'; 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_onchange } from './shared/state.js'; /** * @param {VariableDeclaration} node @@ -118,27 +119,33 @@ export function VariableDeclaration(node, context) { const args = /** @type {CallExpression} */ (init).arguments; const value = args.length > 0 ? /** @type {Expression} */ (context.visit(args[0])) : b.void0; + 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 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); + const create_state_declarator = (id, value, onchange) => { + const binding = /** @type {Binding} */ (context.state.scope.get(id.name)); + const proxied = rune === '$state' && should_proxy(value, context.state.scope); + const is_state = is_state_source(binding, context.state.analysis); + + if (proxied) { + return b.call(is_state ? '$.assignable_proxy' : '$.proxy', value, onchange); } - if (is_state_source(binding, context.state.analysis)) { - value = b.call('$.state', value); + + if (is_state) { + return b.call('$.state', value, onchange); } + 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, onchange)) ); } else { const tmp = context.state.scope.generate('tmp'); @@ -151,7 +158,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..0f8a7b1b5bfd --- /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 } 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 + * @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 7e5196c606b4..4ad912bb81f3 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -23,5 +23,6 @@ export const EFFECT_HAS_DERIVED = 1 << 20; export const EFFECT_IS_UPDATING = 1 << 21; 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(''); diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 14d6e29f5bb4..66f6db67d069 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -141,7 +141,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 d690790e3a78..7c6132d27526 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -1,5 +1,7 @@ /** @import { Source } from '#client' */ import { DEV } from 'esm-env'; +import { UNINITIALIZED } from '../../constants.js'; +import { tracing_mode_flag } from '../flags/index.js'; import { get, active_effect, active_reaction, set_active_reaction } from './runtime.js'; import { array_prototype, @@ -8,24 +10,54 @@ import { is_array, object_prototype } from '../shared/utils.js'; -import { state as source, set } from './reactivity/sources.js'; -import { STATE_SYMBOL } from '#client/constants'; -import { UNINITIALIZED } from '../../constants.js'; -import * as e from './errors.js'; +import { PROXY_ONCHANGE_SYMBOL, STATE_SYMBOL } from '#client/constants'; 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'; + +const array_methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'reverse', 'sort']; + +/** + * 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) { @@ -58,7 +90,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)); } return new Proxy(/** @type {any} */ (value), { @@ -79,12 +111,12 @@ export function proxy(value) { var s = sources.get(prop); if (s === undefined) { - s = with_parent(() => source(descriptor.value, stack)); + s = with_parent(() => source(descriptor.value, onchange, stack)); sources.set(prop, s); } else { set( s, - with_parent(() => proxy(descriptor.value)) + with_parent(() => proxy(descriptor.value, onchange)) ); } @@ -98,7 +130,7 @@ export function proxy(value) { if (prop in target) { sources.set( prop, - with_parent(() => source(UNINITIALIZED, stack)) + with_parent(() => source(UNINITIALIZED, onchange, stack)) ); } } else { @@ -112,6 +144,13 @@ export function proxy(value) { 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 (onchange && typeof s.v === 'object' && s.v !== null && STATE_SYMBOL in s.v) { + s.v[PROXY_ONCHANGE_SYMBOL](onchange, true); + } + set(s, UNINITIALIZED); update_version(version); } @@ -124,12 +163,29 @@ export function proxy(value) { return value; } + 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)) { - s = with_parent(() => source(proxy(exists ? target[prop] : UNINITIALIZED), stack)); + let opt = onchange; + s = with_parent(() => + source(proxy(exists ? target[prop] : UNINITIALIZED, opt), opt, stack) + ); sources.set(prop, s); } @@ -138,7 +194,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_methods.includes(/** @type {string} */ (prop)) + ) { + return batch_onchange(v); + } + + return v; }, getOwnPropertyDescriptor(target, prop) { @@ -177,7 +243,8 @@ export function proxy(value) { (active_effect !== null && (!has || get_descriptor(target, prop)?.writable)) ) { if (s === undefined) { - s = with_parent(() => source(has ? proxy(target[prop]) : UNINITIALIZED, stack)); + let opt = onchange; + s = with_parent(() => source(has ? proxy(target[prop], opt) : UNINITIALIZED, opt, stack)); sources.set(prop, s); } @@ -194,42 +261,60 @@ 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 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' ? 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 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)); + // 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); + } + } else { + has = s.v !== UNINITIALIZED; + + // 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); + } + } + + if (s !== undefined) { set( s, - with_parent(() => proxy(value)) + with_parent(() => proxy(value, onchange)) ); - sources.set(prop, s); } - } else { - has = s.v !== UNINITIALIZED; - set( - s, - with_parent(() => proxy(value)) - ); - } + })(); var descriptor = Reflect.getOwnPropertyDescriptor(target, prop); @@ -281,6 +366,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 {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 9d2ad2baee4e..927a2ff1748b 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -27,13 +27,14 @@ import { UNOWNED, MAYBE_DIRTY, BLOCK_EFFECT, - ROOT_EFFECT + ROOT_EFFECT, + 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 } from '../dev/tracing.js'; -import { component_context, is_runes } from '../context.js'; import { proxy } from '../proxy.js'; +import { component_context, is_runes } from '../context.js'; import { execute_derived } from './deriveds.js'; export let inspect_effects = new Set(); @@ -46,14 +47,41 @@ 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 {() => 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 @@ -61,7 +89,8 @@ export function source(v, stack) { reactions: null, equals, rv: 0, - wv: 0 + wv: 0, + o }; if (DEV && tracing_mode_flag) { @@ -75,11 +104,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); @@ -139,7 +169,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; return internal_set(source, new_value); } @@ -154,6 +184,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 { @@ -215,6 +250,15 @@ export function internal_set(source, value) { inspect_effects.clear(); } + + 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 5ef0097649a4..662a00a926c6 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 StateOptions { + 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; + /** onchange callback */ + o?: () => void; /** Dev only */ created?: Error | null; updated?: Error | null; 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..dc9fe8bfb148 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-classes/_config.js @@ -0,0 +1,21 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [btn, btn2, btn3] = target.querySelectorAll('button'); + + assert.deepEqual(logs, ['constructor count', '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']); + } +}); 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..a42c1c124511 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-classes/main.svelte @@ -0,0 +1,37 @@ + + + + + \ 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..ce41a485c527 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-raw-onchange/_config.js @@ -0,0 +1,43 @@ +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 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']); + + flushSync(() => btn8.click()); + assert.deepEqual(logs, ['count', 'object', 'class count', 'class object']); + + flushSync(() => btn9.click()); + assert.deepEqual(logs, ['count', 'object', 'class count', 'class object']); + + flushSync(() => btn10.click()); + assert.deepEqual(logs, ['count', 'object', 'class count', 'class 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..cc19a21d4f9f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-raw-onchange/main.svelte @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index b233cfcc0b58..9ebb6e72ad91 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -410,6 +410,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; + } /** * Create a snippet programmatically * */ @@ -515,9 +521,6 @@ declare module 'svelte' { export function unmount(component: Record, options?: { outro?: boolean; } | undefined): Promise; - type Getters = { - [K in keyof T]: () => T[K]; - }; export {}; } @@ -2852,6 +2855,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; @@ -2948,6 +2956,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; /**