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
*/
declare function $state<T>(initial?: T, options?: import('svelte').StateOptions): T;
declare function $state<T>(initial: T): T;
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) {
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;

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

@ -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));
}
/**

@ -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)

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

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

@ -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<T>} [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) {

@ -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<V>}
*/
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');

@ -7,6 +7,10 @@ export interface Signal {
wv: number;
}
export interface ValueOptions {
onchange?: () => unknown;
}
export interface Value<V = unknown> extends Signal {
/** Equality function */
equals: Equals;
@ -16,6 +20,8 @@ export interface Value<V = unknown> 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;

@ -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<string, EventCallback | EventCallback[]>;

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

@ -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);
},
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(this.#a, $.proxy(value));
$.set(this.#a, $.proxy(value, $.get_options(this.#a)));
}
#b = $.state();

@ -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;

@ -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();

@ -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<T> = {
[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<T extends Map<any, any> = Map<any, any>>(): T;
type Getters<T> = {
[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<T>(initial?: T, options?: import('svelte').StateOptions): T;
declare function $state<T>(initial: T): T;
declare function $state<T>(): T | undefined;

Loading…
Cancel
Save