Skip to content

Commit 8fef412

Browse files
Rich-Harrisdummdidummtrueadm
authored
feat: use state proxy ancestry for ownership validation (#11184)
* rename metadata.o to metadata.owners, tweak check_ownership implementation * track parent relationships * update * changeset * adjust test to reflect new semantics * prevent component using bind: with object it does not own * failing test * fix #11060 * add explanatory comment * don't accidentally narrow global ownership, fix has_owner method * widen ownership if assigning different state proxies to one another * don't set owners to null when parent exists * fix * only recurse into POJOs * handle cycles on context --------- Co-authored-by: Simon Holthausen <[email protected]> Co-authored-by: Dominic Gannaway <[email protected]>
1 parent 77ed790 commit 8fef412

File tree

21 files changed

+388
-66
lines changed

21 files changed

+388
-66
lines changed

.changeset/late-grapes-judge.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: use state proxy ancestry for ownership validation

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,27 @@ export const javascript_visitors_runes = {
168168
body.push(/** @type {import('estree').MethodDefinition} **/ (visit(definition, child_state)));
169169
}
170170

171+
if (state.options.dev && public_state.size > 0) {
172+
// add an `[$.ADD_OWNER]` method so that a class with state fields can widen ownership
173+
body.push(
174+
b.method(
175+
'method',
176+
b.id('$.ADD_OWNER'),
177+
[b.id('owner')],
178+
Array.from(public_state.keys()).map((name) =>
179+
b.stmt(
180+
b.call(
181+
'$.add_owner',
182+
b.call('$.get', b.member(b.this, b.private_id(name))),
183+
b.id('owner')
184+
)
185+
)
186+
),
187+
true
188+
)
189+
);
190+
}
191+
171192
return { ...node, body };
172193
},
173194
VariableDeclaration(node, { state, visit }) {

packages/svelte/src/internal/client/dev/ownership.js

Lines changed: 103 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/** @typedef {{ file: string, line: number, column: number }} Location */
22

33
import { STATE_SYMBOL } from '../constants.js';
4-
import { untrack } from '../runtime.js';
4+
import { render_effect } from '../reactivity/effects.js';
5+
import { current_component_context, untrack } from '../runtime.js';
6+
import { get_prototype_of } from '../utils.js';
57

68
/** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */
79
const boundaries = {};
@@ -63,6 +65,8 @@ function get_component() {
6365
return null;
6466
}
6567

68+
export const ADD_OWNER = Symbol('ADD_OWNER');
69+
6670
/**
6771
* Together with `mark_module_end`, this function establishes the boundaries of a `.svelte` file,
6872
* such that subsequent calls to `get_component` can tell us which component is responsible
@@ -98,60 +102,130 @@ export function mark_module_end(component) {
98102
}
99103

100104
/**
101-
*
102105
* @param {any} object
103106
* @param {any} owner
107+
* @param {boolean} [global]
104108
*/
105-
export function add_owner(object, owner) {
106-
untrack(() => {
107-
add_owner_to_object(object, owner);
108-
});
109+
export function add_owner(object, owner, global = false) {
110+
if (object && !global) {
111+
// @ts-expect-error
112+
const component = current_component_context.function;
113+
const metadata = object[STATE_SYMBOL];
114+
if (metadata && !has_owner(metadata, component)) {
115+
let original = get_owner(metadata);
116+
117+
if (owner.filename !== component.filename) {
118+
let message = `${component.filename} passed a value to ${owner.filename} with \`bind:\`, but the value is owned by ${original.filename}. Consider creating a binding between ${original.filename} and ${component.filename}`;
119+
120+
// eslint-disable-next-line no-console
121+
console.warn(message);
122+
}
123+
}
124+
}
125+
126+
add_owner_to_object(object, owner, new Set());
109127
}
110128

111129
/**
112-
* @param {any} object
113-
* @param {Function} owner
130+
* @param {import('#client').ProxyMetadata | null} from
131+
* @param {import('#client').ProxyMetadata} to
114132
*/
115-
function add_owner_to_object(object, owner) {
116-
if (object?.[STATE_SYMBOL]?.o && !object[STATE_SYMBOL].o.has(owner)) {
117-
object[STATE_SYMBOL].o.add(owner);
133+
export function widen_ownership(from, to) {
134+
if (to.owners === null) {
135+
return;
136+
}
137+
138+
while (from) {
139+
if (from.owners === null) {
140+
to.owners = null;
141+
break;
142+
}
118143

119-
for (const key in object) {
120-
add_owner_to_object(object[key], owner);
144+
for (const owner of from.owners) {
145+
to.owners.add(owner);
121146
}
147+
148+
from = from.parent;
122149
}
123150
}
124151

125152
/**
126153
* @param {any} object
154+
* @param {Function} owner
155+
* @param {Set<any>} seen
127156
*/
128-
export function strip_owner(object) {
129-
untrack(() => {
130-
strip_owner_from_object(object);
131-
});
157+
function add_owner_to_object(object, owner, seen) {
158+
const metadata = /** @type {import('#client').ProxyMetadata} */ (object?.[STATE_SYMBOL]);
159+
160+
if (metadata) {
161+
// this is a state proxy, add owner directly, if not globally shared
162+
if (metadata.owners !== null) {
163+
metadata.owners.add(owner);
164+
}
165+
} else if (object && typeof object === 'object') {
166+
if (seen.has(object)) return;
167+
seen.add(object);
168+
169+
if (object[ADD_OWNER]) {
170+
// this is a class with state fields. we put this in a render effect
171+
// so that if state is replaced (e.g. `instance.name = { first, last }`)
172+
// the new state is also co-owned by the caller of `getContext`
173+
render_effect(() => {
174+
object[ADD_OWNER](owner);
175+
});
176+
} else {
177+
var proto = get_prototype_of(object);
178+
179+
if (proto === Object.prototype) {
180+
// recurse until we find a state proxy
181+
for (const key in object) {
182+
add_owner_to_object(object[key], owner, seen);
183+
}
184+
} else if (proto === Array.prototype) {
185+
// recurse until we find a state proxy
186+
for (let i = 0; i < object.length; i += 1) {
187+
add_owner_to_object(object[i], owner, seen);
188+
}
189+
}
190+
}
191+
}
132192
}
133193

134194
/**
135-
* @param {any} object
195+
* @param {import('#client').ProxyMetadata} metadata
196+
* @param {Function} component
197+
* @returns {boolean}
136198
*/
137-
function strip_owner_from_object(object) {
138-
if (object?.[STATE_SYMBOL]?.o) {
139-
object[STATE_SYMBOL].o = null;
140-
141-
for (const key in object) {
142-
strip_owner(object[key]);
143-
}
199+
function has_owner(metadata, component) {
200+
if (metadata.owners === null) {
201+
return true;
144202
}
203+
204+
return (
205+
metadata.owners.has(component) ||
206+
(metadata.parent !== null && has_owner(metadata.parent, component))
207+
);
208+
}
209+
210+
/**
211+
* @param {import('#client').ProxyMetadata} metadata
212+
* @returns {any}
213+
*/
214+
function get_owner(metadata) {
215+
return (
216+
metadata?.owners?.values().next().value ??
217+
get_owner(/** @type {import('#client').ProxyMetadata} */ (metadata.parent))
218+
);
145219
}
146220

147221
/**
148-
* @param {Set<Function>} owners
222+
* @param {import('#client').ProxyMetadata} metadata
149223
*/
150-
export function check_ownership(owners) {
224+
export function check_ownership(metadata) {
151225
const component = get_component();
152226

153-
if (component && !owners.has(component)) {
154-
let original = [...owners][0];
227+
if (component && !has_owner(metadata, component)) {
228+
let original = get_owner(metadata);
155229

156230
let message =
157231
// @ts-expect-error

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { hmr } from './dev/hmr.js';
2-
export { add_owner, mark_module_start, mark_module_end } from './dev/ownership.js';
2+
export { ADD_OWNER, add_owner, mark_module_start, mark_module_end } from './dev/ownership.js';
33
export { await_block as await } from './dom/blocks/await.js';
44
export { if_block as if } from './dom/blocks/if.js';
55
export { key_block as key } from './dom/blocks/key.js';

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

Lines changed: 21 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
is_frozen,
1717
object_prototype
1818
} from './utils.js';
19-
import { add_owner, check_ownership, strip_owner } from './dev/ownership.js';
19+
import { check_ownership, widen_ownership } from './dev/ownership.js';
2020
import { mutable_source, source, set } from './reactivity/sources.js';
2121
import { STATE_SYMBOL } from './constants.js';
2222
import { UNINITIALIZED } from '../../constants.js';
@@ -25,26 +25,20 @@ import { UNINITIALIZED } from '../../constants.js';
2525
* @template T
2626
* @param {T} value
2727
* @param {boolean} [immutable]
28-
* @param {Set<Function> | null} [owners]
28+
* @param {import('#client').ProxyMetadata | null} [parent]
2929
* @returns {import('#client').ProxyStateObject<T> | T}
3030
*/
31-
export function proxy(value, immutable = true, owners) {
31+
export function proxy(value, immutable = true, parent = null) {
3232
if (typeof value === 'object' && value != null && !is_frozen(value)) {
3333
// If we have an existing proxy, return it...
3434
if (STATE_SYMBOL in value) {
3535
const metadata = /** @type {import('#client').ProxyMetadata<T>} */ (value[STATE_SYMBOL]);
36+
3637
// ...unless the proxy belonged to a different object, because
3738
// someone copied the state symbol using `Reflect.ownKeys(...)`
3839
if (metadata.t === value || metadata.p === value) {
3940
if (DEV) {
40-
// update ownership
41-
if (owners) {
42-
for (const owner of owners) {
43-
add_owner(value, owner);
44-
}
45-
} else {
46-
strip_owner(value);
47-
}
41+
metadata.parent = parent;
4842
}
4943

5044
return metadata.p;
@@ -70,16 +64,17 @@ export function proxy(value, immutable = true, owners) {
7064
});
7165

7266
if (DEV) {
73-
// set ownership — either of the parent proxy's owners (if provided) or,
74-
// when calling `$.proxy(...)`, to the current component if such there be
7567
// @ts-expect-error
76-
value[STATE_SYMBOL].o =
77-
owners === undefined
78-
? current_component_context
68+
value[STATE_SYMBOL].parent = parent;
69+
70+
// @ts-expect-error
71+
value[STATE_SYMBOL].owners =
72+
parent === null
73+
? current_component_context !== null
7974
? // @ts-expect-error
8075
new Set([current_component_context.function])
8176
: null
82-
: owners && new Set(owners);
77+
: new Set();
8378
}
8479

8580
return proxy;
@@ -162,7 +157,7 @@ const state_proxy_handler = {
162157
const metadata = target[STATE_SYMBOL];
163158

164159
const s = metadata.s.get(prop);
165-
if (s !== undefined) set(s, proxy(descriptor.value, metadata.i, metadata.o));
160+
if (s !== undefined) set(s, proxy(descriptor.value, metadata.i, metadata));
166161
}
167162

168163
return Reflect.defineProperty(target, prop, descriptor);
@@ -208,7 +203,7 @@ const state_proxy_handler = {
208203

209204
// create a source, but only if it's an own property and not a prototype property
210205
if (s === undefined && (!(prop in target) || get_descriptor(target, prop)?.writable)) {
211-
s = (metadata.i ? source : mutable_source)(proxy(target[prop], metadata.i, metadata.o));
206+
s = (metadata.i ? source : mutable_source)(proxy(target[prop], metadata.i, metadata));
212207
metadata.s.set(prop, s);
213208
}
214209

@@ -255,7 +250,7 @@ const state_proxy_handler = {
255250
) {
256251
if (s === undefined) {
257252
s = (metadata.i ? source : mutable_source)(
258-
has ? proxy(target[prop], metadata.i, metadata.o) : UNINITIALIZED
253+
has ? proxy(target[prop], metadata.i, metadata) : UNINITIALIZED
259254
);
260255
metadata.s.set(prop, s);
261256
}
@@ -281,23 +276,18 @@ const state_proxy_handler = {
281276
s = metadata.s.get(prop);
282277
}
283278
if (s !== undefined) {
284-
set(s, proxy(value, metadata.i, metadata.o));
279+
set(s, proxy(value, metadata.i, metadata));
285280
}
286281
const is_array = metadata.a;
287282
const not_has = !(prop in target);
288283

289284
if (DEV) {
290-
// First check ownership of the object that is assigned to.
291-
// Then, if the new object has owners, widen them with the ones from the current object.
292-
// If it doesn't have owners that means it's ownerless, and so the assigned object should be, too.
293-
if (metadata.o) {
294-
check_ownership(metadata.o);
295-
for (const owner in metadata.o) {
296-
add_owner(value, owner);
297-
}
298-
} else {
299-
strip_owner(value);
285+
/** @type {import('#client').ProxyMetadata | undefined} */
286+
const prop_metadata = value?.[STATE_SYMBOL];
287+
if (prop_metadata && prop_metadata?.parent !== metadata) {
288+
widen_ownership(metadata, prop_metadata);
300289
}
290+
check_ownership(metadata);
301291
}
302292

303293
// variable.length = value -> clear all signals with index >= value

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -894,7 +894,7 @@ export function getContext(key) {
894894
// @ts-expect-error
895895
const fn = current_component_context?.function;
896896
if (fn) {
897-
add_owner(result, fn);
897+
add_owner(result, fn, true);
898898
}
899899
}
900900

@@ -950,7 +950,7 @@ export function getAllContexts() {
950950
const fn = current_component_context?.function;
951951
if (fn) {
952952
for (const value of context_map.values()) {
953-
add_owner(value, fn);
953+
add_owner(value, fn, true);
954954
}
955955
}
956956
}
@@ -1152,7 +1152,7 @@ export function deep_read(value, visited = new Set()) {
11521152
// continue
11531153
}
11541154
}
1155-
const proto = Object.getPrototypeOf(value);
1155+
const proto = get_prototype_of(value);
11561156
if (
11571157
proto !== Object.prototype &&
11581158
proto !== Array.prototype &&

packages/svelte/src/internal/client/types.d.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,10 @@ export interface ProxyMetadata<T = Record<string | symbol, any>> {
165165
p: ProxyStateObject<T>;
166166
/** The original target this proxy was created for */
167167
t: T;
168-
/** Dev-only — the components that 'own' this state, if any */
169-
o: null | Set<Function>;
168+
/** Dev-only — the components that 'own' this state, if any. `null` means no owners, i.e. everyone can mutate this state. */
169+
owners: null | Set<Function>;
170+
/** Dev-only — the parent metadata object */
171+
parent: null | ProxyMetadata;
170172
}
171173

172174
export type ProxyStateObject<T = Record<string | symbol, any>> = T & {

0 commit comments

Comments
 (0)