Skip to content

Commit 63ec2e2

Browse files
trueadmdummdidummRich-Harris
authored
feat: adds $state.link rune (#12545)
* feat: adds $state.link rune * add tests * types * docs * debugger * lint * Update .changeset/friendly-rice-confess.md Co-authored-by: Simon H <[email protected]> * Update packages/svelte/src/compiler/phases/2-analyze/index.js Co-authored-by: Simon H <[email protected]> * feedback * feedback * feedback * feedback * rename link_state to linked_state for grammatical consistency * oops, victim of find-replace * no need to store linked_value if setting * simplify tests * test behaviour of objects * update docs * copy * more direct example that shows unlinking and relinking * add callback argument support * fix * tidy up * document callback --------- Co-authored-by: Simon H <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent f6f0e78 commit 63ec2e2

File tree

29 files changed

+423
-27
lines changed

29 files changed

+423
-27
lines changed

.changeset/friendly-rice-confess.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
feat: add `$state.link` rune

documentation/docs/03-runes/01-state.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,51 @@ Objects and arrays are made deeply reactive by wrapping them with [`Proxies`](ht
6262

6363
> Only POJOs (plain old JavaScript objects) are made deeply reactive. Reactivity will stop at class boundaries and leave those alone
6464
65+
## `$state.link`
66+
67+
Linked state stays up to date with its input:
68+
69+
```js
70+
let a = $state(0);
71+
let b = $state.link(a);
72+
73+
a = 1;
74+
console.log(a, b); // 1, 1
75+
```
76+
77+
You can temporarily _unlink_ state. It will be _relinked_ when the input value changes:
78+
79+
```js
80+
let a = 1;
81+
let b = 1;
82+
// ---cut---
83+
b = 2; // unlink
84+
console.log(a, b); // 1, 2
85+
86+
a = 3; // relink
87+
console.log(a, b); // 3, 3
88+
```
89+
90+
As with `$state`, if `$state.link` is passed a plain object or array it will be made deeply reactive. If passed an existing state proxy it will be reused, meaning that mutating the linked state will mutate the original. To clone a state proxy, you can use [`$state.snapshot`](#$state-snapshot).
91+
92+
If you pass a callback to `$state.link`, changes to the input value will invoke the callback rather than updating the linked state, allowing you to choose whether to (for example) preserve or discard local changes, or merge incoming changes with local ones:
93+
94+
```js
95+
let { stuff } = $props();
96+
97+
let incoming = $state();
98+
let hasUnsavedChanges = $state(false);
99+
100+
let current = $state.link({ ...stuff }, (stuff) => {
101+
if (hasUnsavedChanges) {
102+
incoming = stuff;
103+
} else {
104+
incoming = null;
105+
current = stuff;
106+
}
107+
});
108+
```
109+
65110
## `$state.raw`
66111

67112
State declared with `$state.raw` cannot be mutated; it can only be _reassigned_. In other words, rather than assigning to a property of an object, or using an array method like `push`, replace the object or array altogether if you'd like to update it:

packages/svelte/src/ambient.d.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,32 @@ declare namespace $state {
124124
*
125125
* @param initial The initial value
126126
*/
127+
128+
/**
129+
* Declares reactive state that is linked to another value. Local reassignments
130+
* will override the linked value until the linked value changes.
131+
*
132+
* Example:
133+
* ```ts
134+
* let a = $state(0);
135+
* let b = $state.link(a);
136+
137+
* a = 1;
138+
* console.log(a, b); // 1, 1
139+
140+
* b = 2; // unlink
141+
* console.log(a, b); // 1, 2
142+
*
143+
* a = 3; // relink
144+
* console.log(a, b); // 3, 3
145+
* ```
146+
*
147+
* https://svelte-5-preview.vercel.app/docs/runes#$state-link
148+
*
149+
* @param value The linked value
150+
*/
151+
export function link<T>(value: T, callback?: (value: T) => void): T;
152+
127153
export function raw<T>(initial: T): T;
128154
export function raw<T>(): T | undefined;
129155
/**

packages/svelte/src/compiler/phases/2-analyze/visitors/BindDirective.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function BindDirective(node, context) {
3434
node.name !== 'this' && // bind:this also works for regular variables
3535
(!binding ||
3636
(binding.kind !== 'state' &&
37+
binding.kind !== 'linked_state' &&
3738
binding.kind !== 'raw_state' &&
3839
binding.kind !== 'prop' &&
3940
binding.kind !== 'bindable_prop' &&

packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export function CallExpression(node, context) {
169169
}
170170

171171
// `$inspect(foo)` or `$derived(foo) should not trigger the `static-state-reference` warning
172-
if (rune === '$inspect' || rune === '$derived') {
172+
if (rune === '$inspect' || rune === '$derived' || rune === '$state.link') {
173173
context.next({ ...context.state, function_depth: context.state.function_depth + 1 });
174174
} else {
175175
context.next();

packages/svelte/src/compiler/phases/2-analyze/visitors/ExportSpecifier.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ function validate_export(node, scope, name) {
3838
e.derived_invalid_export(node);
3939
}
4040

41-
if ((binding.kind === 'state' || binding.kind === 'raw_state') && binding.reassigned) {
41+
if (
42+
(binding.kind === 'state' || binding.kind === 'raw_state' || binding.kind === 'linked_state') &&
43+
binding.reassigned
44+
) {
4245
e.state_invalid_export(node);
4346
}
4447
}

packages/svelte/src/compiler/phases/2-analyze/visitors/Identifier.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export function Identifier(node, context) {
9999
context.state.function_depth === binding.scope.function_depth &&
100100
// If we have $state that can be proxied or frozen and isn't re-assigned, then that means
101101
// it's likely not using a primitive value and thus this warning isn't that helpful.
102-
((binding.kind === 'state' &&
102+
(((binding.kind === 'state' || binding.kind === 'linked_state') &&
103103
(binding.reassigned ||
104104
(binding.initial?.type === 'CallExpression' &&
105105
binding.initial.arguments.length === 1 &&

packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export function VariableDeclarator(node, context) {
2121
// TODO feels like this should happen during scope creation?
2222
if (
2323
rune === '$state' ||
24+
rune === '$state.link' ||
2425
rune === '$state.raw' ||
2526
rune === '$derived' ||
2627
rune === '$derived.by' ||
@@ -32,13 +33,15 @@ export function VariableDeclarator(node, context) {
3233
binding.kind =
3334
rune === '$state'
3435
? 'state'
35-
: rune === '$state.raw'
36-
? 'raw_state'
37-
: rune === '$derived' || rune === '$derived.by'
38-
? 'derived'
39-
: path.is_rest
40-
? 'rest_prop'
41-
: 'prop';
36+
: rune === '$state.link'
37+
? 'linked_state'
38+
: rune === '$state.raw'
39+
? 'raw_state'
40+
: rune === '$derived' || rune === '$derived.by'
41+
? 'derived'
42+
: path.is_rest
43+
? 'rest_prop'
44+
: 'prop';
4245
}
4346
}
4447

packages/svelte/src/compiler/phases/3-transform/client/types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export interface ComponentClientTransformState extends ClientTransformState {
8888
}
8989

9090
export interface StateField {
91-
kind: 'state' | 'raw_state' | 'derived' | 'derived_by';
91+
kind: 'state' | 'raw_state' | 'linked_state' | 'derived' | 'derived_by';
9292
id: PrivateIdentifier;
9393
}
9494

packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export function ClassBody(node, context) {
4444
const rune = get_rune(definition.value, context.state.scope);
4545
if (
4646
rune === '$state' ||
47+
rune === '$state.link' ||
4748
rune === '$state.raw' ||
4849
rune === '$derived' ||
4950
rune === '$derived.by'
@@ -55,9 +56,11 @@ export function ClassBody(node, context) {
5556
? 'state'
5657
: rune === '$state.raw'
5758
? 'raw_state'
58-
: rune === '$derived.by'
59-
? 'derived_by'
60-
: 'derived',
59+
: rune === '$state.link'
60+
? 'linked_state'
61+
: rune === '$derived.by'
62+
? 'derived_by'
63+
: 'derived',
6164
// @ts-expect-error this is set in the next pass
6265
id: is_private ? definition.key : null
6366
};
@@ -116,11 +119,18 @@ export function ClassBody(node, context) {
116119
'$.source',
117120
should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init
118121
)
119-
: field.kind === 'raw_state'
120-
? b.call('$.source', init)
121-
: field.kind === 'derived_by'
122-
? b.call('$.derived', init)
123-
: b.call('$.derived', b.thunk(init));
122+
: field.kind === 'linked_state'
123+
? b.call(
124+
'$.source_link',
125+
b.thunk(
126+
should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init
127+
)
128+
)
129+
: field.kind === 'raw_state'
130+
? b.call('$.source', init)
131+
: field.kind === 'derived_by'
132+
? b.call('$.derived', init)
133+
: b.call('$.derived', b.thunk(init));
124134
} else {
125135
// if no arguments, we know it's state as `$derived()` is a compile error
126136
value = b.call('$.source');
@@ -133,8 +143,24 @@ export function ClassBody(node, context) {
133143
const member = b.member(b.this, field.id);
134144
body.push(b.prop_def(field.id, value));
135145

136-
// get foo() { return this.#foo; }
137-
body.push(b.method('get', definition.key, [], [b.return(b.call('$.get', member))]));
146+
if (field.kind === 'linked_state') {
147+
// get foo() { return this.#foo; }
148+
body.push(b.method('get', definition.key, [], [b.return(b.call(member))]));
149+
150+
// set foo(value) { this.#foo = value; }
151+
const value = b.id('value');
152+
body.push(
153+
b.method(
154+
'set',
155+
definition.key,
156+
[value],
157+
[b.stmt(b.call(member, build_proxy_reassignment(value, field.id)))]
158+
)
159+
);
160+
} else {
161+
// get foo() { return this.#foo; }
162+
body.push(b.method('get', definition.key, [], [b.return(b.call('$.get', member))]));
163+
}
138164

139165
if (field.kind === 'state') {
140166
// set foo(value) { this.#foo = value; }

0 commit comments

Comments
 (0)