diff --git a/.changeset/breezy-baboons-exercise.md b/.changeset/breezy-baboons-exercise.md
new file mode 100644
index 000000000000..e1da1d3f46a3
--- /dev/null
+++ b/.changeset/breezy-baboons-exercise.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: add `$state.invalidate` rune
diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md
index 16630a977b62..8742e7836916 100644
--- a/documentation/docs/02-runes/02-$state.md
+++ b/documentation/docs/02-runes/02-$state.md
@@ -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
+
+
+```
+
+`$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:
diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md
index 32348bb78182..cf992c8cab3e 100644
--- a/documentation/docs/98-reference/.generated/client-errors.md
+++ b/documentation/docs/98-reference/.generated/client-errors.md
@@ -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
```
diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md
index e8669ead533d..a5d79d049c3f 100644
--- a/documentation/docs/98-reference/.generated/compile-errors.md
+++ b/documentation/docs/98-reference/.generated/compile-errors.md
@@ -858,6 +858,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
```
diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md
index c4e68f8fee80..7511abf8d695 100644
--- a/packages/svelte/messages/client-errors/errors.md
+++ b/packages/svelte/messages/client-errors/errors.md
@@ -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
diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md
index aabcbeae4812..18e51a8185e0 100644
--- a/packages/svelte/messages/compile-errors/script.md
+++ b/packages/svelte/messages/compile-errors/script.md
@@ -220,6 +220,33 @@ It's possible to export a snippet from a `
+ *
+ * ```
+ *
+ * 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.
diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js
index c99f59746863..7849fdb95cf2 100644
--- a/packages/svelte/src/compiler/errors.js
+++ b/packages/svelte/src/compiler/errors.js
@@ -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
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
index 904817b014e4..ece9ff49a23e 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
@@ -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';
@@ -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':
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js
index b110f8eae82c..91fe2dc5a238 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js
@@ -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 '#compiler/builders';
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');
@@ -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(
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js
index 5bcbdee9fbfe..5ccc154bd3fe 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js
@@ -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',
diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js
index 7e5196c606b4..6e3f62984e46 100644
--- a/packages/svelte/src/internal/client/constants.js
+++ b/packages/svelte/src/internal/client/constants.js
@@ -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');
diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js
index 429dd99da9b9..c869e82a502d 100644
--- a/packages/svelte/src/internal/client/errors.js
+++ b/packages/svelte/src/internal/client/errors.js
@@ -291,6 +291,21 @@ 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}
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index 14d6e29f5bb4..d078e9b8b4f1 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -106,7 +106,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,
@@ -141,7 +149,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,
diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js
index fd5706eaf270..b9d1d7a55c3f 100644
--- a/packages/svelte/src/internal/client/proxy.js
+++ b/packages/svelte/src/internal/client/proxy.js
@@ -9,7 +9,7 @@ import {
object_prototype
} from '../shared/utils.js';
import { state as source, set } from './reactivity/sources.js';
-import { STATE_SYMBOL } from '#client/constants';
+import { STATE_SYMBOL, PROXY_SOURCES } from './constants.js';
import { UNINITIALIZED } from '../../constants.js';
import * as e from './errors.js';
import { get_stack } from './dev/tracing.js';
@@ -125,6 +125,10 @@ export function proxy(value) {
return value;
}
+ if (prop === PROXY_SOURCES) {
+ return sources;
+ }
+
var s = sources.get(prop);
var exists = prop in target;
@@ -166,7 +170,7 @@ export function proxy(value) {
},
has(target, prop) {
- if (prop === STATE_SYMBOL) {
+ if (prop === STATE_SYMBOL || prop === PROXY_SOURCES) {
return true;
}
@@ -318,3 +322,22 @@ export function get_proxied_value(value) {
export function is(a, b) {
return Object.is(get_proxied_value(a), get_proxied_value(b));
}
+
+/**
+ * @param {Record} 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} */
+ const sources = object[PROXY_SOURCES];
+ if (sources.has(property)) {
+ return /** @type {Source} */ (sources.get(property));
+ }
+ }
+ }
+ return null;
+}
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 9d2ad2baee4e..d438e1b040df 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -220,6 +220,61 @@ export function internal_set(source, value) {
return value;
}
+/**
+ * @param {Source | null} source
+ */
+export function invalidate(source) {
+ if (source === null || (source.f & DERIVED) !== 0) {
+ e.state_invalidate_invalid_source();
+ }
+ if (
+ active_reaction !== null &&
+ !untracking &&
+ is_runes() &&
+ (active_reaction.f & (DERIVED | BLOCK_EFFECT)) !== 0 &&
+ !reaction_sources?.includes(source)
+ ) {
+ e.state_unsafe_mutation();
+ }
+ 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) {
+ const inspects = Array.from(inspect_effects);
+
+ for (const effect of inspects) {
+ // Mark clean inspect-effects as maybe dirty and then check their dirtiness
+ // instead of just updating the effects - this way we avoid overfiring.
+ if ((effect.f & CLEAN) !== 0) {
+ set_signal_status(effect, MAYBE_DIRTY);
+ }
+ if (check_dirtiness(effect)) {
+ update_effect(effect);
+ }
+ }
+
+ inspect_effects.clear();
+ }
+}
+
/**
* @template {number | bigint} T
* @param {Source} source
diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js
index ada318e85ac7..b9c122915944 100644
--- a/packages/svelte/src/utils.js
+++ b/packages/svelte/src/utils.js
@@ -430,6 +430,7 @@ export function is_mathml(name) {
const RUNES = /** @type {const} */ ([
'$state',
+ '$state.invalidate',
'$state.raw',
'$state.snapshot',
'$props',
diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts
index 8421ae4a7cbf..4e2e51845aa2 100644
--- a/packages/svelte/tests/signals/test.ts
+++ b/packages/svelte/tests/signals/test.ts
@@ -8,7 +8,13 @@ import {
render_effect,
user_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, Value } from '../../src/internal/client/types';
import { proxy } from '../../src/internal/client/proxy';
import { derived } from '../../src/internal/client/reactivity/deriveds';
@@ -1260,4 +1266,24 @@ describe('signals', () => {
destroy();
};
});
+
+ 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();
+ };
+ });
});
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index b233cfcc0b58..208a129f31a8 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -2925,6 +2925,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
+ *
+ *
+ * ```
+ *
+ * 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.