Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add $state.opaque rune #14639

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: adds $state.opaque 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 @@ -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

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

> `%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, (mutate?: (value: T) => void) => void];
export function opaque<T>(): [T | undefined, (mutate?: (value: T) => void) => 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\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
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,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;

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,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(
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,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 {
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
11 changes: 11 additions & 0 deletions packages/svelte/src/compiler/utils/builders.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
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,13 @@
<script>
let { count } = $props();
let [obj, invalidate] = $state.opaque({ count: 0 });
$effect(() => {
invalidate((obj) => {
obj.count = count;
});
});
</script>

<p>{obj.count}</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { flushSync } from 'svelte';
import { test } from '../../test';

export default test({
html: `<p>0</p><button>+1</button>`,

test({ assert, target }) {
const button = target.querySelector('button');

button?.click();
flushSync();
assert.htmlEqual(target.innerHTML, `<p>1</p><button>+1</button>`);

button?.click();
flushSync();
assert.htmlEqual(target.innerHTML, `<p>2</p><button>+1</button>`);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script>
import Child from './Child.svelte';

let count = $state(0);
</script>

<Child {count} />
<button onclick={() => count += 1}>+1</button>
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>`
);
}
});
Loading
Loading