Skip to content

Commit 82a00b8

Browse files
committed
fix: prevent false positive ownership warning when reassigning state
When a proxy is reassigned, we call `$.proxy` again. There are cases where there's a component context set but the reassignment actually happens for variable that is ownerless within shared state or somewhere else. In that case we get false positives right now. The inverse is also true where reassigning can delete owners (because no component context exists) and result in false negatives. The fix is to pass the previous value in to copy over the owners from it. Fixes #11525
1 parent b1e01bf commit 82a00b8

File tree

11 files changed

+115
-42
lines changed

11 files changed

+115
-42
lines changed

.changeset/modern-apricots-promise.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: prevent buggy ownership warning when reassigning state

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ export function serialize_set_binding(node, context, fallback, options) {
203203
assignment.right =
204204
private_state.kind === 'frozen_state'
205205
? b.call('$.freeze', value)
206-
: b.call('$.proxy', value);
206+
: serialize_proxy_reassignment(value, private_state.id, state);
207207
return assignment;
208208
}
209209
}
@@ -216,7 +216,7 @@ export function serialize_set_binding(node, context, fallback, options) {
216216
should_proxy_or_freeze(value, context.state.scope)
217217
? private_state.kind === 'frozen_state'
218218
? b.call('$.freeze', value)
219-
: b.call('$.proxy', value)
219+
: serialize_proxy_reassignment(value, private_state.id, state)
220220
: value
221221
);
222222
}
@@ -240,7 +240,7 @@ export function serialize_set_binding(node, context, fallback, options) {
240240
assignment.right =
241241
public_state.kind === 'frozen_state'
242242
? b.call('$.freeze', value)
243-
: b.call('$.proxy', value);
243+
: serialize_proxy_reassignment(value, public_state.id, state);
244244
return assignment;
245245
}
246246
}
@@ -305,7 +305,7 @@ export function serialize_set_binding(node, context, fallback, options) {
305305
context.state.analysis.runes &&
306306
!options?.skip_proxy_and_freeze &&
307307
should_proxy_or_freeze(value, context.state.scope)
308-
? b.call('$.proxy', value)
308+
? serialize_proxy_reassignment(value, left_name, state)
309309
: value
310310
);
311311
} else if (binding.kind === 'frozen_state') {
@@ -410,6 +410,25 @@ export function serialize_set_binding(node, context, fallback, options) {
410410
return serialize();
411411
}
412412

413+
/**
414+
* @param {import('estree').Expression} value
415+
* @param {import('estree').PrivateIdentifier | string} proxy_reference
416+
* @param {import('./types').ClientTransformState} state
417+
*/
418+
export function serialize_proxy_reassignment(value, proxy_reference, state) {
419+
return state.options.dev
420+
? b.call(
421+
'$.proxy',
422+
value,
423+
b.true,
424+
b.null,
425+
typeof proxy_reference === 'string'
426+
? b.id(proxy_reference)
427+
: b.member(b.this, proxy_reference)
428+
)
429+
: b.call('$.proxy', value);
430+
}
431+
413432
/**
414433
* @param {import('estree').ArrowFunctionExpression | import('estree').FunctionExpression} node
415434
* @param {import('./types').ComponentContext} context

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { get_rune } from '../../../scope.js';
22
import { is_hoistable_function, transform_inspect_rune } from '../../utils.js';
33
import * as b from '../../../../utils/builders.js';
44
import * as assert from '../../../../utils/assert.js';
5-
import { get_prop_source, is_state_source, should_proxy_or_freeze } from '../utils.js';
5+
import {
6+
get_prop_source,
7+
is_state_source,
8+
serialize_proxy_reassignment,
9+
should_proxy_or_freeze
10+
} from '../utils.js';
611
import { extract_paths } from '../../../../utils/ast.js';
712
import { regex_invalid_identifier_chars } from '../../../patterns.js';
813

@@ -139,7 +144,11 @@ export const javascript_visitors_runes = {
139144
'set',
140145
definition.key,
141146
[value],
142-
[b.stmt(b.call('$.set', member, b.call('$.proxy', value)))]
147+
[
148+
b.stmt(
149+
b.call('$.set', member, serialize_proxy_reassignment(value, field.id, state))
150+
)
151+
]
143152
)
144153
);
145154
}

packages/svelte/src/compiler/utils/builders.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ export function do_while(test, body) {
469469

470470
const true_instance = literal(true);
471471
const false_instance = literal(false);
472+
const null_instane = literal(null);
472473

473474
/** @type {import('estree').DebuggerStatement} */
474475
const debugger_builder = {
@@ -630,6 +631,7 @@ export {
630631
return_builder as return,
631632
if_builder as if,
632633
this_instance as this,
634+
null_instane as null,
633635
debugger_builder as debugger
634636
};
635637

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ import * as e from './errors.js';
2727
* @param {T} value
2828
* @param {boolean} [immutable]
2929
* @param {import('#client').ProxyMetadata | null} [parent]
30+
* @param {import('#client').Source<T>} [prev] dev mode only
3031
* @returns {import('#client').ProxyStateObject<T> | T}
3132
*/
32-
export function proxy(value, immutable = true, parent = null) {
33+
export function proxy(value, immutable = true, parent = null, prev) {
3334
if (typeof value === 'object' && value != null && !is_frozen(value)) {
3435
// If we have an existing proxy, return it...
3536
if (STATE_SYMBOL in value) {
@@ -71,13 +72,22 @@ export function proxy(value, immutable = true, parent = null) {
7172
// @ts-expect-error
7273
value[STATE_SYMBOL].parent = parent;
7374

74-
// @ts-expect-error
75-
value[STATE_SYMBOL].owners =
76-
parent === null
77-
? current_component_context !== null
78-
? new Set([current_component_context.function])
79-
: null
80-
: new Set();
75+
if (prev) {
76+
// Reuse owners from previous state; necessary because reassignment is not guaranteed to have correct component context.
77+
// If no previous proxy exists we play it safe and assume ownerless state
78+
// @ts-expect-error
79+
const prev_owners = prev?.v?.[STATE_SYMBOL]?.owners;
80+
// @ts-expect-error
81+
value[STATE_SYMBOL].owners = prev_owners ? new Set(prev_owners) : null;
82+
} else {
83+
// @ts-expect-error
84+
value[STATE_SYMBOL].owners =
85+
parent === null
86+
? current_component_context !== null
87+
? new Set([current_component_context.function])
88+
: null
89+
: new Set();
90+
}
8191
}
8292

8393
return proxy;
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<script>
2-
/** @type {{ object: { count: number }}} */
3-
let { object = $bindable() } = $props();
2+
let { object = $bindable(), reset } = $props();
43
</script>
54

65
<button onclick={() => object.count += 1}>
76
clicks: {object.count}
87
</button>
8+
<button onclick={reset}>reset</button>
Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,30 @@
11
import { flushSync } from 'svelte';
22
import { test } from '../../test';
33

4-
/** @type {typeof console.trace} */
5-
let trace;
6-
74
export default test({
8-
html: `<button>clicks: 0</button>`,
5+
html: `<button>clicks: 0</button> <button>reset</button>`,
96

107
compileOptions: {
118
dev: true
129
},
1310

14-
before_test: () => {
15-
trace = console.trace;
16-
console.trace = () => {};
17-
},
18-
19-
after_test: () => {
20-
console.trace = trace;
21-
},
22-
2311
test({ assert, target, warnings }) {
24-
const btn = target.querySelector('button');
25-
26-
flushSync(() => {
27-
btn?.click();
28-
});
29-
30-
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button>`);
31-
32-
assert.deepEqual(warnings, [
33-
'Counter.svelte mutated a value owned by main.svelte. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead'
34-
]);
12+
const warning =
13+
'Counter.svelte mutated a value owned by main.svelte. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead';
14+
const [btn1, btn2] = target.querySelectorAll('button');
15+
16+
btn1.click();
17+
flushSync();
18+
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button> <button>reset</button>`);
19+
assert.deepEqual(warnings, [warning]);
20+
21+
btn2.click();
22+
flushSync();
23+
assert.htmlEqual(target.innerHTML, `<button>clicks: 0</button> <button>reset</button>`);
24+
25+
btn1.click();
26+
flushSync();
27+
assert.htmlEqual(target.innerHTML, `<button>clicks: 1</button> <button>reset</button>`);
28+
assert.deepEqual(warnings, [warning, warning]);
3529
}
3630
});
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script>
22
import Counter from './Counter.svelte';
33
4-
const object = $state({ count: 0 });
4+
let object = $state({ count: 0 });
55
</script>
66

7-
<Counter {object} />
7+
<Counter {object} reset={() => object = {count: 0}} />
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
compileOptions: {
5+
dev: true
6+
},
7+
8+
async test({ assert, warnings }) {
9+
assert.deepEqual(warnings, []);
10+
}
11+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script context="module">
2+
let toast1 = $state();
3+
let toast2 = $state({});
4+
5+
export async function show_toast() {
6+
toast1 = {
7+
message: 'foo',
8+
show: true
9+
};
10+
toast1.show = false;
11+
12+
toast2 = {
13+
message: 'foo',
14+
show: true
15+
};
16+
toast2.show = false;
17+
}
18+
</script>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
import { show_toast } from "./child.svelte";
3+
4+
show_toast();
5+
</script>

0 commit comments

Comments
 (0)