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 ` + * + * + * ``` + * + * 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

`, + + test({ assert, target }) { + const button = target.querySelector('button'); + + button?.click(); + flushSync(); + assert.htmlEqual(target.innerHTML, `

1

`); + + button?.click(); + flushSync(); + assert.htmlEqual(target.innerHTML, `

2

`); + } +}); 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 @@ + + + + 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: `
0
0
`, + ssrHtml: `
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, + `
0
0
` + ); + assert.equal(input.value, '0'); + + b2?.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + `
1
1
` + ); + assert.equal(input.value, '1'); + + input.value = '2'; + input.dispatchEvent(new window.Event('input')); + flushSync(); + assert.htmlEqual( + target.innerHTML, + `
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}
+ + 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 + * + * + * + * ``` + * + * 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`: *