diff --git a/.changeset/strong-cows-jump.md b/.changeset/strong-cows-jump.md
new file mode 100644
index 000000000000..600352e584d4
--- /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 587d76623331..bd47c4398c7c 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 0f1aff8f5aa7..9868120729fd 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 bf890627f7e0..24b36ce90bc9 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 3e8c4a00c833..4a1e6831d367 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -18,7 +18,10 @@ import {
set_derived_sources,
check_dirtiness,
set_is_flushing_effect,
- is_flushing_effect
+ is_flushing_effect,
+ unsafe_mutating,
+ unsafe_sources,
+ set_unsafe_sources
} from '../runtime.js';
import { equals, safe_equals } from './equality.js';
import {
@@ -147,6 +150,7 @@ export function mutate(source, value) {
export function set(source, value) {
if (
active_reaction !== null &&
+ !unsafe_mutating &&
is_runes() &&
(active_reaction.f & (DERIVED | BLOCK_EFFECT)) !== 0 &&
// If the source was created locally within the current derived, then
@@ -170,6 +174,14 @@ export function internal_set(source, value) {
source.v = value;
source.version = increment_version();
+ if (unsafe_mutating) {
+ if (unsafe_sources === null) {
+ set_unsafe_sources([source]);
+ } else {
+ unsafe_sources.push(source);
+ }
+ }
+
if (DEV && tracing_mode_flag) {
source.updated = get_stack('UpdatedAt');
}
@@ -227,9 +239,10 @@ export function internal_set(source, value) {
/**
* @param {Value} signal
* @param {number} status should be DIRTY or MAYBE_DIRTY
+ * @param {boolean} [unsafe] mark all reactions for unsafe mutations
* @returns {void}
*/
-function mark_reactions(signal, status) {
+export function mark_reactions(signal, status, unsafe = false) {
var reactions = signal.reactions;
if (reactions === null) return;
@@ -241,23 +254,25 @@ function mark_reactions(signal, status) {
var flags = reaction.f;
// Skip any effects that are already dirty
- if ((flags & DIRTY) !== 0) continue;
+ if ((flags & DIRTY) !== 0 && !unsafe) continue;
// In legacy mode, skip the current effect to prevent infinite loops
if (!runes && reaction === active_effect) continue;
- // Inspect effects need to run immediately, so that the stack trace makes sense
- if (DEV && (flags & INSPECT_EFFECT) !== 0) {
+ // Inspect effects need to run immediately, so that the stack trace makes sense.
+ // Skip doing this for the unsafe mutations as they will have already been added
+ // in the unsafe() wrapper
+ if (DEV && !unsafe && (flags & INSPECT_EFFECT) !== 0) {
inspect_effects.add(reaction);
continue;
}
set_signal_status(reaction, status);
- // If the signal a) was previously clean or b) is an unowned derived, then mark it
- if ((flags & (CLEAN | UNOWNED)) !== 0) {
+ // If the signal a) was previously clean or b) is an unowned derived then mark it
+ if ((flags & (CLEAN | UNOWNED)) !== 0 || unsafe) {
if ((flags & DERIVED) !== 0) {
- mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY);
+ mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY, unsafe);
} else {
schedule_effect(/** @type {Effect} */ (reaction));
}
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 4a90a219712f..f1f2baf6e35b 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -29,7 +29,7 @@ import {
} from './constants.js';
import { flush_tasks } from './dom/task.js';
import { add_owner } from './dev/ownership.js';
-import { internal_set, set, source } from './reactivity/sources.js';
+import { internal_set, mark_reactions, set, source } from './reactivity/sources.js';
import { destroy_derived, execute_derived, update_derived } from './reactivity/deriveds.js';
import * as e from './errors.js';
import { lifecycle_outside_component } from '../shared/errors.js';
@@ -78,6 +78,18 @@ let dev_effect_stack = [];
/** @type {null | Reaction} */
export let active_reaction = null;
+export let untracking = false;
+
+export let unsafe_mutating = false;
+
+/** @type {null | Source[]} */
+export let unsafe_sources = null;
+
+/** @param {Source[] | null} value */
+export function set_unsafe_sources(value) {
+ unsafe_sources = value;
+}
+
/** @param {null | Reaction} reaction */
export function set_active_reaction(reaction) {
active_reaction = reaction;
@@ -387,6 +399,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_mutating = unsafe_mutating;
var flags = reaction.f;
new_deps = /** @type {null | Value[]} */ (null);
@@ -396,6 +410,8 @@ export function update_reaction(reaction) {
skip_reaction = !is_flushing_effect && (flags & UNOWNED) !== 0;
derived_sources = null;
component_context = reaction.ctx;
+ untracking = false;
+ unsafe_mutating = false;
try {
var result = /** @type {Function} */ (0, reaction.fn)();
@@ -434,6 +450,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_mutating = previous_unsafe_mutating;
}
}
@@ -507,8 +525,10 @@ export function update_effect(effect) {
var previous_effect = active_effect;
var previous_component_context = component_context;
+ var previous_unsafe_sources = unsafe_sources;
active_effect = effect;
+ unsafe_sources = null;
if (DEV) {
var previous_component_fn = dev_current_component_function;
@@ -526,6 +546,16 @@ export function update_effect(effect) {
execute_effect_teardown(effect);
var teardown = update_reaction(effect);
effect.teardown = typeof teardown === 'function' ? teardown : null;
+
+ // If unsafe() has been used within the effect then we will need
+ // to re-invalidate any unsafe sources that were already invalidated
+ // to ensure consistency of the graph
+ if (unsafe_sources !== null && (effect.f & CLEAN) !== 0) {
+ for (let i = 0; i < /** @type {Source[]} */ (unsafe_sources).length; i++) {
+ mark_reactions(unsafe_sources[i], DIRTY, true);
+ }
+ }
+
effect.version = current_version;
if (DEV) {
@@ -535,6 +565,7 @@ export function update_effect(effect) {
handle_error(error, effect, previous_effect, previous_component_context || effect.ctx);
} finally {
active_effect = previous_effect;
+ unsafe_sources = previous_unsafe_sources;
if (DEV) {
dev_current_component_function = previous_component_fn;
@@ -856,7 +887,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 +1047,30 @@ 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_mutating = unsafe_mutating;
+ try {
+ unsafe_mutating = true;
+ return fn();
+ } finally {
+ unsafe_mutating = previous_unsafe_mutating;
}
}
diff --git a/packages/svelte/src/store/utils.js b/packages/svelte/src/store/utils.js
index db2a62c68c01..4cdd49a002d2 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 ea51f29dfb30..e5f3798948c3 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 @@
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 b9e77c47954b..76e287f28d76 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 @@
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 dc39503e3a94..3bd205b4c756 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 @@
diff --git a/packages/svelte/tests/signals/test.ts b/packages/svelte/tests/signals/test.ts
index 6796655cc8d2..544868b17f04 100644
--- a/packages/svelte/tests/signals/test.ts
+++ b/packages/svelte/tests/signals/test.ts
@@ -781,4 +781,115 @@ describe('signals', () => {
assert.equal($.get(count), 0n);
};
});
+
+ test('unsafe() correctly ensures graph consistency', () => {
+ return () => {
+ const output: any[] = [];
+
+ const destroy = effect_root(() => {
+ const a = state(0);
+ const b = state(0);
+ const c = derived(() => {
+ $.unsafe(() => {
+ set(b, $.get(a) + 1);
+ });
+ return $.get(a);
+ });
+
+ render_effect(() => {
+ output.push('b' + $.get(b));
+ });
+
+ render_effect(() => {
+ output.push('b' + $.get(b), 'c' + $.get(c));
+ });
+
+ flushSync();
+
+ set(a, 1);
+
+ flushSync();
+ });
+
+ destroy();
+
+ assert.deepEqual(output, ['b0', 'b0', 'c0', 'b1', 'b1', 'c0', 'b2', 'c1', 'b2']);
+ };
+ });
+
+ test('unsafe() correctly ensures graph consistency #2', () => {
+ return () => {
+ const output: any[] = [];
+
+ const destroy = effect_root(() => {
+ const a = state(0);
+ const b = state(0);
+ const c = derived(() => {
+ $.unsafe(() => {
+ set(b, $.get(a) + 1);
+ });
+ return $.get(a);
+ });
+ let d = derived(() => $.get(b));
+
+ render_effect(() => {
+ output.push('d' + $.get(d));
+ });
+
+ render_effect(() => {
+ output.push('d' + $.get(d), 'c' + $.get(c));
+ });
+
+ flushSync();
+
+ set(a, 1);
+
+ flushSync();
+ });
+
+ destroy();
+
+ assert.deepEqual(output, ['d0', 'd0', 'c0', 'd1', 'd1', 'c0', 'd2', 'c1', 'd2']);
+ };
+ });
+
+ test('unsafe() correctly ensures graph consistency #3', () => {
+ return () => {
+ const output: any[] = [];
+
+ const destroy = effect_root(() => {
+ const a = state(0);
+ const b = state(0);
+ const c = derived(() => {
+ $.unsafe(() => {
+ set(b, $.get(a) + 1);
+ });
+ return $.get(a);
+ });
+ let d = state(true);
+ let e = derived(() => $.get(b));
+
+ render_effect(() => {
+ if ($.get(d)) {
+ return;
+ }
+ output.push('e' + $.get(e), 'c' + $.get(c));
+ });
+
+ flushSync();
+
+ set(d, false);
+
+ flushSync();
+
+ set(a, 1);
+
+ flushSync();
+ });
+
+ destroy();
+
+ assert.deepEqual(output, ['e0', 'c0', 'e1', 'c0', 'e2', 'c1']);
+ };
+ });
});
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index d422abebbc0f..d126945d910f 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -484,6 +484,12 @@ declare module 'svelte' {
* ```
* */
export function untrack(fn: () => T): T;
+ /**
+ * When used inside a [`$derived`](https://svelte.dev/docs/svelte/$derived),
+ * any state updates to state is allowed.
+ *
+ * */
+ export function unsafe(fn: () => T): T;
/**
* Retrieves the context that belongs to the closest parent component with the specified `key`.
* Must be called during component initialisation.