From cb08a8fea3e99ec3694dc65c4277ae669815feb1 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Tue, 18 Aug 2026 17:50:35 -0500 Subject: [PATCH] fix(solid-query): don't re-trigger Suspense on background refetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every query observer update was funneled through the underlying resource's refetch(), whose fetcher always returned a Promise. Even a Promise that resolves within the same tick leaves the resource in a pending/refreshing state for at least a microtask, so every enclosing boundary flipped to its fallback and back. The flip is never painted, but it detaches and re-inserts the boundary's DOM, restarting CSS animations and resetting focus/scroll/iframe state on every background refetch and on every mount with cached data. The fetcher now returns the observer result synchronously (a non-thenable) when data is available and the query is not in an initial loading state — Solid's resource completes such loads without ever entering a pending state, so Suspense is only triggered by genuine initial loads. Two exceptions preserve existing semantics: an in-flight initial load (pending resolver) still completes through the Promise path so Suspense/Transition bookkeeping resolves correctly, and no-data results (e.g. disabled queries) keep their previous behavior. Also updates the re-mount test, which codified the old suspend-on-remount-with-cached-data behavior; cached data now renders immediately while the mount refetch runs in the background, matching React's useSuspenseQuery semantics. Fixes #9955 Related: #9883, TanStack/router#8000 Co-Authored-By: Claude Fable 5 --- .../solid-suspense-background-refetch.md | 5 ++ .../src/__tests__/suspense.test.tsx | 87 +++++++++++++++++- packages/solid-query/src/useBaseQuery.ts | 88 ++++++++++++++----- 3 files changed, 157 insertions(+), 23 deletions(-) create mode 100644 .changeset/solid-suspense-background-refetch.md diff --git a/.changeset/solid-suspense-background-refetch.md b/.changeset/solid-suspense-background-refetch.md new file mode 100644 index 00000000000..dc18f43289a --- /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 b3aa93e4b78..f4f3b41e573 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 42096176f67..171c7daa2c4 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) }) },