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/solid-suspense-background-refetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-query': patch
---

Stop background refetches and cached-data mounts from re-triggering enclosing `<Suspense>` boundaries. Every observer update used to pass through the resource's Promise path, suspending the boundary for a microtask — which detached and re-inserted its DOM, restarting CSS animations and resetting focus/scroll/iframe state even though no fallback was ever painted. Queries now resolve synchronously when data is available, so Suspense only triggers on genuine initial loads.
87 changes: 86 additions & 1 deletion packages/solid-query/src/__tests__/suspense.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,9 @@ describe("useQuery's in Suspense mode", () => {
expect(rendered.getByText('show')).toBeInTheDocument()

fireEvent.click(rendered.getByText('show'))
expect(rendered.getByText('loading')).toBeInTheDocument()
// Cached data renders immediately without suspending; the mount refetch
// happens in the background
expect(rendered.getByText('data: 1')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('fetching: true')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(100)
Expand Down Expand Up @@ -895,4 +897,87 @@ describe("useQuery's in Suspense mode", () => {
expect(renders).toBe(2)
expect(rendered.queryByText('rendered')).toBeInTheDocument()
})

it('should not trigger Suspense when mounting with cached data, even while a background refetch runs', async () => {
const key = queryKey()
let fetches = 0
let fallbackRenders = 0

queryClient.setQueryData(key, 'cached')

function Fallback() {
fallbackRenders++
return <div>loading</div>
}

function Page() {
const query = useQuery(() => ({
queryKey: key,
queryFn: () => sleep(10).then(() => `data${++fetches}`),
staleTime: 0,
}))

return <div>content: {query.data}</div>
}

const rendered = renderWithClient(queryClient, () => (
<Suspense fallback={<Fallback />}>
<Page />
</Suspense>
))

// Cached data renders immediately without suspending
expect(rendered.getByText('content: cached')).toBeInTheDocument()
const contentElement = rendered.getByText('content: cached')

// staleTime: 0 kicks off a background refetch on mount; it must not
// suspend the boundary (a suspended boundary detaches its DOM, which
// restarts CSS animations even if the fallback is never painted)
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('content: data1')).toBeInTheDocument()

// Same DOM node throughout — no remount, no detach/re-insert
expect(rendered.getByText('content: data1')).toBe(contentElement)
expect(fallbackRenders).toBe(0)
})

it('should not re-trigger Suspense when an invalidation refetches a mounted query', async () => {
const key = queryKey()
let fetches = 0
let fallbackRenders = 0

function Fallback() {
fallbackRenders++
return <div>loading</div>
}

function Page() {
const query = useQuery(() => ({
queryKey: key,
queryFn: () => sleep(10).then(() => `data${++fetches}`),
}))

return <div>content: {query.data}</div>
}

const rendered = renderWithClient(queryClient, () => (
<Suspense fallback={<Fallback />}>
<Page />
</Suspense>
))

// Initial load has no data and should suspend exactly once
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('content: data1')).toBeInTheDocument()
expect(fallbackRenders).toBe(1)
const contentElement = rendered.getByText('content: data1')

// A background refetch of a mounted query must not suspend again
queryClient.invalidateQueries({ queryKey: key })
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('content: data2')).toBeInTheDocument()

expect(rendered.getByText('content: data2')).toBe(contentElement)
expect(fallbackRenders).toBe(1)
})
})
88 changes: 66 additions & 22 deletions packages/solid-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,34 +240,78 @@ export function useBaseQuery<
const [queryResource, { refetch }] = createResource<ResourceData | undefined>(
() => {
const obs = observer()
return new Promise((resolve, reject) => {
resolver = resolve
if (isServer) {
const shouldThrowCurrentError = () =>
observerResult.isError &&
!observerResult.isFetching &&
!isRestoring() &&
shouldThrowError(obs.options.throwOnError, [
observerResult.error,
obs.getCurrentQuery(),
])

if (isServer) {
return new Promise((resolve, reject) => {
resolver = resolve
unsubscribe = createServerSubscriber(resolve, reject)
} else if (!unsubscribe && !isRestoring()) {
unsubscribe = createClientSubscriber()
}
obs.updateResult()
obs.updateResult()

if (shouldThrowCurrentError()) {
setStateWithReconciliation(observerResult)
return reject(observerResult.error)
}
if (!observerResult.isLoading) {
resolver = null
return resolve(
hydratableObserverResult(obs.getCurrentQuery(), observerResult),
)
}

if (
observerResult.isError &&
!observerResult.isFetching &&
!isRestoring() &&
shouldThrowError(obs.options.throwOnError, [
observerResult.error,
obs.getCurrentQuery(),
])
) {
setStateWithReconciliation(observerResult)
return reject(observerResult.error)
}
if (!observerResult.isLoading) {
})
}

if (!unsubscribe && !isRestoring()) {
unsubscribe = createClientSubscriber()
}
obs.updateResult()

if (shouldThrowCurrentError()) {
setStateWithReconciliation(observerResult)
throw observerResult.error
}
/**
* When data is available and the observer is not in an initial loading
* state, we return the result synchronously (a non-thenable) instead of
* a resolved Promise. A Promise — even one that resolves within the same
* tick — leaves the resource in a pending/refreshing state for at least
* a microtask, which makes every enclosing <Suspense> boundary flip to
* its fallback and back. That flip detaches and re-inserts the
* boundary's DOM, restarting CSS animations and resetting
* focus/scroll/iframe state on every background refetch, even though no
* fallback is ever painted. Returning a plain value keeps the resource
* in its ready state, so Suspense is only triggered by genuine initial
* loads.
*
* The exception is when a previous fetcher promise is still pending
* (`resolver` is set): the boundary is already suspended — possibly
* inside a transition — and completing through the Promise path keeps
* Solid's Suspense/Transition bookkeeping intact.
*/
if (!observerResult.isLoading && observerResult.data !== undefined) {
const result = observerResult
if (resolver) {
resolver = null
return resolve(
hydratableObserverResult(obs.getCurrentQuery(), observerResult),
)
return new Promise((resolve) => resolve(result))
}
return result
}
if (!observerResult.isLoading) {
resolver = null
return new Promise((resolve) => resolve(observerResult))
}

return new Promise((resolve) => {
resolver = resolve
setStateWithReconciliation(observerResult)
})
},
Expand Down
Loading