Skip to content

feat: add $state.invalidate rune #15673

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

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/breezy-baboons-exercise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: add `$state.invalidate` rune
48 changes: 48 additions & 0 deletions documentation/docs/02-runes/02-$state.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,54 @@ To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snaps

This is handy when you want to pass some state to an external library or API that doesn't expect a proxy, such as `structuredClone`.

## `$state.invalidate`

In the case that you aren't using a proxied `$state` via use of `$state.raw` or a class instance, you may need to tell Svelte a `$state` has changed. You can do so via `$state.invalidate`:

```svelte
<script>
import Counter from 'external-class';

let counter = $state(new Counter());

function increment() {
counter.increment(); // `counter`'s internal state has changed, but Svelte doesn't know that yet
$state.invalidate(counter);
}
</script>
<button onclick={increment}>
Count is {counter.count}
</button>
```

`$state.invalidate` can also be used with reactive class fields, and properties of `$state` objects:

```js
class Box {
value;

constructor(initial) {
this.value = initial;
}
}

class Counter {
count = $state(new Box(0));

increment() {
this.count.value += 1;
$state.invalidate(this.count);
}
}

let counter = $state({count: new Box(0)});

function increment() {
counter.count.value += 1;
$state.invalidate(counter.count);
}
```

## Passing state into functions

JavaScript is a _pass-by-value_ language — when you call a function, the arguments are the _values_ rather than the _variables_. In other words:
Expand Down
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/client-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files
Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.
```

### state_invalidate_invalid_source

```
The argument passed to `$state.invalidate` must be a variable or class field declared with `$state` or `$state.raw`, or a property of a `$state` object.
```

### state_prototype_fixed

```
Expand Down
31 changes: 31 additions & 0 deletions documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,37 @@ Cannot export state from a module if it is reassigned. Either export a function
`%rune%(...)` can only be used as a variable declaration initializer or a class field
```

### state_invalidate_invalid_this_property

```
`$state.invalidate` can only be called with an argument referencing `this` in a class using a non-computed property
```

Like how you can't use `$state` or `$derived` when declaring computed class fields, you can't use `$state.invalidate` to invalidate a computed class field. For example, while `count` here is not itself a computed property, you can't invalidate it if you reference it in a computed property:
```js
class Box {
value;
constructor(initial) {
this.value = initial;
}
}
const property = 'count';
class Counter {
count = $state(new Box(0));
increment() {
this.count.value += 1;
$state.invalidate(this[property]); // this doesn't work
$state.invalidate(this.count); // this works
}
}
```

### state_invalidate_nonreactive_argument

```
`$state.invalidate` only takes a variable or non-computed class field declared with `$state` or `$state.raw` as its argument
```

### store_invalid_scoped_subscription

```
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/client-errors/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long

> Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.

## state_invalidate_invalid_source

> The argument passed to `$state.invalidate` must be a variable or class field declared with `$state` or `$state.raw`, or a property of a `$state` object.

## state_prototype_fixed

> Cannot set prototype of `$state` object
Expand Down
27 changes: 27 additions & 0 deletions packages/svelte/messages/compile-errors/script.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,33 @@ It's possible to export a snippet from a `<script module>` block, but only if it

> `%rune%(...)` can only be used as a variable declaration initializer or a class field

## state_invalidate_invalid_this_property

> `$state.invalidate` can only be called with an argument referencing `this` in a class using a non-computed property

Like how you can't use `$state` or `$derived` when declaring computed class fields, you can't use `$state.invalidate` to invalidate a computed class field. For example, while `count` here is not itself a computed property, you can't invalidate it if you reference it in a computed property:
```js
class Box {
value;
constructor(initial) {
this.value = initial;
}
}
const property = 'count';
class Counter {
count = $state(new Box(0));
increment() {
this.count.value += 1;
$state.invalidate(this[property]); // this doesn't work
$state.invalidate(this.count); // this works
}
}
```

## state_invalidate_nonreactive_argument

> `$state.invalidate` only takes a variable or non-computed class field declared with `$state` or `$state.raw` as its argument

## store_invalid_scoped_subscription

> Cannot subscribe to stores that are not declared at the top level of the component
Expand Down
24 changes: 24 additions & 0 deletions packages/svelte/src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,30 @@ declare namespace $state {
: never
: never;

/**
* Forces an update on a `$state` or `$state.raw` variable or class field.
* This is primarily meant as an escape hatch to be able to use external or native classes
* with Svelte's reactivity system.
* If you used Svelte 3 or 4, this is the equivalent of `foo = foo`.
* Example:
* ```svelte
* <script>
* import Counter from 'external-class';
*
* let counter = $state(new Counter());
*
* function increment() {
* counter.increment();
* $state.invalidate(counter);
* }
* </script>
* <button onclick={increment}>
* Count is {counter.count}
* </button>
* ```
*/
export function invalidate(source: unknown): void;

/**
* Declares state that is _not_ made deeply reactive — instead of mutating it,
* you must reassign it.
Expand Down
18 changes: 18 additions & 0 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,24 @@ export function state_invalid_placement(node, rune) {
e(node, 'state_invalid_placement', `\`${rune}(...)\` can only be used as a variable declaration initializer or a class field\nhttps://svelte.dev/e/state_invalid_placement`);
}

/**
* `$state.invalidate` can only be called with an argument referencing `this` in a class using a non-computed property
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function state_invalidate_invalid_this_property(node) {
e(node, 'state_invalidate_invalid_this_property', `\`$state.invalidate\` can only be called with an argument referencing \`this\` in a class using a non-computed property\nhttps://svelte.dev/e/state_invalidate_invalid_this_property`);
}

/**
* `$state.invalidate` only takes a variable or non-computed class field declared with `$state` or `$state.raw` as its argument
* @param {null | number | NodeLike} node
* @returns {never}
*/
export function state_invalidate_nonreactive_argument(node) {
e(node, 'state_invalidate_nonreactive_argument', `\`$state.invalidate\` only takes a variable or non-computed class field declared with \`$state\` or \`$state.raw\` as its argument\nhttps://svelte.dev/e/state_invalidate_nonreactive_argument`);
}

/**
* Cannot subscribe to stores that are not declared at the top level of the component
* @param {null | number | NodeLike} node
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/** @import { ArrowFunctionExpression, CallExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, VariableDeclarator } from 'estree' */
/** @import { ArrowFunctionExpression, CallExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, MemberExpression, VariableDeclarator } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { get_rune } from '../../scope.js';
import * as e from '../../../errors.js';
import { get_parent, unwrap_optional } from '../../../utils/ast.js';
import { get_parent, object, unwrap_optional } from '../../../utils/ast.js';
import { is_pure, is_safe_identifier } from './shared/utils.js';
import { dev, locate_node, source } from '../../../state.js';
import * as b from '../../../utils/builders.js';
Expand Down Expand Up @@ -110,6 +110,62 @@ export function CallExpression(node, context) {

break;
}
/* eslint-disable no-fallthrough */
case '$state.invalidate':
if (node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
} else {
let arg = node.arguments[0];
if (arg.type !== 'Identifier' && arg.type !== 'MemberExpression') {
e.state_invalidate_nonreactive_argument(node);
}
if (arg.type === 'MemberExpression') {
if (arg.object.type !== 'ThisExpression') {
const obj = object((arg = /** @type {MemberExpression} */ (context.visit(arg))));
if (obj?.type === 'Identifier') {
// there isn't really a good way to tell because of stuff like `notproxied = proxied`
break;
} else if (obj?.type !== 'ThisExpression') {
e.state_invalidate_nonreactive_argument(node);
}
} else if (arg.computed) {
e.state_invalidate_invalid_this_property(node);
}
const class_body = context.path.findLast((parent) => parent.type === 'ClassBody');
if (!class_body) {
e.state_invalidate_invalid_this_property(node);
}
const possible_this_bindings = context.path.filter((parent, index) => {
return (
parent.type === 'FunctionDeclaration' ||
(parent.type === 'FunctionExpression' &&
context.path[index - 1]?.type !== 'MethodDefinition')
);
});
if (possible_this_bindings.length === 0) {
break;
}
const class_index = context.path.indexOf(class_body);
const last_possible_this_index = context.path.indexOf(
/** @type {AST.SvelteNode} */ (possible_this_bindings.at(-1))
);
if (class_index < last_possible_this_index) {
e.state_invalidate_invalid_this_property(node);
}
// we can't really do anything else yet, so we just wait for the transformation phase
// where we know which class fields are reactive (and what their private aliases are)
break;
} else {
let binding = context.state.scope.get(arg.name);
if (binding) {
if (binding.kind === 'raw_state' || binding.kind === 'state') {
binding.reassigned = true;
break;
}
}
}
e.state_invalidate_nonreactive_argument(node);
}

case '$state':
case '$state.raw':
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
/** @import { CallExpression, Expression } from 'estree' */
/** @import { CallExpression, Expression, Identifier, MemberExpression, Node } from 'estree' */
/** @import { Context } from '../types' */
import { dev, is_ignored } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';
import { get_rune } from '../../../scope.js';
import { transform_inspect_rune } from '../../utils.js';
import * as e from '../../../../errors.js';

/**
* @param {CallExpression} node
* @param {Context} context
*/
export function CallExpression(node, context) {
/**
* Some nodes that get replaced should keep their locations (for better source maps and such)
* @template {Node} N
* @param {N} node
* @param {N} replacement
* @returns {N}
*/
function attach_locations(node, replacement) {
return {
...replacement,
start: node.start,
end: node.end,
loc: node.loc
};
}
switch (get_rune(node, context.state.scope)) {
case '$host':
return b.id('$$props.$$host');
Expand All @@ -23,6 +39,49 @@ export function CallExpression(node, context) {
/** @type {Expression} */ (context.visit(node.arguments[0])),
is_ignored(node, 'state_snapshot_uncloneable') && b.true
);
/* eslint-disable no-fallthrough */
case '$state.invalidate':
if (node.arguments[0].type === 'Identifier') {
return b.call(
attach_locations(/** @type {Expression} */ (node.callee), b.id('$.invalidate')),
node.arguments[0]
);
} else if (node.arguments[0].type === 'MemberExpression') {
const { object, property } = node.arguments[0];
if (object.type === 'ThisExpression') {
let field;
switch (property.type) {
case 'Identifier':
field = context.state.public_state.get(property.name);
break;
case 'PrivateIdentifier':
field = context.state.private_state.get(property.name);
break;
}
if (!field || (field.kind !== 'state' && field.kind !== 'raw_state')) {
e.state_invalidate_nonreactive_argument(node);
}
return b.call(
attach_locations(/** @type {Expression} */ (node.callee), b.id('$.invalidate')),
attach_locations(node.arguments[0], b.member(object, field.id))
);
}
/** @type {Expression[]} */
const source_args = /** @type {Expression[]} */ ([
context.visit(object),
node.arguments[0].computed
? context.visit(property)
: b.literal(/** @type {Identifier} */ (property).name)
]);
const arg = b.call('$.lookup_source', ...source_args);
return b.call(
attach_locations(/** @type {Expression} */ (node.callee), b.id('$.invalidate')),
attach_locations(
/** @type {Expression} */ (node.arguments[0]),
/** @type {Expression} */ (arg)
)
);
}

case '$effect.root':
return b.call(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export function CallExpression(node, context) {
return b.arrow([], b.block([]));
}

if (rune === '$state.invalidate') {
return b.void0;
}

if (rune === '$state.snapshot') {
return b.call(
'$.snapshot',
Expand Down
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 @@ -25,3 +25,4 @@ export const EFFECT_IS_UPDATING = 1 << 21;
export const STATE_SYMBOL = Symbol('$state');
export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
export const PROXY_SOURCES = Symbol('proxy sources');
Loading