Skip to content
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

feat: add onchange option to $state #15069

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
24 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
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`
29 changes: 29 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,35 @@ 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` can optionally accept a second argument. This argument allow you to specify an `onchange` function that will be called synchronously whenever the object change (for `$state` it will also be called for deep mutations).
paoloricciuti marked this conversation as resolved.
Show resolved Hide resolved

The `onchange` function is untracked so even if you assign within an `$effect` it will not cause unwanted dependencies.

```js
let count = $state(0, {
onchange(){
console.log("count is now", count);
}
});

// this will log "count is now 1"
count++;
```

this could be especially useful if you want to sync some stateful variable that could be mutated without using an effect.

```js
let array = $state([], {
onchange(){
localStorage.setItem('array', JSON.stringify(array));
}
});

array.push(array.length);
```

## `$state.snapshot`

To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`:
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
paoloricciuti marked this conversation as resolved.
Show resolved Hide resolved
declare function $state<T>(): T | undefined;

Expand Down Expand Up @@ -116,6 +117,7 @@ declare namespace $state {
*
* @param initial The initial value
*/
export function raw<T>(initial?: T, options?: import('svelte').StateOptions): T;
export function raw<T>(initial: T): T;
paoloricciuti marked this conversation as resolved.
Show resolved Hide resolved
export function raw<T>(): T | undefined;
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
paoloricciuti marked this conversation as resolved.
Show resolved Hide resolved
}

break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))])];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface ClientTransformState extends TransformState {
/** turn `foo` into e.g. `$.get(foo)` */
read: (id: Identifier) => Expression;
/** turn `foo = bar` into e.g. `$.set(foo, bar)` */
assign?: (node: Identifier, value: Expression) => Expression;
assign?: (node: Identifier, value: Expression, proxy?: boolean) => Expression;
/** turn `foo.bar = baz` into e.g. `$.mutate(foo, $.get(foo).bar = baz);` */
mutate?: (node: Identifier, mutation: AssignmentExpression | UpdateExpression) => Expression;
/** turn `foo++` into e.g. `$.update(foo)` */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,6 @@ export function build_getter(node, state) {
return node;
}

/**
* @param {Expression} value
* @param {Expression} previous
*/
export function build_proxy_reassignment(value, previous) {
return dev ? b.call('$.proxy', value, b.null, previous) : b.call('$.proxy', value);
}

/**
* @param {FunctionDeclaration | FunctionExpression | ArrowFunctionExpression} node
* @param {ComponentContext} context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
is_event_attribute
} from '../../../../utils/ast.js';
import { dev, filename, is_ignored, locate_node, locator } from '../../../../state.js';
import { build_proxy_reassignment, should_proxy } from '../utils.js';
import { should_proxy } from '../utils.js';
import { visit_assignment_expression } from '../../shared/assignments.js';

/**
Expand Down Expand Up @@ -65,21 +65,20 @@ function build_assignment(operator, left, right, context) {
context.visit(build_assignment_value(operator, left, right))
);

if (
const needs_proxy =
private_state.kind === 'state' &&
is_non_coercive_operator(operator) &&
should_proxy(value, context.state.scope)
) {
value = build_proxy_reassignment(value, b.member(b.this, private_state.id));
}

if (context.state.in_constructor) {
// inside the constructor, we can assign to `this.#foo.v` rather than using `$.set`,
// since nothing is tracking the signal at this point
return b.assignment(operator, /** @type {Pattern} */ (context.visit(left)), value);
}

return b.call('$.set', left, value);
should_proxy(value, context.state.scope);

return b.call(
// inside the constructor, we use `$.simple_set` rather than using `$.set`,
// that only assign the value and eventually call onchange since nothing is tracking the signal at this point
context.state.in_constructor ? '$.simple_set' : '$.set',
left,
value,
needs_proxy && b.true,
dev && needs_proxy && b.true
);
}
}

Expand Down Expand Up @@ -113,19 +112,17 @@ function build_assignment(operator, left, right, context) {
context.visit(build_assignment_value(operator, left, right))
);

if (
return transform.assign(
object,
value,
!is_primitive &&
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'raw_state' &&
context.state.analysis.runes &&
should_proxy(right, context.state.scope) &&
is_non_coercive_operator(operator)
) {
value = build_proxy_reassignment(value, object);
}

return transform.assign(object, value);
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'raw_state' &&
context.state.analysis.runes &&
should_proxy(right, context.state.scope) &&
is_non_coercive_operator(operator)
);
}

// mutation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { dev, is_ignored } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';
import { regex_invalid_identifier_chars } from '../../../patterns.js';
import { get_rune } from '../../../scope.js';
import { build_proxy_reassignment, should_proxy } from '../utils.js';
import { should_proxy } from '../utils.js';

/**
* @param {ClassBody} node
Expand Down Expand Up @@ -116,14 +116,22 @@ 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);

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('$.assignable_proxy', init, options)
: b.call('$.state', init, options)
: field.kind === 'raw_state'
? b.call('$.state', init)
? b.call('$.state', init, options)
: field.kind === 'derived_by'
? b.call('$.derived', init)
: b.call('$.derived', b.thunk(init));
Expand Down Expand Up @@ -152,7 +160,7 @@ export function ClassBody(node, context) {
'set',
definition.key,
[value],
[b.stmt(b.call('$.set', member, build_proxy_reassignment(value, prev)))]
[b.stmt(b.call('$.set', member, value, b.true, dev && b.true))]
)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,28 +113,34 @@ 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);
}
if (is_state_source(binding, context.state.analysis)) {
value = b.call('$.state', value);
const proxied = rune === '$state' && should_proxy(value, context.state.scope);
const is_state = is_state_source(binding, context.state.analysis);
if (proxied && is_state) {
value = b.call('$.assignable_proxy', value, options);
} else if (proxied) {
value = b.call('$.proxy', value, options);
} else if (is_state) {
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');
Expand All @@ -147,7 +153,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
);
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** @import { Identifier } from 'estree' */
/** @import { ComponentContext, Context } from '../../types' */
import { is_state_source } from '../../utils.js';
import { is_state_source, should_proxy } from '../../utils.js';
import * as b from '../../../../../utils/builders.js';
import { dev } from '../../../../../state.js';

/**
* Turns `foo` into `$.get(foo)`
Expand All @@ -24,8 +25,8 @@ export function add_state_transformers(context) {
) {
context.state.transform[name] = {
read: binding.declaration_kind === 'var' ? (node) => b.call('$.safe_get', node) : get_value,
assign: (node, value) => {
let call = b.call('$.set', node, value);
assign: (node, value, proxy = false) => {
let call = b.call('$.set', node, value, proxy && b.true, dev && proxy && b.true);

if (context.state.scope.get(`$${node.name}`)?.kind === 'store_sub') {
call = b.call('$.store_unsub', call, b.literal(`$${node.name}`), b.id('$$stores'));
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
11 changes: 9 additions & 2 deletions packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,14 @@ 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,
simple_set,
state,
get_options
} from './reactivity/sources.js';
export {
prop,
rest_props,
Expand Down Expand Up @@ -152,7 +159,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
Loading