From ed802760ed16329cf8ad8acdaa48e58823741048 Mon Sep 17 00:00:00 2001
From: Dominic Gannaway <dg@domgan.com>
Date: Thu, 19 Dec 2024 23:40:09 +0000
Subject: [PATCH] fix: make untrack behave correctly in relation to mutations

---
 .changeset/strong-cows-jump.md                |  5 +++
 packages/svelte/src/index-client.js           |  3 +-
 packages/svelte/src/index-server.js           |  3 +-
 .../src/internal/client/reactivity/effects.js |  5 ++-
 .../src/internal/client/reactivity/sources.js |  4 +-
 .../svelte/src/internal/client/runtime.js     | 37 +++++++++++++++++--
 packages/svelte/src/store/utils.js            |  4 +-
 .../samples/derived-map/main.svelte           |  6 +--
 .../each-block-default-arg/main.svelte        |  4 +-
 .../samples/snippet-default-arg/main.svelte   |  4 +-
 packages/svelte/types/index.d.ts              |  7 ++++
 11 files changed, 64 insertions(+), 18 deletions(-)
 create mode 100644 .changeset/strong-cows-jump.md

diff --git a/.changeset/strong-cows-jump.md b/.changeset/strong-cows-jump.md
new file mode 100644
index 0000000000..600352e584
--- /dev/null
+++ b/.changeset/strong-cows-jump.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: make untrack behave correctly in relation to mutations
diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js
index 587d766233..bd47c4398c 100644
--- a/packages/svelte/src/index-client.js
+++ b/packages/svelte/src/index-client.js
@@ -187,7 +187,8 @@ export {
 	hasContext,
 	setContext,
 	tick,
-	untrack
+	untrack,
+	unsafe
 } from './internal/client/runtime.js';
 
 export { createRawSnippet } from './internal/client/dom/blocks/snippet.js';
diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js
index 0f1aff8f5a..9868120729 100644
--- a/packages/svelte/src/index-server.js
+++ b/packages/svelte/src/index-server.js
@@ -14,7 +14,8 @@ export {
 	noop as afterUpdate,
 	noop as onMount,
 	noop as flushSync,
-	run as untrack
+	run as untrack,
+	run as unsafe
 } from './internal/shared/utils.js';
 
 export function createEventDispatcher() {
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index bf890627f7..24b36ce90b 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -16,7 +16,8 @@ import {
 	set_is_flushing_effect,
 	set_signal_status,
 	untrack,
-	skip_reaction
+	skip_reaction,
+	untracking
 } from '../runtime.js';
 import {
 	DIRTY,
@@ -164,7 +165,7 @@ function create_effect(type, fn, sync, push = true) {
  * @returns {boolean}
  */
 export function effect_tracking() {
-	if (active_reaction === null) {
+	if (active_reaction === null || untracking) {
 		return false;
 	}
 
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 3e8c4a00c8..921151cfa9 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -18,7 +18,8 @@ import {
 	set_derived_sources,
 	check_dirtiness,
 	set_is_flushing_effect,
-	is_flushing_effect
+	is_flushing_effect,
+	unsafe_mutations
 } from '../runtime.js';
 import { equals, safe_equals } from './equality.js';
 import {
@@ -147,6 +148,7 @@ export function mutate(source, value) {
 export function set(source, value) {
 	if (
 		active_reaction !== null &&
+		!unsafe_mutations &&
 		is_runes() &&
 		(active_reaction.f & (DERIVED | BLOCK_EFFECT)) !== 0 &&
 		// If the source was created locally within the current derived, then
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 4a90a21971..9dd18a6948 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -78,6 +78,10 @@ let dev_effect_stack = [];
 /** @type {null | Reaction} */
 export let active_reaction = null;
 
+export let untracking = false;
+
+export let unsafe_mutations = false;
+
 /** @param {null | Reaction} reaction */
 export function set_active_reaction(reaction) {
 	active_reaction = reaction;
@@ -387,6 +391,8 @@ export function update_reaction(reaction) {
 	var previous_skip_reaction = skip_reaction;
 	var prev_derived_sources = derived_sources;
 	var previous_component_context = component_context;
+	var previous_untracking = untracking;
+	var previous_unsafe_mutations = unsafe_mutations;
 	var flags = reaction.f;
 
 	new_deps = /** @type {null | Value[]} */ (null);
@@ -396,6 +402,8 @@ export function update_reaction(reaction) {
 	skip_reaction = !is_flushing_effect && (flags & UNOWNED) !== 0;
 	derived_sources = null;
 	component_context = reaction.ctx;
+	untracking = false;
+	unsafe_mutations = false;
 
 	try {
 		var result = /** @type {Function} */ (0, reaction.fn)();
@@ -434,6 +442,8 @@ export function update_reaction(reaction) {
 		skip_reaction = previous_skip_reaction;
 		derived_sources = prev_derived_sources;
 		component_context = previous_component_context;
+		untracking = previous_untracking;
+		unsafe_mutations = previous_unsafe_mutations;
 	}
 }
 
@@ -856,7 +866,7 @@ export function get(signal) {
 	}
 
 	// Register the dependency on the current reaction signal.
-	if (active_reaction !== null) {
+	if (active_reaction !== null && !untracking) {
 		if (derived_sources !== null && derived_sources.includes(signal)) {
 			e.state_unsafe_local_read();
 		}
@@ -1016,12 +1026,31 @@ export function invalidate_inner_signals(fn) {
  * @returns {T}
  */
 export function untrack(fn) {
-	const previous_reaction = active_reaction;
+	var previous_untracking = untracking;
 	try {
-		active_reaction = null;
+		untracking = true;
 		return fn();
 	} finally {
-		active_reaction = previous_reaction;
+		untracking = previous_untracking;
+	}
+}
+
+/**
+ * When used inside a [`$derived`](https://svelte.dev/docs/svelte/$derived),
+ * any state updates to state is allowed.
+ *
+ * ```
+ * @template T
+ * @param {() => T} fn
+ * @returns {T}
+ */
+export function unsafe(fn) {
+	var previous_unsafe_mutations = unsafe_mutations;
+	try {
+		unsafe_mutations = true;
+		return fn();
+	} finally {
+		unsafe_mutations = previous_unsafe_mutations;
 	}
 }
 
diff --git a/packages/svelte/src/store/utils.js b/packages/svelte/src/store/utils.js
index db2a62c68c..4cdd49a002 100644
--- a/packages/svelte/src/store/utils.js
+++ b/packages/svelte/src/store/utils.js
@@ -1,5 +1,5 @@
 /** @import { Readable } from './public' */
-import { untrack } from '../index-client.js';
+import { unsafe } from '../index-client.js';
 import { noop } from '../internal/shared/utils.js';
 
 /**
@@ -22,7 +22,7 @@ export function subscribe_to_store(store, run, invalidate) {
 
 	// Svelte store takes a private second argument
 	// StartStopNotifier could mutate state, and we want to silence the corresponding validation error
-	const unsub = untrack(() =>
+	const unsub = unsafe(() =>
 		store.subscribe(
 			run,
 			// @ts-expect-error
diff --git a/packages/svelte/tests/runtime-runes/samples/derived-map/main.svelte b/packages/svelte/tests/runtime-runes/samples/derived-map/main.svelte
index ea51f29dfb..e5f3798948 100644
--- a/packages/svelte/tests/runtime-runes/samples/derived-map/main.svelte
+++ b/packages/svelte/tests/runtime-runes/samples/derived-map/main.svelte
@@ -1,5 +1,5 @@
 <script>
-	import { untrack } from 'svelte';
+	import { unsafe } from 'svelte';
 	import { SvelteMap } from 'svelte/reactivity';
 
 	const cache = new SvelteMap();
@@ -13,7 +13,7 @@
 				cache.set(id, id.toString());
 			}).then(() => cache.get(id));
 
-			untrack(() => {
+			unsafe(() => {
 				cache.set(id, promise);
 			});
 
@@ -25,7 +25,7 @@
 
 	const value = $derived(get_async(1));
 	const value2 = $derived(get_async(1));
-	// both values are read before the set 
+	// both values are read before the set
 	value;
 	value2;
 </script>
diff --git a/packages/svelte/tests/runtime-runes/samples/each-block-default-arg/main.svelte b/packages/svelte/tests/runtime-runes/samples/each-block-default-arg/main.svelte
index b9e77c4795..76e287f28d 100644
--- a/packages/svelte/tests/runtime-runes/samples/each-block-default-arg/main.svelte
+++ b/packages/svelte/tests/runtime-runes/samples/each-block-default-arg/main.svelte
@@ -1,9 +1,9 @@
 <script>
-	import { untrack } from 'svelte';
+	import { unsafe, untrack } from 'svelte';
 
 	let count = $state(0);
 	function default_arg() {
-		untrack(() => count++);
+		untrack(() => unsafe(() => count++));
 		return 1;
 	}
 </script>
diff --git a/packages/svelte/tests/runtime-runes/samples/snippet-default-arg/main.svelte b/packages/svelte/tests/runtime-runes/samples/snippet-default-arg/main.svelte
index dc39503e3a..3bd205b4c7 100644
--- a/packages/svelte/tests/runtime-runes/samples/snippet-default-arg/main.svelte
+++ b/packages/svelte/tests/runtime-runes/samples/snippet-default-arg/main.svelte
@@ -1,9 +1,9 @@
 <script>
-	import { untrack } from 'svelte';
+	import { unsafe, untrack } from 'svelte';
 
 	let count = $state(0);
 	function default_arg() {
-		untrack(() => count++);
+		untrack(() => unsafe(() => count++));
 		return 1;
 	}
 </script>
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index d422abebbc..68cf4fde8b 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -484,6 +484,13 @@ declare module 'svelte' {
 	 * ```
 	 * */
 	export function untrack<T>(fn: () => T): T;
+	/**
+	 * When used inside a [`$derived`](https://svelte.dev/docs/svelte/$derived),
+	 * any state updates to state is allowed.
+	 *
+	 * ```
+	 * */
+	export function unsafe<T>(fn: () => T): T;
 	/**
 	 * Retrieves the context that belongs to the closest parent component with the specified `key`.
 	 * Must be called during component initialisation.