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)
})
},