diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md index b90c71366a..75081c240a 100644 --- a/documentation/docs/02-runes/02-$state.md +++ b/documentation/docs/02-runes/02-$state.md @@ -150,6 +150,20 @@ This can improve performance with large arrays and objects that you weren't plan As with `$state`, you can declare class fields using `$state.raw`. +## State options + +`$state` and `$state.raw` accept an options object as their second argument. Its `onchange` function is called synchronously whenever the value is reassigned or, for `$state`, when any of its deeply reactive contents are mutated, before any effects run. Use it to persist state as it changes, or to constrain it: + +```js +let count = $state(0, { + onchange() { + count = Math.min(count, 10); + } +}); +``` + +Reads inside `onchange` are not tracked, and a mutation it makes to its own state does not call it again. + ## `$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 a9b2cebe1a..1eca86e91a 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; @@ -130,6 +135,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 312a93e5fd..0147a1ef0a 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,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 (node.arguments.length > 1) { - e.rune_invalid_arguments_length(node, rune, 'zero or one arguments'); + } else 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 cbb5f2eac7..850fd0009f 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 @@ -289,8 +289,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 f69bc5fe6e..4ba73e4287 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 { should_proxy } from '../utils.js'; import { get_inspect_args } from '../../utils.js'; +import { get_onchange, with_onchange } from './shared/state.js'; /** * @param {CallExpression} node @@ -40,7 +41,11 @@ export function CallExpression(node, context) { } const callee = b.id('$.state', node.callee.loc); - return b.call(callee, value); + const onchange = get_onchange( + /** @type {Expression | undefined} */ (node.arguments[1]), + context + ); + return with_onchange(b.call(callee, value), onchange); } case '$derived': 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 246feaccf6..9a59853ee6 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 @@ -14,6 +14,7 @@ import { should_proxy } from '../utils.js'; import { get_value } from './shared/declarations.js'; +import { get_onchange, with_onchange } from './shared/state.js'; /** * @param {VariableDeclaration} node @@ -130,6 +131,7 @@ 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 | undefined} */ (args[1]), context); if (rune === '$state' || rune === '$state.raw') { /** @@ -144,7 +146,8 @@ export function VariableDeclaration(node, context) { const is_proxy = should_proxy(value, context.state.scope); if (rune === '$state' && is_proxy) { - value = b.call('$.proxy', value); + // a proxy that is never reassigned has no source, so the callback attaches to the proxy itself + value = is_state ? b.call('$.proxy', value) : b.call('$.proxy', value, onchange); if (dev && !is_state) { value = b.call('$.tag_proxy', value, b.literal(id.name)); @@ -158,6 +161,8 @@ export function VariableDeclaration(node, context) { if (dev) { value = b.call('$.tag', value, b.literal(id.name)); } + + value = with_onchange(value, onchange); } return 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 0000000000..8e7d4fb936 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/state.js @@ -0,0 +1,34 @@ +/** @import { Expression, Property } from 'estree' */ +/** @import { ComponentContext, Context } from '../../types' */ +import * as b from '#compiler/builders'; + +/** + * The `onchange` callback from a `$state` rune's options argument, if any + * @param {Expression | undefined} options the rune's second argument + * @param {ComponentContext | Context} context + * @returns {Expression | undefined} + */ +export function get_onchange(options, context) { + if (options?.type !== 'ObjectExpression') return; + + const property = options.properties.find( + (property) => + property.type === 'Property' && + !property.computed && + property.key.type === 'Identifier' && + property.key.name === 'onchange' + ); + + if (property === undefined) return; + + return /** @type {Expression} */ (context.visit(/** @type {Property} */ (property).value)); +} + +/** + * Wraps a `$.state(...)` call so `onchange` is registered on the source + * @param {Expression} call + * @param {Expression | undefined} onchange + */ +export function with_onchange(call, onchange) { + return onchange === undefined ? call : b.call('$.onchange', call, onchange); +} diff --git a/packages/svelte/src/index.d.ts b/packages/svelte/src/index.d.ts index 7823c8e33e..cdb16d20dd 100644 --- a/packages/svelte/src/index.d.ts +++ b/packages/svelte/src/index.d.ts @@ -374,4 +374,6 @@ export interface Fork { discard(): void; } +export type { 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 15e92aa255..6f07b6a0d4 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -131,7 +131,15 @@ export { user_effect, user_pre_effect } from './reactivity/effects.js'; -export { mutable_source, mutate, set, state, update, update_pre } from './reactivity/sources.js'; +export { + mutable_source, + mutate, + set, + state, + update, + update_pre, + onchange +} from './reactivity/sources.js'; export { prop, rest_props, diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index de701e925c..898dd274af 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -28,7 +28,8 @@ import { ASYNC, WAS_MARKED, CONNECTED, - REACTION_IS_UPDATING + REACTION_IS_UPDATING, + STATE_SYMBOL } from '#client/constants'; import * as e from '../errors.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; @@ -42,7 +43,7 @@ import { schedule_effect, legacy_updates } from './batch.js'; -import { proxy } from '../proxy.js'; +import { proxy, remove_onchange } from '../proxy.js'; import { execute_derived } from './deriveds.js'; import { set_signal_status, update_derived_status } from './status.js'; @@ -189,6 +190,14 @@ export function set(source, value, should_proxy = false) { */ export function internal_set(source, value, updated_during_traversal = null) { if (!source.equals(value)) { + var callback = source.o; + + if (callback !== undefined) { + // the old tree stops reporting to this source's callback, the new one starts + remove_onchange(source.v, callback); + attach_onchange(value, callback); + } + if (is_destroying_effect) { old_values.set(source, value); } else if (!old_values.has(source)) { @@ -271,11 +280,51 @@ export function internal_set(source, value, updated_during_traversal = null) { if (!batch.is_fork && eager_effects.size > 0 && eager_effects_deferred === 0) { flush_eager_effects(); } + + if (callback !== undefined) callback(); } return value; } +/** + * Registers `onchange` on a state source: it fires when the source is reassigned, and + * every proxy tree the source holds reports its mutations to it + * @template {Source} S + * @param {S} source + * @param {() => void} callback + * @returns {S} + */ +export function onchange(source, callback) { + var running = false; + + // one guard per declaration, shared by the reassignment path and every proxy tree + // the source holds, so a callback that writes its own state runs once + source.o = () => { + if (running) return; + running = true; + + try { + callback(); + } finally { + running = false; + } + }; + + attach_onchange(source.v, source.o); + return source; +} + +/** + * @param {unknown} value + * @param {() => void} callback + */ +function attach_onchange(value, callback) { + if (typeof value === 'object' && value !== null && STATE_SYMBOL in value) { + proxy(value, callback); + } +} + export function flush_eager_effects() { try { for (const effect of eager_effects) { diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 0ee8570c3d..ab659e7035 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -15,6 +15,11 @@ export interface Signal { wv: number; } +export interface StateOptions { + /** Called synchronously whenever the state is reassigned or, for `$state`, mutated anywhere in its tree */ + onchange?: () => void; +} + export interface Value extends Signal { /** Equality function */ equals: Equals; @@ -24,6 +29,8 @@ export interface Value extends Signal { rv: number; /** The latest value for this signal */ v: V; + /** `onchange` callback, fired on reassignment and attached to every proxy tree this source holds */ + o?: () => void; // 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 993ca18f47..d2c92ca681 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 af226559d1..59efc6af39 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 0000000000..619fab8a2c --- /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 0000000000..1a299533a4 --- /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 0000000000..3985156f03 --- /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 0000000000..1a1fc40891 --- /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 0000000000..3ad5b749ee --- /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 0000000000..dcea39d2c3 --- /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 0000000000..d77d3f9aa7 --- /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 0000000000..41f8c7a948 --- /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 0000000000..76380eddc9 --- /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 0000000000..e1f6093422 --- /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 0000000000..9ed80bd660 --- /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 0000000000..8f49d5294b --- /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 0000000000..ad79aa0092 --- /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 0000000000..4d586c7707 --- /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 0000000000..42cbcef005 --- /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 0000000000..5340b23159 --- /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 0000000000..d9e2a1fdad --- /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 0000000000..8661a3de26 --- /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-self-reassign/_config.js b/packages/svelte/tests/runtime-runes/samples/state-onchange-self-reassign/_config.js new file mode 100644 index 0000000000..0eaea8cf45 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-self-reassign/_config.js @@ -0,0 +1,17 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [count, items] = target.querySelectorAll('button'); + + flushSync(() => count.click()); + assert.deepEqual(logs, ['count', 20]); + assert.htmlEqual(count.innerHTML, '10'); + + logs.length = 0; + flushSync(() => items.click()); + assert.deepEqual(logs, ['items', 3]); + assert.htmlEqual(items.innerHTML, '2'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-onchange-self-reassign/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-onchange-self-reassign/main.svelte new file mode 100644 index 0000000000..89a655e80d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-onchange-self-reassign/main.svelte @@ -0,0 +1,18 @@ + + + + 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..ecade967c2 --- /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 0000000000..8dc265b1df --- /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 0000000000..160da47283 --- /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 0000000000..2fc9f4e187 --- /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 661b840354..86e9537e43 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -458,16 +458,9 @@ declare module 'svelte' { * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * */ export function afterUpdate(fn: () => void): void; - export function hydratable(key: string, fn: () => T): T; - /** - * Create a snippet programmatically - * */ - export function createRawSnippet(fn: (...params: Getters) => { - render: () => string; - setup?: (element: Element) => void | (() => void); - }): Snippet; - /** Anything except a function */ - type NotFunction = T extends Function ? never : T; + type Getters = { + [K in keyof T]: () => T[K]; + }; /** * Synchronously flush any pending updates. * Returns void if no callback is provided, otherwise returns the result of calling the callback. @@ -491,6 +484,20 @@ declare module 'svelte' { * @since 5.42 */ export function fork(fn: () => void): Fork; + export interface StateOptions { + /** Called synchronously whenever the state is reassigned or, for `$state`, mutated anywhere in its tree */ + onchange?: () => void; + } + export function hydratable(key: string, fn: () => T): T; + /** + * Create a snippet programmatically + * */ + export function createRawSnippet(fn: (...params: Getters) => { + render: () => string; + setup?: (element: Element) => void | (() => void); + }): Snippet; + /** Anything except a function */ + type NotFunction = T extends Function ? never : T; /** * Returns a `[get, set, has]` triplet of functions for working with context in a type-safe way. * @@ -605,9 +612,6 @@ declare module 'svelte' { * ``` * */ export function untrack(fn: () => T): T; - type Getters = { - [K in keyof T]: () => T[K]; - }; export {}; } @@ -3338,6 +3342,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; @@ -3448,6 +3457,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; /**