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 8e6c91fad769..5e09810fb3b4 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 db848a0299ee..9b38b7e5b38d 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -890,6 +890,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 ``` 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 e11975aef26a..be1887de49db 100644 --- a/packages/svelte/messages/compile-errors/script.md +++ b/packages/svelte/messages/compile-errors/script.md @@ -248,6 +248,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 diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index a1484718cc77..28373a784532 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -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 + * + * + * ``` + * + * 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 25e72340c64d..ea91dcab2035 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -499,6 +499,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 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 33abb52cac5c..20e1e326d4b5 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 665be9e23bf4..b3f8548edb95 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,9 +1,10 @@ -/** @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'; /** @@ -11,6 +12,21 @@ import { should_proxy } from '../utils.js'; * @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) { @@ -56,6 +72,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 35c79988b08b..7ab725271878 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' || rune === '$state.raw') { return node.arguments[0] ? context.visit(node.arguments[0]) : b.void0; } diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 98cef658bf6c..645737456f76 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -25,4 +25,5 @@ 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'); export const PROXY_PATH_SYMBOL = Symbol('proxy path'); 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 60f9af912060..ec76df1fe20c 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -109,7 +109,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, @@ -144,7 +152,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 487050669933..2566e7cadc7f 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 { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; +import { PROXY_PATH_SYMBOL, PROXY_SOURCES, STATE_SYMBOL } from '#client/constants'; import { UNINITIALIZED } from '../../constants.js'; import * as e from './errors.js'; import { get_stack, tag } from './dev/tracing.js'; @@ -148,6 +148,10 @@ export function proxy(value) { return value; } + if (prop === PROXY_SOURCES) { + return sources; + } + if (DEV && prop === PROXY_PATH_SYMBOL) { return update_path; } @@ -203,7 +207,7 @@ export function proxy(value) { }, has(target, prop) { - if (prop === STATE_SYMBOL) { + if (prop === STATE_SYMBOL || prop === PROXY_SOURCES) { return true; } @@ -383,3 +387,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 40a3e4e77f14..2b82b8955aa4 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -228,6 +228,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 921eaec57cf5..8e46a2db2ebc 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -430,6 +430,7 @@ export function is_mathml(name) { export const STATE_CREATION_RUNES = /** @type {const} */ ([ '$state', + '$state.invalidate', '$state.raw', '$derived', '$derived.by' 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 1a83e0d0f100..a509858f9268 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -3098,6 +3098,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. pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy