Repository: sveltejs/svelte (JavaScript)

Issue:
Object ownership not added for `$state` inside `$derived`
### Describe the bug

I've been using this pattern from a [@PatrickG's comment](https://github.com/sveltejs/kit/issues/12999#issuecomment-2479924013) and I consider it a decent userland-implementation for [`$state.link`](https://github.com/sveltejs/svelte/issues/13452):
```js
let count = $state(0);
const linked = $derived.by(()=>{
  const state = $state({ current: count });
  return state;
})
linked.current++;
```
It worked fine in earlier versions of Svelte 5. However, after the performance patch in PR #14533, object ownership isn't added for `$state` inside `$derived` anymore. Quote:
> Potentially someone could create a $state while creating $state.raw or inside a $derived.by, but that feels so much of an edge case that it doesn't warrant a perf hit for the common case.

As a consequence, trying to use this pattern across component boundaries, either via the context API or props will produce the `ownership_invalid_binding` warning, even with proper prop binding. I've seen people asking problems that can be solved perfectly by this pattern, but I'm hesitant to recommend it to them. Sometimes I offer an alternative solution using `$effect` instead and they're happy to use it because there's no warning. However I feel bad about using `$effect` because 1. it's not synchronous and 2. updating states inside it is considered a bad practice.  

Recently I saw a @paoloricciuti 's [comment](https://github.com/sveltejs/svelte/pull/15069#issuecomment-2603387723) regarding this pattern and [it may be  even added to the docs](https://github.com/sveltejs/svelte/pull/15107#issuecomment-2613850216).  I'm relieved that it's a valid pattern that is recognized by the team. I feel like it's time the revisit the ownership check for this case again, especially after the recent PR #15153.


### Reproduction

https://svelte.dev/playground/d51b66bbb70e413498fe70b23944fcd7?version=5.19.6

### Logs

```shell

```

### System Info

```shell
N/A
```

### Severity

annoyance


Discussion:
one definite drawback of this linked implementation via a derived is that you can't `bind:` to the derived as it's not permitted by svelte.  So, the only choice is to pass it in as read-only and if writing to it will get a `ownership_invalid_mutation` warning.  It can be ignored though and it's only in dev.

> I feel bad about using $effect because 1. it's not synchronous and 2. updating states inside it is considered a bad practice.

for 1, I wonder what's the use case for needing a synchronous update?  Deriveds are only rerun when they're read and it would really only happen inside an effect if state changes.

for 2, I would say that it's acceptable to use `$effect` or `$effect.pre` to mutate state for this purpose.

> one definite drawback of this linked implementation via a derived is that you can't `bind:` to the derived as it's not permitted by svelte.

You can't bind to the derived itself but you should bind to it's property anyway.

> for 1, I wonder what's the use case for needing a synchronous update? Deriveds are only rerun when they're read and it would really only happen inside an effect if state changes.

You can read a derived also outside an effect....if you read a linked prop the line after setting the prop with derived you would get the right value, with effect the wrong one.

> for 2, I would say that it's acceptable to use `$effect` or `$effect.pre` to mutate state for this purpose.

It's way better to do the derived thing
> You can't bind to the derived itself but you should bind to it's property anyway.

right, just assumed the whole thing would be passed in, e.g. if there were more props in the derived vs one by one.  I was trying an example where the state was created outside of the `$derived` (but there is some kind of issue if a derived is read in the component's instance code section which `unsafe` fixes)

> It's way better to do the derived thing

If you need to read it in the instance code then yeah for sure and during ssr. Otherwise, I don't see a problem as long as one understands how `$effect` works with dependencies and `untrack()`. 

Implementing it this ways does seem somewhat cumbersome, I wonder if we had a rune `$state.derived` that would work just like a derived but be writable at the same time.

e.g. 

```js
let count = $state(0);
let state = $state.derived(() => {
  count; // read dependencies, function runs on the first read and on reads if dependencies change

  return count; // or specify whatever conditions make sense,  but able to reference itself (state in this case) vs $derived
});
```
> If you need to read it in the instance code then yeah for sure and during ssr. Otherwise, I don't see a problem as long as one understands how `$effect` works with dependencies and `untrack()`.

It's not only for SSR and during instance (which are also important)

[REPL](https://svelte.dev/playground/effcb2e548f944639c109342e4903caa?version=5.19.6)

Changes to derives are immediately live, the same is not true for effects 
that's an interesting use case with events, they do fire before the `$effect.pre` runs... so you'd have to read the state that gets updated via `$effect.pre` on the next `tick()`.

@paoloricciuti what do you think of the idea to create `$state.derived` ?

> Implementing it this ways does seem somewhat cumbersome, I wonder if we had a rune $state.derived that would work just like a derived but be writable at the same time.
>
> e.g.
>
> ```js
> let count = $state(0);
> let state = $state.derived(() => {
>   count; // read dependencies, function runs on the first read and on reads if dependencies change
> 
>   return count; // or specify whatever conditions make sense,  but able to reference itself (state in this case) vs $derived
> });
> ```



> [@paoloricciuti](https://github.com/paoloricciuti) what do you think of the idea to create `$state.derived` ?

That's basically `$state.link`...personally I think it should be desugaree to the derived+state pattern...on the other hand is really not difficult to do in user land and you can even create an utility for it and that would save us from yet another rune. But we are still keeping it in the back of our mind
right, I just didn't want to name it that since the "api" (and implementation) is different than the one that was proposed originally.  Definitely agree that it's basically syntactic sugar but it does make life easier especially for less experienced svelte devs.



Patch:
diff --git a/.changeset/thick-carrots-arrive.md b/.changeset/thick-carrots-arrive.md
new file mode 100644
index 000000000000..582cf5e6e15b
--- /dev/null
+++ b/.changeset/thick-carrots-arrive.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: recurse into `$derived` for ownership validation
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js
index 7b3a9a4d0e29..ed800e5226ce 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/ClassBody.js
@@ -190,22 +190,21 @@ export function ClassBody(node, context) {
 				'method',
 				b.id('$.ADD_OWNER'),
 				[b.id('owner')],
-				Array.from(public_state)
-					// Only run ownership addition on $state fields.
-					// Theoretically someone could create a `$state` while creating `$state.raw` or inside a `$derived.by`,
-					// but that feels so much of an edge case that it doesn't warrant a perf hit for the common case.
-					.filter(([_, { kind }]) => kind === 'state')
-					.map(([name]) =>
-						b.stmt(
-							b.call(
-								'$.add_owner',
-								b.call('$.get', b.member(b.this, b.private_id(name))),
-								b.id('owner'),
-								b.literal(false),
-								is_ignored(node, 'ownership_invalid_binding') && b.true
-							)
+				[
+					b.stmt(
+						b.call(
+							'$.add_owner_to_class',
+							b.this,
+							b.id('owner'),
+							b.array(
+								Array.from(public_state).map(([name]) =>
+									b.thunk(b.call('$.get', b.member(b.this, b.private_id(name))))
+								)
+							),
+							is_ignored(node, 'ownership_invalid_binding') && b.true
 						)
-					),
+					)
+				],
 				true
 			)
 		);
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js
index 15e4f68e9e49..2bae4486dc58 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js
@@ -180,37 +180,18 @@ export function build_component(node, component_name, context, anchor = context.
 			const expression = /** @type {Expression} */ (context.visit(attribute.expression));
 
 			if (dev && attribute.name !== 'this') {
-				let should_add_owner = true;
-
-				if (attribute.expression.type !== 'SequenceExpression') {
-					const left = object(attribute.expression);
-
-					if (left?.type === 'Identifier') {
-						const binding = context.state.scope.get(left.name);
-
-						// Only run ownership addition on $state fields.
-						// Theoretically someone could create a `$state` while creating `$state.raw` or inside a `$derived.by`,
-						// but that feels so much of an edge case that it doesn't warrant a perf hit for the common case.
-						if (binding?.kind === 'derived' || binding?.kind === 'raw_state') {
-							should_add_owner = false;
-						}
-					}
-				}
-
-				if (should_add_owner) {
-					binding_initializers.push(
-						b.stmt(
-							b.call(
-								b.id('$.add_owner_effect'),
-								expression.type === 'SequenceExpression'
-									? expression.expressions[0]
-									: b.thunk(expression),
-								b.id(component_name),
-								is_ignored(node, 'ownership_invalid_binding') && b.true
-							)
+				binding_initializers.push(
+					b.stmt(
+						b.call(
+							b.id('$.add_owner_effect'),
+							expression.type === 'SequenceExpression'
+								? expression.expressions[0]
+								: b.thunk(expression),
+							b.id(component_name),
+							is_ignored(node, 'ownership_invalid_binding') && b.true
 						)
-					);
-				}
+					)
+				);
 			}
 
 			if (expression.type === 'SequenceExpression') {
diff --git a/packages/svelte/src/internal/client/dev/ownership.js b/packages/svelte/src/internal/client/dev/ownership.js
index 2a2527803af9..62119b36dbd6 100644
--- a/packages/svelte/src/internal/client/dev/ownership.js
+++ b/packages/svelte/src/internal/client/dev/ownership.js
@@ -6,7 +6,7 @@ import { render_effect, user_pre_effect } from '../reactivity/effects.js';
 import { dev_current_component_function } from '../context.js';
 import { get_prototype_of } from '../../shared/utils.js';
 import * as w from '../warnings.js';
-import { FILENAME } from '../../../constants.js';
+import { FILENAME, UNINITIALIZED } from '../../../constants.js';
 
 /** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */
 const boundaries = {};
@@ -140,6 +140,25 @@ export function add_owner_effect(get_object, Component, skip_warning = false) {
 	});
 }
 
+/**
+ * @param {any} _this
+ * @param {Function} owner
+ * @param {Array<() => any>} getters
+ * @param {boolean} skip_warning
+ */
+export function add_owner_to_class(_this, owner, getters, skip_warning) {
+	_this[ADD_OWNER].current ||= getters.map(() => UNINITIALIZED);
+
+	for (let i = 0; i < getters.length; i += 1) {
+		const current = getters[i]();
+		// For performance reasons we only re-add the owner if the state has changed
+		if (current !== _this[ADD_OWNER][i]) {
+			_this[ADD_OWNER].current[i] = current;
+			add_owner(current, owner, false, skip_warning);
+		}
+	}
+}
+
 /**
  * @param {ProxyMetadata | null} from
  * @param {ProxyMetadata} to
@@ -196,7 +215,19 @@ function add_owner_to_object(object, owner, seen) {
 			if (proto === Object.prototype) {
 				// recurse until we find a state proxy
 				for (const key in object) {
-					add_owner_to_object(object[key], owner, seen);
+					if (Object.getOwnPropertyDescriptor(object, key)?.get) {
+						// Similar to the class case; the getter could update with a new state
+						let current = UNINITIALIZED;
+						render_effect(() => {
+							const next = object[key];
+							if (current !== next) {
+								current = next;
+								add_owner_to_object(next, owner, seen);
+							}
+						});
+					} else {
+						add_owner_to_object(object[key], owner, seen);
+					}
 				}
 			} else if (proto === Array.prototype) {
 				// recurse until we find a state proxy
@@ -221,9 +252,10 @@ function has_owner(metadata, component) {
 	return (
 		metadata.owners.has(component) ||
 		// This helps avoid false positives when using HMR, where the component function is replaced
-		[...metadata.owners].some(
-			(owner) => /** @type {any} */ (owner)[FILENAME] === /** @type {any} */ (component)?.[FILENAME]
-		) ||
+		(FILENAME in component &&
+			[...metadata.owners].some(
+				(owner) => /** @type {any} */ (owner)[FILENAME] === component[FILENAME]
+			)) ||
 		(metadata.parent !== null && has_owner(metadata.parent, component))
 	);
 }
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index 3f3b29b029dd..4c45ee4a8c5d 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -10,6 +10,7 @@ export {
 	mark_module_start,
 	mark_module_end,
 	add_owner_effect,
+	add_owner_to_class,
 	skip_ownership_validation
 } from './dev/ownership.js';
 export { check_target, legacy_api } from './dev/legacy.js';


Test patch:
diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterBinding.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterBinding.svelte
new file mode 100644
index 000000000000..d6da559fb176
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterBinding.svelte
@@ -0,0 +1,7 @@
+<script>
+	let { linked3 = $bindable(), linked4 = $bindable() } = $props();
+</script>
+
+<p>Binding</p>
+<button onclick={() => linked3.count++}>Increment Linked 1 ({linked3.count})</button>
+<button onclick={() => linked4.count++}>Increment Linked 2 ({linked4.count})</button>
diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterContext.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterContext.svelte
new file mode 100644
index 000000000000..b935f0a472dc
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/CounterContext.svelte
@@ -0,0 +1,13 @@
+<script>
+	import { getContext } from 'svelte';
+	const linked1 = getContext('linked1');
+	const linked2 = getContext('linked2');
+</script>
+
+<p>Context</p>
+<button onclick={() => linked1.linked.current.count++}
+	>Increment Linked 1 ({linked1.linked.current.count})</button
+>
+<button onclick={() => linked2.linked.current.count++}
+	>Increment Linked 2 ({linked2.linked.current.count})</button
+>
diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/_config.js b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/_config.js
new file mode 100644
index 000000000000..d6d12d01cd09
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/_config.js
@@ -0,0 +1,34 @@
+import { flushSync } from 'svelte';
+import { test } from '../../test';
+
+// Tests that ownership is widened with $derived (on class or on its own) that contains $state
+export default test({
+	compileOptions: {
+		dev: true
+	},
+
+	test({ assert, target, warnings }) {
+		const [root, counter_context1, counter_context2, counter_binding1, counter_binding2] =
+			target.querySelectorAll('button');
+
+		counter_context1.click();
+		counter_context2.click();
+		counter_binding1.click();
+		counter_binding2.click();
+		flushSync();
+
+		assert.equal(warnings.length, 0);
+
+		root.click();
+		flushSync();
+		counter_context1.click();
+		counter_context2.click();
+		counter_binding1.click();
+		counter_binding2.click();
+		flushSync();
+
+		assert.equal(warnings.length, 0);
+	},
+
+	warnings: []
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/main.svelte b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/main.svelte
new file mode 100644
index 000000000000..aaade26e162c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/non-local-mutation-with-binding-8/main.svelte
@@ -0,0 +1,46 @@
+<script>
+	import CounterBinding from './CounterBinding.svelte';
+	import CounterContext from './CounterContext.svelte';
+	import { setContext } from 'svelte';
+
+	let counter = $state({ count: 0 });
+
+	class Linked {
+		#getter;
+		linked = $derived.by(() => {
+			const state = $state({ current: $state.snapshot(this.#getter()) });
+			return state;
+		});
+
+		constructor(fn) {
+			this.#getter = fn;
+		}
+	}
+
+	const linked1 = $derived.by(() => {
+		const state = $state({ current: $state.snapshot(counter) });
+		return state;
+	});
+	const linked2 = new Linked(() => counter);
+
+	setContext('linked1', {
+		get linked() {
+			return linked1;
+		}
+	});
+	setContext('linked2', linked2);
+
+	const linked3 = $derived.by(() => {
+		const state = $state({ current: $state.snapshot(counter) });
+		return state;
+	});
+	const linked4 = new Linked(() => counter);
+</script>
+
+<p>Parent</p>
+<button onclick={() => counter.count++}>
+	Increment Original ({counter.count})
+</button>
+
+<CounterContext />
+<CounterBinding bind:linked3={linked3.current} bind:linked4={linked4.linked.current} />