diff --git a/.changeset/red-rules-share.md b/.changeset/red-rules-share.md new file mode 100644 index 0000000000..2a4d29b798 --- /dev/null +++ b/.changeset/red-rules-share.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add `onchange` option to `$state` diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index fbcecba8e4..211064c535 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -20,6 +20,7 @@ declare module '*.svelte' { * * @param initial The initial value */ +declare function $state(initial?: T, options?: import('svelte').StateOptions): T; declare function $state(initial: T): T; declare function $state(): 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 9f51cd61de..2eff18cd51 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,8 @@ 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' && node.arguments.length > 2) { + e.rune_invalid_arguments_length(node, rune, 'zero, one or 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 f4a6c9a414..e7ac6a9653 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/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/utils.js index c59a5544df..4256b84962 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/utils.js @@ -50,7 +50,9 @@ export function build_getter(node, state) { * @param {Expression} previous */ export function build_proxy_reassignment(value, previous) { - return dev ? b.call('$.proxy', value, b.null, previous) : b.call('$.proxy', value); + return dev + ? b.call('$.proxy', value, b.call('$.get_options', previous), b.null, previous) + : b.call('$.proxy', value, b.call('$.get_options', previous)); } /** 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 7b3a9a4d0e..1ade350898 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 @@ -116,11 +116,32 @@ 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); + + if (field.kind === 'state' && proxied && options != null) { + let generated = 'state_options'; + let i = 0; + while (private_ids.includes(generated)) { + generated = `state_options_${i++}`; + } + private_ids.push(generated); + body.push(b.prop_def(b.private_id(generated), options)); + options = b.member(b.this, `#${generated}`); + } + 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('$.proxy', init, options) : init, + options ) : field.kind === 'raw_state' ? b.call('$.state', init) 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 afb90bbec7..6c266fde21 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,37 @@ 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); + const proxied = rune === '$state' && should_proxy(value, context.state.scope); + if (proxied) { + if (options != null) { + const generated = context.state.scope.generate('state_options'); + declarations.push(b.declarator(generated, options)); + options = b.id(generated); + } + value = b.call('$.proxy', value, options); } if (is_state_source(binding, context.state.analysis)) { - value = b.call('$.state', value); + 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 +156,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/index.d.ts b/packages/svelte/src/index.d.ts index 554510542e..f94e6b5425 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/index.js b/packages/svelte/src/internal/client/index.js index 2bf58c51f7..0251b8e47b 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -109,7 +109,7 @@ 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, state, get_options } from './reactivity/sources.js'; export { prop, rest_props, diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index 6cbd6394df..9a2e88cc85 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -1,4 +1,4 @@ -/** @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 { @@ -19,11 +19,12 @@ import { tracing_mode_flag } from '../flags/index.js'; /** * @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) { /** @type {Error | null} */ var stack = null; if (DEV && tracing_mode_flag) { @@ -48,7 +49,7 @@ 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, options, stack)); } /** @type {ProxyMetadata} */ @@ -94,10 +95,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, options, stack); sources.set(prop, s); } else { - set(s, proxy(descriptor.value, metadata)); + set(s, proxy(descriptor.value, options, metadata)); } return true; @@ -108,7 +109,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, options, stack)); } } else { // When working with arrays, we need to also ensure we update the length when removing @@ -142,7 +143,7 @@ export function proxy(value, parent = null, prev) { // 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); + s = source(proxy(exists ? target[prop] : UNINITIALIZED, options, metadata), options, stack); sources.set(prop, s); } @@ -210,7 +211,7 @@ 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); + s = source(has ? proxy(target[prop], options, metadata) : UNINITIALIZED, options, stack); sources.set(prop, s); } @@ -237,7 +238,7 @@ export function proxy(value, parent = null, prev) { // 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, options, stack); sources.set(i + '', other_s); } } @@ -249,13 +250,13 @@ 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)); + s = source(undefined, options, stack); + set(s, proxy(value, options, metadata)); sources.set(prop, s); } } else { has = s.v !== UNINITIALIZED; - set(s, proxy(value, metadata)); + set(s, proxy(value, options, metadata)); } if (DEV) { diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 4500a7c5a8..4cd9da7e3c 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, @@ -47,10 +47,11 @@ export function set_inspect_effects(v) { /** * @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 @@ -58,7 +59,8 @@ export function source(v, stack) { reactions: null, equals, rv: 0, - wv: 0 + wv: 0, + o }; if (DEV && tracing_mode_flag) { @@ -72,9 +74,18 @@ export function source(v, stack) { /** * @template V * @param {V} v + * @param {ValueOptions} [o] */ -export function state(v) { - return push_derived_source(source(v)); +export function state(v, o) { + return push_derived_source(source(v, o)); +} + +/** + * @param {Source} source + * @returns {ValueOptions | undefined} + */ +export function get_options(source) { + return source.o; } /** @@ -171,6 +182,7 @@ export function internal_set(source, value) { var old_value = source.v; source.v = value; source.wv = increment_write_version(); + untrack(() => source.o?.onchange?.()); if (DEV && tracing_mode_flag) { source.updated = get_stack('UpdatedAt'); diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 3a76a3ff83..0b1492e4dd 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 7208ed7783..e7ce4137fb 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 993ca18f47..bd8349d9b8 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 zero, one or two arguments' } }); 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 0000000000..4864e64cd6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange/_config.js @@ -0,0 +1,44 @@ +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'); + 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' + ]); + } +}); 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 0000000000..46253488d2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange/main.svelte @@ -0,0 +1,36 @@ + + + + + + + + + \ 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 fa990b33ee..df765613aa 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, $.proxy($$value, $.get_options(value))); } }); 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 2898f31a6f..b6d0f002a7 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,7 +12,7 @@ export default function Class_state_field_constructor_assignment($$anchor, $$pro } set a(value) { - $.set(this.#a, $.proxy(value)); + $.set(this.#a, $.proxy(value, $.get_options(this.#a))); } #b = $.state(); 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 9651713c52..d791efe646 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, $.proxy(array[0], $.get_options(a))), + $.set(b, $.proxy(array[1], $.get_options(b))) ); [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 c545608bca..aca73406bd 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, $.proxy(plusOne($.get(count)), $.get_options(count))), children: ($$anchor, $$slotProps) => { $.next(); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index d00b2b01ed..1f9f946466 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,7 @@ declare module 'svelte/types/compiler/interfaces' { * * @param initial The initial value */ +declare function $state(initial?: T, options?: import('svelte').StateOptions): T; declare function $state(initial: T): T; declare function $state(): T | undefined;