Skip to content

Commit d7876af

Browse files
committed
feat: add onchange option to $state
1 parent de94159 commit d7876af

File tree

21 files changed

+185
-39
lines changed

21 files changed

+185
-39
lines changed

.changeset/red-rules-share.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: add `onchange` option to `$state`

packages/svelte/src/ambient.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ declare module '*.svelte' {
2020
*
2121
* @param initial The initial value
2222
*/
23+
declare function $state<T>(initial?: T, options?: import('svelte').StateOptions): T;
2324
declare function $state<T>(initial: T): T;
2425
declare function $state<T>(): T | undefined;
2526

packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ export function CallExpression(node, context) {
8787

8888
if ((rune === '$derived' || rune === '$derived.by') && node.arguments.length !== 1) {
8989
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
90-
} else if (rune === '$state' && node.arguments.length > 1) {
91-
e.rune_invalid_arguments_length(node, rune, 'zero or one arguments');
90+
} else if (rune === '$state' && node.arguments.length > 2) {
91+
e.rune_invalid_arguments_length(node, rune, 'zero, one or two arguments');
9292
}
9393

9494
break;

packages/svelte/src/compiler/phases/3-transform/client/transform-client.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,10 @@ export function client_component(analysis, options) {
292292
}
293293

294294
if (binding?.kind === 'state' || binding?.kind === 'raw_state') {
295-
const value = binding.kind === 'state' ? b.call('$.proxy', b.id('$$value')) : b.id('$$value');
295+
const value =
296+
binding.kind === 'state'
297+
? b.call('$.proxy', b.id('$$value'), b.call('$.get_options', b.id(name)))
298+
: b.id('$$value');
296299
return [getter, b.set(alias ?? name, [b.stmt(b.call('$.set', b.id(name), value))])];
297300
}
298301

packages/svelte/src/compiler/phases/3-transform/client/utils.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ export function build_getter(node, state) {
5050
* @param {Expression} previous
5151
*/
5252
export function build_proxy_reassignment(value, previous) {
53-
return dev ? b.call('$.proxy', value, b.null, previous) : b.call('$.proxy', value);
53+
return dev
54+
? b.call('$.proxy', value, b.call('$.get_options', previous), b.null, previous)
55+
: b.call('$.proxy', value, b.call('$.get_options', previous));
5456
}
5557

5658
/**

packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,32 @@ export function ClassBody(node, context) {
116116
context.visit(definition.value.arguments[0], child_state)
117117
);
118118

119+
let options =
120+
definition.value.arguments.length === 2
121+
? /** @type {Expression} **/ (
122+
context.visit(definition.value.arguments[1], child_state)
123+
)
124+
: undefined;
125+
126+
let proxied = should_proxy(init, context.state.scope);
127+
128+
if (field.kind === 'state' && proxied && options != null) {
129+
let generated = 'state_options';
130+
let i = 0;
131+
while (private_ids.includes(generated)) {
132+
generated = `state_options_${i++}`;
133+
}
134+
private_ids.push(generated);
135+
body.push(b.prop_def(b.private_id(generated), options));
136+
options = b.member(b.this, `#${generated}`);
137+
}
138+
119139
value =
120140
field.kind === 'state'
121141
? b.call(
122142
'$.state',
123-
should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init
143+
should_proxy(init, context.state.scope) ? b.call('$.proxy', init, options) : init,
144+
options
124145
)
125146
: field.kind === 'raw_state'
126147
? b.call('$.state', init)

packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,28 +113,37 @@ export function VariableDeclaration(node, context) {
113113
const args = /** @type {CallExpression} */ (init).arguments;
114114
const value =
115115
args.length === 0 ? b.id('undefined') : /** @type {Expression} */ (context.visit(args[0]));
116+
let options =
117+
args.length === 2 ? /** @type {Expression} */ (context.visit(args[1])) : undefined;
116118

117119
if (rune === '$state' || rune === '$state.raw') {
118120
/**
119121
* @param {Identifier} id
120122
* @param {Expression} value
123+
* @param {Expression} [options]
121124
*/
122-
const create_state_declarator = (id, value) => {
125+
const create_state_declarator = (id, value, options) => {
123126
const binding = /** @type {import('#compiler').Binding} */ (
124127
context.state.scope.get(id.name)
125128
);
126-
if (rune === '$state' && should_proxy(value, context.state.scope)) {
127-
value = b.call('$.proxy', value);
129+
const proxied = rune === '$state' && should_proxy(value, context.state.scope);
130+
if (proxied) {
131+
if (options != null) {
132+
const generated = context.state.scope.generate('state_options');
133+
declarations.push(b.declarator(generated, options));
134+
options = b.id(generated);
135+
}
136+
value = b.call('$.proxy', value, options);
128137
}
129138
if (is_state_source(binding, context.state.analysis)) {
130-
value = b.call('$.state', value);
139+
value = b.call('$.state', value, options);
131140
}
132141
return value;
133142
};
134143

135144
if (declarator.id.type === 'Identifier') {
136145
declarations.push(
137-
b.declarator(declarator.id, create_state_declarator(declarator.id, value))
146+
b.declarator(declarator.id, create_state_declarator(declarator.id, value, options))
138147
);
139148
} else {
140149
const tmp = context.state.scope.generate('tmp');
@@ -147,7 +156,7 @@ export function VariableDeclaration(node, context) {
147156
return b.declarator(
148157
path.node,
149158
binding?.kind === 'state' || binding?.kind === 'raw_state'
150-
? create_state_declarator(binding.node, value)
159+
? create_state_declarator(binding.node, value, options)
151160
: value
152161
);
153162
})

packages/svelte/src/index.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,4 +351,6 @@ export type MountOptions<Props extends Record<string, any> = Record<string, any>
351351
props: Props;
352352
});
353353

354+
export { ValueOptions as StateOptions } from './internal/client/types.js';
355+
354356
export * from './index-client.js';

packages/svelte/src/internal/client/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export {
109109
user_effect,
110110
user_pre_effect
111111
} from './reactivity/effects.js';
112-
export { mutable_state, mutate, set, state } from './reactivity/sources.js';
112+
export { mutable_state, mutate, set, state, get_options } from './reactivity/sources.js';
113113
export {
114114
prop,
115115
rest_props,

packages/svelte/src/internal/client/proxy.js

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { ProxyMetadata, ProxyStateObject, Source } from '#client' */
1+
/** @import { ProxyMetadata, ProxyStateObject, Source, ValueOptions } from '#client' */
22
import { DEV } from 'esm-env';
33
import { get, component_context, active_effect } from './runtime.js';
44
import {
@@ -19,11 +19,12 @@ import { tracing_mode_flag } from '../flags/index.js';
1919
/**
2020
* @template T
2121
* @param {T} value
22+
* @param {ValueOptions} [options]
2223
* @param {ProxyMetadata | null} [parent]
2324
* @param {Source<T>} [prev] dev mode only
2425
* @returns {T}
2526
*/
26-
export function proxy(value, parent = null, prev) {
27+
export function proxy(value, options, parent = null, prev) {
2728
/** @type {Error | null} */
2829
var stack = null;
2930
if (DEV && tracing_mode_flag) {
@@ -48,7 +49,7 @@ export function proxy(value, parent = null, prev) {
4849
if (is_proxied_array) {
4950
// We need to create the length source eagerly to ensure that
5051
// mutations to the array are properly synced with our proxy
51-
sources.set('length', source(/** @type {any[]} */ (value).length, stack));
52+
sources.set('length', source(/** @type {any[]} */ (value).length, options, stack));
5253
}
5354

5455
/** @type {ProxyMetadata} */
@@ -94,10 +95,10 @@ export function proxy(value, parent = null, prev) {
9495
var s = sources.get(prop);
9596

9697
if (s === undefined) {
97-
s = source(descriptor.value, stack);
98+
s = source(descriptor.value, options, stack);
9899
sources.set(prop, s);
99100
} else {
100-
set(s, proxy(descriptor.value, metadata));
101+
set(s, proxy(descriptor.value, options, metadata));
101102
}
102103

103104
return true;
@@ -108,7 +109,7 @@ export function proxy(value, parent = null, prev) {
108109

109110
if (s === undefined) {
110111
if (prop in target) {
111-
sources.set(prop, source(UNINITIALIZED, stack));
112+
sources.set(prop, source(UNINITIALIZED, options, stack));
112113
}
113114
} else {
114115
// 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) {
142143

143144
// create a source, but only if it's an own property and not a prototype property
144145
if (s === undefined && (!exists || get_descriptor(target, prop)?.writable)) {
145-
s = source(proxy(exists ? target[prop] : UNINITIALIZED, metadata), stack);
146+
s = source(proxy(exists ? target[prop] : UNINITIALIZED, options, metadata), options, stack);
146147
sources.set(prop, s);
147148
}
148149

@@ -210,7 +211,7 @@ export function proxy(value, parent = null, prev) {
210211
(active_effect !== null && (!has || get_descriptor(target, prop)?.writable))
211212
) {
212213
if (s === undefined) {
213-
s = source(has ? proxy(target[prop], metadata) : UNINITIALIZED, stack);
214+
s = source(has ? proxy(target[prop], options, metadata) : UNINITIALIZED, options, stack);
214215
sources.set(prop, s);
215216
}
216217

@@ -237,7 +238,7 @@ export function proxy(value, parent = null, prev) {
237238
// If the item exists in the original, we need to create a uninitialized source,
238239
// else a later read of the property would result in a source being created with
239240
// the value of the original item at that index.
240-
other_s = source(UNINITIALIZED, stack);
241+
other_s = source(UNINITIALIZED, options, stack);
241242
sources.set(i + '', other_s);
242243
}
243244
}
@@ -249,13 +250,13 @@ export function proxy(value, parent = null, prev) {
249250
// object property before writing to that property.
250251
if (s === undefined) {
251252
if (!has || get_descriptor(target, prop)?.writable) {
252-
s = source(undefined, stack);
253-
set(s, proxy(value, metadata));
253+
s = source(undefined, options, stack);
254+
set(s, proxy(value, options, metadata));
254255
sources.set(prop, s);
255256
}
256257
} else {
257258
has = s.v !== UNINITIALIZED;
258-
set(s, proxy(value, metadata));
259+
set(s, proxy(value, options, metadata));
259260
}
260261

261262
if (DEV) {

packages/svelte/src/internal/client/reactivity/sources.js

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */
1+
/** @import { Derived, Effect, Reaction, Source, Value, ValueOptions } from '#client' */
22
import { DEV } from 'esm-env';
33
import {
44
component_context,
@@ -47,18 +47,20 @@ export function set_inspect_effects(v) {
4747
/**
4848
* @template V
4949
* @param {V} v
50+
* @param {ValueOptions} [o]
5051
* @param {Error | null} [stack]
5152
* @returns {Source<V>}
5253
*/
53-
export function source(v, stack) {
54+
export function source(v, o, stack) {
5455
/** @type {Value} */
5556
var signal = {
5657
f: 0, // TODO ideally we could skip this altogether, but it causes type errors
5758
v,
5859
reactions: null,
5960
equals,
6061
rv: 0,
61-
wv: 0
62+
wv: 0,
63+
o
6264
};
6365

6466
if (DEV && tracing_mode_flag) {
@@ -72,9 +74,18 @@ export function source(v, stack) {
7274
/**
7375
* @template V
7476
* @param {V} v
77+
* @param {ValueOptions} [o]
7578
*/
76-
export function state(v) {
77-
return push_derived_source(source(v));
79+
export function state(v, o) {
80+
return push_derived_source(source(v, o));
81+
}
82+
83+
/**
84+
* @param {Source} source
85+
* @returns {ValueOptions | undefined}
86+
*/
87+
export function get_options(source) {
88+
return source.o;
7889
}
7990

8091
/**
@@ -171,6 +182,7 @@ export function internal_set(source, value) {
171182
var old_value = source.v;
172183
source.v = value;
173184
source.wv = increment_write_version();
185+
untrack(() => source.o?.onchange?.());
174186

175187
if (DEV && tracing_mode_flag) {
176188
source.updated = get_stack('UpdatedAt');

packages/svelte/src/internal/client/reactivity/types.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export interface Signal {
77
wv: number;
88
}
99

10+
export interface ValueOptions {
11+
onchange?: () => unknown;
12+
}
13+
1014
export interface Value<V = unknown> extends Signal {
1115
/** Equality function */
1216
equals: Equals;
@@ -16,6 +20,8 @@ export interface Value<V = unknown> extends Signal {
1620
rv: number;
1721
/** The latest value for this signal */
1822
v: V;
23+
/** Options for the source */
24+
o?: ValueOptions;
1925
/** Dev only */
2026
created?: Error | null;
2127
updated?: Error | null;

packages/svelte/src/internal/client/types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Store } from '#shared';
22
import { STATE_SYMBOL } from './constants.js';
3-
import type { Effect, Source, Value, Reaction } from './reactivity/types.js';
3+
import type { Effect, Source, Value, Reaction, ValueOptions } from './reactivity/types.js';
44

55
type EventCallback = (event: Event) => boolean;
66
export type EventCallbackMap = Record<string, EventCallback | EventCallback[]>;

packages/svelte/tests/compiler-errors/samples/runes-wrong-state-args/_config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ import { test } from '../../test';
33
export default test({
44
error: {
55
code: 'rune_invalid_arguments_length',
6-
message: '`$state` must be called with zero or one arguments'
6+
message: '`$state` must be called with zero, one or two arguments'
77
}
88
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
async test({ assert, target, logs }) {
6+
const [btn, btn2, btn3, btn4, btn5, btn6] = target.querySelectorAll('button');
7+
flushSync(() => {
8+
btn.click();
9+
});
10+
assert.deepEqual(logs, ['count']);
11+
12+
flushSync(() => {
13+
btn2.click();
14+
});
15+
assert.deepEqual(logs, ['count', 'proxy']);
16+
17+
flushSync(() => {
18+
btn3.click();
19+
});
20+
assert.deepEqual(logs, ['count', 'proxy', 'proxy']);
21+
22+
flushSync(() => {
23+
btn4.click();
24+
});
25+
assert.deepEqual(logs, ['count', 'proxy', 'proxy', 'class count']);
26+
27+
flushSync(() => {
28+
btn5.click();
29+
});
30+
assert.deepEqual(logs, ['count', 'proxy', 'proxy', 'class count', 'class proxy']);
31+
32+
flushSync(() => {
33+
btn6.click();
34+
});
35+
assert.deepEqual(logs, [
36+
'count',
37+
'proxy',
38+
'proxy',
39+
'class count',
40+
'class proxy',
41+
'class proxy'
42+
]);
43+
}
44+
});

0 commit comments

Comments
 (0)