From 69b196d605b3cb90fc8a6b862c83282ce0fe44d2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 25 Feb 2026 09:41:54 -0500 Subject: [PATCH 1/4] fix: reinstate reactivity loss tracking --- .changeset/twelve-stars-serve.md | 5 ++++ .../src/internal/client/reactivity/async.js | 12 ++++----- .../internal/client/reactivity/deriveds.js | 13 +++++---- .../svelte/src/internal/client/runtime.js | 27 +++++++++---------- 4 files changed, 30 insertions(+), 27 deletions(-) create mode 100644 .changeset/twelve-stars-serve.md diff --git a/.changeset/twelve-stars-serve.md b/.changeset/twelve-stars-serve.md new file mode 100644 index 0000000000..6a4d7ecfa7 --- /dev/null +++ b/.changeset/twelve-stars-serve.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: reinstate reactivity loss tracking diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 31408ee8f9..3431d0ffa5 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -19,10 +19,10 @@ import { import { Batch, current_batch } from './batch.js'; import { async_derived, - current_async_effect, + active_async_effect, derived, derived_safe_equal, - set_from_async_derived + set_active_async_effect } from './deriveds.js'; import { aborted } from './effects.js'; @@ -120,7 +120,7 @@ export function capture() { if (activate_batch) previous_batch?.activate(); if (DEV) { - set_from_async_derived(null); + set_active_async_effect(null); set_dev_stack(previous_dev_stack); } }; @@ -152,11 +152,11 @@ export async function save(promise) { * @returns {Promise<() => T>} */ export async function track_reactivity_loss(promise) { - var previous_async_effect = current_async_effect; + var previous_async_effect = active_async_effect; var value = await promise; return () => { - set_from_async_derived(previous_async_effect); + set_active_async_effect(previous_async_effect); return value; }; } @@ -213,7 +213,7 @@ export function unset_context(deactivate_batch = true) { if (deactivate_batch) current_batch?.deactivate(); if (DEV) { - set_from_async_derived(null); + set_active_async_effect(null); set_dev_stack(null); } } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 7df7651294..272c85aad5 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -36,7 +36,6 @@ import { import { eager_effects, internal_set, set_eager_effects, source } from './sources.js'; import { get_error } from '../../shared/dev.js'; import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js'; -import { Boundary } from '../dom/blocks/boundary.js'; import { component_context } from '../context.js'; import { UNINITIALIZED } from '../../../constants.js'; import { batch_values, current_batch } from './batch.js'; @@ -45,11 +44,11 @@ import { deferred, includes, noop } from '../../shared/utils.js'; import { set_signal_status, update_derived_status } from './status.js'; /** @type {Effect | null} */ -export let current_async_effect = null; +export let active_async_effect = null; /** @param {Effect | null} v */ -export function set_from_async_derived(v) { - current_async_effect = v; +export function set_active_async_effect(v) { + active_async_effect = v; } export const recent_async_deriveds = new Set(); @@ -123,7 +122,7 @@ export function async_derived(fn, label, location) { var deferreds = new Map(); async_effect(() => { - if (DEV) current_async_effect = active_effect; + if (DEV) active_async_effect = active_effect; /** @type {ReturnType>} */ var d = deferred(); @@ -139,7 +138,7 @@ export function async_derived(fn, label, location) { unset_context(); } - if (DEV) current_async_effect = null; + if (DEV) active_async_effect = null; var batch = /** @type {Batch} */ (current_batch); @@ -156,7 +155,7 @@ export function async_derived(fn, label, location) { * @param {unknown} error */ const handler = (value, error = undefined) => { - current_async_effect = null; + if (DEV) active_async_effect = null; batch.activate(); diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 3260c24b8c..79e0f0991d 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -27,7 +27,7 @@ import { } from './constants.js'; import { old_values } from './reactivity/sources.js'; import { - destroy_derived_effects, + active_async_effect, execute_derived, freeze_derived_effects, recent_async_deriveds, @@ -58,6 +58,7 @@ import { UNINITIALIZED } from '../../constants.js'; import { captured_signals } from './legacy.js'; import { without_reactive_context } from './dom/elements/bindings/shared.js'; import { set_signal_status, update_derived_status } from './reactivity/status.js'; +import * as w from './warnings.js'; let is_updating_effect = false; @@ -568,19 +569,17 @@ export function get(signal) { } if (DEV) { - // TODO reinstate this, but make it actually work - // if (current_async_effect) { - // var tracking = (current_async_effect.f & REACTION_IS_UPDATING) !== 0; - // var was_read = current_async_effect.deps?.includes(signal); - - // if (!tracking && !untracking && !was_read) { - // w.await_reactivity_loss(/** @type {string} */ (signal.label)); + if ( + !untracking && + active_async_effect && + (active_async_effect.f & REACTION_IS_UPDATING) === 0 + ) { + w.await_reactivity_loss(/** @type {string} */ (signal.label)); - // var trace = get_error('traced at'); - // // eslint-disable-next-line no-console - // if (trace) console.warn(trace); - // } - // } + var trace = get_error('traced at'); + // eslint-disable-next-line no-console + if (trace) console.warn(trace); + } recent_async_deriveds.delete(signal); @@ -595,7 +594,7 @@ export function get(signal) { if (signal.trace) { signal.trace(); } else { - var trace = get_error('traced at'); + trace = get_error('traced at'); if (trace) { var entry = tracing_expressions.entries.get(signal); From 18ba2e33e57b291f778029f20a6d63d6a1b419aa Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 25 Feb 2026 09:53:42 -0500 Subject: [PATCH 2/4] reinstate tests --- .../_config.js | 23 ++++++++++--------- .../samples/async-reactivity-loss/_config.js | 3 --- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js index ce7cd6bd49..ea6efa9490 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js @@ -1,10 +1,8 @@ import { tick } from 'svelte'; import { test } from '../../test'; +import { normalise_trace_logs } from '../../../helpers.js'; export default test({ - // TODO reinstate - skip: true, - compileOptions: { dev: true }, @@ -15,13 +13,16 @@ export default test({ await tick(); assert.htmlEqual(target.innerHTML, '

3

'); - assert.equal( - warnings[0], - 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' - ); - - assert.equal(warnings[1].name, 'traced at'); - - assert.equal(warnings.length, 2); + assert.deepEqual(normalise_trace_logs(warnings), [ + { + log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`' + }, + { + log: 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' + }, + { + log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`' + } + ]); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js index ad333a573a..747648e83f 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js @@ -2,9 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - // TODO reinstate this - skip: true, - compileOptions: { dev: true }, From c1e560d38c9e94ffa462f5f0f90abb0e47a761e3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 25 Feb 2026 10:05:21 -0500 Subject: [PATCH 3/4] prevent duplicate warnings --- .../src/internal/client/reactivity/async.js | 4 +-- .../internal/client/reactivity/deriveds.js | 27 ++++++++++++++----- .../svelte/src/internal/client/runtime.js | 9 ++++--- .../_config.js | 6 ----- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 3431d0ffa5..959a796517 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -19,7 +19,7 @@ import { import { Batch, current_batch } from './batch.js'; import { async_derived, - active_async_effect, + reactivity_loss_tracker, derived, derived_safe_equal, set_active_async_effect @@ -152,7 +152,7 @@ export async function save(promise) { * @returns {Promise<() => T>} */ export async function track_reactivity_loss(promise) { - var previous_async_effect = active_async_effect; + var previous_async_effect = reactivity_loss_tracker; var value = await promise; return () => { diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 272c85aad5..25843ef519 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -43,12 +43,16 @@ import { increment_pending, unset_context } from './async.js'; import { deferred, includes, noop } from '../../shared/utils.js'; import { set_signal_status, update_derived_status } from './status.js'; -/** @type {Effect | null} */ -export let active_async_effect = null; +/** + * This allows us to track 'reactivity loss' that occurs when signals + * are read after a non-context-restoring `await`. Dev-only + * @type {{ effect: Effect, warned: boolean } | null} + */ +export let reactivity_loss_tracker = null; -/** @param {Effect | null} v */ +/** @param {{ effect: Effect, warned: boolean } | null} v */ export function set_active_async_effect(v) { - active_async_effect = v; + reactivity_loss_tracker = v; } export const recent_async_deriveds = new Set(); @@ -122,7 +126,12 @@ export function async_derived(fn, label, location) { var deferreds = new Map(); async_effect(() => { - if (DEV) active_async_effect = active_effect; + if (DEV) { + reactivity_loss_tracker = { + effect: /** @type {Effect} */ (active_effect), + warned: false + }; + } /** @type {ReturnType>} */ var d = deferred(); @@ -138,7 +147,9 @@ export function async_derived(fn, label, location) { unset_context(); } - if (DEV) active_async_effect = null; + if (DEV) { + reactivity_loss_tracker = null; + } var batch = /** @type {Batch} */ (current_batch); @@ -155,7 +166,9 @@ export function async_derived(fn, label, location) { * @param {unknown} error */ const handler = (value, error = undefined) => { - if (DEV) active_async_effect = null; + if (DEV) { + reactivity_loss_tracker = null; + } batch.activate(); diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 79e0f0991d..906d68fbf0 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -27,7 +27,7 @@ import { } from './constants.js'; import { old_values } from './reactivity/sources.js'; import { - active_async_effect, + reactivity_loss_tracker, execute_derived, freeze_derived_effects, recent_async_deriveds, @@ -571,9 +571,12 @@ export function get(signal) { if (DEV) { if ( !untracking && - active_async_effect && - (active_async_effect.f & REACTION_IS_UPDATING) === 0 + reactivity_loss_tracker && + !reactivity_loss_tracker.warned && + (reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0 ) { + reactivity_loss_tracker.warned = true; + w.await_reactivity_loss(/** @type {string} */ (signal.label)); var trace = get_error('traced at'); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js index ea6efa9490..a5dd7fa28a 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js @@ -14,12 +14,6 @@ export default test({ assert.htmlEqual(target.innerHTML, '

3

'); assert.deepEqual(normalise_trace_logs(warnings), [ - { - log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`' - }, - { - log: 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' - }, { log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`' } From 9860a36a17f304977549545e66b8b8d395890cb4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 25 Feb 2026 10:07:56 -0500 Subject: [PATCH 4/4] rename --- packages/svelte/src/internal/client/reactivity/async.js | 8 ++++---- .../svelte/src/internal/client/reactivity/deriveds.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 959a796517..e0f1f086e3 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -22,7 +22,7 @@ import { reactivity_loss_tracker, derived, derived_safe_equal, - set_active_async_effect + set_reactivity_loss_tracker } from './deriveds.js'; import { aborted } from './effects.js'; @@ -120,7 +120,7 @@ export function capture() { if (activate_batch) previous_batch?.activate(); if (DEV) { - set_active_async_effect(null); + set_reactivity_loss_tracker(null); set_dev_stack(previous_dev_stack); } }; @@ -156,7 +156,7 @@ export async function track_reactivity_loss(promise) { var value = await promise; return () => { - set_active_async_effect(previous_async_effect); + set_reactivity_loss_tracker(previous_async_effect); return value; }; } @@ -213,7 +213,7 @@ export function unset_context(deactivate_batch = true) { if (deactivate_batch) current_batch?.deactivate(); if (DEV) { - set_active_async_effect(null); + set_reactivity_loss_tracker(null); set_dev_stack(null); } } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 25843ef519..415a167f9c 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -51,7 +51,7 @@ import { set_signal_status, update_derived_status } from './status.js'; export let reactivity_loss_tracker = null; /** @param {{ effect: Effect, warned: boolean } | null} v */ -export function set_active_async_effect(v) { +export function set_reactivity_loss_tracker(v) { reactivity_loss_tracker = v; }