Skip to content

feat: add onchange option to $state #15069

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 63 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
d7876af
feat: add `onchange` option to `$state`
paoloricciuti Jan 20, 2025
82d45a2
fix: create `assignable_proxy` utils to prevent declaring an external…
paoloricciuti Jan 21, 2025
e42c7cd
fix: move logic to proxy inside `set`
paoloricciuti Jan 21, 2025
807ffbb
fix: only call `onchange` once for array mutations (#15073)
Rich-Harris Jan 21, 2025
3353faf
chore: add tests for arrays
paoloricciuti Jan 21, 2025
23df27f
chore: update types for `$state.raw`
paoloricciuti Jan 21, 2025
07499da
fix: add options to `$state.raw` in classes
paoloricciuti Jan 21, 2025
1fb57eb
docs: add docs for state options
paoloricciuti Jan 21, 2025
4ed4351
fix: invoke `onchange` in component constructor
paoloricciuti Jan 21, 2025
e2c2580
fix: move `onchange` call right before inspect effects
paoloricciuti Jan 21, 2025
f013e87
fix: only batch array methods if there's an `onchange` function
paoloricciuti Jan 21, 2025
37888f4
fix: move easier condition up
paoloricciuti Jan 21, 2025
c83d01c
fix: move `onchange` after `inspect` effects
paoloricciuti Jan 21, 2025
4229776
Merge remote-tracking branch 'origin/main' into state-onchange
paoloricciuti Jan 21, 2025
7fc930a
chore: bette phrasing for docs and error
paoloricciuti Jan 22, 2025
7c215bf
fix: notify both `onchange` if proxy is passed into proxy
paoloricciuti Jan 22, 2025
ec77f8b
chore: add error for non-inline options
paoloricciuti Jan 22, 2025
d0d9a36
chore: add test for agglomerated `onchange`
paoloricciuti Jan 22, 2025
e237132
fix: correct types
paoloricciuti Jan 22, 2025
f16e445
chore: push failing test for extrapolated reference
paoloricciuti Jan 23, 2025
316a341
fix: make it work properly with reassigned references
paoloricciuti Jan 23, 2025
55fdccc
fix: make it work with reassigned `length`
paoloricciuti Jan 23, 2025
873cd5f
fix: double log on push
paoloricciuti Jan 24, 2025
35e2afe
fix: test for `simple_set` and `simple_set`
paoloricciuti Jan 24, 2025
19acec4
Merge remote-tracking branch 'origin/main' into state-onchange
paoloricciuti Feb 12, 2025
df62dd6
Merge remote-tracking branch 'origin/main' into state-onchange
paoloricciuti Mar 17, 2025
2a3fb7a
fix: lint and test
paoloricciuti Mar 17, 2025
128c325
fix: remove source onchange from proxy on reassignment
paoloricciuti Mar 17, 2025
3e886c7
fix: add extra check
paoloricciuti Mar 17, 2025
25e03b3
fix: batch assignment to length of an array
paoloricciuti Mar 17, 2025
e1e372b
merge main
Rich-Harris Mar 19, 2025
1b2719f
merge main
Rich-Harris Mar 19, 2025
8d02009
Apply suggestions from code review
Rich-Harris Mar 21, 2025
0d4add1
merge main
Rich-Harris Mar 21, 2025
0b8d2fa
remove static analysis restriction
Rich-Harris Mar 21, 2025
a97465d
simplify
Rich-Harris Mar 21, 2025
e7fa79a
simplify
Rich-Harris Mar 21, 2025
c793cf3
simplify
Rich-Harris Mar 21, 2025
51ecbef
oops
Rich-Harris Mar 21, 2025
b158c8c
unused
Rich-Harris Mar 21, 2025
ea75c5e
tidy up
Rich-Harris Mar 21, 2025
954eb8d
merge main
Rich-Harris Mar 21, 2025
3cb7b79
chore: split tests
paoloricciuti Mar 21, 2025
35e4023
Update packages/svelte/tests/runtime-runes/samples/state-onchange-rea…
Rich-Harris Mar 21, 2025
118e9aa
put flushSync calls on single line — makes it easier to connect the e…
Rich-Harris Mar 21, 2025
36bfef9
not a proxy!
Rich-Harris Mar 21, 2025
0fd4d2a
Merge branch 'main' into state-onchange
Rich-Harris Mar 21, 2025
458ed29
Merge branch 'main' into state-onchange
Rich-Harris Mar 21, 2025
a33ff30
Merge branch 'main' into state-onchange
Rich-Harris Mar 22, 2025
714c042
extract `onchange` callbacks from options (#15579)
Rich-Harris Mar 22, 2025
9f34f7c
merge main
Rich-Harris Apr 12, 2025
af70cef
ValueOptions -> StateOptions
Rich-Harris Apr 12, 2025
bfa0883
Merge branch 'main' into state-onchange
Rich-Harris Apr 12, 2025
bde0252
tweak docs
Rich-Harris Apr 12, 2025
c4182f5
simplify
Rich-Harris Apr 12, 2025
0e57669
cosmetic tweak
Rich-Harris Apr 12, 2025
42f73a0
tweak conditions
Rich-Harris Apr 12, 2025
dbf2b4c
simplify
Rich-Harris Apr 12, 2025
d5f785b
add a failing test
Rich-Harris Apr 12, 2025
d8e60f0
fix
Rich-Harris Apr 12, 2025
6c9380c
failing test
Rich-Harris Apr 12, 2025
ac05b73
fix
Rich-Harris Apr 12, 2025
cfd8ef2
merge main
Rich-Harris Apr 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-rules-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: add `onchange` option to `$state`
16 changes: 16 additions & 0 deletions documentation/docs/02-runes/02-$state.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ person = {

This can improve performance with large arrays and objects that you weren't planning to mutate anyway, since it avoids the cost of making them reactive. Note that raw state can _contain_ reactive state (for example, a raw array of reactive objects).

## State options

Both `$state` and `$state.raw` accept an optional second argument that includes an `onchange` function.

This function is called synchronously whenever the value is reassigned or (for `$state`) mutated, allowing you to respond to changes before [effects]($effect) run. It's useful for — for example — persisting data, or validating it:

```js
let count = $state(0, {
onchange() {
count = Math.min(count, 10);
}
});
```

> The `onchange` function is [untracked](svelte#untrack).

## `$state.snapshot`

To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`:
Expand Down
10 changes: 10 additions & 0 deletions packages/svelte/src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ declare module '*.svelte' {
*
* @param initial The initial value
*/
declare function $state<T>(
initial: undefined,
options?: import('svelte').StateOptions
): T | undefined;
declare function $state<T>(initial: T, options?: import('svelte').StateOptions): T;
declare function $state<T>(initial: T): T;
declare function $state<T>(): T | undefined;

Expand Down Expand Up @@ -116,6 +121,11 @@ declare namespace $state {
*
* @param initial The initial value
*/
export function raw<T>(
initial: undefined,
options?: import('svelte').StateOptions
): T | undefined;
export function raw<T>(initial?: T, options?: import('svelte').StateOptions): T;
export function raw<T>(initial: T): T;
export function raw<T>(): T | undefined;
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,10 @@ 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 (rune === '$state' || rune === '$state.raw') {
if (node.arguments.length > 2) {
e.rune_invalid_arguments_length(node, rune, 'at most two arguments');
}
}

break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as b from '#compiler/builders';
import { regex_invalid_identifier_chars } from '../../../patterns.js';
import { get_rune } from '../../../scope.js';
import { should_proxy } from '../utils.js';
import { get_onchange } from './shared/state.js';

/**
* @param {ClassBody} node
Expand Down Expand Up @@ -114,17 +115,20 @@ export function ClassBody(node, context) {
context.visit(definition.value.arguments[0], child_state)
);

value =
field.kind === 'state'
? b.call(
'$.state',
should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init
)
: field.kind === 'raw_state'
? b.call('$.state', init)
: field.kind === 'derived_by'
? b.call('$.derived', init)
: b.call('$.derived', b.thunk(init));
if (field.kind === 'state' || field.kind === 'raw_state') {
const onchange = get_onchange(
/** @type {Expression} */ (definition.value.arguments[1]),
// @ts-ignore mismatch between Context and ComponentContext. TODO look into
context
);

value =
field.kind === 'state' && should_proxy(init, context.state.scope)
? b.call('$.assignable_proxy', init, onchange)
: b.call('$.state', init, onchange);
} else {
value = b.call('$.derived', field.kind === 'derived_by' ? init : b.thunk(init));
}
} else {
// if no arguments, we know it's state as `$derived()` is a compile error
value = b.call('$.state');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as assert from '../../../../utils/assert.js';
import { get_rune } from '../../../scope.js';
import { get_prop_source, is_prop_source, is_state_source, should_proxy } from '../utils.js';
import { is_hoisted_function } from '../../utils.js';
import { get_onchange } from './shared/state.js';

/**
* @param {VariableDeclaration} node
Expand Down Expand Up @@ -118,27 +119,33 @@ export function VariableDeclaration(node, context) {
const args = /** @type {CallExpression} */ (init).arguments;
const value = args.length > 0 ? /** @type {Expression} */ (context.visit(args[0])) : b.void0;

const onchange = get_onchange(/** @type {Expression} */ (args[1]), context);

if (rune === '$state' || rune === '$state.raw') {
/**
* @param {Identifier} id
* @param {Expression} value
* @param {Expression} [onchange]
*/
const create_state_declarator = (id, value) => {
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 create_state_declarator = (id, value, onchange) => {
const binding = /** @type {Binding} */ (context.state.scope.get(id.name));
const proxied = rune === '$state' && should_proxy(value, context.state.scope);
const is_state = is_state_source(binding, context.state.analysis);

if (proxied) {
return b.call(is_state ? '$.assignable_proxy' : '$.proxy', value, onchange);
}
if (is_state_source(binding, context.state.analysis)) {
value = b.call('$.state', value);

if (is_state) {
return b.call('$.state', value, onchange);
}

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, onchange))
);
} else {
const tmp = context.state.scope.generate('tmp');
Expand All @@ -151,7 +158,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, onchange)
: value
);
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/** @import { Expression, Property } from 'estree' */
/** @import { ComponentContext } from '../../types' */
import * as b from '../../../../../utils/builders.js';

/**
* Extract the `onchange` callback from the options passed to `$state`
* @param {Expression} options
* @param {ComponentContext} context
* @returns {Expression | undefined}
*/
export function get_onchange(options, context) {
if (!options) return;

if (options.type === 'ObjectExpression') {
const onchange = /** @type {Property | undefined} */ (
options.properties.find(
(property) =>
property.type === 'Property' &&
!property.computed &&
property.key.type === 'Identifier' &&
property.key.name === 'onchange'
)
);

if (!onchange) return;

return /** @type {Expression} */ (context.visit(onchange.value));
}

return b.member(/** @type {Expression} */ (context.visit(options)), 'onchange');
}
2 changes: 2 additions & 0 deletions packages/svelte/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,4 +352,6 @@ export type MountOptions<Props extends Record<string, any> = Record<string, any>
props: Props;
});

export { StateOptions } from './internal/client/types.js';

export * from './index-client.js';
1 change: 1 addition & 0 deletions packages/svelte/src/internal/client/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ export const EFFECT_HAS_DERIVED = 1 << 20;
export const EFFECT_IS_UPDATING = 1 << 21;

export const STATE_SYMBOL = Symbol('$state');
export const PROXY_ONCHANGE_SYMBOL = Symbol('proxy onchange');
export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export {
} from './runtime.js';
export { validate_binding, validate_each_keys } from './validate.js';
export { raf } from './timing.js';
export { proxy } from './proxy.js';
export { proxy, assignable_proxy } from './proxy.js';
export { create_custom_element } from './dom/elements/custom-element.js';
export {
child,
Expand Down
Loading