Skip to content

Commit 0812b10

Browse files
authored
breaking: overhaul proxies, remove $state.is (#12916)
* chore: use closures for state proxies * use variables * early return * tidy up * move ownership stuff into separate object * put original value directly on STATE_SYMBOL * rename * tidy up * tidy * tweak * fix * remove is_frozen check * remove `$state.is` * avoid mutations * tweak * changesets * changeset * changeset * regenerate * add comment * add note * add test
1 parent 5797f5e commit 0812b10

File tree

34 files changed

+395
-402
lines changed

34 files changed

+395
-402
lines changed

.changeset/fifty-actors-agree.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+
breaking: disallow `Object.defineProperty` on state proxies with non-basic descriptors

.changeset/gorgeous-pans-sort.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+
breaking: allow frozen objects to be proxied

.changeset/heavy-houses-pay.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+
breaking: avoid mutations to underlying proxied object with $state

.changeset/short-starfishes-beg.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+
breaking: remove $state.is rune

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

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -101,28 +101,6 @@ To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snaps
101101

102102
This is handy when you want to pass some state to an external library or API that doesn't expect a proxy, such as `structuredClone`.
103103

104-
## `$state.is`
105-
106-
Sometimes you might need to compare two values, one of which is a reactive `$state(...)` proxy but the other is not. For this you can use `$state.is(a, b)`:
107-
108-
```svelte
109-
<script>
110-
let foo = $state({});
111-
let bar = {};
112-
113-
foo.bar = bar;
114-
115-
console.log(foo.bar === bar); // false — `foo.bar` is a reactive proxy
116-
console.log($state.is(foo.bar, bar)); // true
117-
</script>
118-
```
119-
120-
This is handy when you might want to check if the object exists within a deeply reactive object/array.
121-
122-
Under the hood, `$state.is` uses [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is) for comparing the values.
123-
124-
> Use this as an escape hatch - most of the time you don't need this. Svelte will warn you at dev time if you happen to run into this problem
125-
126104
## `$derived`
127105

128106
Derived state is declared with the `$derived` rune:

packages/svelte/messages/client-errors/errors.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@
6464

6565
> The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files
6666
67+
## state_descriptors_fixed
68+
69+
> Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.
70+
6771
## state_prototype_fixed
6872

6973
> Cannot set prototype of `$state` object

packages/svelte/messages/client-warnings/warnings.md

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
4545
## state_proxy_equality_mismatch
4646

47-
> Reactive `$state(...)` proxies and the values they proxy have different identities. Because of this, comparisons with `%operator%` will produce unexpected results. Consider using `$state.is(a, b)` instead%details%
47+
> Reactive `$state(...)` proxies and the values they proxy have different identities. Because of this, comparisons with `%operator%` will produce unexpected results
4848
4949
`$state(...)` creates a [proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) of the value it is passed. The proxy and the value have different identities, meaning equality checks will always return `false`:
5050

@@ -57,15 +57,4 @@
5757
</script>
5858
```
5959

60-
In the rare case that you need to compare them, you can use `$state.is`, which unwraps proxies:
61-
62-
```svelte
63-
<script>
64-
let value = { foo: 'bar' };
65-
let proxy = $state(value);
66-
67-
$state.is(value, proxy); // true
68-
</script>
69-
```
70-
71-
During development, Svelte will warn you when comparing values with proxies.
60+
To resolve this, ensure you're comparing values where both values were created with `$state(...)`, or neither were. Note that `$state.raw(...)` will _not_ create a state proxy.

packages/svelte/messages/compile-errors/script.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@
122122

123123
> Cannot use rune without parentheses
124124
125+
## rune_removed
126+
127+
> The `%name%` rune has been removed
128+
125129
## rune_renamed
126130

127131
> `%name%` is now `%replacement%`

packages/svelte/src/ambient.d.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -147,27 +147,6 @@ declare namespace $state {
147147
*/
148148
export function snapshot<T>(state: T): Snapshot<T>;
149149

150-
/**
151-
* Compare two values, one or both of which is a reactive `$state(...)` proxy.
152-
*
153-
* Example:
154-
* ```ts
155-
* <script>
156-
* let foo = $state({});
157-
* let bar = {};
158-
*
159-
* foo.bar = bar;
160-
*
161-
* console.log(foo.bar === bar); // false — `foo.bar` is a reactive proxy
162-
* console.log($state.is(foo.bar, bar)); // true
163-
* </script>
164-
* ```
165-
*
166-
* https://svelte-5-preview.vercel.app/docs/runes#$state.is
167-
*
168-
*/
169-
export function is(a: any, b: any): boolean;
170-
171150
// prevent intellisense from being unhelpful
172151
/** @deprecated */
173152
export const apply: never;

packages/svelte/src/compiler/errors.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,16 @@ export function rune_missing_parentheses(node) {
348348
e(node, "rune_missing_parentheses", "Cannot use rune without parentheses");
349349
}
350350

351+
/**
352+
* The `%name%` rune has been removed
353+
* @param {null | number | NodeLike} node
354+
* @param {string} name
355+
* @returns {never}
356+
*/
357+
export function rune_removed(node, name) {
358+
e(node, "rune_removed", `The \`${name}\` rune has been removed`);
359+
}
360+
351361
/**
352362
* `%name%` is now `%replacement%`
353363
* @param {null | number | NodeLike} node

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

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,6 @@ export function CallExpression(node, context) {
140140
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
141141
}
142142

143-
break;
144-
145-
case '$state.is':
146-
if (node.arguments.length !== 2) {
147-
e.rune_invalid_arguments_length(node, rune, 'exactly two arguments');
148-
}
149-
150143
break;
151144
}
152145

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export function Identifier(node, context) {
6161
e.rune_renamed(parent, '$state.frozen', '$state.raw');
6262
}
6363

64+
if (name === '$state.is') {
65+
e.rune_removed(parent, '$state.is');
66+
}
67+
6468
e.rune_invalid_name(parent, name);
6569
}
6670
}

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

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,6 @@ export function CallExpression(node, context) {
2424
is_ignored(node, 'state_snapshot_uncloneable') && b.true
2525
);
2626

27-
case '$state.is':
28-
return b.call(
29-
'$.is',
30-
/** @type {Expression} */ (context.visit(node.arguments[0])),
31-
/** @type {Expression} */ (context.visit(node.arguments[1]))
32-
);
33-
3427
case '$effect.root':
3528
return b.call(
3629
'$.effect_root',

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ export function VariableDeclaration(node, context) {
2828
rune === '$effect.tracking' ||
2929
rune === '$effect.root' ||
3030
rune === '$inspect' ||
31-
rune === '$state.snapshot' ||
32-
rune === '$state.is'
31+
rune === '$state.snapshot'
3332
) {
3433
if (init != null && is_hoisted_function(init)) {
3534
context.state.hoisted.push(

packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,6 @@ export function CallExpression(node, context) {
3333
);
3434
}
3535

36-
if (rune === '$state.is') {
37-
return b.call(
38-
'Object.is',
39-
/** @type {Expression} */ (context.visit(node.arguments[0])),
40-
/** @type {Expression} */ (context.visit(node.arguments[1]))
41-
);
42-
}
43-
4436
if (rune === '$inspect' || rune === '$inspect().with') {
4537
return transform_inspect_rune(node, context);
4638
}

packages/svelte/src/internal/client/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ export const INSPECT_EFFECT = 1 << 17;
2020
export const HEAD_EFFECT = 1 << 18;
2121

2222
export const STATE_SYMBOL = Symbol('$state');
23+
export const STATE_SYMBOL_METADATA = Symbol('$state metadata');
2324
export const LOADING_ATTR_SYMBOL = Symbol('');

packages/svelte/src/internal/client/dev/equality.js

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,7 @@ export function init_array_prototype_warnings() {
2020
const test = indexOf.call(get_proxied_value(this), get_proxied_value(item), from_index);
2121

2222
if (test !== -1) {
23-
w.state_proxy_equality_mismatch(
24-
'array.indexOf(...)',
25-
': `array.findIndex(entry => $state.is(entry, item))`'
26-
);
23+
w.state_proxy_equality_mismatch('array.indexOf(...)');
2724
}
2825
}
2926

@@ -45,10 +42,7 @@ export function init_array_prototype_warnings() {
4542
);
4643

4744
if (test !== -1) {
48-
w.state_proxy_equality_mismatch(
49-
'array.lastIndexOf(...)',
50-
': `array.findLastIndex(entry => $state.is(entry, item))`'
51-
);
45+
w.state_proxy_equality_mismatch('array.lastIndexOf(...)');
5246
}
5347
}
5448

@@ -62,10 +56,7 @@ export function init_array_prototype_warnings() {
6256
const test = includes.call(get_proxied_value(this), get_proxied_value(item), from_index);
6357

6458
if (test) {
65-
w.state_proxy_equality_mismatch(
66-
'array.includes(...)',
67-
': `array.some(entry => $state.is(entry, item))`'
68-
);
59+
w.state_proxy_equality_mismatch('array.includes(...)');
6960
}
7061
}
7162

@@ -88,7 +79,7 @@ export function init_array_prototype_warnings() {
8879
*/
8980
export function strict_equals(a, b, equal = true) {
9081
if ((a === b) !== (get_proxied_value(a) === get_proxied_value(b))) {
91-
w.state_proxy_equality_mismatch(equal ? '===' : '!==', '');
82+
w.state_proxy_equality_mismatch(equal ? '===' : '!==');
9283
}
9384

9485
return (a === b) === equal;
@@ -102,7 +93,7 @@ export function strict_equals(a, b, equal = true) {
10293
*/
10394
export function equals(a, b, equal = true) {
10495
if ((a == b) !== (get_proxied_value(a) == get_proxied_value(b))) {
105-
w.state_proxy_equality_mismatch(equal ? '==' : '!=', '');
96+
w.state_proxy_equality_mismatch(equal ? '==' : '!=');
10697
}
10798

10899
return (a == b) === equal;

packages/svelte/src/internal/client/dev/ownership.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** @import { ProxyMetadata } from '#client' */
22
/** @typedef {{ file: string, line: number, column: number }} Location */
33

4-
import { STATE_SYMBOL } from '../constants.js';
4+
import { STATE_SYMBOL_METADATA } from '../constants.js';
55
import { render_effect, user_pre_effect } from '../reactivity/effects.js';
66
import { dev_current_component_function } from '../runtime.js';
77
import { get_prototype_of } from '../../shared/utils.js';
@@ -113,7 +113,7 @@ export function mark_module_end(component) {
113113
export function add_owner(object, owner, global = false, skip_warning = false) {
114114
if (object && !global) {
115115
const component = dev_current_component_function;
116-
const metadata = object[STATE_SYMBOL];
116+
const metadata = object[STATE_SYMBOL_METADATA];
117117
if (metadata && !has_owner(metadata, component)) {
118118
let original = get_owner(metadata);
119119

@@ -138,8 +138,8 @@ export function add_owner_effect(get_object, Component, skip_warning = false) {
138138
}
139139

140140
/**
141-
* @param {ProxyMetadata<any> | null} from
142-
* @param {ProxyMetadata<any>} to
141+
* @param {ProxyMetadata | null} from
142+
* @param {ProxyMetadata} to
143143
*/
144144
export function widen_ownership(from, to) {
145145
if (to.owners === null) {
@@ -166,7 +166,7 @@ export function widen_ownership(from, to) {
166166
* @param {Set<any>} seen
167167
*/
168168
function add_owner_to_object(object, owner, seen) {
169-
const metadata = /** @type {ProxyMetadata} */ (object?.[STATE_SYMBOL]);
169+
const metadata = /** @type {ProxyMetadata} */ (object?.[STATE_SYMBOL_METADATA]);
170170

171171
if (metadata) {
172172
// this is a state proxy, add owner directly, if not globally shared

packages/svelte/src/internal/client/dom/elements/bindings/this.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import { queue_micro_task } from '../../task.js';
99
* @returns {boolean}
1010
*/
1111
function is_bound_this(bound_value, element_or_component) {
12-
// Find the original target if the value is proxied.
13-
var proxy_target = bound_value && bound_value[STATE_SYMBOL]?.t;
14-
return bound_value === element_or_component || proxy_target === element_or_component;
12+
return (
13+
bound_value === element_or_component || bound_value?.[STATE_SYMBOL] === element_or_component
14+
);
1515
}
1616

1717
/**

packages/svelte/src/internal/client/errors.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,22 @@ export function rune_outside_svelte(rune) {
278278
}
279279
}
280280

281+
/**
282+
* Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.
283+
* @returns {never}
284+
*/
285+
export function state_descriptors_fixed() {
286+
if (DEV) {
287+
const error = new Error(`state_descriptors_fixed\nProperty descriptors defined on \`$state\` objects must contain \`value\` and always be \`enumerable\`, \`configurable\` and \`writable\`.`);
288+
289+
error.name = 'Svelte error';
290+
throw error;
291+
} else {
292+
// TODO print a link to the documentation
293+
throw new Error("state_descriptors_fixed");
294+
}
295+
}
296+
281297
/**
282298
* Cannot set prototype of `$state` object
283299
* @returns {never}

packages/svelte/src/internal/client/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export {
150150
validate_prop_bindings
151151
} from './validate.js';
152152
export { raf } from './timing.js';
153-
export { proxy, is } from './proxy.js';
153+
export { proxy } from './proxy.js';
154154
export { create_custom_element } from './dom/elements/custom-element.js';
155155
export {
156156
child,

0 commit comments

Comments
 (0)