feat: add state.opqaue rune

opaque-rune
Dominic Gannaway 3 weeks ago
parent 1a0b822f48
commit 3e222fca39

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: add $state.opqaue rune

@ -748,6 +748,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 array destructuring pattern (e.g. `let [state, invalidate] = $state.opaque(data);`)
```
### state_invalid_placement
```

@ -168,6 +168,10 @@ It's possible to export a snippet from a `<script module>` block, but only if it
> 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 array destructuring pattern (e.g. `let [state, invalidate] = $state.opaque(data);`)
## state_invalid_placement
> `%rune%(...)` can only be used as a variable declaration initializer or a class field

@ -118,6 +118,35 @@ declare namespace $state {
*/
export function raw<T>(initial: T): T;
export function raw<T>(): 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
* <script>
* let [items, invalidate] = $state.opaque([0]);
*
* const addItem = () => {
* items.push(items.length);
* invalidate();
* };
* </script>
*
* <button on:click={addItem}>
* {items.join(', ')}
* </button>
* ```
*
* https://svelte.dev/docs/svelte/$state#$state.opaque
*
* @param initial The initial value
*/
export function opaque<T>(initial: T): [T, () => void];
export function opaque<T>(): [T | undefined, () => void];
/**
* To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`:
*

@ -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");
}
/**
* `$state.opaque` must be declared with an array destructuring pattern (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 array destructuring pattern (e.g. `let [state, invalidate] = $state.opaque(data);`)");
}
/**
* `%rune%(...)` can only be used as a variable declaration initializer or a class field
* @param {null | number | NodeLike} node

@ -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,21 @@ 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')
) {
e.state_invalid_opaque_declaration(node);
}
break;

@ -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';
}
}

@ -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,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, b.call('$.opaque_state', value)),
b.declarator(
invalidation_id,
b.thunk(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(

@ -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);
}
};
}
}
}

@ -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,17 @@ 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.thunk(b.block([])))
);
continue;
}
declarations.push(...create_state_declarators(declarator, context.state.scope, value));
}
} else {

@ -274,6 +274,7 @@ export interface Binding {
| 'rest_prop'
| 'state'
| 'raw_state'
| 'opaque_state'
| 'derived'
| 'each'
| 'snippet'

@ -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,

@ -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;
}

@ -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<V>}
*/
export function opaque_state(v) {
var s = source(v);
s.equals = opaque_equals;
return push_derived_source(s);
}
/**
* @template V
* @param {V} v

@ -420,6 +420,7 @@ export function is_mathml(name) {
const RUNES = /** @type {const} */ ([
'$state',
'$state.raw',
'$state.opaque',
'$state.snapshot',
'$props',
'$bindable',

@ -0,0 +1,38 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
import { assert_ok } from '../../../suite';
export default test({
html: `<button>+</button><button>invalidate</button><div>0</div><div>0</div><input>`,
ssrHtml: `<button>+</button><button>invalidate</button><div>0</div><div>0</div><input value="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,
`<button>+</button><button>invalidate</button><div>0</div><div>0</div><input>`
);
assert.equal(input.value, '0');
b2?.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
`<button>+</button><button>invalidate</button><div>1</div><div>1</div><input>`
);
assert.equal(input.value, '1');
input.value = '2';
input.dispatchEvent(new window.Event('input'));
flushSync();
assert.htmlEqual(
target.innerHTML,
`<button>+</button><button>invalidate</button><div>1</div><div>1</div><input>`
);
}
});

@ -0,0 +1,19 @@
<script>
let [count, invalidate_count] = $state.opaque(0);
let [value, invalidate_value] = $state.opaque({ count: 0 });
</script>
<button onclick={() => {
count++
value.count++
}}>+</button>
<button onclick={() => {
invalidate_count();
invalidate_value();
}}>invalidate</button>
<div>{count}</div>
<div>{value.count}</div>
<input bind:value={count}>

@ -2665,6 +2665,35 @@ declare namespace $state {
*/
export function raw<T>(initial: T): T;
export function raw<T>(): 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
* <script>
* let [items, invalidate] = $state.opaque([0]);
*
* const addItem = () => {
* items.push(items.length);
* invalidate();
* };
* </script>
*
* <button on:click={addItem}>
* {items.join(', ')}
* </button>
* ```
*
* https://svelte.dev/docs/svelte/$state#$state.opaque
*
* @param initial The initial value
*/
export function opaque<T>(initial: T): [T, () => void];
export function opaque<T>(): [T | undefined, () => void];
/**
* To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`:
*

Loading…
Cancel
Save