Repository: sveltejs/svelte (2025-08-06T20:49:51Z)

Issue:
False positive state_unsafe_mutation
### Describe the bug

The repro itself speaks more than a thousand words here. Either I am missing something, or callback shouldn't be run in reactive context in this case.

### Reproduction

[REPL](https://svelte.dev/playground/hello-world?version=5.37.3#H4sIAAAAAAAAE61STY_TMBD9K4NBaiJF6b20lVYIiQOc4EY5uMlksda1I3vSsrL83xnH3u1md-GAOCSW37w3H28chJEnFBvxCbW2cLFO91Bhrwj7WjRiUBq92HwPgu7HxEsA40V1M46tP6OmhB2lx9fwzhpCQ5xGbH3n1Ej7gzmQOo3WEXywfBqOw-DsCVbt-hEpKVbMZn6npffwDT1BSPcDnaWeEHbwzpMkrAapPdYpFIvCGiZTUuzA4GUWVzNDI4H_aS8v1dv1tUezPU5E1oA1nVbd3S5UNez2UBXpm3TWcZ_bY0KSZwlj4a0a5iKpHdpeBw2ppQjrxFmrgcPsEuEvEhtyE8bmD24_N2bp-SvRvzsfeKwvdjLUwC3SzZGxr-rWSN0Ag052dxDLUsoi3j_1NWRnY7JwdHb02dn0lbxVdqtsK6v8XIE1y5JFS_mfErcP202WZDhrW9n3H8881mfleTp01UqmRKsGFvWepZn3myMxP5L66TSPb6lHp87Yt8f7f2j__3Z4IIc0OQNX2kPji1ca5lBcPqIffJNKX5TpxWZOHX8DJEukHe0DAAA=)

### Logs

```shell

```

### System Info

```shell
irrelevant
```

### Severity

annoyance


Discussion:
If you use [`on` from `svelte/events`](https://svelte.dev/docs/svelte/svelte-events#on) instead of `addEventListener`, there will be no errors 🤔
The problem is that abort controller runs sync

```ts
const controller = new AbortController();
let x;
controller.signal.addEventListener("abort", ()=>{
    x = 42;
});
controller.abort();
console.log(x); // 42
```
which means that once the `test.value` changes, the derived is about to be run again, the controller is aborted (since the derived value changed) and the listener is executed so you are technically assigning in a derived.

I tried locally to move the abort before setting the reaction and the error goes away but i'm not sure if the behaviour can cause problems (for example in this example the derived is executed again but never get's a new value since you are setting `test.value` back to false right before executing it.

Not really sure if this is a bug 🤔
Maybe calling abort could be wrapped within untrack internally?

Not sure, but it feels a bit unexpected and not aligned with other cases. Like listening for click
> Like listening for click

That's my concert with untracking...onclick and abort controller are different: one is emitted the next microtask one is sync...it's definitely unexpected but is JS unexpected, not svelte unexpected.

That said not `untrack`ing would mean that reading a variable in the abort controller could mean having a new dependency in the derived so i think we should do it. Let me see what the others thinks
> it's definitely unexpected but is JS unexpected, not svelte unexpected

That is true, though the problem is the way svelte behaves will depend on where `abort` is called. If it's called outside tracking everything will be fine, however if it called inside - we get a problem. So moving one line can change things badly for the end user.

So while I wholeheartedly agree that it's not a svelte problem it feels like a footgun svelte can workaround for better DX



Patch:
diff --git a/.changeset/khaki-feet-teach.md b/.changeset/khaki-feet-teach.md
new file mode 100644
index 000000000000..51b724428f0a
--- /dev/null
+++ b/.changeset/khaki-feet-teach.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: wrap `abort` in `without_reactive_context`
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index f44efa32f1ca..68a155503237 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -42,6 +42,7 @@ import { get_next_sibling } from '../dom/operations.js';
 import { component_context, dev_current_component_function, dev_stack } from '../context.js';
 import { Batch, schedule_effect } from './batch.js';
 import { flatten } from './async.js';
+import { without_reactive_context } from '../dom/elements/bindings/shared.js';
 
 /**
  * @param {'$effect' | '$effect.pre' | '$inspect'} rune
@@ -406,7 +407,13 @@ export function destroy_effect_children(signal, remove_dom = false) {
 	signal.first = signal.last = null;
 
 	while (effect !== null) {
-		effect.ac?.abort(STALE_REACTION);
+		const controller = effect.ac;
+
+		if (controller !== null) {
+			without_reactive_context(() => {
+				controller.abort(STALE_REACTION);
+			});
+		}
 
 		var next = effect.next;
 
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 3d760e4b9ef7..22a1890e0f50 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -46,6 +46,7 @@ import { Batch, batch_deriveds, flushSync, schedule_effect } from './reactivity/
 import { handle_error } from './error-handling.js';
 import { UNINITIALIZED } from '../../constants.js';
 import { captured_signals } from './legacy.js';
+import { without_reactive_context } from './dom/elements/bindings/shared.js';
 
 export let is_updating_effect = false;
 
@@ -278,7 +279,10 @@ export function update_reaction(reaction) {
 	update_version = ++read_version;
 
 	if (reaction.ac !== null) {
-		reaction.ac.abort(STALE_REACTION);
+		without_reactive_context(() => {
+			/** @type {AbortController} */ (reaction.ac).abort(STALE_REACTION);
+		});
+
 		reaction.ac = null;
 	}
 


Test patch:
diff --git a/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/_config.js b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/_config.js
new file mode 100644
index 000000000000..2dacf188d7bc
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/_config.js
@@ -0,0 +1,12 @@
+import { ok, test } from '../../test';
+import { flushSync } from 'svelte';
+
+export default test({
+	async test({ assert, target, errors }) {
+		const btn = target.querySelector('button');
+		flushSync(() => {
+			btn?.click();
+		});
+		assert.deepEqual(errors, []);
+	}
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/main.svelte b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/main.svelte
new file mode 100644
index 000000000000..ebefe38fb27f
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/abort-signal-derived-set-state/main.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+	import { getAbortSignal } from "svelte";
+
+	let aborted = $state(0);
+
+	let count = $state(0);
+
+	let der = $derived.by(()=>{
+		const signal = getAbortSignal();
+
+		signal.addEventListener("abort", () => {
+			try{
+				aborted++;
+			}catch(e){
+				console.error(e);
+			}
+		});
+		return count;
+	})
+</script>
+
+{der}
+
+<button onclick={() => count++}></button>
\ No newline at end of file