Skip to content

Commit f0bb9c5

Browse files
committed
fix: recurse into $derived for ownership validation
- `$derived` can contain `$state` declarations so we cannot ignore them, so this reverts #14533 - instead, we add equality checks to not do this expensive work unnecessarily - this also adds a render effect similar to the class ownership addition when it detects a getter on a POJO during ownership addition fixes #15164
1 parent 73fa9d3 commit f0bb9c5

File tree

9 files changed

+204
-50
lines changed

9 files changed

+204
-50
lines changed

.changeset/thick-carrots-arrive.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: recurse into `$derived` for ownership validation

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

+14-15
Original file line numberDiff line numberDiff line change
@@ -190,22 +190,21 @@ export function ClassBody(node, context) {
190190
'method',
191191
b.id('$.ADD_OWNER'),
192192
[b.id('owner')],
193-
Array.from(public_state)
194-
// Only run ownership addition on $state fields.
195-
// Theoretically someone could create a `$state` while creating `$state.raw` or inside a `$derived.by`,
196-
// but that feels so much of an edge case that it doesn't warrant a perf hit for the common case.
197-
.filter(([_, { kind }]) => kind === 'state')
198-
.map(([name]) =>
199-
b.stmt(
200-
b.call(
201-
'$.add_owner',
202-
b.call('$.get', b.member(b.this, b.private_id(name))),
203-
b.id('owner'),
204-
b.literal(false),
205-
is_ignored(node, 'ownership_invalid_binding') && b.true
206-
)
193+
[
194+
b.stmt(
195+
b.call(
196+
'$.add_owner_to_class',
197+
b.this,
198+
b.id('owner'),
199+
b.array(
200+
Array.from(public_state).map(([name]) =>
201+
b.thunk(b.call('$.get', b.member(b.this, b.private_id(name))))
202+
)
203+
),
204+
is_ignored(node, 'ownership_invalid_binding') && b.true
207205
)
208-
),
206+
)
207+
],
209208
true
210209
)
211210
);

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

+11-30
Original file line numberDiff line numberDiff line change
@@ -180,37 +180,18 @@ export function build_component(node, component_name, context, anchor = context.
180180
const expression = /** @type {Expression} */ (context.visit(attribute.expression));
181181

182182
if (dev && attribute.name !== 'this') {
183-
let should_add_owner = true;
184-
185-
if (attribute.expression.type !== 'SequenceExpression') {
186-
const left = object(attribute.expression);
187-
188-
if (left?.type === 'Identifier') {
189-
const binding = context.state.scope.get(left.name);
190-
191-
// Only run ownership addition on $state fields.
192-
// Theoretically someone could create a `$state` while creating `$state.raw` or inside a `$derived.by`,
193-
// but that feels so much of an edge case that it doesn't warrant a perf hit for the common case.
194-
if (binding?.kind === 'derived' || binding?.kind === 'raw_state') {
195-
should_add_owner = false;
196-
}
197-
}
198-
}
199-
200-
if (should_add_owner) {
201-
binding_initializers.push(
202-
b.stmt(
203-
b.call(
204-
b.id('$.add_owner_effect'),
205-
expression.type === 'SequenceExpression'
206-
? expression.expressions[0]
207-
: b.thunk(expression),
208-
b.id(component_name),
209-
is_ignored(node, 'ownership_invalid_binding') && b.true
210-
)
183+
binding_initializers.push(
184+
b.stmt(
185+
b.call(
186+
b.id('$.add_owner_effect'),
187+
expression.type === 'SequenceExpression'
188+
? expression.expressions[0]
189+
: b.thunk(expression),
190+
b.id(component_name),
191+
is_ignored(node, 'ownership_invalid_binding') && b.true
211192
)
212-
);
213-
}
193+
)
194+
);
214195
}
215196

216197
if (expression.type === 'SequenceExpression') {

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

+37-5
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { render_effect, user_pre_effect } from '../reactivity/effects.js';
66
import { dev_current_component_function } from '../context.js';
77
import { get_prototype_of } from '../../shared/utils.js';
88
import * as w from '../warnings.js';
9-
import { FILENAME } from '../../../constants.js';
9+
import { FILENAME, UNINITIALIZED } from '../../../constants.js';
1010

1111
/** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */
1212
const boundaries = {};
@@ -140,6 +140,25 @@ export function add_owner_effect(get_object, Component, skip_warning = false) {
140140
});
141141
}
142142

143+
/**
144+
* @param {any} _this
145+
* @param {Function} owner
146+
* @param {Array<() => any>} getters
147+
* @param {boolean} skip_warning
148+
*/
149+
export function add_owner_to_class(_this, owner, getters, skip_warning) {
150+
_this[ADD_OWNER].current ||= getters.map(() => UNINITIALIZED);
151+
152+
for (let i = 0; i < getters.length; i += 1) {
153+
const current = getters[i]();
154+
// For performance reasons we only re-add the owner if the state has changed
155+
if (current !== _this[ADD_OWNER][i]) {
156+
_this[ADD_OWNER].current[i] = current;
157+
add_owner(current, owner, false, skip_warning);
158+
}
159+
}
160+
}
161+
143162
/**
144163
* @param {ProxyMetadata | null} from
145164
* @param {ProxyMetadata} to
@@ -196,7 +215,19 @@ function add_owner_to_object(object, owner, seen) {
196215
if (proto === Object.prototype) {
197216
// recurse until we find a state proxy
198217
for (const key in object) {
199-
add_owner_to_object(object[key], owner, seen);
218+
if (Object.getOwnPropertyDescriptor(object, key)?.get) {
219+
// Similar to the class case; the getter could update with a new state
220+
let current = UNINITIALIZED;
221+
render_effect(() => {
222+
const next = object[key];
223+
if (current !== next) {
224+
current = next;
225+
add_owner_to_object(next, owner, seen);
226+
}
227+
});
228+
} else {
229+
add_owner_to_object(object[key], owner, seen);
230+
}
200231
}
201232
} else if (proto === Array.prototype) {
202233
// recurse until we find a state proxy
@@ -221,9 +252,10 @@ function has_owner(metadata, component) {
221252
return (
222253
metadata.owners.has(component) ||
223254
// This helps avoid false positives when using HMR, where the component function is replaced
224-
[...metadata.owners].some(
225-
(owner) => /** @type {any} */ (owner)[FILENAME] === /** @type {any} */ (component)?.[FILENAME]
226-
) ||
255+
(FILENAME in component &&
256+
[...metadata.owners].some(
257+
(owner) => /** @type {any} */ (owner)[FILENAME] === component[FILENAME]
258+
)) ||
227259
(metadata.parent !== null && has_owner(metadata.parent, component))
228260
);
229261
}

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

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export {
1010
mark_module_start,
1111
mark_module_end,
1212
add_owner_effect,
13+
add_owner_to_class,
1314
skip_ownership_validation
1415
} from './dev/ownership.js';
1516
export { check_target, legacy_api } from './dev/legacy.js';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script>
2+
let { linked3 = $bindable(), linked4 = $bindable() } = $props();
3+
</script>
4+
5+
<p>Binding</p>
6+
<button onclick={() => linked3.count++}>Increment Linked 1 ({linked3.count})</button>
7+
<button onclick={() => linked4.count++}>Increment Linked 2 ({linked4.count})</button>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<script>
2+
import { getContext } from 'svelte';
3+
const linked1 = getContext('linked1');
4+
const linked2 = getContext('linked2');
5+
</script>
6+
7+
<p>Context</p>
8+
<button onclick={() => linked1.linked.current.count++}
9+
>Increment Linked 1 ({linked1.linked.current.count})</button
10+
>
11+
<button onclick={() => linked2.linked.current.count++}
12+
>Increment Linked 2 ({linked2.linked.current.count})</button
13+
>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
// Tests that ownership is widened with $derived (on class or on its own) that contains $state
5+
export default test({
6+
compileOptions: {
7+
dev: true
8+
},
9+
10+
test({ assert, target, warnings }) {
11+
const [root, counter_context1, counter_context2, counter_binding1, counter_binding2] =
12+
target.querySelectorAll('button');
13+
14+
counter_context1.click();
15+
counter_context2.click();
16+
counter_binding1.click();
17+
counter_binding2.click();
18+
flushSync();
19+
20+
assert.equal(warnings.length, 0);
21+
22+
root.click();
23+
flushSync();
24+
counter_context1.click();
25+
counter_context2.click();
26+
counter_binding1.click();
27+
counter_binding2.click();
28+
flushSync();
29+
30+
assert.equal(warnings.length, 0);
31+
},
32+
33+
warnings: []
34+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<script>
2+
import CounterBinding from './CounterBinding.svelte';
3+
import CounterContext from './CounterContext.svelte';
4+
import { setContext } from 'svelte';
5+
6+
let counter = $state({ count: 0 });
7+
8+
class Linked {
9+
#getter;
10+
linked = $derived.by(() => {
11+
const state = $state({ current: $state.snapshot(this.#getter()) });
12+
return state;
13+
});
14+
15+
constructor(fn) {
16+
this.#getter = fn;
17+
}
18+
}
19+
20+
const linked1 = $derived.by(() => {
21+
const state = $state({ current: $state.snapshot(counter) });
22+
return state;
23+
});
24+
const linked2 = new Linked(() => counter);
25+
26+
setContext('linked1', {
27+
get linked() {
28+
return linked1;
29+
}
30+
});
31+
setContext('linked2', linked2);
32+
33+
const linked3 = $derived.by(() => {
34+
const state = $state({ current: $state.snapshot(counter) });
35+
return state;
36+
});
37+
const linked4 = new Linked(() => counter);
38+
</script>
39+
40+
<p>Parent</p>
41+
<button onclick={() => counter.count++}>
42+
Increment Original ({counter.count})
43+
</button>
44+
45+
<CounterContext />
46+
<CounterBinding bind:linked3={linked3.current} bind:linked4={linked4.linked.current} />
47+
48+
<!-- <script>
49+
import { createPortalKey } from 'svelte';
50+
let x = createPortalKey();
51+
let count = $state(0);
52+
let root = $state();
53+
54+
$effect(() => {
55+
root = document.querySelector('#root');
56+
})
57+
</script>
58+
59+
<div>
60+
<svelte:portal for={x}></svelte:portal>
61+
</div>
62+
63+
<button onclick={() => count++}>increment</button>
64+
65+
<svelte:portal target={x}>
66+
hello {count}
67+
</svelte:portal>
68+
69+
{#if count < 5}
70+
<svelte:portal target={x}>
71+
<span>hello2 {count}</span>
72+
</svelte:portal>
73+
{/if}
74+
75+
<p></p>
76+
77+
<svelte:portal target="{root}">
78+
I'm rendered in the
79+
<button onclick={() => root = document.querySelector('p')}>
80+
{root === document.querySelector('#root') ? 'root' : 'p'}
81+
</button>
82+
</svelte:portal> -->

0 commit comments

Comments
 (0)