mirror of https://github.com/sveltejs/svelte
fix: stack-trace-based readonly validation (#10464)
* fix: remove readonly validation The readonly dev time validation results in false-negative object equality checks: The original object is proxified and thus comparisons could be between the unproxified and proxified version, which will always return false. Fixes #10372 There's also the problem that an object could be passed into a component but then passed upwards into shared state. At that point, it should no longer be readonly, but it's not possible to statically analyze these points. Fixes #10372 Lastly, the each block logic mutates an internal array and that also throws errors with the readonly logic. Fixes #10037 * reinstate tests * track ownership of state and mutations * working? * remove old changeset * tidy * error * simplify * fix * fix * fix * tidy * make it a warning * rename test * remove unused test * update tests * slap ts-expect-error everywhere, because its too finicky otherwise * oops * oh no the hall monitor is here * only call add_owner in dev * only owners can transfer ownership * simplify * fixes * tidy up * fix type error * while we're at it * rename file * rename functions * add some comments * move ownership checking logic * ugh eslint * more detailed message * only add filename in dev * comment * update tests * move more code * undo change to sourcemap tests * allow proxy to have multiple owners * use SignalDebug * i was doing this all wrong * tidy up * implement inheritance * fix * tidy up * update filename stuff * changeset --------- Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com> Co-authored-by: Rich Harris <rich.harris@vercel.com>pull/10568/head
parent
f1550b2ea3
commit
8613a6f28a
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: replace proxy-based readonly validation with stack-trace-based ownership tracking
|
@ -0,0 +1,154 @@
|
||||
/** @typedef {{ file: string, line: number, column: number }} Location */
|
||||
|
||||
import { STATE_SYMBOL } from '../proxy.js';
|
||||
import { untrack } from '../runtime.js';
|
||||
|
||||
/** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */
|
||||
const boundaries = {};
|
||||
|
||||
const chrome_pattern = /at (?:.+ \()?(.+):(\d+):(\d+)\)?$/;
|
||||
const firefox_pattern = /@(.+):(\d+):(\d+)$/;
|
||||
|
||||
export function get_stack() {
|
||||
const stack = new Error().stack;
|
||||
if (!stack) return null;
|
||||
|
||||
const entries = [];
|
||||
|
||||
for (const line of stack.split('\n')) {
|
||||
let match = chrome_pattern.exec(line) ?? firefox_pattern.exec(line);
|
||||
|
||||
if (match) {
|
||||
entries.push({
|
||||
file: match[1],
|
||||
line: +match[2],
|
||||
column: +match[3]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which `.svelte` component is responsible for a given state change
|
||||
* @returns {Function | null}
|
||||
*/
|
||||
export function get_component() {
|
||||
const stack = get_stack();
|
||||
if (!stack) return null;
|
||||
|
||||
for (const entry of stack) {
|
||||
const modules = boundaries[entry.file];
|
||||
if (!modules) continue;
|
||||
|
||||
for (const module of modules) {
|
||||
if (module.start.line < entry.line && module.end.line > entry.line) {
|
||||
return module.component;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Together with `mark_module_end`, this function establishes the boundaries of a `.svelte` file,
|
||||
* such that subsequent calls to `get_component` can tell us which component is responsible
|
||||
* for a given state change
|
||||
* @param {Function} component
|
||||
*/
|
||||
export function mark_module_start(component) {
|
||||
const start = get_stack()?.[2];
|
||||
|
||||
if (start) {
|
||||
(boundaries[start.file] ??= []).push({
|
||||
start,
|
||||
// @ts-expect-error
|
||||
end: null,
|
||||
component
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function mark_module_end() {
|
||||
const end = get_stack()?.[2];
|
||||
|
||||
if (end) {
|
||||
// @ts-expect-error
|
||||
boundaries[end.file].at(-1).end = end;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {any} object
|
||||
* @param {any} owner
|
||||
*/
|
||||
export function add_owner(object, owner) {
|
||||
untrack(() => {
|
||||
add_owner_to_object(object, owner);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} object
|
||||
* @param {Function} owner
|
||||
*/
|
||||
function add_owner_to_object(object, owner) {
|
||||
if (object?.[STATE_SYMBOL]?.o && !object[STATE_SYMBOL].o.has(owner)) {
|
||||
object[STATE_SYMBOL].o.add(owner);
|
||||
|
||||
for (const key in object) {
|
||||
add_owner_to_object(object[key], owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} object
|
||||
*/
|
||||
export function strip_owner(object) {
|
||||
untrack(() => {
|
||||
strip_owner_from_object(object);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} object
|
||||
*/
|
||||
function strip_owner_from_object(object) {
|
||||
if (object?.[STATE_SYMBOL]?.o) {
|
||||
object[STATE_SYMBOL].o = null;
|
||||
|
||||
for (const key in object) {
|
||||
strip_owner(object[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set<Function>} owners
|
||||
*/
|
||||
export function check_ownership(owners) {
|
||||
const component = get_component();
|
||||
|
||||
if (component && !owners.has(component)) {
|
||||
let original = [...owners][0];
|
||||
|
||||
let message =
|
||||
// @ts-expect-error
|
||||
original.filename !== component.filename
|
||||
? // @ts-expect-error
|
||||
`${component.filename} mutated a value owned by ${original.filename}. This is strongly discouraged`
|
||||
: 'Mutating a value outside the component that created it is strongly discouraged';
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`${message}. Consider passing values to child components with \`bind:\`, or use a callback instead.`
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.trace();
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
import { test } from '../../test';
|
||||
|
||||
/** @type {typeof console.warn} */
|
||||
let warn;
|
||||
|
||||
/** @type {typeof console.trace} */
|
||||
let trace;
|
||||
|
||||
/** @type {any[]} */
|
||||
let warnings = [];
|
||||
|
||||
export default test({
|
||||
html: `<button>clicks: 0</button>`,
|
||||
|
||||
compileOptions: {
|
||||
dev: true
|
||||
},
|
||||
|
||||
before_test: () => {
|
||||
warn = console.warn;
|
||||
trace = console.trace;
|
||||
|
||||
console.warn = (...args) => {
|
||||
warnings.push(...args);
|
||||
};
|
||||
|
||||
console.trace = () => {};
|
||||
},
|
||||
|
||||
after_test: () => {
|
||||
console.warn = warn;
|
||||
console.trace = trace;
|
||||
|
||||
warnings = [];
|
||||
},
|
||||
|
||||
async test({ assert, target }) {
|
||||
const btn = target.querySelector('button');
|
||||
await btn?.click();
|
||||
|
||||
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button>`);
|
||||
|
||||
assert.deepEqual(warnings, [
|
||||
'.../samples/non-local-mutation-discouraged/Counter.svelte mutated a value owned by .../samples/non-local-mutation-discouraged/main.svelte. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead.'
|
||||
]);
|
||||
}
|
||||
});
|
@ -0,0 +1,7 @@
|
||||
<script>
|
||||
import { global } from './state.svelte.js';
|
||||
</script>
|
||||
|
||||
<button onclick={() => global.object.count += 1}>
|
||||
clicks: {global.object.count}
|
||||
</button>
|
@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import Counter from './Counter.svelte';
|
||||
import { global } from './state.svelte.js';
|
||||
|
||||
let object = $state({ count: 0 });
|
||||
global.object = object;
|
||||
</script>
|
||||
|
||||
<Counter />
|
@ -0,0 +1,3 @@
|
||||
export let global = $state({
|
||||
object: { count: -1 }
|
||||
});
|
@ -0,0 +1,37 @@
|
||||
import { test } from '../../test';
|
||||
|
||||
/** @type {typeof console.warn} */
|
||||
let warn;
|
||||
|
||||
/** @type {any[]} */
|
||||
let warnings = [];
|
||||
|
||||
export default test({
|
||||
html: `<button>clicks: 0</button>`,
|
||||
|
||||
compileOptions: {
|
||||
dev: true
|
||||
},
|
||||
|
||||
before_test: () => {
|
||||
warn = console.warn;
|
||||
|
||||
console.warn = (...args) => {
|
||||
warnings.push(...args);
|
||||
};
|
||||
},
|
||||
|
||||
after_test: () => {
|
||||
console.warn = warn;
|
||||
warnings = [];
|
||||
},
|
||||
|
||||
async test({ assert, target }) {
|
||||
const btn = target.querySelector('button');
|
||||
await btn?.click();
|
||||
|
||||
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button>`);
|
||||
|
||||
assert.deepEqual(warnings, []);
|
||||
}
|
||||
});
|
@ -0,0 +1,7 @@
|
||||
<script>
|
||||
import Counter from './Counter.svelte';
|
||||
|
||||
let object = $state({ count: 0 });
|
||||
</script>
|
||||
|
||||
<Counter bind:object={object} />
|
@ -0,0 +1,15 @@
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
html: `<button>add</button> <p>1</p><p>1</p><p>1</p>`,
|
||||
|
||||
async test({ assert, target }) {
|
||||
const btn = target.querySelector('button');
|
||||
|
||||
await btn?.click();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`<button>add</button> <p>1</p><p>2</p><p>1</p><p>2</p><p>1</p><p>2</p>`
|
||||
);
|
||||
}
|
||||
});
|
@ -0,0 +1,15 @@
|
||||
<script>
|
||||
let { array } = $props();
|
||||
</script>
|
||||
|
||||
{#each array as number}
|
||||
<p>{number.v}</p>
|
||||
{/each}
|
||||
|
||||
{#each array as number (number)}
|
||||
<p>{number.v}</p>
|
||||
{/each}
|
||||
|
||||
{#each array as number (number.v)}
|
||||
<p>{number.v}</p>
|
||||
{/each}
|
@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import Child from './child.svelte';
|
||||
|
||||
let array = $state([{v: 1}]);
|
||||
|
||||
const addNew = () => {
|
||||
array.push({v: 2})
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={addNew}>add</button>
|
||||
<Child {array} />
|
@ -0,0 +1,30 @@
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
html: `
|
||||
<button>mutate: 0</button>
|
||||
<button>reassign: 0</button>
|
||||
`,
|
||||
|
||||
async test({ assert, target }) {
|
||||
const [btn1, btn2] = target.querySelectorAll('button');
|
||||
|
||||
await btn1?.click();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>mutate: 0</button>
|
||||
<button>reassign: 0</button>
|
||||
`
|
||||
);
|
||||
|
||||
await btn2?.click();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>mutate: 2</button>
|
||||
<button>reassign: 2</button>
|
||||
`
|
||||
);
|
||||
}
|
||||
});
|
@ -0,0 +1,45 @@
|
||||
import { test } from '../../test';
|
||||
|
||||
export default test({
|
||||
html: `
|
||||
<button>a true</button><button>b true</button>
|
||||
<button>a true</button><button>b true</button>
|
||||
<button>a true</button><button>b true</button>
|
||||
`,
|
||||
|
||||
async test({ assert, target }) {
|
||||
let [btn1, _btn2, btn3, _btn4, btn5] = target.querySelectorAll('button');
|
||||
|
||||
await btn1.click();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>a+ true</button><button>b true</button>
|
||||
<button>a+ true</button><button>b true</button>
|
||||
<button>a+ true</button><button>b true</button>
|
||||
`
|
||||
);
|
||||
|
||||
[btn1, _btn2, btn3, _btn4, btn5] = target.querySelectorAll('button');
|
||||
await btn3.click();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>a++ true</button><button>b true</button>
|
||||
<button>a++ true</button><button>b true</button>
|
||||
<button>a++ true</button><button>b true</button>
|
||||
`
|
||||
);
|
||||
|
||||
[btn1, _btn2, btn3, _btn4, btn5] = target.querySelectorAll('button');
|
||||
await btn5.click();
|
||||
assert.htmlEqual(
|
||||
target.innerHTML,
|
||||
`
|
||||
<button>a+++ true</button><button>b true</button>
|
||||
<button>a+++ true</button><button>b true</button>
|
||||
<button>a+++ true</button><button>b true</button>
|
||||
`
|
||||
);
|
||||
}
|
||||
});
|
@ -0,0 +1,7 @@
|
||||
<script>
|
||||
let {item, items, onclick} = $props()
|
||||
</script>
|
||||
|
||||
<button {onclick}>
|
||||
{item.name} {items.includes(item)}
|
||||
</button>
|
@ -0,0 +1,19 @@
|
||||
<script>
|
||||
import Item from './item.svelte'
|
||||
|
||||
let items = $state([{name: 'a'}, {name: 'b'}]);
|
||||
</script>
|
||||
|
||||
<!-- test that each block doesn't mess with item identity -->
|
||||
|
||||
{#each items as item (item)}
|
||||
<Item {item} {items} onclick={() => item.name = item.name + '+'} />
|
||||
{/each}
|
||||
|
||||
{#each items as item (item.name)}
|
||||
<Item {item} {items} onclick={() => {console.log('hello'); item.name = item.name + '+'}} />
|
||||
{/each}
|
||||
|
||||
{#each items as item}
|
||||
<Item {item} {items} onclick={() => item.name = item.name + '+'} />
|
||||
{/each}
|
@ -1,22 +0,0 @@
|
||||
import { test } from '../../test';
|
||||
|
||||
// Tests that readonly bails on setters/classes
|
||||
export default test({
|
||||
html: `<button>clicks: 0</button><button>clicks: 0</button>`,
|
||||
|
||||
compileOptions: {
|
||||
dev: true
|
||||
},
|
||||
|
||||
async test({ assert, target }) {
|
||||
const [btn1, btn2] = target.querySelectorAll('button');
|
||||
|
||||
await btn1.click();
|
||||
await btn2.click();
|
||||
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button><button>clicks: 1</button>`);
|
||||
|
||||
await btn1.click();
|
||||
await btn2.click();
|
||||
assert.htmlEqual(target.innerHTML, `<button>clicks: 2</button><button>clicks: 2</button>`);
|
||||
}
|
||||
});
|
@ -1,25 +0,0 @@
|
||||
<script>
|
||||
import Counter from './Counter.svelte';
|
||||
|
||||
function createCounter() {
|
||||
let count = $state(0)
|
||||
return {
|
||||
get count() {
|
||||
return count;
|
||||
},
|
||||
set count(upd) {
|
||||
count = upd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CounterClass {
|
||||
count = $state(0);
|
||||
}
|
||||
|
||||
const counterSetter = createCounter();
|
||||
const counterClass = new CounterClass();
|
||||
</script>
|
||||
|
||||
<Counter object={counterSetter} />
|
||||
<Counter object={counterClass} />
|
@ -1,8 +0,0 @@
|
||||
<script>
|
||||
/** @type {{ object?: { count: number }}} */
|
||||
let { object = { count: 0 } } = $props();
|
||||
</script>
|
||||
|
||||
<button onclick={() => object.count += 1}>
|
||||
clicks: {object.count}
|
||||
</button>
|
@ -1,19 +0,0 @@
|
||||
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:
|
||||
'Non-bound props cannot be mutated — to make the `count` settable, ensure the object it is used within is bound as a prop `bind:<prop>={...}`. Fallback values can never be mutated.'
|
||||
});
|
@ -1,5 +0,0 @@
|
||||
<script>
|
||||
import Counter from './Counter.svelte';
|
||||
</script>
|
||||
|
||||
<Counter />
|
@ -1,19 +0,0 @@
|
||||
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:
|
||||
'Non-bound props cannot be mutated — to make the `count` settable, ensure the object it is used within is bound as a prop `bind:<prop>={...}`. Fallback values can never be mutated.'
|
||||
});
|
Loading…
Reference in new issue