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.