Skip to content

Commit 8578857

Browse files
authored
fix: ensure deep mutation ownership widening (#11094)
Previously, ownership widening/removal was only done if the immediate object that was encountered was state. This isn't always the case. It also didn't take into account classes with state on it (which turn into getters). This change takes both these cases into account and now always traverses the given object deeply. fixes #11060
1 parent b1a8038 commit 8578857

File tree

5 files changed

+148
-13
lines changed

5 files changed

+148
-13
lines changed

.changeset/mighty-frogs-obey.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: ensure deep mutation ownership widening

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

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { STATE_SYMBOL } from '../constants.js';
44
import { untrack } from '../runtime.js';
5+
import { get_descriptors } from '../utils.js';
56

67
/** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */
78
const boundaries = {};
@@ -91,49 +92,107 @@ export function mark_module_end() {
9192
}
9293
}
9394

95+
let add_owner_visited = new Set();
96+
9497
/**
9598
*
9699
* @param {any} object
97100
* @param {any} owner
98101
*/
99102
export function add_owner(object, owner) {
100-
untrack(() => {
101-
add_owner_to_object(object, owner);
102-
});
103+
// Needed because ownership addition can invoke getters on a proxy,
104+
// calling add_owner anew, so just keeping the set as part of
105+
// add_owner_to_object would not be enough.
106+
const prev = add_owner_visited;
107+
try {
108+
add_owner_visited = new Set(add_owner_visited);
109+
untrack(() => {
110+
add_owner_to_object(object, owner, add_owner_visited);
111+
});
112+
} finally {
113+
add_owner_visited = prev;
114+
}
103115
}
104116

105117
/**
106118
* @param {any} object
107119
* @param {Function} owner
120+
* @param {Set<any>} visited
108121
*/
109-
function add_owner_to_object(object, owner) {
122+
function add_owner_to_object(object, owner, visited) {
123+
if (visited.has(object)) return;
124+
visited.add(object);
125+
110126
if (object?.[STATE_SYMBOL]?.o && !object[STATE_SYMBOL].o.has(owner)) {
111127
object[STATE_SYMBOL].o.add(owner);
112-
113-
for (const key in object) {
114-
add_owner_to_object(object[key], owner);
115-
}
116128
}
129+
// Not inside previous if-block; there could be normal objects in-between
130+
traverse_for_owners(object, (nested) => add_owner_to_object(nested, owner, visited));
117131
}
118132

133+
let strip_owner_visited = new Set();
134+
119135
/**
120136
* @param {any} object
121137
*/
122138
export function strip_owner(object) {
123-
untrack(() => {
124-
strip_owner_from_object(object);
125-
});
139+
// Needed because ownership stripping can invoke getters on a proxy,
140+
// calling strip_owner anew, so just keeping the set as part of
141+
// strip_owner_from_object would not be enough.
142+
const prev = strip_owner_visited;
143+
try {
144+
untrack(() => {
145+
strip_owner_from_object(object, strip_owner_visited);
146+
});
147+
} finally {
148+
strip_owner_visited = prev;
149+
}
126150
}
127151

128152
/**
129153
* @param {any} object
154+
* @param {Set<any>} visited
130155
*/
131-
function strip_owner_from_object(object) {
156+
function strip_owner_from_object(object, visited) {
157+
if (visited.has(object)) return;
158+
visited.add(object);
159+
132160
if (object?.[STATE_SYMBOL]?.o) {
133161
object[STATE_SYMBOL].o = null;
162+
}
163+
// Not inside previous if-block; there could be normal objects in-between
164+
traverse_for_owners(object, (nested) => strip_owner_from_object(nested, visited));
165+
}
134166

167+
/**
168+
* @param {any} object
169+
* @param {(obj: any) => void} cb
170+
*/
171+
function traverse_for_owners(object, cb) {
172+
if (typeof object === 'object' && object !== null && !(object instanceof EventTarget)) {
135173
for (const key in object) {
136-
strip_owner(object[key]);
174+
cb(object[key]);
175+
}
176+
// deal with state on classes
177+
const proto = Object.getPrototypeOf(object);
178+
if (
179+
proto !== Object.prototype &&
180+
proto !== Array.prototype &&
181+
proto !== Map.prototype &&
182+
proto !== Set.prototype &&
183+
proto !== Date.prototype
184+
) {
185+
const descriptors = get_descriptors(proto);
186+
for (let key in descriptors) {
187+
const get = descriptors[key].get;
188+
if (get) {
189+
try {
190+
cb(object[key]);
191+
} catch (e) {
192+
// continue
193+
}
194+
}
195+
}
137196
}
138197
}
139198
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
/** @type {typeof console.warn} */
5+
let warn;
6+
7+
/** @type {any[]} */
8+
let warnings = [];
9+
10+
export default test({
11+
compileOptions: {
12+
dev: true
13+
},
14+
15+
before_test: () => {
16+
warn = console.warn;
17+
18+
console.warn = (...args) => {
19+
warnings.push(...args);
20+
};
21+
},
22+
23+
after_test: () => {
24+
console.warn = warn;
25+
warnings = [];
26+
},
27+
28+
async test({ assert, target }) {
29+
const btn = target.querySelector('button');
30+
31+
await btn?.click();
32+
await tick();
33+
assert.deepEqual(warnings.length, 0);
34+
}
35+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script lang="ts">
2+
import { setContext } from 'svelte';
3+
import Sub from './sub.svelte';
4+
5+
class Person1 {
6+
value = $state({ person: 'John', age: 33 })
7+
}
8+
const class_nested_state = $state(new Person1());
9+
10+
class Person2 {
11+
person = $state('John');
12+
age = $state(33);
13+
}
14+
const state_nested_class = $state({ value: new Person2() });
15+
16+
const nested_state = $state({ person: 'John', age: 33 });
17+
18+
setContext('foo', {
19+
nested_state,
20+
get class_nested_state() { return class_nested_state },
21+
get state_nested_class() { return state_nested_class }
22+
})
23+
</script>
24+
25+
<Sub {class_nested_state} {state_nested_class} {nested_state} />
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<script>
2+
import { getContext } from "svelte";
3+
4+
const foo = getContext('foo')
5+
</script>
6+
7+
<button onclick={() => {
8+
foo.class_nested_state.value.age++;
9+
foo.state_nested_class.value.age++;
10+
foo.nested_state.age++;
11+
}}>mutate</button>

0 commit comments

Comments
 (0)