Skip to content

Commit 5768ac3

Browse files
authored
fix: better ownership mutation validation (#10673)
- widen ownership when getContext is called, part of #10649 - widen ownership when assigning one proxy to another - skip first 4 stack trace entries and bail if first after that is not a module, hinting at a mutation encapsulated in a .svelte.js file; part of #10649
1 parent 74f8e26 commit 5768ac3

File tree

18 files changed

+350
-107
lines changed

18 files changed

+350
-107
lines changed

.changeset/curvy-buses-laugh.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: better ownership mutation validation

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,24 @@ export function get_stack() {
3434
* Determines which `.svelte` component is responsible for a given state change
3535
* @returns {Function | null}
3636
*/
37-
export function get_component() {
38-
const stack = get_stack();
37+
function get_component() {
38+
// first 4 lines are svelte internals; adjust this number if we change the internal call stack
39+
const stack = get_stack()?.slice(4);
3940
if (!stack) return null;
4041

41-
for (const entry of stack) {
42+
for (let i = 0; i < stack.length; i++) {
43+
const entry = stack[i];
4244
const modules = boundaries[entry.file];
43-
if (!modules) continue;
45+
if (!modules) {
46+
// If the first entry is not a component, that means the modification very likely happened
47+
// within a .svelte.js file, possibly triggered by a component. Since these files are not part
48+
// of the bondaries/component context heuristic, we need to bail in this case, else we would
49+
// have false positives when the .svelte.ts file provides a state creator function, encapsulating
50+
// the state and its mutations, and is being called from a component other than the one who
51+
// called the state creator function.
52+
if (i === 0) return null;
53+
continue;
54+
}
4455

4556
for (const module of modules) {
4657
if (module.start.line < entry.line && module.end.line > entry.line) {

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { STATE_SYMBOL, UNINITIALIZED } from './constants.js';
2727
* @template T
2828
* @param {T} value
2929
* @param {boolean} [immutable]
30-
* @param {Function[]} [owners]
30+
* @param {Set<Function> | null} [owners]
3131
* @returns {import('./types.js').ProxyStateObject<T> | T}
3232
*/
3333
export function proxy(value, immutable = true, owners) {
@@ -162,6 +162,7 @@ function update_version(signal, d = 1) {
162162
const state_proxy_handler = {
163163
defineProperty(target, prop, descriptor) {
164164
if (descriptor.value) {
165+
/** @type {import('./types.js').ProxyMetadata} */
165166
const metadata = target[STATE_SYMBOL];
166167

167168
const s = metadata.s.get(prop);
@@ -172,6 +173,7 @@ const state_proxy_handler = {
172173
},
173174

174175
deleteProperty(target, prop) {
176+
/** @type {import('./types.js').ProxyMetadata} */
175177
const metadata = target[STATE_SYMBOL];
176178
const s = metadata.s.get(prop);
177179
const is_array = metadata.a;
@@ -204,6 +206,7 @@ const state_proxy_handler = {
204206
return Reflect.get(target, STATE_SYMBOL);
205207
}
206208

209+
/** @type {import('./types.js').ProxyMetadata} */
207210
const metadata = target[STATE_SYMBOL];
208211
let s = metadata.s.get(prop);
209212

@@ -234,6 +237,7 @@ const state_proxy_handler = {
234237
getOwnPropertyDescriptor(target, prop) {
235238
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
236239
if (descriptor && 'value' in descriptor) {
240+
/** @type {import('./types.js').ProxyMetadata} */
237241
const metadata = target[STATE_SYMBOL];
238242
const s = metadata.s.get(prop);
239243

@@ -249,6 +253,7 @@ const state_proxy_handler = {
249253
if (prop === STATE_SYMBOL) {
250254
return true;
251255
}
256+
/** @type {import('./types.js').ProxyMetadata} */
252257
const metadata = target[STATE_SYMBOL];
253258
const has = Reflect.has(target, prop);
254259

@@ -269,6 +274,7 @@ const state_proxy_handler = {
269274
},
270275

271276
set(target, prop, value, receiver) {
277+
/** @type {import('./types.js').ProxyMetadata} */
272278
const metadata = target[STATE_SYMBOL];
273279
let s = metadata.s.get(prop);
274280
// If we haven't yet created a source for this property, we need to ensure
@@ -286,8 +292,18 @@ const state_proxy_handler = {
286292
const is_array = metadata.a;
287293
const not_has = !(prop in target);
288294

289-
if (DEV && metadata.o) {
290-
check_ownership(metadata.o);
295+
if (DEV) {
296+
// First check ownership of the object that is assigned to.
297+
// Then, if the new object has owners, widen them with the ones from the current object.
298+
// If it doesn't have owners that means it's ownerless, and so the assigned object should be, too.
299+
if (metadata.o) {
300+
check_ownership(metadata.o);
301+
for (const owner in metadata.o) {
302+
add_owner(value, owner);
303+
}
304+
} else {
305+
strip_owner(value);
306+
}
291307
}
292308

293309
// variable.length = value -> clear all signals with index >= value
@@ -321,6 +337,7 @@ const state_proxy_handler = {
321337
},
322338

323339
ownKeys(target) {
340+
/** @type {import('./types.js').ProxyMetadata} */
324341
const metadata = target[STATE_SYMBOL];
325342

326343
get(metadata.v);

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

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
STATE_SYMBOL
3131
} from './constants.js';
3232
import { flush_tasks } from './dom/task.js';
33+
import { add_owner } from './dev/ownership.js';
3334

3435
const IS_EFFECT = EFFECT | PRE_EFFECT | RENDER_EFFECT;
3536

@@ -1087,8 +1088,89 @@ export function is_signal(val) {
10871088
);
10881089
}
10891090

1091+
/**
1092+
* Retrieves the context that belongs to the closest parent component with the specified `key`.
1093+
* Must be called during component initialisation.
1094+
*
1095+
* https://svelte.dev/docs/svelte#getcontext
1096+
* @template T
1097+
* @param {any} key
1098+
* @returns {T}
1099+
*/
1100+
export function getContext(key) {
1101+
const context_map = get_or_init_context_map();
1102+
const result = /** @type {T} */ (context_map.get(key));
1103+
1104+
if (DEV) {
1105+
// @ts-expect-error
1106+
const fn = current_component_context?.function;
1107+
if (fn) {
1108+
add_owner(result, fn);
1109+
}
1110+
}
1111+
1112+
return result;
1113+
}
1114+
1115+
/**
1116+
* Associates an arbitrary `context` object with the current component and the specified `key`
1117+
* and returns that object. The context is then available to children of the component
1118+
* (including slotted content) with `getContext`.
1119+
*
1120+
* Like lifecycle functions, this must be called during component initialisation.
1121+
*
1122+
* https://svelte.dev/docs/svelte#setcontext
1123+
* @template T
1124+
* @param {any} key
1125+
* @param {T} context
1126+
* @returns {T}
1127+
*/
1128+
export function setContext(key, context) {
1129+
const context_map = get_or_init_context_map();
1130+
context_map.set(key, context);
1131+
return context;
1132+
}
1133+
1134+
/**
1135+
* Checks whether a given `key` has been set in the context of a parent component.
1136+
* Must be called during component initialisation.
1137+
*
1138+
* https://svelte.dev/docs/svelte#hascontext
1139+
* @param {any} key
1140+
* @returns {boolean}
1141+
*/
1142+
export function hasContext(key) {
1143+
const context_map = get_or_init_context_map();
1144+
return context_map.has(key);
1145+
}
1146+
1147+
/**
1148+
* Retrieves the whole context map that belongs to the closest parent component.
1149+
* Must be called during component initialisation. Useful, for example, if you
1150+
* programmatically create a component and want to pass the existing context to it.
1151+
*
1152+
* https://svelte.dev/docs/svelte#getallcontexts
1153+
* @template {Map<any, any>} [T=Map<any, any>]
1154+
* @returns {T}
1155+
*/
1156+
export function getAllContexts() {
1157+
const context_map = get_or_init_context_map();
1158+
1159+
if (DEV) {
1160+
// @ts-expect-error
1161+
const fn = current_component_context?.function;
1162+
if (fn) {
1163+
for (const value of context_map.values()) {
1164+
add_owner(value, fn);
1165+
}
1166+
}
1167+
}
1168+
1169+
return /** @type {T} */ (context_map);
1170+
}
1171+
10901172
/** @returns {Map<unknown, unknown>} */
1091-
export function get_or_init_context_map() {
1173+
function get_or_init_context_map() {
10921174
const component_context = current_component_context;
10931175
if (component_context === null) {
10941176
throw new Error(

packages/svelte/src/internal/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ export {
1919
unwrap,
2020
freeze,
2121
deep_read,
22-
deep_read_state
22+
deep_read_state,
23+
getAllContexts,
24+
getContext,
25+
setContext,
26+
hasContext
2327
} from './client/runtime.js';
2428
export * from './client/dev/ownership.js';
2529
export { await_block as await } from './client/dom/blocks/await.js';

packages/svelte/src/main/main-client.js

Lines changed: 6 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import {
2-
current_component_context,
3-
get_or_init_context_map,
4-
untrack
5-
} from '../internal/client/runtime.js';
1+
import { current_component_context, untrack } from '../internal/client/runtime.js';
62
import { is_array } from '../internal/client/utils.js';
73
import { user_effect } from '../internal/index.js';
84

@@ -53,66 +49,6 @@ export function onDestroy(fn) {
5349
onMount(() => () => untrack(fn));
5450
}
5551

56-
/**
57-
* Retrieves the context that belongs to the closest parent component with the specified `key`.
58-
* Must be called during component initialisation.
59-
*
60-
* https://svelte.dev/docs/svelte#getcontext
61-
* @template T
62-
* @param {any} key
63-
* @returns {T}
64-
*/
65-
export function getContext(key) {
66-
const context_map = get_or_init_context_map();
67-
return /** @type {T} */ (context_map.get(key));
68-
}
69-
70-
/**
71-
* Associates an arbitrary `context` object with the current component and the specified `key`
72-
* and returns that object. The context is then available to children of the component
73-
* (including slotted content) with `getContext`.
74-
*
75-
* Like lifecycle functions, this must be called during component initialisation.
76-
*
77-
* https://svelte.dev/docs/svelte#setcontext
78-
* @template T
79-
* @param {any} key
80-
* @param {T} context
81-
* @returns {T}
82-
*/
83-
export function setContext(key, context) {
84-
const context_map = get_or_init_context_map();
85-
context_map.set(key, context);
86-
return context;
87-
}
88-
89-
/**
90-
* Checks whether a given `key` has been set in the context of a parent component.
91-
* Must be called during component initialisation.
92-
*
93-
* https://svelte.dev/docs/svelte#hascontext
94-
* @param {any} key
95-
* @returns {boolean}
96-
*/
97-
export function hasContext(key) {
98-
const context_map = get_or_init_context_map();
99-
return context_map.has(key);
100-
}
101-
102-
/**
103-
* Retrieves the whole context map that belongs to the closest parent component.
104-
* Must be called during component initialisation. Useful, for example, if you
105-
* programmatically create a component and want to pass the existing context to it.
106-
*
107-
* https://svelte.dev/docs/svelte#getallcontexts
108-
* @template {Map<any, any>} [T=Map<any, any>]
109-
* @returns {T}
110-
*/
111-
export function getAllContexts() {
112-
const context_map = get_or_init_context_map();
113-
return /** @type {T} */ (context_map);
114-
}
115-
11652
/**
11753
* @template [T=any]
11854
* @param {string} type
@@ -241,5 +177,9 @@ export {
241177
unmount,
242178
untrack,
243179
unstate,
244-
createRoot
180+
createRoot,
181+
hasContext,
182+
getContext,
183+
getAllContexts,
184+
setContext
245185
} from '../internal/index.js';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { test } from '../../test';
2+
3+
/** @type {typeof console.warn} */
4+
let warn;
5+
6+
/** @type {any[]} */
7+
let warnings = [];
8+
9+
export default test({
10+
html: `<button>[]</button>`,
11+
12+
compileOptions: {
13+
dev: true
14+
},
15+
16+
before_test: () => {
17+
warn = console.warn;
18+
19+
console.warn = (...args) => {
20+
warnings.push(...args);
21+
};
22+
},
23+
24+
after_test: () => {
25+
console.warn = warn;
26+
warnings = [];
27+
},
28+
29+
async test({ assert, target }) {
30+
const btn = target.querySelector('button');
31+
await btn?.click();
32+
33+
assert.htmlEqual(target.innerHTML, `<button>[foo]</button>`);
34+
35+
assert.deepEqual(warnings, [], 'expected getContext to have widened ownership');
36+
}
37+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
import { setContext } from 'svelte';
3+
import Sub from './sub.svelte';
4+
5+
let list = $state([]);
6+
setContext('list', list);
7+
</script>
8+
9+
<Sub />
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script>
2+
import { getContext } from 'svelte';
3+
4+
const list = getContext('list');
5+
</script>
6+
7+
<button onclick={() => list.push('foo')}>[{list.join(',')}]</button>

0 commit comments

Comments
 (0)