From 23136f400857a9a031b45771bc09f8b4fc77c8a5 Mon Sep 17 00:00:00 2001 From: gzuuus Date: Fri, 18 Sep 2026 17:21:22 +0100 Subject: [PATCH] fix(svelte-query): untrack observer subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For queries without cached data, queryFn executes synchronously inside the subscription effect (onSubscribe -> shouldFetchOnMount -> fetch). Any reactive state read by queryFn before its first await was therefore recorded as a dependency of that effect: writes to the state tore the observer down (cancelling the in-flight fetch) and re-subscribed, starting a new fetch from a still-dataless query — indefinitely. Keep the observer read tracked so changing options still re-subscribes, but run the subscription itself inside untrack(). --- .changeset/svelte-query-untrack-subscribe.md | 5 +++ .../src/createBaseQuery.svelte.ts | 10 ++++- .../svelte-query/src/createQueries.svelte.ts | 10 ++++- .../createQueries.svelte.test.ts | 40 ++++++++++++++++++ .../createQuery/createQuery.svelte.test.ts | 42 ++++++++++++++++++- 5 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 .changeset/svelte-query-untrack-subscribe.md diff --git a/.changeset/svelte-query-untrack-subscribe.md b/.changeset/svelte-query-untrack-subscribe.md new file mode 100644 index 00000000000..decbfb25368 --- /dev/null +++ b/.changeset/svelte-query-untrack-subscribe.md @@ -0,0 +1,5 @@ +--- +'@tanstack/svelte-query': patch +--- + +Run the observer subscription inside `untrack()` so that reactive state read by a `queryFn` before its first `await` can no longer become a dependency of the subscription effect, which tore the observer down (cancelling the in-flight fetch) and re-subscribed on every write to that state. diff --git a/packages/svelte-query/src/createBaseQuery.svelte.ts b/packages/svelte-query/src/createBaseQuery.svelte.ts index 03fc6b28db4..e03fef848e1 100644 --- a/packages/svelte-query/src/createBaseQuery.svelte.ts +++ b/packages/svelte-query/src/createBaseQuery.svelte.ts @@ -1,3 +1,4 @@ +import { untrack } from 'svelte' import { useIsRestoring } from './useIsRestoring.js' import { useQueryClient } from './useQueryClient.js' import { createRawRef } from './containers.svelte.js' @@ -72,9 +73,16 @@ export function createBaseQuery< ) $effect(() => { + // Keep the observer read tracked (changing options must re-subscribe), but + // untrack the subscription itself: subscribing synchronously executes + // `queryFn` for queries without cached data, and any reactive state it reads before + // its first `await` would otherwise become a dependency of this effect. + // Writes to that state would then tear the observer down (cancelling the + // in-flight fetch) and re-subscribe indefinitely. + const o = observer const unsubscribe = isRestoring.current ? () => undefined - : observer.subscribe(() => update(createResult())) + : untrack(() => o.subscribe(() => update(createResult()))) observer.updateResult() return unsubscribe }) diff --git a/packages/svelte-query/src/createQueries.svelte.ts b/packages/svelte-query/src/createQueries.svelte.ts index 96ea1a9443a..1f840ae698c 100644 --- a/packages/svelte-query/src/createQueries.svelte.ts +++ b/packages/svelte-query/src/createQueries.svelte.ts @@ -1,4 +1,5 @@ import { QueriesObserver } from '@tanstack/query-core' +import { untrack } from 'svelte' import { useIsRestoring } from './useIsRestoring.js' import { createRawRef } from './containers.svelte.js' import { useQueryClient } from './useQueryClient.js' @@ -308,9 +309,16 @@ export function createQueries< const [results, update] = createRawRef(createResult()) $effect(() => { + // Keep the observer read tracked (changing queries must re-subscribe), but + // untrack the subscription itself: subscribing synchronously executes + // `queryFn` for queries without cached data, and any reactive state it reads before + // its first `await` would otherwise become a dependency of this effect. + // Writes to that state would then tear the observer down (cancelling the + // in-flight fetch) and re-subscribe indefinitely. + const o = observer const unsubscribe = isRestoring.current ? () => undefined - : observer.subscribe(() => update(createResult())) + : untrack(() => o.subscribe(() => update(createResult()))) return unsubscribe }) diff --git a/packages/svelte-query/tests/createQueries/createQueries.svelte.test.ts b/packages/svelte-query/tests/createQueries/createQueries.svelte.test.ts index 57e2e925209..c16d420e3b9 100644 --- a/packages/svelte-query/tests/createQueries/createQueries.svelte.test.ts +++ b/packages/svelte-query/tests/createQueries/createQueries.svelte.test.ts @@ -373,5 +373,45 @@ describe('createQueries', () => { expect(rendered.getByTestId('data2')).toHaveTextContent('undefined') expect(queryFn1).toHaveBeenCalledTimes(0) expect(queryFn2).toHaveBeenCalledTimes(0) + }) + it( + 'should not re-subscribe when queryFn reads reactive state before its first await', + withEffectRoot(async () => { + const key = queryKey() + const tick = ref(0) + const fetches: Array = [] + + const result = createQueries( + () => ({ + queries: [ + { + queryKey: key, + queryFn: async (ctx) => { + // consume the abort signal, like real transports do, so that + // tearing the observer down cancels the in-flight fetch + void ctx.signal + // reactive read before the first await: executing queryFn in + // the subscription effect used to track this state + const startedAt = tick.value + fetches.push(startedAt) + await sleep(150) + // write to the same state while the fetch is in flight + tick.value = startedAt + 1 + await sleep(150) + return startedAt + }, + }, + ], + }), + () => queryClient, + ) + + await vi.advanceTimersByTimeAsync(1000) + + expect(fetches.length).toBe(1) + expect(result[0].data).toBe(0) + expect(result[0].status).toBe('success') + }), + ) }) diff --git a/packages/svelte-query/tests/createQuery/createQuery.svelte.test.ts b/packages/svelte-query/tests/createQuery/createQuery.svelte.test.ts index 3864b369076..8edcaca793b 100644 --- a/packages/svelte-query/tests/createQuery/createQuery.svelte.test.ts +++ b/packages/svelte-query/tests/createQuery/createQuery.svelte.test.ts @@ -8,7 +8,11 @@ import { keepPreviousData, noop, } from '../../src/index.js' -import { promiseWithResolvers, withEffectRoot } from '../utils.svelte.js' +import { + promiseWithResolvers, + ref, + withEffectRoot, +} from '../utils.svelte.js' import Base from './Base.svelte' import Counter from './Counter.svelte' import IsRestoring from './IsRestoring.svelte' @@ -1649,5 +1653,41 @@ describe('createQuery', () => { expect(rendered.getByTestId('fetchStatus')).toHaveTextContent('idle') expect(rendered.getByTestId('data')).toHaveTextContent('undefined') expect(queryFn).toHaveBeenCalledTimes(0) + }) + it( + 'should not re-subscribe when queryFn reads reactive state before its first await', + withEffectRoot(async () => { + const key = queryKey() + const tick = ref(0) + const fetches: Array = [] + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: async (ctx) => { + // consume the abort signal, like real transports do, so that + // tearing the observer down cancels the in-flight fetch + void ctx.signal + // reactive read before the first await: executing queryFn in + // the subscription effect used to track this state + const startedAt = tick.value + fetches.push(startedAt) + await sleep(150) + // write to the same state while the fetch is in flight + tick.value = startedAt + 1 + await sleep(150) + return startedAt + }, + }), + () => queryClient, + ) + + await vi.advanceTimersByTimeAsync(1000) + + expect(fetches.length).toBe(1) + expect(query.data).toBe(0) + expect(query.status).toBe('success') + }), + ) })