Skip to content

Commit 62c9292

Browse files
authored
feat: make fallback prop values readonly (#9789)
* WIP * update tests * only make readonly in runes mode * remove this for now * changeset * ugh * add reassignment test * tweak message --------- Co-authored-by: Rich Harris <[email protected]>
1 parent bd8f7db commit 62c9292

File tree

25 files changed

+136
-27
lines changed

25 files changed

+136
-27
lines changed

.changeset/lazy-months-knock.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: make fallback prop values readonly

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function readonly(value) {
4444

4545
/** @returns {never} */
4646
const readonly_error = () => {
47-
throw new Error(`Props are read-only, unless used with \`bind:\``);
47+
throw new Error(`Props cannot be mutated, unless used with \`bind:\``);
4848
};
4949

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

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { subscribe_to_store } from '../../store/utils.js';
33
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';
6+
import { readonly } from './proxy/readonly.js';
67

78
export const SOURCE = 1;
89
export const DERIVED = 1 << 1;
@@ -1422,13 +1423,14 @@ export function is_store(val) {
14221423
export function prop_source(props_obj, key, flags, default_value) {
14231424
const call_default_value = (flags & PROPS_CALL_DEFAULT_VALUE) !== 0;
14241425
const immutable = (flags & PROPS_IS_IMMUTABLE) !== 0;
1426+
const runes = (flags & PROPS_IS_RUNES) !== 0;
14251427

14261428
const props = is_signal(props_obj) ? get(props_obj) : props_obj;
14271429
const update_bound_prop = get_descriptor(props, key)?.set;
14281430
let value = props[key];
14291431
const should_set_default_value = value === undefined && default_value !== undefined;
14301432

1431-
if (update_bound_prop && default_value !== undefined && (flags & PROPS_IS_RUNES) !== 0) {
1433+
if (update_bound_prop && runes && default_value !== undefined) {
14321434
// TODO consolidate all these random runtime errors
14331435
throw new Error('Cannot use fallback values with bind:');
14341436
}
@@ -1437,6 +1439,10 @@ export function prop_source(props_obj, key, flags, default_value) {
14371439
value =
14381440
// @ts-expect-error would need a cumbersome method overload to type this
14391441
call_default_value ? default_value() : default_value;
1442+
1443+
if (DEV && runes) {
1444+
value = readonly(/** @type {any} */ (value));
1445+
}
14401446
}
14411447

14421448
const source_signal = immutable ? source(value) : mutable_source(value);

packages/svelte/tests/runtime-runes/samples/class-state-derived-unowned/_config.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,41 @@
11
import { flushSync } from 'svelte';
22
import { test } from '../../test';
3+
import { log } from './log.js';
34

45
export default test({
56
// The component context class instance gets shared between tests, strangely, causing hydration to fail?
67
skip_if_hydrate: 'permanent',
78

8-
async test({ assert, target, component }) {
9+
before_test() {
10+
log.length = 0;
11+
},
12+
13+
async test({ assert, target }) {
914
const btn = target.querySelector('button');
1015

1116
flushSync(() => {
1217
btn?.click();
1318
});
1419

15-
assert.deepEqual(component.log, [0, 'class trigger false', 'local trigger false', 1]);
20+
assert.deepEqual(log, [0, 'class trigger false', 'local trigger false', 1]);
1621

1722
flushSync(() => {
1823
btn?.click();
1924
});
2025

21-
assert.deepEqual(component.log, [0, 'class trigger false', 'local trigger false', 1, 2]);
26+
assert.deepEqual(log, [0, 'class trigger false', 'local trigger false', 1, 2]);
2227

2328
flushSync(() => {
2429
btn?.click();
2530
});
2631

27-
assert.deepEqual(component.log, [0, 'class trigger false', 'local trigger false', 1, 2, 3]);
32+
assert.deepEqual(log, [0, 'class trigger false', 'local trigger false', 1, 2, 3]);
2833

2934
flushSync(() => {
3035
btn?.click();
3136
});
3237

33-
assert.deepEqual(component.log, [
38+
assert.deepEqual(log, [
3439
0,
3540
'class trigger false',
3641
'local trigger false',
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @type {any[]} */
2+
export const log = [];

packages/svelte/tests/runtime-runes/samples/class-state-derived-unowned/main.svelte

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
<script context="module">
2-
class SomeLogic {
3-
someValue = $state(0);
4-
isAboveThree = $derived(this.someValue > 3);
5-
trigger() {
6-
this.someValue++;
7-
}
2+
class SomeLogic {
3+
someValue = $state(0);
4+
isAboveThree = $derived(this.someValue > 3);
5+
trigger() {
6+
this.someValue++;
87
}
8+
}
99
1010
const someLogic = new SomeLogic();
1111
</script>
1212

1313
<script>
14-
const {log = []} = $props();
14+
import { log } from './log.js';
1515
1616
function increment() {
1717
someLogic.trigger();
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { test } from '../../test';
2+
import { log } from './log.js';
23

34
export default test({
45
html: `<button>0</button>`,
56

6-
async test({ assert, target, component }) {
7+
before_test() {
8+
log.length = 0;
9+
},
10+
11+
async test({ assert, target }) {
712
const btn = target.querySelector('button');
813

914
await btn?.click();
@@ -12,6 +17,6 @@ export default test({
1217
await btn?.click();
1318
assert.htmlEqual(target.innerHTML, `<button>2</button>`);
1419

15-
assert.deepEqual(component.log, [undefined]);
20+
assert.deepEqual(log, [undefined]);
1621
}
1722
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @type {any[]} */
2+
export const log = [];

packages/svelte/tests/runtime-runes/samples/class-state-init-eager-2/main.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script>
2-
const {log = []} = $props();
2+
import { log } from './log.js';
33
44
const logger = (obj) => {
55
log.push(obj.count)
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { test } from '../../test';
2+
import { log } from './log.js';
23

34
export default test({
45
html: `<button>0</button>`,
56

6-
async test({ assert, target, component }) {
7+
before_test() {
8+
log.length = 0;
9+
},
10+
11+
async test({ assert, target }) {
712
const btn = target.querySelector('button');
813

914
await btn?.click();
@@ -12,6 +17,6 @@ export default test({
1217
await btn?.click();
1318
assert.htmlEqual(target.innerHTML, `<button>2</button>`);
1419

15-
assert.deepEqual(component.log, [undefined]);
20+
assert.deepEqual(log, [undefined]);
1621
}
1722
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @type {any[]} */
2+
export const log = [];

packages/svelte/tests/runtime-runes/samples/class-state-init-eager-3/main.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script>
2-
const {log = []} = $props();
2+
import { log } from './log.js';
33
44
class Counter {
55
count = $state();
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { test } from '../../test';
2+
import { log } from './log.js';
23

34
export default test({
45
html: `<button>0</button>`,
56

6-
async test({ assert, target, component }) {
7+
before_test() {
8+
log.length = 0;
9+
},
10+
11+
async test({ assert, target }) {
712
const btn = target.querySelector('button');
813

914
await btn?.click();
@@ -12,6 +17,6 @@ export default test({
1217
await btn?.click();
1318
assert.htmlEqual(target.innerHTML, `<button>2</button>`);
1419

15-
assert.deepEqual(component.log, [100]);
20+
assert.deepEqual(log, [100]);
1621
}
1722
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @type {any[]} */
2+
export const log = [];

packages/svelte/tests/runtime-runes/samples/class-state-init-eager/main.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script>
2-
const {log = []} = $props();
2+
import { log } from './log.js';
33
44
class Counter {
55
count = $state(100);
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import { flushSync } from 'svelte';
22
import { test } from '../../test';
3+
import { log } from './log.js';
34

45
export default test({
5-
async test({ assert, target, component }) {
6+
before_test() {
7+
log.length = 0;
8+
},
9+
10+
async test({ assert, target }) {
611
const [b1] = target.querySelectorAll('button');
712

813
flushSync(() => {
914
b1?.click();
1015
});
1116

1217
await Promise.resolve();
13-
assert.deepEqual(component.log, ['onclick']);
18+
assert.deepEqual(log, ['onclick']);
1419
}
1520
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @type {any[]} */
2+
export const log = [];

packages/svelte/tests/runtime-runes/samples/event-attribute-template/main.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script>
2-
const {log = []} = $props();
2+
import { log } from './log.js';
33
44
function send() {
55
log.push("onclick")
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 = { count: 0 } } = $props();
4+
</script>
5+
6+
<button onclick={() => object = { count: object.count + 1 } }>
7+
clicks: {object.count}
8+
</button>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
html: `<button>clicks: 0</button>`,
5+
6+
compileOptions: {
7+
dev: true
8+
},
9+
10+
async test({ assert, target }) {
11+
const btn = target.querySelector('button');
12+
13+
await btn?.click();
14+
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button>`);
15+
16+
await btn?.click();
17+
assert.htmlEqual(target.innerHTML, `<button>clicks: 2</button>`);
18+
}
19+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
import Counter from './Counter.svelte';
3+
</script>
4+
5+
<Counter />
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 = { count: 0 } } = $props();
4+
</script>
5+
6+
<button onclick={() => object.count += 1}>
7+
clicks: {object.count}
8+
</button>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
html: `<button>clicks: 0</button>`,
5+
6+
compileOptions: {
7+
dev: true
8+
},
9+
10+
async test({ assert, target }) {
11+
const btn = target.querySelector('button');
12+
await btn?.click();
13+
14+
assert.htmlEqual(target.innerHTML, `<button>clicks: 0</button>`);
15+
},
16+
17+
runtime_error: 'Props cannot be mutated, unless used with `bind:`'
18+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
import Counter from './Counter.svelte';
3+
</script>
4+
5+
<Counter />

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

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

17-
runtime_error: 'Props are read-only, unless used with `bind:`'
17+
runtime_error: 'Props cannot be mutated, unless used with `bind:`'
1818
});

0 commit comments

Comments
 (0)