Skip to content

Commit 5667785

Browse files
authored
fix: better readonly checks for proxies (#9808)
- Expect the thing that's checked to be wrapped with the proxy already, so that we can just check for the state symbol - Make error message more descriptive
1 parent d5167e7 commit 5667785

File tree

9 files changed

+84
-23
lines changed

9 files changed

+84
-23
lines changed

.changeset/five-tigers-search.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+
fix: better readonly checks for proxies

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ import {
1616
is_array,
1717
object_keys
1818
} from '../utils.js';
19-
import { READONLY_SYMBOL } from './readonly.js';
2019

2120
/** @typedef {{ s: Map<string | symbol, import('../types.js').SourceSignal<any>>; v: import('../types.js').SourceSignal<number>; a: boolean, i: boolean }} Metadata */
2221
/** @typedef {Record<string | symbol, any> & { [STATE_SYMBOL]: Metadata }} StateObject */
2322

2423
export const STATE_SYMBOL = Symbol('$state');
24+
export const READONLY_SYMBOL = Symbol('readonly');
2525

2626
const object_prototype = Object.prototype;
2727
const array_prototype = Array.prototype;

packages/svelte/src/internal/client/proxy/readonly.js

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
1-
import { define_property, get_descriptor } from '../utils.js';
1+
import { define_property } from '../utils.js';
2+
import { READONLY_SYMBOL, STATE_SYMBOL } from './proxy.js';
23

34
/**
45
* @template {Record<string | symbol, any>} T
56
* @typedef {T & { [READONLY_SYMBOL]: Proxy<T> }} StateObject
67
*/
78

8-
export const READONLY_SYMBOL = Symbol('readonly');
9-
10-
const object_prototype = Object.prototype;
11-
const array_prototype = Array.prototype;
12-
const get_prototype_of = Object.getPrototypeOf;
139
const is_frozen = Object.isFrozen;
1410

1511
/**
12+
* Expects a value that was wrapped with `proxy` and makes it readonly.
13+
*
1614
* @template {Record<string | symbol, any>} T
1715
* @template {StateObject<T>} U
1816
* @param {U} value
@@ -26,25 +24,26 @@ export function readonly(value) {
2624
typeof value === 'object' &&
2725
value != null &&
2826
!is_frozen(value) &&
27+
STATE_SYMBOL in value && // TODO handle Map and Set as well
2928
!(READONLY_SYMBOL in value)
3029
) {
31-
const prototype = get_prototype_of(value);
32-
33-
// TODO handle Map and Set as well
34-
if (prototype === object_prototype || prototype === array_prototype) {
35-
const proxy = new Proxy(value, handler);
36-
define_property(value, READONLY_SYMBOL, { value: proxy, writable: false });
37-
38-
return proxy;
39-
}
30+
const proxy = new Proxy(value, handler);
31+
define_property(value, READONLY_SYMBOL, { value: proxy, writable: false });
32+
return proxy;
4033
}
4134

4235
return value;
4336
}
4437

45-
/** @returns {never} */
46-
const readonly_error = () => {
47-
throw new Error(`Props cannot be mutated, unless used with \`bind:\``);
38+
/**
39+
* @param {any} _
40+
* @param {string} prop
41+
* @returns {never}
42+
*/
43+
const readonly_error = (_, prop) => {
44+
throw new Error(
45+
`Props cannot be mutated, unless used with \`bind:\`. Use \`bind:prop-in-question={..}\` to make \`${prop}\` settable. Fallback values can never be mutated.`
46+
);
4847
};
4948

5049
/** @type {ProxyHandler<StateObject<any>>} */

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { EMPTY_FUNC, run_all } from '../common.js';
44
import { get_descriptor, get_descriptors, is_array } from './utils.js';
55
import { PROPS_CALL_DEFAULT_VALUE, PROPS_IS_IMMUTABLE, PROPS_IS_RUNES } from '../../constants.js';
66
import { readonly } from './proxy/readonly.js';
7-
import { observe } from './proxy/proxy.js';
7+
import { observe, proxy } from './proxy/proxy.js';
88

99
export const SOURCE = 1;
1010
export const DERIVED = 1 << 1;
@@ -1426,7 +1426,7 @@ export function prop_source(props, key, flags, default_value) {
14261426
call_default_value ? default_value() : default_value;
14271427

14281428
if (DEV && runes) {
1429-
value = readonly(/** @type {any} */ (value));
1429+
value = readonly(proxy(/** @type {any} */ (value)));
14301430
}
14311431
}
14321432

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
/** @type {{ object: { count: number }}} */
3+
let { object } = $props();
4+
</script>
5+
6+
<button onclick={() => object = { count: object.count + 1 } }>
7+
clicks: {object.count}
8+
</button>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { test } from '../../test';
2+
3+
// Tests that readonly bails on setters/classes
4+
export default test({
5+
html: `<button>clicks: 0</button><button>clicks: 0</button>`,
6+
7+
compileOptions: {
8+
dev: true
9+
},
10+
11+
async test({ assert, target }) {
12+
const [btn1, btn2] = target.querySelectorAll('button');
13+
14+
await btn1.click();
15+
await btn2.click();
16+
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button><button>clicks: 1</button>`);
17+
18+
await btn1.click();
19+
await btn2.click();
20+
assert.htmlEqual(target.innerHTML, `<button>clicks: 2</button><button>clicks: 2</button>`);
21+
}
22+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script>
2+
import Counter from './Counter.svelte';
3+
4+
function createCounter() {
5+
let count = $state(0)
6+
return {
7+
get count() {
8+
return count;
9+
},
10+
set count(upd) {
11+
count = upd
12+
}
13+
}
14+
}
15+
16+
class CounterClass {
17+
count = $state(0);
18+
}
19+
20+
const counterSetter = createCounter();
21+
const counterClass = new CounterClass();
22+
</script>
23+
24+
<Counter object={counterSetter} />
25+
<Counter object={counterClass} />

packages/svelte/tests/runtime-runes/samples/proxy-prop-default-readonly/_config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ export default test({
1414
assert.htmlEqual(target.innerHTML, `<button>clicks: 0</button>`);
1515
},
1616

17-
runtime_error: 'Props cannot be mutated, unless used with `bind:`'
17+
runtime_error:
18+
'Props cannot be mutated, unless used with `bind:`. Use `bind:prop-in-question={..}` to make `count` settable. Fallback values can never be mutated.'
1819
});

packages/svelte/tests/runtime-runes/samples/proxy-prop-readonly/_config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ export default test({
1414
assert.htmlEqual(target.innerHTML, `<button>clicks: 0</button>`);
1515
},
1616

17-
runtime_error: 'Props cannot be mutated, unless used with `bind:`'
17+
runtime_error:
18+
'Props cannot be mutated, unless used with `bind:`. Use `bind:prop-in-question={..}` to make `count` settable. Fallback values can never be mutated.'
1819
});

0 commit comments

Comments
 (0)