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

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

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

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

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

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

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

@ -1,9 +1,14 @@
import { test } from '../../test'; import { test } from '../../test';
import { log } from './log.js';
export default test({ export default test({
html: `<button>0</button>`, html: `<button>0</button>`,
async test({ assert, target, component }) { before_test() {
log.length = 0;
},
async test({ assert, target }) {
const btn = target.querySelector('button'); const btn = target.querySelector('button');
await btn?.click(); await btn?.click();
@ -12,6 +17,6 @@ export default test({
await btn?.click(); await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>2</button>`); 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> <script>
const {log = []} = $props(); import { log } from './log.js';
class Counter { class Counter {
count = $state(100); count = $state(100);

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

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