From 05235f9ea53ed6df97bf95e4603405c0d5583e81 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Thu, 19 Feb 2026 03:12:12 +0900 Subject: [PATCH 1/6] feat(solid-query/useQueries): add Suspense support with 'createResource' following 'useBaseQuery' pattern --- .../src/__tests__/suspense.test.tsx | 819 ++++++++++++++++++ packages/solid-query/src/useQueries.ts | 257 ++++-- 2 files changed, 1014 insertions(+), 62 deletions(-) diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx index 8fbb86e9f3b..27a5dce40c6 100644 --- a/packages/solid-query/src/__tests__/suspense.test.tsx +++ b/packages/solid-query/src/__tests__/suspense.test.tsx @@ -9,11 +9,13 @@ import { on, } from 'solid-js' import { queryKey, sleep } from '@tanstack/query-test-utils' +import { onlineManager } from '@tanstack/query-core' import { QueryCache, QueryClient, QueryClientProvider, useInfiniteQuery, + useQueries, useQuery, } from '..' import type { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from '..' @@ -952,3 +954,820 @@ describe("useQuery's in Suspense mode", () => { expect(rendered.queryByText('rendered')).toBeInTheDocument() }) }) + +describe("useQueries's in Suspense mode", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + + it('should suspend when data is accessed in JSX', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 'data1'), + }, + { + queryKey: key2, + queryFn: () => sleep(20).then(() => 'data2'), + }, + ], + })) + + return ( +
+ data1: {String(queries[0].data)} data2: {String(queries[1].data)} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data1: data1 data2: data2')).toBeInTheDocument() + }) + + it('should not call the queryFn twice when used in Suspense mode', async () => { + const key = queryKey() + + const queryFn = vi.fn(() => sleep(10).then(() => 'data')) + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: [key], + queryFn, + }, + ], + })) + + return
data: {String(queries[0].data)}
+ } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should remove query instance when component unmounted', async () => { + const key = queryKey() + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }, + ], + })) + + return
data: {String(queries[0].data)}
+ } + + function App() { + const [show, setShow] = createSignal(false) + + return ( + <> + {show() && } + + data: {String(queries[0].data)} + + ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText(`data: ${key1}`)).toBeInTheDocument() + + fireEvent.click(rendered.getByText('switch')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText(`data: ${key2}`)).toBeInTheDocument() + }) + + it('should not suspend when queries are disabled', async () => { + const key = queryKey() + + const queryFn = vi.fn(() => sleep(10).then(() => 'data')) + + function Page() { + const [enabled, setEnabled] = createSignal(false) + + const queries = useQueries(() => ({ + queries: [ + { + queryKey: [key], + queryFn, + enabled: enabled(), + }, + ], + })) + + return ( +
+ + data: {String(queries[0].data)} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(0) + + await vi.advanceTimersByTimeAsync(10) + fireEvent.click(rendered.getByRole('button', { name: /enable/i })) + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data')).toBeInTheDocument() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should not suspend on mount if query has been already fetched', () => { + const key = queryKey() + + queryClient.setQueryData(key, 'cached-data') + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key, + queryFn: () => sleep(10).then(() => 'fresh-data'), + }, + ], + })) + + return
data: {String(queries[0].data)}
+ } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByText('data: cached-data')).toBeInTheDocument() + }) + + it('should suspend all queries in parallel', async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: Array = [] + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => + sleep(10).then(() => { + results.push('1') + return '1' + }), + }, + { + queryKey: key2, + queryFn: () => + sleep(20).then(() => { + results.push('2') + return '2' + }), + }, + ], + })) + + return ( +
+ data: {String(queries[0].data)},{String(queries[1].data)} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(20) + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() + + expect(results).toEqual(['1', '2']) + }) + + it('should only call combine after resolving', async () => { + const key = queryKey() + const combineSpy = vi.fn() + + function Page() { + const queries = useQueries(() => ({ + queries: [1, 2, 3].map((value) => ({ + queryKey: [...key, { value }], + queryFn: () => + sleep(value * 10).then(() => ({ value: value * 10 })), + })), + combine: (result) => { + combineSpy(result.map((r) => r.data)) + return result + }, + })) + + return ( +
+ data:{' '} + {queries + .map((q) => JSON.stringify(q.data)) + .join(',')} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(30) + + const resolvedCalls = combineSpy.mock.calls.filter((call) => + call[0].every((d: unknown) => d !== undefined), + ) + expect(resolvedCalls.length).toBeGreaterThanOrEqual(1) + expect(resolvedCalls[0]![0]).toEqual([ + { value: 10 }, + { value: 20 }, + { value: 30 }, + ]) + }) + + it('should handle duplicate query keys without infinite loops', async () => { + const key = queryKey() + let renderCount = 0 + + function getUserData() { + return { + queryKey: key, + queryFn: () => + sleep(10).then(() => ({ name: 'John Doe', age: 50 })), + } + } + + function getName() { + return { + ...getUserData(), + select: (data: { name: string; age: number }) => data.name, + } + } + + function getAge() { + return { + ...getUserData(), + select: (data: { name: string; age: number }) => data.age, + } + } + + function Page() { + renderCount++ + const queries = useQueries(() => ({ + queries: [getName(), getAge()], + })) + + return ( +
+ name: {String(queries[0].data)}, age: {String(queries[1].data)} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect( + rendered.getByText('name: John Doe, age: 50'), + ).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(100) + + expect(renderCount).toBeLessThan(10) + }) + + it('should not break suspense when queries change without resolving', async () => { + const key = queryKey() + + function Page(props: { ids: Array }) { + const queries = useQueries(() => ({ + queries: props.ids.map((id) => ({ + queryKey: [...key, id], + queryFn: () => sleep(10).then(() => id), + })), + })) + + return ( +
data: {queries.map((q) => String(q.data)).join(',')}
+ ) + } + + function App() { + const [ids, setIds] = createSignal([1, 2]) + + return ( + <> + + + + + + ) + } + + const rendered = render(() => ( + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + + // Change queries before they resolve + fireEvent.click(rendered.getByText('change')) + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: 3,4')).toBeInTheDocument() + }) + + it('should suspend only once per queries change', async () => { + const key = queryKey() + let suspenseCount = 0 + + function Fallback() { + suspenseCount++ + return
loading
+ } + + function Page() { + const [ids, setIds] = createSignal([1, 2]) + + const queries = useQueries(() => ({ + queries: ids().map((id) => ({ + queryKey: [...key, id], + queryFn: () => sleep(10).then(() => id), + })), + })) + + return ( +
+ + data: {queries.map((q) => String(q.data)).join(',')} +
+ ) + } + + function App() { + return ( + }> + + + ) + } + + const rendered = render(() => ( + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('change')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: 3,4')).toBeInTheDocument() + + expect(suspenseCount).toBe(2) + }) + + it("shouldn't unmount before all promises fetched", async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: Array = [] + + function Fallback() { + results.push('loading') + return
loading
+ } + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => + sleep(10).then(() => { + results.push('1') + return '1' + }), + }, + { + queryKey: key2, + queryFn: () => + sleep(20).then(() => { + results.push('2') + return '2' + }), + }, + ], + })) + + return ( +
+ data: {String(queries[0].data)},{String(queries[1].data)} +
+ ) + } + + const rendered = render(() => ( + + }> + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(20) + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() + + // Both queries should have resolved before the component rendered + // The order should be: loading -> query 1 resolves -> query 2 resolves + expect(results).toEqual(['loading', '1', '2']) + }) + + it('should throw error when queryKey changes and new query fails', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + + function Page() { + const [fail, setFail] = createSignal(false) + const queries = useQueries(() => ({ + queries: [ + { + queryKey: [key, fail()], + queryFn: () => + sleep(10).then(() => { + if (fail()) throw new Error('Suspense Error Bingo') + return 'data' + }), + retry: false, + throwOnError: true, + }, + ], + })) + + return ( +
+ + data: {String(queries[0].data)} +
+ ) + } + + const rendered = render(() => ( + +
error boundary
}> + + + +
+
+ )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('trigger fail')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should show error boundary even with gcTime:0', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + let count = 0 + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key, + queryFn: () => + sleep(10).then(() => { + count++ + throw new Error('Query failed') + }), + gcTime: 0, + retry: false, + throwOnError: true, + }, + ], + })) + + return
data: {String(queries[0].data)}
+ } + + const rendered = render(() => ( + + +
There was an error!
}> + +
+
+
+ )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('There was an error!')).toBeInTheDocument() + + expect(count).toBe(1) + + consoleMock.mockRestore() + }) + + it('should gc when unmounted while fetching with low gcTime', async () => { + const key = queryKey() + + function Component() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key, + queryFn: () => sleep(3000).then(() => 'data'), + gcTime: 1000, + }, + ], + })) + + return
data: {String(queries[0].data)}
+ } + + function App() { + const [show, setShow] = createSignal(true) + + return ( +
+ page2
}> + + + + + + + ) + } + + const rendered = render(() => ( + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('hide')) + expect(rendered.getByText('page2')).toBeInTheDocument() + // wait for query to be resolved + await vi.advanceTimersByTimeAsync(3000) + expect(queryClient.getQueryData(key)).toBe('data') + // wait for gc + await vi.advanceTimersByTimeAsync(1000) + expect(queryClient.getQueryData(key)).toBe(undefined) + }) + + it('should suspend on offline when query changes, and data should not be undefined', async () => { + const key = queryKey() + + function Page() { + const [id, setId] = createSignal(0) + + const queries = useQueries(() => ({ + queries: [ + { + queryKey: [...key, id()], + queryFn: () => sleep(10).then(() => `Data ${id()}`), + }, + ], + })) + + return ( +
+
{String(queries[0].data)}
+ +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('Data 0')).toBeInTheDocument() + + // go offline + onlineManager.setOnline(false) + + // click changes id to 1, but query is paused so previous data should be kept + fireEvent.click(rendered.getByText('fetch')) + expect(rendered.getByText('Data 0')).toBeInTheDocument() + + // go back online + onlineManager.setOnline(true) + + // click changes id to 2, which triggers a new fetch + fireEvent.click(rendered.getByText('fetch')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + // Solid signals update immediately (unlike React useState which preserves + // the previous render tree during suspense), so id is 2 after two clicks + expect(rendered.getByText('Data 2')).toBeInTheDocument() + + // restore online state for subsequent tests + onlineManager.setOnline(true) + }) + + it('should still suspense if queryClient has placeholderData config', async () => { + const key = queryKey() + const queryClientWithPlaceholder = new QueryClient({ + defaultOptions: { + queries: { + placeholderData: (previousData: any) => previousData, + }, + }, + }) + + function Page() { + const [count, setCount] = createSignal(0) + + const queries = useQueries(() => ({ + queries: [ + { + queryKey: [...key, count()], + queryFn: () => sleep(10).then(() => 'data' + count()), + }, + ], + })) + + return ( +
+ +
data: {String(queries[0].data)}
+
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data0')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('inc')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data1')).toBeInTheDocument() + }) +}) diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index 1e5592775db..7f336f1a0b3 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -1,4 +1,8 @@ -import { QueriesObserver, noop } from '@tanstack/query-core' +import { + QueriesObserver, + noop, + shouldThrowError, +} from '@tanstack/query-core' import { createStore, unwrap } from 'solid-js/store' import { batch, @@ -232,56 +236,138 @@ export function useQueries< createRenderEffect( on( () => queriesOptions().queries.length, - () => - setState( - observer.getOptimisticResult( - defaultedQueries(), - (queriesOptions() as QueriesObserverOptions) - .combine, - )[1](), - ), + () => { + const optimisticResult = observer.getOptimisticResult( + defaultedQueries(), + (queriesOptions() as QueriesObserverOptions) + .combine, + ) + // When queries are paused (e.g. offline), skip state update to + // keep showing previous data instead of showing undefined. + const hasPaused = optimisticResult[0].some( + (r) => r.fetchStatus === 'paused', + ) + if (!hasPaused) { + setState(optimisticResult[1]()) + } + }, ), ) - const dataResources = createMemo( - on( - () => state.length, - () => - state.map((queryRes) => { - const dataPromise = () => - new Promise((resolve) => { - if (queryRes.isFetching && queryRes.isLoading) return - resolve(unwrap(queryRes.data)) - }) - return createResource(dataPromise) - }), - ), - ) + let observerResults = observer.getOptimisticResult( + defaultedQueries(), + (queriesOptions() as QueriesObserverOptions).combine, + )[0] - batch(() => { - const dataResources_ = dataResources() - for (let index = 0; index < dataResources_.length; index++) { - const dataResource = dataResources_[index]! - dataResource[1].mutate(() => unwrap(state[index]!.data)) - dataResource[1].refetch() - } - }) + // Single resolver for the unified suspense resource. + // Modeled after useBaseQuery: one resource, re-triggered via refetch(). + let resolver: { + resolve: (value: any) => void + reject: (reason: any) => void + } | null = null + + const needsSuspend = () => + observerResults.some((r) => r.isFetching && r.isLoading) + + // Single resource created once. Re-triggered via refetch() on query changes. + // Follows the same pattern as useBaseQuery's createResource. + const [queryResource, { refetch }] = createResource< + Array | undefined + >( + () => { + return new Promise((resolve, reject) => { + if (needsSuspend()) { + resolver = { resolve, reject } + return + } + // Check if any query has a throwable error + for (let i = 0; i < observerResults.length; i++) { + const result = observerResults[i]! + if ( + result.isError && + !result.isFetching && + shouldThrowError(defaultedQueries()[i]?.throwOnError, [ + result.error, + observer.getQueries()[i]!, + ]) + ) { + resolver = null + reject(result.error) + return + } + } + resolver = null + resolve(observerResults) + }) + }, + needsSuspend() ? {} : { initialValue: observerResults }, + ) let taskQueue: Array<() => void> = [] const subscribeToObserver = () => observer.subscribe((result) => { + const allFinished = result.every( + (r) => !(r.isFetching && r.isLoading), + ) + + if (allFinished) { + observerResults = [...result] + } + taskQueue.push(() => { - batch(() => { - const dataResources_ = dataResources() - for (let index = 0; index < dataResources_.length; index++) { - const dataResource = dataResources_[index]! - const unwrappedResult = { ...unwrap(result[index]) } - // @ts-expect-error typescript pedantry regarding the possible range of index - setState(index, unwrap(unwrappedResult)) - dataResource[1].mutate(() => unwrap(state[index]!.data)) - dataResource[1].refetch() + if (allFinished) { + // When queries are paused (e.g. offline), skip state update to + // keep showing previous data instead of showing undefined. + const hasPaused = result.some( + (r) => r.fetchStatus === 'paused', + ) + if (!hasPaused) { + // Update with combine-aware result when all queries are done + const optimisticResult = observer.getOptimisticResult( + defaultedQueries(), + (queriesOptions() as QueriesObserverOptions) + .combine, + ) + setState(optimisticResult[1]()) + } + } else { + // Intermediate update for non-Suspense usage + batch(() => { + for (let index = 0; index < result.length; index++) { + const queryResult = result[index]! + const unwrappedResult = { ...unwrap(queryResult) } + // @ts-expect-error typescript pedantry regarding the possible range of index + setState(index, unwrap(unwrappedResult)) + } + }) + return + } + + // Resolve or reject the single suspense resource + if (resolver) { + // Check for throwable errors first + for (let i = 0; i < result.length; i++) { + const queryResult = result[i]! + if ( + queryResult.isError && + shouldThrowError( + defaultedQueries()[i]?.throwOnError, + [queryResult.error, observer.getQueries()[i]!], + ) + ) { + resolver.reject(queryResult.error) + resolver = null + return + } } - }) + resolver.resolve(observerResults) + resolver = null + } else { + // No resolver means resource was already resolved (e.g. cached data). + // Schedule refetch to update the resource value, following + // the same pattern as useBaseQuery's subscriber. + queueMicrotask(() => refetch()) + } }) queueMicrotask(() => { @@ -298,7 +384,14 @@ export function useQueries< // cleanup needs to be scheduled after synchronous effects take place return () => queueMicrotask(unsubscribe) }) - onCleanup(unsubscribe) + onCleanup(() => { + unsubscribe() + // Resolve pending resource on unmount to prevent Suspense hanging + if (resolver) { + resolver.resolve(observerResults) + resolver = null + } + }) onMount(() => { observer.setQueries( @@ -311,33 +404,73 @@ export function useQueries< ) }) - createComputed(() => { - observer.setQueries( - defaultedQueries(), - queriesOptions().combine - ? ({ - combine: queriesOptions().combine, - } as QueriesObserverOptions) - : undefined, - ) - }) + createComputed( + on( + defaultedQueries, + () => { + observer.setQueries( + defaultedQueries(), + queriesOptions().combine + ? ({ + combine: queriesOptions().combine, + } as QueriesObserverOptions) + : undefined, + ) + + const optimisticResult = observer.getOptimisticResult( + defaultedQueries(), + (queriesOptions() as QueriesObserverOptions) + .combine, + ) + observerResults = optimisticResult[0] + + // When queries are paused (e.g. offline), skip state update to + // keep showing previous data instead of showing undefined. + const hasPaused = observerResults.some( + (r) => r.fetchStatus === 'paused', + ) + if (!hasPaused) { + setState(optimisticResult[1]()) + } + + // Only refetch (re-trigger Suspense) if queries actually need to suspend. + // This prevents unnecessary Suspense fallback when offline or when + // queries don't need fetching. + if (needsSuspend()) { + refetch() + } + }, + { defer: true }, + ), + ) const handler = (index: number) => ({ get(target: QueryObserverResult, prop: keyof QueryObserverResult): any { if (prop === 'data') { - return dataResources()[index]![0]() + // If data exists in state, return it directly (no Suspense trigger) + if (target.data !== undefined) { + return target.data + } + // When query is paused (e.g. offline), don't suspend - return undefined + // to keep showing previous content without triggering Suspense fallback + if (observerResults[index]?.fetchStatus === 'paused') { + return undefined + } + // Reading queryResource() triggers Suspense when pending + queryResource() + return undefined } return Reflect.get(target, prop) }, }) - const getProxies = () => - state.map((s, index) => { - return new Proxy(s, handler(index)) - }) - - const [proxyState, setProxyState] = createStore(getProxies()) - createRenderEffect(() => setProxyState(getProxies())) - - return proxyState as TCombinedResult + return new Proxy(state, { + get(target, prop, receiver) { + const index = typeof prop === 'string' ? Number(prop) : NaN + if (!Number.isNaN(index) && index >= 0 && index < target.length) { + return new Proxy(target[index]!, handler(index)) + } + return Reflect.get(target, prop, receiver) + }, + }) } From 352036dda68aa8f00b2a8f8472a3970dda184e00 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:29:07 +0000 Subject: [PATCH 2/6] ci: apply automated fixes --- .../src/__tests__/suspense.test.tsx | 21 ++++---------- packages/solid-query/src/useQueries.ts | 28 ++++++------------- 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx index 27a5dce40c6..01737abec05 100644 --- a/packages/solid-query/src/__tests__/suspense.test.tsx +++ b/packages/solid-query/src/__tests__/suspense.test.tsx @@ -1264,8 +1264,7 @@ describe("useQueries's in Suspense mode", () => { const queries = useQueries(() => ({ queries: [1, 2, 3].map((value) => ({ queryKey: [...key, { value }], - queryFn: () => - sleep(value * 10).then(() => ({ value: value * 10 })), + queryFn: () => sleep(value * 10).then(() => ({ value: value * 10 })), })), combine: (result) => { combineSpy(result.map((r) => r.data)) @@ -1274,12 +1273,7 @@ describe("useQueries's in Suspense mode", () => { })) return ( -
- data:{' '} - {queries - .map((q) => JSON.stringify(q.data)) - .join(',')} -
+
data: {queries.map((q) => JSON.stringify(q.data)).join(',')}
) } @@ -1313,8 +1307,7 @@ describe("useQueries's in Suspense mode", () => { function getUserData() { return { queryKey: key, - queryFn: () => - sleep(10).then(() => ({ name: 'John Doe', age: 50 })), + queryFn: () => sleep(10).then(() => ({ name: 'John Doe', age: 50 })), } } @@ -1355,9 +1348,7 @@ describe("useQueries's in Suspense mode", () => { expect(rendered.getByText('loading')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(10) - expect( - rendered.getByText('name: John Doe, age: 50'), - ).toBeInTheDocument() + expect(rendered.getByText('name: John Doe, age: 50')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(100) @@ -1375,9 +1366,7 @@ describe("useQueries's in Suspense mode", () => { })), })) - return ( -
data: {queries.map((q) => String(q.data)).join(',')}
- ) + return
data: {queries.map((q) => String(q.data)).join(',')}
} function App() { diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index 7f336f1a0b3..d4c859e03ac 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -1,8 +1,4 @@ -import { - QueriesObserver, - noop, - shouldThrowError, -} from '@tanstack/query-core' +import { QueriesObserver, noop, shouldThrowError } from '@tanstack/query-core' import { createStore, unwrap } from 'solid-js/store' import { batch, @@ -239,8 +235,7 @@ export function useQueries< () => { const optimisticResult = observer.getOptimisticResult( defaultedQueries(), - (queriesOptions() as QueriesObserverOptions) - .combine, + (queriesOptions() as QueriesObserverOptions).combine, ) // When queries are paused (e.g. offline), skip state update to // keep showing previous data instead of showing undefined. @@ -306,9 +301,7 @@ export function useQueries< let taskQueue: Array<() => void> = [] const subscribeToObserver = () => observer.subscribe((result) => { - const allFinished = result.every( - (r) => !(r.isFetching && r.isLoading), - ) + const allFinished = result.every((r) => !(r.isFetching && r.isLoading)) if (allFinished) { observerResults = [...result] @@ -318,9 +311,7 @@ export function useQueries< if (allFinished) { // When queries are paused (e.g. offline), skip state update to // keep showing previous data instead of showing undefined. - const hasPaused = result.some( - (r) => r.fetchStatus === 'paused', - ) + const hasPaused = result.some((r) => r.fetchStatus === 'paused') if (!hasPaused) { // Update with combine-aware result when all queries are done const optimisticResult = observer.getOptimisticResult( @@ -350,10 +341,10 @@ export function useQueries< const queryResult = result[i]! if ( queryResult.isError && - shouldThrowError( - defaultedQueries()[i]?.throwOnError, - [queryResult.error, observer.getQueries()[i]!], - ) + shouldThrowError(defaultedQueries()[i]?.throwOnError, [ + queryResult.error, + observer.getQueries()[i]!, + ]) ) { resolver.reject(queryResult.error) resolver = null @@ -419,8 +410,7 @@ export function useQueries< const optimisticResult = observer.getOptimisticResult( defaultedQueries(), - (queriesOptions() as QueriesObserverOptions) - .combine, + (queriesOptions() as QueriesObserverOptions).combine, ) observerResults = optimisticResult[0] From 79f109278c96f7de6cc877167717d98c6758b2fb Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 23 Feb 2026 02:30:20 +0900 Subject: [PATCH 3/6] chore(changeset): add changeset for solid-query useQueries Suspense support --- .changeset/thick-cloths-lie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/thick-cloths-lie.md diff --git a/.changeset/thick-cloths-lie.md b/.changeset/thick-cloths-lie.md new file mode 100644 index 00000000000..c0b2cd3a7a4 --- /dev/null +++ b/.changeset/thick-cloths-lie.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-query': minor +--- + +feat(solid-query/useQueries): add Suspense support From ceff26582204ddf3b64dc08995c89b1c180c7bb7 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 23 Feb 2026 02:33:54 +0900 Subject: [PATCH 4/6] ci: trigger CI From fb81a652d6eee3f9f338c810415a3111b8465c0c Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 23 Feb 2026 02:38:59 +0900 Subject: [PATCH 5/6] test(solid-query/suspense): add real-world Suspense test scenarios for 'useQueries' --- .../src/__tests__/suspense.test.tsx | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx index 01737abec05..6d10ffc597b 100644 --- a/packages/solid-query/src/__tests__/suspense.test.tsx +++ b/packages/solid-query/src/__tests__/suspense.test.tsx @@ -1759,4 +1759,273 @@ describe("useQueries's in Suspense mode", () => { await vi.advanceTimersByTimeAsync(10) expect(rendered.getByText('data: data1')).toBeInTheDocument() }) + + it('should suspend only pending queries when some already have cached data', async () => { + const key1 = queryKey() + const key2 = queryKey() + const key3 = queryKey() + + // Pre-populate cache for key1 + queryClient.setQueryData(key1, 'cached1') + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 'fresh1'), + }, + { + queryKey: key2, + queryFn: () => sleep(10).then(() => 'data2'), + }, + { + queryKey: key3, + queryFn: () => sleep(20).then(() => 'data3'), + }, + ], + })) + + return ( +
+ q1: {String(queries[0].data)}, q2: {String(queries[1].data)}, q3:{' '} + {String(queries[2].data)} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + // key2 and key3 are pending, so should suspend + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + // key1 had cached data, key2 resolved at 10ms, key3 still pending + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + // All resolved — key1 refetched in background so shows fresh data + expect( + rendered.getByText('q1: fresh1, q2: data2, q3: data3'), + ).toBeInTheDocument() + }) + + it('should suspend when queries count increases dynamically', async () => { + const key = queryKey() + + function Page() { + const [count, setCount] = createSignal(2) + + const queries = useQueries(() => ({ + queries: Array.from({ length: count() }, (_, i) => ({ + queryKey: [...key, i], + queryFn: () => sleep(10).then(() => `data${i}`), + })), + })) + + return ( +
+ + data: {queries.map((q) => String(q.data)).join(',')} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data0,data1')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('add')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('data: data0,data1,data2')).toBeInTheDocument() + }) + + it('should suspend with select option', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => ({ value: 42 })), + select: (data: { value: number }) => data.value * 2, + }, + { + queryKey: key2, + queryFn: () => sleep(10).then(() => 'raw'), + }, + ], + })) + + return ( +
+ q1: {String(queries[0].data)}, q2: {String(queries[1].data)} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('q1: 84, q2: raw')).toBeInTheDocument() + }) + + it('should not suspend disabled queries while enabled queries suspend', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 'enabled-data'), + }, + { + queryKey: key2, + queryFn: () => sleep(10).then(() => 'disabled-data'), + enabled: false, + }, + ], + })) + + return ( +
+ q1: {String(queries[0].data)}, q2: {String(queries[1].data)} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect( + rendered.getByText('q1: enabled-data, q2: undefined'), + ).toBeInTheDocument() + }) + + it('should not re-suspend when invalidating queries with existing data', async () => { + const key1 = queryKey() + const key2 = queryKey() + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 'data1'), + }, + { + queryKey: key2, + queryFn: () => sleep(10).then(() => 'data2'), + }, + ], + })) + + return ( +
+ + q1: {String(queries[0].data)}, q2: {String(queries[1].data)} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('q1: data1, q2: data2')).toBeInTheDocument() + + // Invalidate should refetch in background, not re-suspend + fireEvent.click(rendered.getByText('invalidate')) + expect( + rendered.getByText('q1: data1, q2: data2'), + ).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('q1: data1, q2: data2')).toBeInTheDocument() + }) + + it('should not suspend when all queries have staleTime: Infinity and cached data', async () => { + const key1 = queryKey() + const key2 = queryKey() + + queryClient.setQueryData(key1, 'cached1') + queryClient.setQueryData(key2, 'cached2') + + function Page() { + const queries = useQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 'fresh1'), + staleTime: Infinity, + }, + { + queryKey: key2, + queryFn: () => sleep(10).then(() => 'fresh2'), + staleTime: Infinity, + }, + ], + })) + + return ( +
+ q1: {String(queries[0].data)}, q2: {String(queries[1].data)} +
+ ) + } + + const rendered = render(() => ( + + + + + + )) + + // Should not suspend because data is cached and not stale + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect( + rendered.getByText('q1: cached1, q2: cached2'), + ).toBeInTheDocument() + }) }) From 8b25dc6f30a804385b0026440d186c3b2f3760b7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:40:12 +0000 Subject: [PATCH 6/6] ci: apply automated fixes --- packages/solid-query/src/__tests__/suspense.test.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx index 6d10ffc597b..426b9f42993 100644 --- a/packages/solid-query/src/__tests__/suspense.test.tsx +++ b/packages/solid-query/src/__tests__/suspense.test.tsx @@ -1977,9 +1977,7 @@ describe("useQueries's in Suspense mode", () => { // Invalidate should refetch in background, not re-suspend fireEvent.click(rendered.getByText('invalidate')) - expect( - rendered.getByText('q1: data1, q2: data2'), - ).toBeInTheDocument() + expect(rendered.getByText('q1: data1, q2: data2')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(10) expect(rendered.getByText('q1: data1, q2: data2')).toBeInTheDocument() }) @@ -2024,8 +2022,6 @@ describe("useQueries's in Suspense mode", () => { // Should not suspend because data is cached and not stale expect(rendered.queryByText('loading')).not.toBeInTheDocument() - expect( - rendered.getByText('q1: cached1, q2: cached2'), - ).toBeInTheDocument() + expect(rendered.getByText('q1: cached1, q2: cached2')).toBeInTheDocument() }) })