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.