pull/15673/merge
ComputerGuy 4 days ago committed by GitHub
commit 645706d73f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: add `$state.invalidate` rune

@ -166,6 +166,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:

@ -196,6 +196,12 @@ This restriction only applies when using the `experimental.async` option, which
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
```

@ -953,6 +953,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, a class field declaration, or the first assignment to a class field at the top level of the constructor.
```
### 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
```

@ -146,6 +146,10 @@ This restriction only applies when using the `experimental.async` option, which
> 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

@ -260,6 +260,33 @@ class Counter {
> `%rune%(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.
## 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

@ -93,6 +93,32 @@ declare namespace $state {
: never
: never;
/**
* Forces an update on a variable or class field declared with `$state` or `$state.raw`, or a property of a `$state` object.
* 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>
* ```
*
* https://svelte.dev/docs/svelte/$state#$state.invalidate
*/
export function invalidate(source: unknown): void;
/**
* Declares state that is _not_ made deeply reactive instead of mutating it,
* you must reassign it.

@ -529,6 +529,24 @@ export function state_invalid_placement(node, rune) {
e(node, 'state_invalid_placement', `\`${rune}(...)\` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.\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

@ -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 } 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 '#compiler/builders';
@ -111,6 +111,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':

@ -1,16 +1,33 @@
/** @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 '#compiler/builders';
import { get_rune } from '../../../scope.js';
import { transform_inspect_rune } from '../../utils.js';
import * as e from '../../../../errors.js';
import { should_proxy } from '../utils.js';
import { get_name } from '../../../nodes.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
};
}
const rune = get_rune(node, context.state.scope);
switch (rune) {
@ -57,6 +74,42 @@ 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') {
const name = /** @type {string} */ (get_name(property));
const field = context.state.state_fields.get(name);
if (!field || (field.type !== '$state' && field.type !== '$state.raw')) {
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.key))
);
}
/** @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(

@ -25,6 +25,10 @@ export function CallExpression(node, context) {
return b.arrow([], b.block([]));
}
if (rune === '$state.invalidate') {
return b.void0;
}
if (rune === '$effect.pending') {
return b.literal(0);
}

@ -29,6 +29,7 @@ export const ERROR_VALUE = 1 << 23;
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');
export const PROXY_PATH_SYMBOL = Symbol('proxy path');
/** allow users to ignore aborted signal errors if `reason.name === 'StaleReactionError` */

@ -393,6 +393,22 @@ export function state_descriptors_fixed() {
}
}
/**
* 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.
* @returns {never}
*/
export function state_invalidate_invalid_source() {
if (DEV) {
const error = new Error(`state_invalidate_invalid_source\nThe 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.\nhttps://svelte.dev/e/state_invalidate_invalid_source`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/state_invalidate_invalid_source`);
}
}
/**
* Cannot set prototype of `$state` object
* @returns {never}

@ -122,7 +122,15 @@ export {
user_effect,
user_pre_effect
} from './reactivity/effects.js';
export { mutable_source, mutate, set, state, update, update_pre } from './reactivity/sources.js';
export {
invalidate,
mutable_source,
mutate,
set,
state,
update,
update_pre
} from './reactivity/sources.js';
export {
prop,
rest_props,
@ -157,7 +165,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, lookup_source } from './proxy.js';
export { create_custom_element } from './dom/elements/custom-element.js';
export {
child,

@ -15,6 +15,7 @@ import {
is_array,
object_prototype
} from '../shared/utils.js';
import { PROXY_PATH_SYMBOL, PROXY_SOURCES, STATE_SYMBOL } from '#client/constants';
import {
state as source,
set,
@ -22,7 +23,6 @@ import {
flush_inspect_effects,
set_inspect_effects_deferred
} from './reactivity/sources.js';
import { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants';
import { UNINITIALIZED } from '../../constants.js';
import * as e from './errors.js';
import { get_stack, tag } from './dev/tracing.js';
@ -166,6 +166,10 @@ export function proxy(value) {
return value;
}
if (prop === PROXY_SOURCES) {
return sources;
}
if (DEV && prop === PROXY_PATH_SYMBOL) {
return update_path;
}
@ -221,7 +225,7 @@ export function proxy(value) {
},
has(target, prop) {
if (prop === STATE_SYMBOL) {
if (prop === STATE_SYMBOL || prop === PROXY_SOURCES) {
return true;
}
@ -391,6 +395,25 @@ export function is(a, b) {
return Object.is(get_proxied_value(a), get_proxied_value(b));
}
/**
* @param {Record<string | symbol, any>} object
* @param {string | symbol} property
* @returns {Source | null}
*/
export function lookup_source(object, property) {
if (typeof object !== 'object' || object === null) return null;
if (STATE_SYMBOL in object) {
if (property in object) {
/** @type {Map<string | symbol, Source>} */
const sources = object[PROXY_SOURCES];
if (sources.has(property)) {
return /** @type {Source} */ (sources.get(property));
}
}
}
return null;
}
const ARRAY_MUTATING_METHODS = new Set([
'copyWithin',
'fill',

@ -262,6 +262,81 @@ export function flush_inspect_effects() {
inspect_effects.clear();
}
/**
* @param {Source | null} source
*/
export function invalidate(source) {
if (source === null || (source.f & DERIVED) !== 0) {
e.state_invalidate_invalid_source();
}
if (
active_reaction !== null &&
// since we are untracking the function inside `$inspect.with` we need to add this check
// to ensure we error if state is set inside an inspect effect
(!untracking || (active_reaction.f & INSPECT_EFFECT) !== 0) &&
is_runes() &&
(active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | INSPECT_EFFECT)) !== 0 &&
!current_sources?.includes(source)
) {
e.state_unsafe_mutation();
}
if (DEV) {
if (tracing_mode_flag || active_effect !== null) {
const error = get_stack('UpdatedAt');
if (error !== null) {
source.updated ??= new Map();
let entry = source.updated.get(error.stack);
if (!entry) {
entry = { error, count: 0 };
source.updated.set(error.stack, entry);
}
entry.count++;
}
}
if (active_effect !== null) {
source.set_during_effect = true;
}
}
if ((source.f & DERIVED) !== 0) {
// if we are assigning to a dirty derived we set it to clean/maybe dirty but we also eagerly execute it to track the dependencies
if ((source.f & DIRTY) !== 0) {
execute_derived(/** @type {Derived} */ (source));
}
set_signal_status(source, (source.f & UNOWNED) === 0 ? CLEAN : MAYBE_DIRTY);
}
source.wv = increment_write_version();
mark_reactions(source, DIRTY);
// It's possible that the current reaction might not have up-to-date dependencies
// whilst it's actively running. So in the case of ensuring it registers the reaction
// properly for itself, we need to ensure the current effect actually gets
// scheduled. i.e: `$effect(() => x++)`
if (
is_runes() &&
active_effect !== null &&
(active_effect.f & CLEAN) !== 0 &&
(active_effect.f & (BRANCH_EFFECT | ROOT_EFFECT)) === 0
) {
if (untracked_writes === null) {
set_untracked_writes([source]);
} else {
untracked_writes.push(source);
}
}
if (DEV && inspect_effects.size > 0 && !inspect_effects_deferred) {
flush_inspect_effects();
}
}
/**
* @template {number | bigint} T
* @param {Source<T>} source

@ -437,6 +437,7 @@ const STATE_CREATION_RUNES = /** @type {const} */ ([
const RUNES = /** @type {const} */ ([
...STATE_CREATION_RUNES,
'$state.invalidate',
'$state.snapshot',
'$props',
'$props.id',

@ -9,7 +9,13 @@ import {
user_effect,
user_pre_effect
} from '../../src/internal/client/reactivity/effects';
import { state, set, update, update_pre } from '../../src/internal/client/reactivity/sources';
import {
state,
set,
update,
update_pre,
invalidate
} from '../../src/internal/client/reactivity/sources';
import type { Derived, Effect, Source, Value } from '../../src/internal/client/types';
import { proxy } from '../../src/internal/client/proxy';
import { derived } from '../../src/internal/client/reactivity/deriveds';
@ -1391,6 +1397,26 @@ describe('signals', () => {
};
});
test('invalidate reruns dependent effects', () => {
let updates = 0;
return () => {
const a = state(0);
const destroy = effect_root(() => {
render_effect(() => {
$.get(a);
updates++;
});
});
set(a, 1);
flushSync();
assert.equal(updates, 2);
invalidate(a);
flushSync();
assert.equal(updates, 3);
destroy();
};
});
test('$effect.root inside deriveds stay alive independently', () => {
const log: any[] = [];
const c = state(0);

@ -3177,6 +3177,32 @@ declare namespace $state {
: never
: never;
/**
* Forces an update on a variable or class field declared with `$state` or `$state.raw`, or a property of a `$state` object.
* 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>
* ```
*
* https://svelte.dev/docs/svelte/$state#$state.invalidate
*/
export function invalidate(source: unknown): void;
/**
* Declares state that is _not_ made deeply reactive instead of mutating it,
* you must reassign it.

Loading…
Cancel
Save