Skip to content

Commit 8613a6f

Browse files
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 <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent f1550b2 commit 8613a6f

File tree

39 files changed

+554
-228
lines changed

39 files changed

+554
-228
lines changed

.changeset/rich-olives-yell.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: replace proxy-based readonly validation with stack-trace-based ownership tracking

packages/svelte/src/compiler/phases/3-transform/client/transform-client.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,11 @@ export function client_component(source, analysis, options) {
258258
}
259259
}
260260

261+
const push_args = [b.id('$$props'), b.literal(analysis.runes)];
262+
if (options.dev) push_args.push(b.id(analysis.name));
263+
261264
const component_block = b.block([
262-
b.stmt(b.call('$.push', b.id('$$props'), b.literal(analysis.runes))),
265+
b.stmt(b.call('$.push', ...push_args)),
263266
...store_setup,
264267
...legacy_reactive_declarations,
265268
...group_binding_declarations,
@@ -339,6 +342,27 @@ export function client_component(source, analysis, options) {
339342
)
340343
];
341344

345+
if (options.dev) {
346+
if (options.filename) {
347+
let filename = options.filename;
348+
if (/(\/|\w:)/.test(options.filename)) {
349+
// filename is absolute — truncate it
350+
const parts = filename.split(/[/\\]/);
351+
filename = parts.length > 3 ? ['...', ...parts.slice(-3)].join('/') : filename;
352+
}
353+
354+
// add `App.filename = 'App.svelte'` so that we can print useful messages later
355+
body.push(
356+
b.stmt(
357+
b.assignment('=', b.member(b.id(analysis.name), b.id('filename')), b.literal(filename))
358+
)
359+
);
360+
}
361+
362+
body.unshift(b.stmt(b.call(b.id('$.mark_module_start'), b.id(analysis.name))));
363+
body.push(b.stmt(b.call(b.id('$.mark_module_end'))));
364+
}
365+
342366
if (options.discloseVersion) {
343367
body.unshift(b.imports([], 'svelte/internal/disclose-version'));
344368
}

packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,8 @@ function serialize_inline_component(node, component_name, context) {
764764
/** @type {import('estree').Identifier | import('estree').MemberExpression | null} */
765765
let bind_this = null;
766766

767+
const binding_initializers = [];
768+
767769
/**
768770
* If this component has a slot property, it is a named slot within another component. In this case
769771
* the slot scope applies to the component itself, too, and not just its children.
@@ -843,8 +845,6 @@ function serialize_inline_component(node, component_name, context) {
843845
arg = b.call('$.get', id);
844846
}
845847

846-
if (context.state.options.dev) arg = b.call('$.readonly', arg);
847-
848848
push_prop(b.get(attribute.name, [b.return(arg)]));
849849
} else {
850850
push_prop(b.init(attribute.name, value));
@@ -853,14 +853,23 @@ function serialize_inline_component(node, component_name, context) {
853853
if (attribute.name === 'this') {
854854
bind_this = attribute.expression;
855855
} else {
856-
push_prop(
857-
b.get(attribute.name, [
858-
b.return(
859-
/** @type {import('estree').Expression} */ (context.visit(attribute.expression))
860-
)
861-
])
856+
const expression = /** @type {import('estree').Expression} */ (
857+
context.visit(attribute.expression)
862858
);
863859

860+
if (context.state.options.dev) {
861+
binding_initializers.push(
862+
b.stmt(
863+
b.call(
864+
b.id('$.pre_effect'),
865+
b.thunk(b.call(b.id('$.add_owner'), expression, b.id(component_name)))
866+
)
867+
)
868+
);
869+
}
870+
871+
push_prop(b.get(attribute.name, [b.return(expression)]));
872+
864873
const assignment = b.assignment('=', attribute.expression, b.id('$$value'));
865874
push_prop(
866875
b.set(attribute.name, [
@@ -1004,14 +1013,13 @@ function serialize_inline_component(node, component_name, context) {
10041013
);
10051014
}
10061015

1007-
/** @type {import('estree').Statement} */
1008-
let statement = b.stmt(fn(context.state.node));
1009-
1010-
if (snippet_declarations.length > 0) {
1011-
statement = b.block([...snippet_declarations, statement]);
1012-
}
1016+
const statements = [
1017+
...snippet_declarations,
1018+
...binding_initializers,
1019+
b.stmt(fn(context.state.node))
1020+
];
10131021

1014-
return statement;
1022+
return statements.length > 1 ? b.block(statements) : statements[0];
10151023
}
10161024

10171025
/**
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/** @typedef {{ file: string, line: number, column: number }} Location */
2+
3+
import { STATE_SYMBOL } from '../proxy.js';
4+
import { untrack } from '../runtime.js';
5+
6+
/** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */
7+
const boundaries = {};
8+
9+
const chrome_pattern = /at (?:.+ \()?(.+):(\d+):(\d+)\)?$/;
10+
const firefox_pattern = /@(.+):(\d+):(\d+)$/;
11+
12+
export function get_stack() {
13+
const stack = new Error().stack;
14+
if (!stack) return null;
15+
16+
const entries = [];
17+
18+
for (const line of stack.split('\n')) {
19+
let match = chrome_pattern.exec(line) ?? firefox_pattern.exec(line);
20+
21+
if (match) {
22+
entries.push({
23+
file: match[1],
24+
line: +match[2],
25+
column: +match[3]
26+
});
27+
}
28+
}
29+
30+
return entries;
31+
}
32+
33+
/**
34+
* Determines which `.svelte` component is responsible for a given state change
35+
* @returns {Function | null}
36+
*/
37+
export function get_component() {
38+
const stack = get_stack();
39+
if (!stack) return null;
40+
41+
for (const entry of stack) {
42+
const modules = boundaries[entry.file];
43+
if (!modules) continue;
44+
45+
for (const module of modules) {
46+
if (module.start.line < entry.line && module.end.line > entry.line) {
47+
return module.component;
48+
}
49+
}
50+
}
51+
52+
return null;
53+
}
54+
55+
/**
56+
* Together with `mark_module_end`, this function establishes the boundaries of a `.svelte` file,
57+
* such that subsequent calls to `get_component` can tell us which component is responsible
58+
* for a given state change
59+
* @param {Function} component
60+
*/
61+
export function mark_module_start(component) {
62+
const start = get_stack()?.[2];
63+
64+
if (start) {
65+
(boundaries[start.file] ??= []).push({
66+
start,
67+
// @ts-expect-error
68+
end: null,
69+
component
70+
});
71+
}
72+
}
73+
74+
export function mark_module_end() {
75+
const end = get_stack()?.[2];
76+
77+
if (end) {
78+
// @ts-expect-error
79+
boundaries[end.file].at(-1).end = end;
80+
}
81+
}
82+
83+
/**
84+
*
85+
* @param {any} object
86+
* @param {any} owner
87+
*/
88+
export function add_owner(object, owner) {
89+
untrack(() => {
90+
add_owner_to_object(object, owner);
91+
});
92+
}
93+
94+
/**
95+
* @param {any} object
96+
* @param {Function} owner
97+
*/
98+
function add_owner_to_object(object, owner) {
99+
if (object?.[STATE_SYMBOL]?.o && !object[STATE_SYMBOL].o.has(owner)) {
100+
object[STATE_SYMBOL].o.add(owner);
101+
102+
for (const key in object) {
103+
add_owner_to_object(object[key], owner);
104+
}
105+
}
106+
}
107+
108+
/**
109+
* @param {any} object
110+
*/
111+
export function strip_owner(object) {
112+
untrack(() => {
113+
strip_owner_from_object(object);
114+
});
115+
}
116+
117+
/**
118+
* @param {any} object
119+
*/
120+
function strip_owner_from_object(object) {
121+
if (object?.[STATE_SYMBOL]?.o) {
122+
object[STATE_SYMBOL].o = null;
123+
124+
for (const key in object) {
125+
strip_owner(object[key]);
126+
}
127+
}
128+
}
129+
130+
/**
131+
* @param {Set<Function>} owners
132+
*/
133+
export function check_ownership(owners) {
134+
const component = get_component();
135+
136+
if (component && !owners.has(component)) {
137+
let original = [...owners][0];
138+
139+
let message =
140+
// @ts-expect-error
141+
original.filename !== component.filename
142+
? // @ts-expect-error
143+
`${component.filename} mutated a value owned by ${original.filename}. This is strongly discouraged`
144+
: 'Mutating a value outside the component that created it is strongly discouraged';
145+
146+
// eslint-disable-next-line no-console
147+
console.warn(
148+
`${message}. Consider passing values to child components with \`bind:\`, or use a callback instead.`
149+
);
150+
151+
// eslint-disable-next-line no-console
152+
console.trace();
153+
}
154+
}

0 commit comments

Comments
 (0)