Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/svelte-query-untrack-subscribe.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion packages/svelte-query/src/createBaseQuery.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { untrack } from 'svelte'
import { useIsRestoring } from './useIsRestoring.js'
import { useQueryClient } from './useQueryClient.js'
import { createRawRef } from './containers.svelte.js'
Expand Down Expand Up @@ -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
})
Expand Down
10 changes: 9 additions & 1 deletion packages/svelte-query/src/createQueries.svelte.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -308,9 +309,16 @@ export function createQueries<
const [results, update] = createRawRef<TCombinedResult>(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
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> = []

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')
}),
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<number> = []

const query = createQuery<number, Error>(
() => ({
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')
}),
)
})