diff --git a/.changeset/large-papayas-serve.md b/.changeset/large-papayas-serve.md
new file mode 100644
index 000000000000..f4885eda88d9
--- /dev/null
+++ b/.changeset/large-papayas-serve.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: adds $state.opaque rune
diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md
index d726d25fa188..c4111dc5290c 100644
--- a/documentation/docs/98-reference/.generated/compile-errors.md
+++ b/documentation/docs/98-reference/.generated/compile-errors.md
@@ -760,6 +760,12 @@ This snippet is shadowing the prop `%prop%` with the same name
Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value's properties
```
+### state_invalid_opaque_declaration
+
+```
+`$state.opaque(...)` must be declared with an destructured array pattern and the state expression and invalidate expression must be an identifier (e.g. `let [state, invalidate] = $state.opaque(data);`)
+```
+
### state_invalid_placement
```
diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md
index 69007bfb5919..c2a21629b6f2 100644
--- a/packages/svelte/messages/compile-errors/script.md
+++ b/packages/svelte/messages/compile-errors/script.md
@@ -168,6 +168,10 @@ It's possible to export a snippet from a `
+ *
+ *
+ * {items.join(', ')}
+ *
+ * ```
+ *
+ * https://svelte.dev/docs/svelte/$state#$state.opaque
+ *
+ * @param initial The initial value
+ */
+ export function opaque(initial: T): [T, (mutate?: (value: T) => void) => void];
+ export function opaque(): [T | undefined, (mutate?: (value: T) => void) => void];
+
/**
* To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`:
*
diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js
index fd509eb3ab75..7c882093ed69 100644
--- a/packages/svelte/src/compiler/errors.js
+++ b/packages/svelte/src/compiler/errors.js
@@ -432,6 +432,15 @@ export function state_invalid_export(node) {
e(node, "state_invalid_export", `Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value's properties\nhttps://svelte.dev/e/state_invalid_export`);
}
+/**
+ * `$state.opaque(...)` must be declared with an destructured array pattern and the state expression and invalidate expression must be an identifier (e.g. `let [state, invalidate] = $state.opaque(data);`)
+ * @param {null | number | NodeLike} node
+ * @returns {never}
+ */
+export function state_invalid_opaque_declaration(node) {
+ e(node, "state_invalid_opaque_declaration", `\`$state.opaque(...)\` must be declared with an destructured array pattern and the state expression and invalidate expression must be an identifier (e.g. \`let [state, invalidate] = $state.opaque(data);\`)\nhttps://svelte.dev/e/state_invalid_opaque_declaration`);
+}
+
/**
* `%rune%(...)` can only be used as a variable declaration initializer or a class field
* @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 c7ade4856bcb..d7e26c9e53ab 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
@@ -75,6 +75,7 @@ export function CallExpression(node, context) {
case '$state':
case '$state.raw':
+ case '$state.opaque':
case '$derived':
case '$derived.by':
if (
@@ -86,9 +87,22 @@ export function CallExpression(node, context) {
if ((rune === '$derived' || rune === '$derived.by') && node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
- } else if (rune === '$state' && node.arguments.length > 1) {
+ } else if (
+ (rune === '$state' || rune === '$state.raw' || rune === '$state.opaque') &&
+ node.arguments.length > 1
+ ) {
e.rune_invalid_arguments_length(node, rune, 'zero or one arguments');
}
+ if (
+ rune === '$state.opaque' &&
+ (parent.type !== 'VariableDeclarator' ||
+ parent.id.type !== 'ArrayPattern' ||
+ parent.id.elements.length !== 2 ||
+ parent.id.elements[0]?.type !== 'Identifier' ||
+ parent.id.elements[1]?.type !== 'Identifier')
+ ) {
+ e.state_invalid_opaque_declaration(node);
+ }
break;
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js
index a7d08d315d8f..e2d8bd8986fa 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js
@@ -27,11 +27,15 @@ export function VariableDeclarator(node, context) {
if (
rune === '$state' ||
rune === '$state.raw' ||
+ rune === '$state.opaque' ||
rune === '$derived' ||
rune === '$derived.by' ||
rune === '$props'
) {
- for (const path of paths) {
+ for (let i = 0; i < paths.length; i++) {
+ if (rune === '$state.opaque' && i === 1) continue;
+
+ const path = paths[i];
// @ts-ignore this fails in CI for some insane reason
const binding = /** @type {Binding} */ (context.state.scope.get(path.node.name));
binding.kind =
@@ -39,11 +43,13 @@ export function VariableDeclarator(node, context) {
? 'state'
: rune === '$state.raw'
? 'raw_state'
- : rune === '$derived' || rune === '$derived.by'
- ? 'derived'
- : path.is_rest
- ? 'rest_prop'
- : 'prop';
+ : rune === '$state.opaque'
+ ? 'opaque_state'
+ : rune === '$derived' || rune === '$derived.by'
+ ? 'derived'
+ : path.is_rest
+ ? 'rest_prop'
+ : 'prop';
}
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js
index 04685b66bd0c..6e9bcb00094c 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js
@@ -1,4 +1,4 @@
-/** @import { CallExpression, Expression, Identifier, Literal, VariableDeclaration, VariableDeclarator } from 'estree' */
+/** @import { ArrayPattern, CallExpression, Expression, Identifier, Literal, VariableDeclaration, VariableDeclarator } from 'estree' */
/** @import { Binding } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext } from '../types' */
import { dev } from '../../../../state.js';
@@ -156,6 +156,26 @@ export function VariableDeclaration(node, context) {
continue;
}
+ if (rune === '$state.opaque') {
+ const pattern = /** @type {ArrayPattern} */ (declarator.id);
+ const state_id = /** @type {Identifier} */ (pattern.elements[0]);
+ const invalidation_id = /** @type {Identifier} */ (pattern.elements[1]);
+ declarations.push(
+ b.declarator(state_id, b.call('$.opaque_state', value)),
+ b.declarator(
+ invalidation_id,
+ b.arrow(
+ [b.id('$$fn')],
+ b.sequence([
+ b.chain_call(b.id('$$fn'), b.member(state_id, b.id('v'))),
+ b.call('$.set', state_id, b.member(state_id, b.id('v')))
+ ])
+ )
+ )
+ );
+ continue;
+ }
+
if (rune === '$derived' || rune === '$derived.by') {
if (declarator.id.type === 'Identifier') {
declarations.push(
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js
index 0bd8c352f6a9..759c2fa91c5e 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/declarations.js
@@ -1,4 +1,4 @@
-/** @import { Identifier } from 'estree' */
+/** @import { Expression, Identifier } from 'estree' */
/** @import { ComponentContext, Context } from '../../types' */
import { is_state_source } from '../../utils.js';
import * as b from '../../../../../utils/builders.js';
@@ -48,6 +48,16 @@ export function add_state_transformers(context) {
);
}
};
+ } else if (binding.kind === 'opaque_state') {
+ context.state.transform[name] = {
+ read: binding.declaration_kind === 'var' ? (node) => b.call('$.safe_get', node) : get_value,
+ assign: (node, value) => {
+ return b.assignment('=', b.member(node, b.id('v')), /** @type {Expression} */ (value));
+ },
+ update: (node) => {
+ return b.update(node.operator, b.member(node.argument, b.id('v')), node.prefix);
+ }
+ };
}
}
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/VariableDeclaration.js
index 31de811ac76f..a522310203c9 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/VariableDeclaration.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/VariableDeclaration.js
@@ -1,4 +1,4 @@
-/** @import { VariableDeclaration, VariableDeclarator, Expression, CallExpression, Pattern, Identifier } from 'estree' */
+/** @import { ArrayPattern, VariableDeclaration, VariableDeclarator, Expression, CallExpression, Pattern, Identifier } from 'estree' */
/** @import { Binding } from '#compiler' */
/** @import { Context } from '../types.js' */
/** @import { Scope } from '../../../scope.js' */
@@ -92,6 +92,20 @@ export function VariableDeclaration(node, context) {
continue;
}
+ if (rune === '$state.opaque') {
+ const pattern = /** @type {ArrayPattern} */ (declarator.id);
+ const state_id = /** @type {Identifier} */ (pattern.elements[0]);
+ const invalidation_id = /** @type {Identifier} */ (pattern.elements[1]);
+ declarations.push(
+ b.declarator(state_id, value),
+ b.declarator(
+ invalidation_id,
+ b.arrow([b.id('$$fn')], b.chain_call(b.id('$$fn'), state_id))
+ )
+ );
+ continue;
+ }
+
declarations.push(...create_state_declarators(declarator, context.state.scope, value));
}
} else {
diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts
index fe306bd020e1..89359eb3414d 100644
--- a/packages/svelte/src/compiler/types/index.d.ts
+++ b/packages/svelte/src/compiler/types/index.d.ts
@@ -274,6 +274,7 @@ export interface Binding {
| 'rest_prop'
| 'state'
| 'raw_state'
+ | 'opaque_state'
| 'derived'
| 'each'
| 'snippet'
diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js
index ecb595d74dbd..c1e68363a050 100644
--- a/packages/svelte/src/compiler/utils/builders.js
+++ b/packages/svelte/src/compiler/utils/builders.js
@@ -130,6 +130,17 @@ export function call(callee, ...args) {
};
}
+/**
+ * @param {string | ESTree.Expression} callee
+ * @param {...(ESTree.Expression | ESTree.SpreadElement | false | undefined)} args
+ * @returns {ESTree.ChainExpression}
+ */
+export function chain_call(callee, ...args) {
+ const expression = /** @type {ESTree.SimpleCallExpression} */ (call(callee, ...args));
+ expression.optional = true;
+ return { type: 'ChainExpression', expression };
+}
+
/**
* @param {string | ESTree.Expression} callee
* @param {...ESTree.Expression} args
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index b706e52a5378..e5be80d50801 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -108,7 +108,7 @@ export {
user_effect,
user_pre_effect
} from './reactivity/effects.js';
-export { mutable_state, mutate, set, state } from './reactivity/sources.js';
+export { mutable_state, mutate, set, state, opaque_state } from './reactivity/sources.js';
export {
prop,
rest_props,
diff --git a/packages/svelte/src/internal/client/reactivity/equality.js b/packages/svelte/src/internal/client/reactivity/equality.js
index 37a9994ab8cc..929d4cbc54b7 100644
--- a/packages/svelte/src/internal/client/reactivity/equality.js
+++ b/packages/svelte/src/internal/client/reactivity/equality.js
@@ -28,3 +28,7 @@ export function not_equal(a, b) {
export function safe_equals(value) {
return !safe_not_equal(value, this.v);
}
+
+export function opaque_equals() {
+ return false;
+}
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 4bbd470d08c8..f9b54853ec04 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -20,7 +20,7 @@ import {
set_is_flushing_effect,
is_flushing_effect
} from '../runtime.js';
-import { equals, safe_equals } from './equality.js';
+import { equals, opaque_equals, safe_equals } from './equality.js';
import {
CLEAN,
DERIVED,
@@ -88,6 +88,17 @@ export function mutable_source(initial_value, immutable = false) {
return s;
}
+/**
+ * @template V
+ * @param {V} v
+ * @returns {Source}
+ */
+export function opaque_state(v) {
+ var s = source(v);
+ s.equals = opaque_equals;
+ return push_derived_source(s);
+}
+
/**
* @template V
* @param {V} v
diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js
index 75171c17865a..1144717b3dee 100644
--- a/packages/svelte/src/utils.js
+++ b/packages/svelte/src/utils.js
@@ -420,6 +420,7 @@ export function is_mathml(name) {
const RUNES = /** @type {const} */ ([
'$state',
'$state.raw',
+ '$state.opaque',
'$state.snapshot',
'$props',
'$bindable',
diff --git a/packages/svelte/tests/runtime-runes/samples/opaque-state-fn/Child.svelte b/packages/svelte/tests/runtime-runes/samples/opaque-state-fn/Child.svelte
new file mode 100644
index 000000000000..449f93e4508b
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/opaque-state-fn/Child.svelte
@@ -0,0 +1,13 @@
+
+
+{obj.count}
diff --git a/packages/svelte/tests/runtime-runes/samples/opaque-state-fn/_config.js b/packages/svelte/tests/runtime-runes/samples/opaque-state-fn/_config.js
new file mode 100644
index 000000000000..b5f7e8815459
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/opaque-state-fn/_config.js
@@ -0,0 +1,18 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ html: `0
+1 `,
+
+ test({ assert, target }) {
+ const button = target.querySelector('button');
+
+ button?.click();
+ flushSync();
+ assert.htmlEqual(target.innerHTML, `1
+1 `);
+
+ button?.click();
+ flushSync();
+ assert.htmlEqual(target.innerHTML, `2
+1 `);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/opaque-state-fn/main.svelte b/packages/svelte/tests/runtime-runes/samples/opaque-state-fn/main.svelte
new file mode 100644
index 000000000000..8efd150aece4
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/opaque-state-fn/main.svelte
@@ -0,0 +1,8 @@
+
+
+
+ count += 1}>+1
diff --git a/packages/svelte/tests/runtime-runes/samples/opaque-state/_config.js b/packages/svelte/tests/runtime-runes/samples/opaque-state/_config.js
new file mode 100644
index 000000000000..77c0fd0aa8ec
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/opaque-state/_config.js
@@ -0,0 +1,38 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+import { assert_ok } from '../../../suite';
+
+export default test({
+ html: `+ invalidate 0
0
`,
+ ssrHtml: `+ invalidate 0
0
`,
+
+ test({ assert, target }) {
+ const [b1, b2] = target.querySelectorAll('button');
+ const input = target.querySelector('input');
+ assert_ok(input);
+
+ b1?.click();
+ flushSync();
+ assert.htmlEqual(
+ target.innerHTML,
+ `+ invalidate 0
0
`
+ );
+ assert.equal(input.value, '0');
+
+ b2?.click();
+ flushSync();
+ assert.htmlEqual(
+ target.innerHTML,
+ `+ invalidate 1
1
`
+ );
+ assert.equal(input.value, '1');
+
+ input.value = '2';
+ input.dispatchEvent(new window.Event('input'));
+ flushSync();
+ assert.htmlEqual(
+ target.innerHTML,
+ `+ invalidate 1
1
`
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/opaque-state/main.svelte b/packages/svelte/tests/runtime-runes/samples/opaque-state/main.svelte
new file mode 100644
index 000000000000..54016cefce3b
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/opaque-state/main.svelte
@@ -0,0 +1,19 @@
+
+
+ {
+ count++
+ value.count++
+}}>+
+
+ {
+ invalidate_count();
+ invalidate_value();
+}}>invalidate
+
+{count}
+{value.count}
+
+
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index 61a34dcb8e93..f39299a08af3 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -2665,6 +2665,35 @@ declare namespace $state {
*/
export function raw(initial: T): T;
export function raw(): T | undefined;
+
+ /**
+ * Declares state that is _not_ known to Svelte and thus is completely opaque to
+ * reassignments and mutations. To let Svelte know that the value has changed,
+ * you must invoke its invalidate function manually.
+ *
+ * Example:
+ * ```ts
+ *
+ *
+ *
+ * {items.join(', ')}
+ *
+ * ```
+ *
+ * https://svelte.dev/docs/svelte/$state#$state.opaque
+ *
+ * @param initial The initial value
+ */
+ export function opaque(initial: T): [T, (mutate?: (value: T) => void) => void];
+ export function opaque(): [T | undefined, (mutate?: (value: T) => void) => void];
+
/**
* To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`:
*