Skip to content

Commit

Permalink
feat: add state.opqaue rune
Browse files Browse the repository at this point in the history
  • Loading branch information
trueadm committed Dec 9, 2024
1 parent 1a0b822 commit 3e222fc
Show file tree
Hide file tree
Showing 18 changed files with 222 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/large-papayas-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: add $state.opqaue rune
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/compile-errors/script.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/svelte/src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
*
Expand Down
9 changes: 9 additions & 0 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function CallExpression(node, context) {

case '$state':
case '$state.raw':
case '$state.opaque':
case '$derived':
case '$derived.by':
if (
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,29 @@ 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 =
rune === '$state'
? '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';
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
};
}
}
}
Original file line number Diff line number Diff line change
@@ -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' */
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/compiler/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ export interface Binding {
| 'rest_prop'
| 'state'
| 'raw_state'
| 'opaque_state'
| 'derived'
| 'each'
| 'snippet'
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/src/internal/client/reactivity/equality.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 12 additions & 1 deletion packages/svelte/src/internal/client/reactivity/sources.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ export function is_mathml(name) {
const RUNES = /** @type {const} */ ([
'$state',
'$state.raw',
'$state.opaque',
'$state.snapshot',
'$props',
'$bindable',
Expand Down
Original file line number Diff line number Diff line change
@@ -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>`
);
}
});
Original file line number Diff line number Diff line change
@@ -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}>
29 changes: 29 additions & 0 deletions packages/svelte/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
*
Expand Down

0 comments on commit 3e222fc

Please sign in to comment.