feat: add `onchange` option to `$state`

pull/15073/head
paoloricciuti 8 months ago
parent de94159238
commit d7876af34b

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: add `onchange` option to `$state`

@ -20,6 +20,7 @@ declare module '*.svelte' {
* *
* @param initial The initial value * @param initial The initial value
*/ */
declare function $state<T>(initial?: T, options?: import('svelte').StateOptions): T;
declare function $state<T>(initial: T): T; declare function $state<T>(initial: T): T;
declare function $state<T>(): T | undefined; declare function $state<T>(): T | undefined;

@ -87,8 +87,8 @@ export function CallExpression(node, context) {
if ((rune === '$derived' || rune === '$derived.by') && node.arguments.length !== 1) { if ((rune === '$derived' || rune === '$derived.by') && node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument'); e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
} else if (rune === '$state' && node.arguments.length > 1) { } else if (rune === '$state' && node.arguments.length > 2) {
e.rune_invalid_arguments_length(node, rune, 'zero or one arguments'); e.rune_invalid_arguments_length(node, rune, 'zero, one or two arguments');
} }
break; break;

@ -292,7 +292,10 @@ export function client_component(analysis, options) {
} }
if (binding?.kind === 'state' || binding?.kind === 'raw_state') { 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))])]; return [getter, b.set(alias ?? name, [b.stmt(b.call('$.set', b.id(name), value))])];
} }

@ -50,7 +50,9 @@ export function build_getter(node, state) {
* @param {Expression} previous * @param {Expression} previous
*/ */
export function build_proxy_reassignment(value, 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));
} }
/** /**

@ -116,11 +116,32 @@ export function ClassBody(node, context) {
context.visit(definition.value.arguments[0], child_state) 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 = value =
field.kind === 'state' field.kind === 'state'
? b.call( ? b.call(
'$.state', '$.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' : field.kind === 'raw_state'
? b.call('$.state', init) ? b.call('$.state', init)

@ -113,28 +113,37 @@ export function VariableDeclaration(node, context) {
const args = /** @type {CallExpression} */ (init).arguments; const args = /** @type {CallExpression} */ (init).arguments;
const value = const value =
args.length === 0 ? b.id('undefined') : /** @type {Expression} */ (context.visit(args[0])); 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') { if (rune === '$state' || rune === '$state.raw') {
/** /**
* @param {Identifier} id * @param {Identifier} id
* @param {Expression} value * @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} */ ( const binding = /** @type {import('#compiler').Binding} */ (
context.state.scope.get(id.name) context.state.scope.get(id.name)
); );
if (rune === '$state' && should_proxy(value, context.state.scope)) { const proxied = rune === '$state' && should_proxy(value, context.state.scope);
value = b.call('$.proxy', value); 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)) { if (is_state_source(binding, context.state.analysis)) {
value = b.call('$.state', value); value = b.call('$.state', value, options);
} }
return value; return value;
}; };
if (declarator.id.type === 'Identifier') { if (declarator.id.type === 'Identifier') {
declarations.push( declarations.push(
b.declarator(declarator.id, create_state_declarator(declarator.id, value)) b.declarator(declarator.id, create_state_declarator(declarator.id, value, options))
); );
} else { } else {
const tmp = context.state.scope.generate('tmp'); const tmp = context.state.scope.generate('tmp');
@ -147,7 +156,7 @@ export function VariableDeclaration(node, context) {
return b.declarator( return b.declarator(
path.node, path.node,
binding?.kind === 'state' || binding?.kind === 'raw_state' binding?.kind === 'state' || binding?.kind === 'raw_state'
? create_state_declarator(binding.node, value) ? create_state_declarator(binding.node, value, options)
: value : value
); );
}) })

@ -351,4 +351,6 @@ export type MountOptions<Props extends Record<string, any> = Record<string, any>
props: Props; props: Props;
}); });
export { ValueOptions as StateOptions } from './internal/client/types.js';
export * from './index-client.js'; export * from './index-client.js';

@ -109,7 +109,7 @@ export {
user_effect, user_effect,
user_pre_effect user_pre_effect
} from './reactivity/effects.js'; } 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 { export {
prop, prop,
rest_props, rest_props,

@ -1,4 +1,4 @@
/** @import { ProxyMetadata, ProxyStateObject, Source } from '#client' */ /** @import { ProxyMetadata, ProxyStateObject, Source, ValueOptions } from '#client' */
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { get, component_context, active_effect } from './runtime.js'; import { get, component_context, active_effect } from './runtime.js';
import { import {
@ -19,11 +19,12 @@ import { tracing_mode_flag } from '../flags/index.js';
/** /**
* @template T * @template T
* @param {T} value * @param {T} value
* @param {ValueOptions} [options]
* @param {ProxyMetadata | null} [parent] * @param {ProxyMetadata | null} [parent]
* @param {Source<T>} [prev] dev mode only * @param {Source<T>} [prev] dev mode only
* @returns {T} * @returns {T}
*/ */
export function proxy(value, parent = null, prev) { export function proxy(value, options, parent = null, prev) {
/** @type {Error | null} */ /** @type {Error | null} */
var stack = null; var stack = null;
if (DEV && tracing_mode_flag) { if (DEV && tracing_mode_flag) {
@ -48,7 +49,7 @@ export function proxy(value, parent = null, prev) {
if (is_proxied_array) { if (is_proxied_array) {
// We need to create the length source eagerly to ensure that // We need to create the length source eagerly to ensure that
// mutations to the array are properly synced with our proxy // 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} */ /** @type {ProxyMetadata} */
@ -94,10 +95,10 @@ export function proxy(value, parent = null, prev) {
var s = sources.get(prop); var s = sources.get(prop);
if (s === undefined) { if (s === undefined) {
s = source(descriptor.value, stack); s = source(descriptor.value, options, stack);
sources.set(prop, s); sources.set(prop, s);
} else { } else {
set(s, proxy(descriptor.value, metadata)); set(s, proxy(descriptor.value, options, metadata));
} }
return true; return true;
@ -108,7 +109,7 @@ export function proxy(value, parent = null, prev) {
if (s === undefined) { if (s === undefined) {
if (prop in target) { if (prop in target) {
sources.set(prop, source(UNINITIALIZED, stack)); sources.set(prop, source(UNINITIALIZED, options, stack));
} }
} else { } else {
// When working with arrays, we need to also ensure we update the length when removing // 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 // 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)) { 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); sources.set(prop, s);
} }
@ -210,7 +211,7 @@ export function proxy(value, parent = null, prev) {
(active_effect !== null && (!has || get_descriptor(target, prop)?.writable)) (active_effect !== null && (!has || get_descriptor(target, prop)?.writable))
) { ) {
if (s === undefined) { 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); 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, // 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 // else a later read of the property would result in a source being created with
// the value of the original item at that index. // 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); sources.set(i + '', other_s);
} }
} }
@ -249,13 +250,13 @@ export function proxy(value, parent = null, prev) {
// object property before writing to that property. // object property before writing to that property.
if (s === undefined) { if (s === undefined) {
if (!has || get_descriptor(target, prop)?.writable) { if (!has || get_descriptor(target, prop)?.writable) {
s = source(undefined, stack); s = source(undefined, options, stack);
set(s, proxy(value, metadata)); set(s, proxy(value, options, metadata));
sources.set(prop, s); sources.set(prop, s);
} }
} else { } else {
has = s.v !== UNINITIALIZED; has = s.v !== UNINITIALIZED;
set(s, proxy(value, metadata)); set(s, proxy(value, options, metadata));
} }
if (DEV) { if (DEV) {

@ -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 { DEV } from 'esm-env';
import { import {
component_context, component_context,
@ -47,10 +47,11 @@ export function set_inspect_effects(v) {
/** /**
* @template V * @template V
* @param {V} v * @param {V} v
* @param {ValueOptions} [o]
* @param {Error | null} [stack] * @param {Error | null} [stack]
* @returns {Source<V>} * @returns {Source<V>}
*/ */
export function source(v, stack) { export function source(v, o, stack) {
/** @type {Value} */ /** @type {Value} */
var signal = { var signal = {
f: 0, // TODO ideally we could skip this altogether, but it causes type errors 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, reactions: null,
equals, equals,
rv: 0, rv: 0,
wv: 0 wv: 0,
o
}; };
if (DEV && tracing_mode_flag) { if (DEV && tracing_mode_flag) {
@ -72,9 +74,18 @@ export function source(v, stack) {
/** /**
* @template V * @template V
* @param {V} v * @param {V} v
* @param {ValueOptions} [o]
*/ */
export function state(v) { export function state(v, o) {
return push_derived_source(source(v)); 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; var old_value = source.v;
source.v = value; source.v = value;
source.wv = increment_write_version(); source.wv = increment_write_version();
untrack(() => source.o?.onchange?.());
if (DEV && tracing_mode_flag) { if (DEV && tracing_mode_flag) {
source.updated = get_stack('UpdatedAt'); source.updated = get_stack('UpdatedAt');

@ -7,6 +7,10 @@ export interface Signal {
wv: number; wv: number;
} }
export interface ValueOptions {
onchange?: () => unknown;
}
export interface Value<V = unknown> extends Signal { export interface Value<V = unknown> extends Signal {
/** Equality function */ /** Equality function */
equals: Equals; equals: Equals;
@ -16,6 +20,8 @@ export interface Value<V = unknown> extends Signal {
rv: number; rv: number;
/** The latest value for this signal */ /** The latest value for this signal */
v: V; v: V;
/** Options for the source */
o?: ValueOptions;
/** Dev only */ /** Dev only */
created?: Error | null; created?: Error | null;
updated?: Error | null; updated?: Error | null;

@ -1,6 +1,6 @@
import type { Store } from '#shared'; import type { Store } from '#shared';
import { STATE_SYMBOL } from './constants.js'; 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; type EventCallback = (event: Event) => boolean;
export type EventCallbackMap = Record<string, EventCallback | EventCallback[]>; export type EventCallbackMap = Record<string, EventCallback | EventCallback[]>;

@ -3,6 +3,6 @@ import { test } from '../../test';
export default test({ export default test({
error: { error: {
code: 'rune_invalid_arguments_length', 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'
} }
}); });

@ -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'
]);
}
});

@ -0,0 +1,36 @@
<script>
let count = $state(0, {
onchange(){
console.log("count");
}
})
let proxy = $state({count: 0}, {
onchange(){
console.log("proxy");
}
})
class Test{
count = $state(0, {
onchange(){
console.log("class count");
}
})
proxy = $state({count: 0}, {
onchange(){
console.log("class proxy");
}
})
}
const class_test = new Test();
</script>
<button onclick={()=> count++}>{count}</button>
<button onclick={()=> proxy.count++}>{proxy.count}</button>
<button onclick={()=> proxy = {count: proxy.count+1}}>{proxy.count}</button>
<button onclick={()=> class_test.count++}>{class_test.count}</button>
<button onclick={()=> class_test.proxy.count++}>{class_test.proxy.count}</button>
<button onclick={()=> class_test.proxy = {count: class_test.proxy.count+1}}>{class_test.proxy.count}</button>

@ -23,7 +23,7 @@ export default function Bind_component_snippet($$anchor) {
return $.get(value); return $.get(value);
}, },
set value($$value) { set value($$value) {
$.set(value, $.proxy($$value)); $.set(value, $.proxy($$value, $.get_options(value)));
} }
}); });

@ -12,7 +12,7 @@ export default function Class_state_field_constructor_assignment($$anchor, $$pro
} }
set a(value) { set a(value) {
$.set(this.#a, $.proxy(value)); $.set(this.#a, $.proxy(value, $.get_options(this.#a)));
} }
#b = $.state(); #b = $.state();

@ -8,8 +8,8 @@ let d = 4;
export function update(array) { export function update(array) {
( (
$.set(a, $.proxy(array[0])), $.set(a, $.proxy(array[0], $.get_options(a))),
$.set(b, $.proxy(array[1])) $.set(b, $.proxy(array[1], $.get_options(b)))
); );
[c, d] = array; [c, d] = array;

@ -13,7 +13,7 @@ export default function Function_prop_no_getter($$anchor) {
Button($$anchor, { Button($$anchor, {
onmousedown: () => $.set(count, $.get(count) + 1), onmousedown: () => $.set(count, $.get(count) + 1),
onmouseup, onmouseup,
onmouseenter: () => $.set(count, $.proxy(plusOne($.get(count)))), onmouseenter: () => $.set(count, $.proxy(plusOne($.get(count)), $.get_options(count))),
children: ($$anchor, $$slotProps) => { children: ($$anchor, $$slotProps) => {
$.next(); $.next();

@ -412,6 +412,12 @@ declare module 'svelte' {
* Synchronously flushes any pending state changes and those that result from it. * Synchronously flushes any pending state changes and those that result from it.
* */ * */
export function flushSync(fn?: (() => void) | undefined): void; export function flushSync(fn?: (() => void) | undefined): void;
type Getters<T> = {
[K in keyof T]: () => T[K];
};
export interface StateOptions {
onchange?: () => unknown;
}
/** /**
* Create a snippet programmatically * Create a snippet programmatically
* */ * */
@ -512,9 +518,6 @@ declare module 'svelte' {
* *
* */ * */
export function getAllContexts<T extends Map<any, any> = Map<any, any>>(): T; export function getAllContexts<T extends Map<any, any> = Map<any, any>>(): T;
type Getters<T> = {
[K in keyof T]: () => T[K];
};
export {}; export {};
} }
@ -2676,6 +2679,7 @@ declare module 'svelte/types/compiler/interfaces' {
* *
* @param initial The initial value * @param initial The initial value
*/ */
declare function $state<T>(initial?: T, options?: import('svelte').StateOptions): T;
declare function $state<T>(initial: T): T; declare function $state<T>(initial: T): T;
declare function $state<T>(): T | undefined; declare function $state<T>(): T | undefined;

Loading…
Cancel
Save