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