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 <rich.harris@vercel.com>
pull/9771/head
Rich Harris 1 year ago committed by GitHub
parent bd8f7db754
commit 62c9292947
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: make fallback prop values readonly

@ -44,7 +44,7 @@ export function readonly(value) {
/** @returns {never} */
const readonly_error = () => {
throw new Error(`Props are read-only, unless used with \`bind:\``);
throw new Error(`Props cannot be mutated, unless used with \`bind:\``);
};
/** @type {ProxyHandler<StateObject<any>>} */

@ -3,6 +3,7 @@ import { subscribe_to_store } from '../../store/utils.js';
import { EMPTY_FUNC, run_all } from '../common.js';
import { get_descriptor, get_descriptors, is_array } from './utils.js';
import { PROPS_CALL_DEFAULT_VALUE, PROPS_IS_IMMUTABLE, PROPS_IS_RUNES } from '../../constants.js';
import { readonly } from './proxy/readonly.js';
export const SOURCE = 1;
export const DERIVED = 1 << 1;
@ -1422,13 +1423,14 @@ export function is_store(val) {
export function prop_source(props_obj, key, flags, default_value) {
const call_default_value = (flags & PROPS_CALL_DEFAULT_VALUE) !== 0;
const immutable = (flags & PROPS_IS_IMMUTABLE) !== 0;
const runes = (flags & PROPS_IS_RUNES) !== 0;
const props = is_signal(props_obj) ? get(props_obj) : props_obj;
const update_bound_prop = get_descriptor(props, key)?.set;
let value = props[key];
const should_set_default_value = value === undefined && default_value !== undefined;
if (update_bound_prop && default_value !== undefined && (flags & PROPS_IS_RUNES) !== 0) {
if (update_bound_prop && runes && default_value !== undefined) {
// TODO consolidate all these random runtime errors
throw new Error('Cannot use fallback values with bind:');
}
@ -1437,6 +1439,10 @@ export function prop_source(props_obj, key, flags, default_value) {
value =
// @ts-expect-error would need a cumbersome method overload to type this
call_default_value ? default_value() : default_value;
if (DEV && runes) {
value = readonly(/** @type {any} */ (value));
}
}
const source_signal = immutable ? source(value) : mutable_source(value);

@ -1,36 +1,41 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
import { log } from './log.js';
export default test({
// The component context class instance gets shared between tests, strangely, causing hydration to fail?
skip_if_hydrate: 'permanent',
async test({ assert, target, component }) {
before_test() {
log.length = 0;
},
async test({ assert, target }) {
const btn = target.querySelector('button');
flushSync(() => {
btn?.click();
});
assert.deepEqual(component.log, [0, 'class trigger false', 'local trigger false', 1]);
assert.deepEqual(log, [0, 'class trigger false', 'local trigger false', 1]);
flushSync(() => {
btn?.click();
});
assert.deepEqual(component.log, [0, 'class trigger false', 'local trigger false', 1, 2]);
assert.deepEqual(log, [0, 'class trigger false', 'local trigger false', 1, 2]);
flushSync(() => {
btn?.click();
});
assert.deepEqual(component.log, [0, 'class trigger false', 'local trigger false', 1, 2, 3]);
assert.deepEqual(log, [0, 'class trigger false', 'local trigger false', 1, 2, 3]);
flushSync(() => {
btn?.click();
});
assert.deepEqual(component.log, [
assert.deepEqual(log, [
0,
'class trigger false',
'local trigger false',

@ -1,17 +1,17 @@
<script context="module">
class SomeLogic {
someValue = $state(0);
isAboveThree = $derived(this.someValue > 3);
trigger() {
this.someValue++;
}
class SomeLogic {
someValue = $state(0);
isAboveThree = $derived(this.someValue > 3);
trigger() {
this.someValue++;
}
}
const someLogic = new SomeLogic();
</script>
<script>
const {log = []} = $props();
import { log } from './log.js';
function increment() {
someLogic.trigger();

@ -1,9 +1,14 @@
import { test } from '../../test';
import { log } from './log.js';
export default test({
html: `<button>0</button>`,
async test({ assert, target, component }) {
before_test() {
log.length = 0;
},
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
@ -12,6 +17,6 @@ export default test({
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>2</button>`);
assert.deepEqual(component.log, [undefined]);
assert.deepEqual(log, [undefined]);
}
});

@ -1,5 +1,5 @@
<script>
const {log = []} = $props();
import { log } from './log.js';
const logger = (obj) => {
log.push(obj.count)

@ -1,9 +1,14 @@
import { test } from '../../test';
import { log } from './log.js';
export default test({
html: `<button>0</button>`,
async test({ assert, target, component }) {
before_test() {
log.length = 0;
},
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
@ -12,6 +17,6 @@ export default test({
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>2</button>`);
assert.deepEqual(component.log, [undefined]);
assert.deepEqual(log, [undefined]);
}
});

@ -1,5 +1,5 @@
<script>
const {log = []} = $props();
import { log } from './log.js';
class Counter {
count = $state();

@ -1,9 +1,14 @@
import { test } from '../../test';
import { log } from './log.js';
export default test({
html: `<button>0</button>`,
async test({ assert, target, component }) {
before_test() {
log.length = 0;
},
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
@ -12,6 +17,6 @@ export default test({
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>2</button>`);
assert.deepEqual(component.log, [100]);
assert.deepEqual(log, [100]);
}
});

@ -0,0 +1,2 @@
/** @type {any[]} */
export const log = [];

@ -1,5 +1,5 @@
<script>
const {log = []} = $props();
import { log } from './log.js';
class Counter {
count = $state(100);

@ -1,8 +1,13 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
import { log } from './log.js';
export default test({
async test({ assert, target, component }) {
before_test() {
log.length = 0;
},
async test({ assert, target }) {
const [b1] = target.querySelectorAll('button');
flushSync(() => {
@ -10,6 +15,6 @@ export default test({
});
await Promise.resolve();
assert.deepEqual(component.log, ['onclick']);
assert.deepEqual(log, ['onclick']);
}
});

@ -1,5 +1,5 @@
<script>
const {log = []} = $props();
import { log } from './log.js';
function send() {
log.push("onclick")

@ -0,0 +1,8 @@
<script>
/** @type {{ object?: { count: number }}} */
let { object = { count: 0 } } = $props();
</script>
<button onclick={() => object = { count: object.count + 1 } }>
clicks: {object.count}
</button>

@ -0,0 +1,19 @@
import { test } from '../../test';
export default test({
html: `<button>clicks: 0</button>`,
compileOptions: {
dev: true
},
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button>`);
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>clicks: 2</button>`);
}
});

@ -0,0 +1,5 @@
<script>
import Counter from './Counter.svelte';
</script>
<Counter />

@ -0,0 +1,8 @@
<script>
/** @type {{ object?: { count: number }}} */
let { object = { count: 0 } } = $props();
</script>
<button onclick={() => object.count += 1}>
clicks: {object.count}
</button>

@ -0,0 +1,18 @@
import { test } from '../../test';
export default test({
html: `<button>clicks: 0</button>`,
compileOptions: {
dev: true
},
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>clicks: 0</button>`);
},
runtime_error: 'Props cannot be mutated, unless used with `bind:`'
});

@ -0,0 +1,5 @@
<script>
import Counter from './Counter.svelte';
</script>
<Counter />

@ -14,5 +14,5 @@ export default test({
assert.htmlEqual(target.innerHTML, `<button>clicks: 0</button>`);
},
runtime_error: 'Props are read-only, unless used with `bind:`'
runtime_error: 'Props cannot be mutated, unless used with `bind:`'
});

Loading…
Cancel
Save