From acc8361cd26d7d24c401e03541632ffc629adf51 Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Wed, 2 Apr 2025 22:43:10 -0700 Subject: [PATCH 01/12] init --- .changeset/breezy-baboons-exercise.md | 5 +++ .../98-reference/.generated/compile-errors.md | 6 +++ .../svelte/messages/compile-errors/script.md | 4 ++ packages/svelte/src/ambient.d.ts | 24 +++++++++++ packages/svelte/src/compiler/errors.js | 9 ++++ .../2-analyze/visitors/CallExpression.js | 18 ++++++++ .../client/visitors/CallExpression.js | 2 + packages/svelte/src/internal/client/index.js | 10 ++++- .../src/internal/client/reactivity/sources.js | 43 +++++++++++++++++++ packages/svelte/src/utils.js | 1 + packages/svelte/types/index.d.ts | 24 +++++++++++ 11 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 .changeset/breezy-baboons-exercise.md 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/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index a8c39aaf9713..d2dc537006fe 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -828,6 +828,12 @@ 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_nonreactive_argument + +``` +`$state.invalidate` only takes a variable declared with `$state` or `$state.raw` as its argument +``` + ### store_invalid_scoped_subscription ``` diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md index aabcbeae4812..a194b7e3dc97 100644 --- a/packages/svelte/messages/compile-errors/script.md +++ b/packages/svelte/messages/compile-errors/script.md @@ -220,6 +220,10 @@ It's possible to export a snippet from a ` + * + * ``` + */ + 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 6bf973948b92..2930ce46bb8b 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -480,6 +480,15 @@ 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` only takes a variable 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 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 2eac934b332c..d299abf62654 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -111,6 +111,24 @@ export function CallExpression(node, context) { break; } + 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') { + e.rune_invalid_arguments(node, rune); + } + 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': case '$derived': 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 fda43ad7911a..4f5f52e96921 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 @@ -23,6 +23,8 @@ export function CallExpression(node, context) { /** @type {Expression} */ (context.visit(node.arguments[0])), is_ignored(node, 'state_snapshot_uncloneable') && b.true ); + case '$state.invalidate': + return b.call('$.invalidate', node.arguments[0]); case '$effect.root': return b.call( diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index a5f93e8b171b..5e72a46554fb 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -113,7 +113,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, diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index e4834902fe3f..36189cb749fe 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -211,6 +211,49 @@ export function internal_set(source, value) { return value; } +/** + * @param {Source} source + */ +export function invalidate(source) { + 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 d4d106d56deb..9c64fe0fadb4 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/types/index.d.ts b/packages/svelte/types/index.d.ts index c6000fc4b67f..6509b1cf1e21 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2760,6 +2760,30 @@ declare namespace $state { : never : never; + /** + * Forces an update on a `$state` or `$state.raw` variable. + * 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 + * + * + * ``` + */ + export function invalidate(source: unknown): void; + /** * Declares state that is _not_ made deeply reactive — instead of mutating it, * you must reassign it. From dfae0e9b8f46f8e3a2e6123331b26bef7ccf10f3 Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Wed, 2 Apr 2025 23:18:45 -0700 Subject: [PATCH 02/12] tweak error, maybe fix lint --- .../src/compiler/phases/2-analyze/visitors/CallExpression.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d299abf62654..ccabd1e460fe 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -110,14 +110,14 @@ 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') { - e.rune_invalid_arguments(node, rune); + e.state_invalidate_nonreactive_argument(node); } let binding = context.state.scope.get(arg.name); if (binding) { From b05dbbfdc61c6f00a32daebe83c91462c6775169 Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Wed, 2 Apr 2025 23:26:25 -0700 Subject: [PATCH 03/12] actually fix lint --- packages/svelte/src/ambient.d.ts | 6 +++--- packages/svelte/types/index.d.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index 1953c2780f3b..e421b79a1729 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -102,11 +102,11 @@ declare namespace $state { * ```svelte * diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 6509b1cf1e21..70f7a057f6c4 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2769,11 +2769,11 @@ declare namespace $state { * ```svelte * From 524d22954b5aa93bea8cd190648eb7c7a153861d Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Thu, 3 Apr 2025 19:20:10 -0700 Subject: [PATCH 04/12] add support for class fields --- .../98-reference/.generated/compile-errors.md | 27 ++++++++++++- .../svelte/messages/compile-errors/script.md | 25 +++++++++++- packages/svelte/src/ambient.d.ts | 2 +- packages/svelte/src/compiler/errors.js | 13 +++++- .../2-analyze/visitors/CallExpression.js | 40 ++++++++++++++++--- .../client/visitors/CallExpression.js | 20 +++++++++- packages/svelte/types/index.d.ts | 2 +- 7 files changed, 117 insertions(+), 12 deletions(-) diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index d2dc537006fe..3deeef83782b 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -828,10 +828,35 @@ 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 declared with `$state` or `$state.raw` as its 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/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md index a194b7e3dc97..18e51a8185e0 100644 --- a/packages/svelte/messages/compile-errors/script.md +++ b/packages/svelte/messages/compile-errors/script.md @@ -220,9 +220,32 @@ It's possible to export a snippet from a ` + +``` + +`$state.invalidate` can also be used with reactive class fields: + +```js +class Box { + value; + + constructor(initial) { + this.value = initial; + } +} + +class Counter { + count = $state(new Box(0)); + + increment() { + this.count.value++; + $state.invalidate(this.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/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 36189cb749fe..3bb8a2e4cfaa 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -215,6 +215,15 @@ export function internal_set(source, value) { * @param {Source} source */ export function invalidate(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); From d4394c55d3ffae1b2d4a59692adab37f2c264ebc Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Thu, 10 Apr 2025 18:32:02 -0700 Subject: [PATCH 08/12] try adding support for individual property invalidation, might revert later --- .../2-analyze/visitors/CallExpression.js | 16 ++++++-- .../client/visitors/CallExpression.js | 41 ++++++++++++------- .../svelte/src/internal/client/constants.js | 1 + packages/svelte/src/internal/client/index.js | 2 +- packages/svelte/src/internal/client/proxy.js | 27 +++++++++++- .../src/internal/client/reactivity/sources.js | 5 ++- 6 files changed, 70 insertions(+), 22 deletions(-) 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 1d7af8b4f5c4..80b6a5cdde12 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, 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'; @@ -121,10 +121,18 @@ export function CallExpression(node, context) { } if (arg.type === 'MemberExpression') { if (arg.object.type !== 'ThisExpression') { - e.state_invalidate_nonreactive_argument(node); + 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 (arg.computed || !class_body) { + if (!class_body) { e.state_invalidate_invalid_this_property(node); } const possible_this_bindings = context.path.filter((parent, index) => { 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 3983479fe7fd..e29ec869482e 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,10 +1,11 @@ -/** @import { CallExpression, Expression } from 'estree' */ +/** @import { CallExpression, Expression, Identifier } 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'; +import { object } from '../../../../utils/ast.js'; /** * @param {CallExpression} node @@ -29,20 +30,32 @@ export function CallExpression(node, context) { if (node.arguments[0].type === 'Identifier') { return b.call('$.invalidate', node.arguments[0]); } else if (node.arguments[0].type === 'MemberExpression') { - const { property } = node.arguments[0]; - 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; + const { object: obj, property } = node.arguments[0]; + const root = object(node.arguments[0]); + if (obj.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('$.invalidate', b.member(b.this, field.id)); } - if (!field || (field.kind !== 'state' && field.kind !== 'raw_state')) { - e.state_invalidate_nonreactive_argument(node); - } - return b.call('$.invalidate', b.member(b.this, field.id)); + /** @type {Expression[]} */ + const source_args = /** @type {Expression[]} */ ([ + context.visit(obj), + node.arguments[0].computed + ? context.visit(property) + : b.literal(/** @type {Identifier} */ (property).name) + ]); + const arg = b.call('$.lookup_source', ...source_args); + return b.call('$.invalidate', arg); } case '$effect.root': 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/index.js b/packages/svelte/src/internal/client/index.js index 88091ed84807..b409dcf5a427 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -148,7 +148,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 5e0aa3dbc35f..32cf1eb664a9 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 './constants.js'; +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'; @@ -124,6 +124,10 @@ export function proxy(value) { return value; } + if (prop === PROXY_SOURCES) { + return sources; + } + var s = sources.get(prop); var exists = prop in target; @@ -165,7 +169,7 @@ export function proxy(value) { }, has(target, prop) { - if (prop === STATE_SYMBOL) { + if (prop === STATE_SYMBOL || prop === PROXY_SOURCES) { return true; } @@ -317,3 +321,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 7724edad6822..044b80542998 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -221,9 +221,12 @@ export function internal_set(source, value) { } /** - * @param {Source} source + * @param {Source | null} source */ export function invalidate(source) { + if (source === null) { + return; + } if ( active_reaction !== null && !untracking && From 885f3d61536c22bb560ff3ce1695e4045524320d Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:27:39 -0700 Subject: [PATCH 09/12] add error message if source doesn't exist, cleanup code --- documentation/docs/02-runes/02-$state.md | 11 ++++- .../98-reference/.generated/client-errors.md | 6 +++ .../svelte/messages/client-errors/errors.md | 4 ++ .../client/visitors/CallExpression.js | 43 +++++++++++++++---- packages/svelte/src/internal/client/errors.js | 15 +++++++ .../src/internal/client/reactivity/sources.js | 4 +- 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md index 8415ac9947c9..8742e7836916 100644 --- a/documentation/docs/02-runes/02-$state.md +++ b/documentation/docs/02-runes/02-$state.md @@ -184,7 +184,7 @@ In the case that you aren't using a proxied `$state` via use of `$state.raw` or ``` -`$state.invalidate` can also be used with reactive class fields: +`$state.invalidate` can also be used with reactive class fields, and properties of `$state` objects: ```js class Box { @@ -199,10 +199,17 @@ class Counter { count = $state(new Box(0)); increment() { - this.count.value++; + 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 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/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/src/compiler/phases/3-transform/client/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js index e29ec869482e..d18f9ff7d99d 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,17 +1,31 @@ -/** @import { CallExpression, Expression, Identifier } 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'; -import { object } from '../../../../utils/ast.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'); @@ -28,11 +42,13 @@ export function CallExpression(node, context) { /* eslint-disable no-fallthrough */ case '$state.invalidate': if (node.arguments[0].type === 'Identifier') { - return b.call('$.invalidate', node.arguments[0]); + return b.call( + attach_locations(/** @type {Expression} */ (node.callee), b.id('$.invalidate')), + node.arguments[0] + ); } else if (node.arguments[0].type === 'MemberExpression') { - const { object: obj, property } = node.arguments[0]; - const root = object(node.arguments[0]); - if (obj.type === 'ThisExpression') { + const { object, property } = node.arguments[0]; + if (object.type === 'ThisExpression') { let field; switch (property.type) { case 'Identifier': @@ -45,17 +61,26 @@ export function CallExpression(node, context) { if (!field || (field.kind !== 'state' && field.kind !== 'raw_state')) { e.state_invalidate_nonreactive_argument(node); } - return b.call('$.invalidate', b.member(b.this, field.id)); + 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(obj), + 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('$.invalidate', arg); + 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': 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/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 8aa7b650196c..20fa1106baac 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -224,8 +224,8 @@ export function internal_set(source, value) { * @param {Source | null} source */ export function invalidate(source) { - if (source === null) { - return; + if (source === null || (source.f & DERIVED) !== 0) { + e.state_invalidate_invalid_source(); } if ( active_reaction !== null && From 0adad95dc0c08e77450cabeb29a526bdfc3738cd Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Thu, 17 Apr 2025 23:39:38 -0700 Subject: [PATCH 10/12] fix --- packages/svelte/src/internal/client/proxy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index 561b4a4f6ced..32cf1eb664a9 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, PROXY_SOURCES } from '#client/constants.js'; +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'; From 32cee9f77f9fa508372005b022722246118a77c2 Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:52:27 -0700 Subject: [PATCH 11/12] tweak jsdoc description --- packages/svelte/src/ambient.d.ts | 4 +++- packages/svelte/types/index.d.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index ff8fd370d1b0..11586918b51a 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -94,7 +94,7 @@ declare namespace $state { : never; /** - * Forces an update on a `$state` or `$state.raw` variable or class field. + * 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`. @@ -114,6 +114,8 @@ declare namespace $state { * Count is {counter.count} * * ``` + * + * https://svelte.dev/docs/svelte/$state#$state.invalidate */ export function invalidate(source: unknown): void; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index b5f9a4b54a02..97a86a3f83de 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2926,7 +2926,7 @@ declare namespace $state { : never; /** - * Forces an update on a `$state` or `$state.raw` variable or class field. + * 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`. @@ -2946,6 +2946,8 @@ declare namespace $state { * Count is {counter.count} * * ``` + * + * https://svelte.dev/docs/svelte/$state#$state.invalidate */ export function invalidate(source: unknown): void; From ff8dd1033263f8a3ac67628a0c503282a62f8ded Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:58:27 -0700 Subject: [PATCH 12/12] lint --- packages/svelte/src/ambient.d.ts | 4 ++-- packages/svelte/types/index.d.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index 11586918b51a..28373a784532 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -94,7 +94,7 @@ declare namespace $state { : never; /** - * Forces an update on a variable or class field declared with `$state` or `$state.raw`, or a property of a `$state` object. + * 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`. @@ -114,7 +114,7 @@ declare namespace $state { * Count is {counter.count} * * ``` - * + * * https://svelte.dev/docs/svelte/$state#$state.invalidate */ export function invalidate(source: unknown): void; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 97a86a3f83de..208a129f31a8 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2926,7 +2926,7 @@ declare namespace $state { : never; /** - * Forces an update on a variable or class field declared with `$state` or `$state.raw`, or a property of a `$state` object. + * 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`. @@ -2946,7 +2946,7 @@ declare namespace $state { * Count is {counter.count} * * ``` - * + * * https://svelte.dev/docs/svelte/$state#$state.invalidate */ export function invalidate(source: unknown): void;