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 diff --git a/packages/solid-query/src/__tests__/suspense.test.tsx b/packages/solid-query/src/__tests__/suspense.test.tsx index 8fbb86e9f3b..426b9f42993 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,1074 @@ 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() + }) + + 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() + }) +}) diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index 1e5592775db..d4c859e03ac 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -1,4 +1,4 @@ -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 +232,133 @@ 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 +375,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 +395,72 @@ 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) + }, + }) }