Skip to content
Closed
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/shiny-walls-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/svelte-query': patch
---

fix(svelte-query): fix reactive observer subscription lifecycle during restoration
49 changes: 25 additions & 24 deletions 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 @@ -46,6 +47,7 @@ export function createBaseQuery<
resolvedOptions,
),
)

watchChanges(
() => client,
'pre',
Expand All @@ -66,42 +68,41 @@ export function createBaseQuery<
? observer.trackResult(result)
: result
}

const [query, update] = createRawRef(
// svelte-ignore state_referenced_locally - intentional, initial value
createResult(),
)

$effect(() => {
const unsubscribe = isRestoring.current
? () => undefined
: observer.subscribe(() => update(createResult()))
observer.updateResult()
return unsubscribe
})
const refreshResult = () => {
untrack(() => update(createResult()))
}

// Keep observer options updated before DOM flush
watchChanges(
() => resolvedOptions,
'pre',
() => {
observer.setOptions(resolvedOptions)
},
)
watchChanges(
() => [resolvedOptions, observer],
'pre',
() => {
// The only reason this is necessary is because of `isRestoring`.
// Because we don't subscribe while restoring, the following can occur:
// - `isRestoring` is true
// - `isRestoring` becomes false
// - `observer.subscribe` and `observer.updateResult` is called in the above effect,
// but the subsequent `fetch` has already completed
// - `result` misses the intermediate restored-but-not-fetched state
//
// this could technically be its own effect but that doesn't seem necessary
update(createResult())
},
)

// Manage subscription lifecycle reactively to prevent restoration race conditions
$effect(() => {
if (isRestoring.current) {
return
}

const unsubscribe = observer.subscribe(refreshResult)

// Surface any state that settled between render and subscription commit without tracking resolvedOptions
refreshResult()
observer.updateResult()

return () => {
unsubscribe()
}
})

return query
}
}
31 changes: 31 additions & 0 deletions packages/svelte-query/tests/createQuery/IsRestoringDynamic.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import {
setIsRestoringContext,
setQueryClientContext,
} from '../../src/context.js'
import { createQuery } from '../../src/index.js'

type Props = {
queryClient: QueryClient
queryFn: () => Promise<string>
queryKey: Array<string>
isRestoringRef: { current: boolean }
}

let { queryClient, queryFn, queryKey, isRestoringRef }: Props = $props()

setQueryClientContext(queryClient)
setIsRestoringContext(isRestoringRef)

const query = createQuery(() => ({
queryKey,
queryFn,
}))
</script>

<div>
<div data-testid="status">{query.status}</div>
<div data-testid="fetchStatus">{query.fetchStatus}</div>
<div data-testid="data">{query.data ?? 'undefined'}</div>
</div>
41 changes: 41 additions & 0 deletions packages/svelte-query/tests/createQuery/createQuery.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { promiseWithResolvers, withEffectRoot } from '../utils.svelte.js'
import Base from './Base.svelte'
import Counter from './Counter.svelte'
import IsRestoringDynamic from './IsRestoringDynamic.svelte'
import IsRestoring from './IsRestoring.svelte'
import Select from './Select.svelte'
import TwoQueries from './TwoQueries.svelte'
Expand Down Expand Up @@ -1650,4 +1651,44 @@ describe('createQuery', () => {
expect(rendered.getByTestId('data')).toHaveTextContent('undefined')
expect(queryFn).toHaveBeenCalledTimes(0)
})

it('should subscribe and fetch when isRestoring transitions to false', async () => {
const key = queryKey()
const queryFn = vi.fn().mockImplementation(async () => {
await sleep(10)
return 'restored-data'
})

const isRestoringRef = $state({ current: true })

const rendered = render(IsRestoringDynamic, {
props: {
queryClient,
queryFn,
queryKey: key,
isRestoringRef,
},
})

await vi.advanceTimersByTimeAsync(0)

// While restoring, observer should not fetch
expect(rendered.getByTestId('status')).toHaveTextContent('pending')
expect(rendered.getByTestId('fetchStatus')).toHaveTextContent('idle')
expect(queryFn).toHaveBeenCalledTimes(0)

// Complete restoration
isRestoringRef.current = false

// Wait for the effect to attach the subscription and start fetching
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByTestId('fetchStatus')).toHaveTextContent('fetching')
expect(queryFn).toHaveBeenCalledTimes(1)

// Let the fetch resolve
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByTestId('status')).toHaveTextContent('success')
expect(rendered.getByTestId('fetchStatus')).toHaveTextContent('idle')
expect(rendered.getByTestId('data')).toHaveTextContent('restored-data')
})
})