diff --git a/.changeset/solid-suspense-background-refetch.md b/.changeset/solid-suspense-background-refetch.md new file mode 100644 index 0000000000..dc18f43289 --- /dev/null +++ b/.changeset/solid-suspense-background-refetch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-query': patch +--- + +Stop background refetches and cached-data mounts from re-triggering enclosing `` 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. diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx index b3aa93e4b7..f4f3b41e57 100644 --- a/packages/solid-query/src/__tests__/suspense.test.tsx +++ b/packages/solid-query/src/__tests__/suspense.test.tsx @@ -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) @@ -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
loading
+ } + + function Page() { + const query = useQuery(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => `data${++fetches}`), + staleTime: 0, + })) + + return
content: {query.data}
+ } + + const rendered = renderWithClient(queryClient, () => ( + }> + + + )) + + // 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
loading
+ } + + function Page() { + const query = useQuery(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => `data${++fetches}`), + })) + + return
content: {query.data}
+ } + + const rendered = renderWithClient(queryClient, () => ( + }> + + + )) + + // 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) + }) }) diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index 42096176f6..171c7daa2c 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -240,34 +240,78 @@ export function useBaseQuery< const [queryResource, { refetch }] = createResource( () => { 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 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) }) },