(null)
diff --git a/packages/preact-query/src/__tests__/ErrorBoundary/index.ts b/packages/preact-query/src/__tests__/ErrorBoundary/index.ts
new file mode 100644
index 0000000000..51ee3ea0df
--- /dev/null
+++ b/packages/preact-query/src/__tests__/ErrorBoundary/index.ts
@@ -0,0 +1,9 @@
+/**
+ * Custom Error Boundary port from 'react-error-boundary'
+ * Taken directly from https://github.com/bvaughn/react-error-boundary/
+ * and modified to server a preact use case
+ */
+
+export * from './ErrorBoundary'
+export * from './ErrorBoundaryContext'
+export * from './types'
diff --git a/packages/preact-query/src/__tests__/ErrorBoundary/types.ts b/packages/preact-query/src/__tests__/ErrorBoundary/types.ts
new file mode 100644
index 0000000000..31e4123c6e
--- /dev/null
+++ b/packages/preact-query/src/__tests__/ErrorBoundary/types.ts
@@ -0,0 +1,48 @@
+import {
+ ComponentType,
+ ErrorInfo,
+ ComponentChildren,
+ ComponentChild,
+} from 'preact'
+
+export type FallbackProps = {
+ error: any
+ resetErrorBoundary: (...args: any[]) => void
+}
+
+export type PropsWithChildren = P & {
+ children?: ComponentChildren
+}
+
+type ErrorBoundarySharedProps = PropsWithChildren<{
+ onError?: (error: Error, info: ErrorInfo) => void
+ onReset?: (
+ details:
+ | { reason: 'imperative-api'; args: any[] }
+ | { reason: 'keys'; prev: any[] | undefined; next: any[] | undefined },
+ ) => void
+ resetKeys?: any[]
+}>
+
+export type ErrorBoundaryPropsWithComponent = ErrorBoundarySharedProps & {
+ fallback?: never
+ FallbackComponent: ComponentType
+ fallbackRender?: never
+}
+
+export type ErrorBoundaryPropsWithRender = ErrorBoundarySharedProps & {
+ fallback?: never
+ FallbackComponent?: never
+ fallbackRender: (props: FallbackProps) => ComponentChild
+}
+
+export type ErrorBoundaryPropsWithFallback = ErrorBoundarySharedProps & {
+ fallback: ComponentChild
+ FallbackComponent?: never
+ fallbackRender?: never
+}
+
+export type ErrorBoundaryProps =
+ | ErrorBoundaryPropsWithFallback
+ | ErrorBoundaryPropsWithComponent
+ | ErrorBoundaryPropsWithRender
diff --git a/packages/preact-query/src/__tests__/HydrationBoundary.test.tsx b/packages/preact-query/src/__tests__/HydrationBoundary.test.tsx
new file mode 100644
index 0000000000..b9c90ffba5
--- /dev/null
+++ b/packages/preact-query/src/__tests__/HydrationBoundary.test.tsx
@@ -0,0 +1,483 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
+import { render } from '@testing-library/preact'
+import * as coreModule from '@tanstack/query-core'
+import { sleep } from '@tanstack/query-test-utils'
+import {
+ HydrationBoundary,
+ QueryClient,
+ QueryClientProvider,
+ dehydrate,
+ useQuery,
+} from '..'
+import type { hydrate } from '@tanstack/query-core'
+import { startTransition, Suspense } from 'preact/compat'
+
+describe('React hydration', () => {
+ let stringifiedState: string
+
+ beforeEach(async () => {
+ vi.useFakeTimers()
+ const queryClient = new QueryClient()
+ queryClient.prefetchQuery({
+ queryKey: ['string'],
+ queryFn: () => sleep(10).then(() => ['stringCached']),
+ })
+ await vi.advanceTimersByTimeAsync(10)
+ const dehydrated = dehydrate(queryClient)
+ stringifiedState = JSON.stringify(dehydrated)
+ queryClient.clear()
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ test('should hydrate queries to the cache on context', async () => {
+ const dehydratedState = JSON.parse(stringifiedState)
+ const queryClient = new QueryClient()
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: ['string'],
+ queryFn: () => sleep(20).then(() => ['string']),
+ })
+ return (
+
+
{data}
+
+ )
+ }
+
+ const rendered = render(
+
+
+
+
+ ,
+ )
+
+ expect(rendered.getByText('stringCached')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(21)
+ expect(rendered.getByText('string')).toBeInTheDocument()
+ queryClient.clear()
+ })
+
+ test('should hydrate queries to the cache on custom context', async () => {
+ const queryClientInner = new QueryClient()
+ const queryClientOuter = new QueryClient()
+
+ const dehydratedState = JSON.parse(stringifiedState)
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: ['string'],
+ queryFn: () => sleep(20).then(() => ['string']),
+ })
+ return (
+
+
{data}
+
+ )
+ }
+
+ const rendered = render(
+
+
+
+
+
+
+ ,
+ )
+
+ expect(rendered.getByText('stringCached')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(21)
+ expect(rendered.getByText('string')).toBeInTheDocument()
+
+ queryClientInner.clear()
+ queryClientOuter.clear()
+ })
+
+ describe('ReactQueryCacheProvider with hydration support', () => {
+ test('should hydrate new queries if queries change', async () => {
+ const dehydratedState = JSON.parse(stringifiedState)
+ const queryClient = new QueryClient()
+
+ function Page({ queryKey }: { queryKey: [string] }) {
+ const { data } = useQuery({
+ queryKey,
+ queryFn: () => sleep(20).then(() => queryKey),
+ })
+ return (
+
+
{data}
+
+ )
+ }
+
+ const rendered = render(
+
+
+
+
+ ,
+ )
+
+ expect(rendered.getByText('stringCached')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(21)
+ expect(rendered.getByText('string')).toBeInTheDocument()
+
+ const intermediateClient = new QueryClient()
+
+ intermediateClient.prefetchQuery({
+ queryKey: ['string'],
+ queryFn: () => sleep(20).then(() => ['should change']),
+ })
+ intermediateClient.prefetchQuery({
+ queryKey: ['added'],
+ queryFn: () => sleep(20).then(() => ['added']),
+ })
+ await vi.advanceTimersByTimeAsync(20)
+ const dehydrated = dehydrate(intermediateClient)
+ intermediateClient.clear()
+
+ rendered.rerender(
+
+
+
+
+
+ ,
+ )
+
+ // Existing observer should not have updated at this point,
+ // as that would indicate a side effect in the render phase
+ expect(rendered.getByText('string')).toBeInTheDocument()
+ // New query data should be available immediately
+ expect(rendered.getByText('added')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(0)
+ // After effects phase has had time to run, the observer should have updated
+ expect(rendered.queryByText('string')).not.toBeInTheDocument()
+ expect(rendered.getByText('should change')).toBeInTheDocument()
+
+ queryClient.clear()
+ })
+
+ // When we hydrate in transitions that are later aborted, it could be
+ // confusing to both developers and users if we suddenly updated existing
+ // state on the screen (why did this update when it was not stale, nothing
+ // remounted, I didn't change tabs etc?).
+ // Any queries that does not exist in the cache yet can still be hydrated
+ // since they don't have any observers on the current page that would update.
+ test('should hydrate new but not existing queries if transition is aborted', async () => {
+ const initialDehydratedState = JSON.parse(stringifiedState)
+ const queryClient = new QueryClient()
+
+ function Page({ queryKey }: { queryKey: [string] }) {
+ const { data } = useQuery({
+ queryKey,
+ queryFn: () => sleep(20).then(() => queryKey),
+ })
+ return (
+
+
{data}
+
+ )
+ }
+
+ const rendered = render(
+
+
+
+
+ ,
+ )
+
+ expect(rendered.getByText('stringCached')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(21)
+ expect(rendered.getByText('string')).toBeInTheDocument()
+
+ const intermediateClient = new QueryClient()
+ intermediateClient.prefetchQuery({
+ queryKey: ['string'],
+ queryFn: () => sleep(20).then(() => ['should not change']),
+ })
+ intermediateClient.prefetchQuery({
+ queryKey: ['added'],
+ queryFn: () => sleep(20).then(() => ['added']),
+ })
+ await vi.advanceTimersByTimeAsync(20)
+
+ const newDehydratedState = dehydrate(intermediateClient)
+ intermediateClient.clear()
+
+ function Thrower(): never {
+ throw new Promise(() => {
+ // Never resolve
+ })
+ }
+
+ startTransition(() => {
+ rendered.rerender(
+
+
+
+
+
+
+
+
+ ,
+ )
+
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ })
+
+ startTransition(() => {
+ rendered.rerender(
+
+
+
+
+
+ ,
+ )
+
+ // This query existed before the transition so it should stay the same
+ expect(rendered.getByText('string')).toBeInTheDocument()
+ expect(
+ rendered.queryByText('should not change'),
+ ).not.toBeInTheDocument()
+ // New query data should be available immediately because it was
+ // hydrated in the previous transition, even though the new dehydrated
+ // state did not contain it
+ expect(rendered.getByText('added')).toBeInTheDocument()
+ })
+
+ await vi.advanceTimersByTimeAsync(20)
+ // It should stay the same even after effects have had a chance to run
+ expect(rendered.getByText('string')).toBeInTheDocument()
+ expect(rendered.queryByText('should not change')).not.toBeInTheDocument()
+
+ queryClient.clear()
+ })
+
+ test('should hydrate queries to new cache if cache changes', async () => {
+ const dehydratedState = JSON.parse(stringifiedState)
+ const queryClient = new QueryClient()
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: ['string'],
+ queryFn: () => sleep(20).then(() => ['string']),
+ })
+ return (
+
+
{data}
+
+ )
+ }
+
+ const rendered = render(
+
+
+
+
+ ,
+ )
+
+ expect(rendered.getByText('stringCached')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(21)
+ expect(rendered.getByText('string')).toBeInTheDocument()
+ const newClientQueryClient = new QueryClient()
+
+ rendered.rerender(
+
+
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(20)
+ expect(rendered.getByText('string')).toBeInTheDocument()
+
+ queryClient.clear()
+ newClientQueryClient.clear()
+ })
+ })
+
+ test('should not hydrate queries if state is null', async () => {
+ const queryClient = new QueryClient()
+
+ const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
+
+ function Page() {
+ return null
+ }
+
+ render(
+
+
+
+
+ ,
+ )
+
+ await Promise.all(
+ Array.from({ length: 1000 }).map(async (_, index) => {
+ await vi.advanceTimersByTimeAsync(index)
+ expect(hydrateSpy).toHaveBeenCalledTimes(0)
+ }),
+ )
+
+ hydrateSpy.mockRestore()
+ queryClient.clear()
+ })
+
+ test('should not hydrate queries if state is undefined', async () => {
+ const queryClient = new QueryClient()
+
+ const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
+
+ function Page() {
+ return null
+ }
+
+ render(
+
+
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(hydrateSpy).toHaveBeenCalledTimes(0)
+
+ hydrateSpy.mockRestore()
+ queryClient.clear()
+ })
+
+ test('should not hydrate queries if state is not an object', async () => {
+ const queryClient = new QueryClient()
+
+ const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
+
+ function Page() {
+ return null
+ }
+
+ render(
+
+
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(hydrateSpy).toHaveBeenCalledTimes(0)
+
+ hydrateSpy.mockRestore()
+ queryClient.clear()
+ })
+
+ test('should handle state without queries property gracefully', async () => {
+ const queryClient = new QueryClient()
+
+ const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
+
+ function Page() {
+ return null
+ }
+
+ render(
+
+
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(hydrateSpy).toHaveBeenCalledTimes(0)
+
+ hydrateSpy.mockRestore()
+ queryClient.clear()
+ })
+
+ // https://github.com/TanStack/query/issues/8677
+ test('should not infinite loop when hydrating promises that resolve to errors', async () => {
+ const originalHydrate = coreModule.hydrate
+ const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
+ let hydrationCount = 0
+ hydrateSpy.mockImplementation((...args: Parameters) => {
+ hydrationCount++
+ // Arbitrary number
+ if (hydrationCount > 10) {
+ // This is a rough way to detect it. Calling hydrate multiple times with
+ // the same data is usually fine, but in this case it indicates the
+ // logic in HydrationBoundary is not working as expected.
+ throw new Error('Too many hydrations detected')
+ }
+ return originalHydrate(...args)
+ })
+
+ // For the bug to trigger, there needs to already be a query in the cache,
+ // with a dataUpdatedAt earlier than the dehydratedAt of the next query
+ const clientQueryClient = new QueryClient()
+ clientQueryClient.prefetchQuery({
+ queryKey: ['promise'],
+ queryFn: () => sleep(20).then(() => 'existing'),
+ })
+ await vi.advanceTimersByTimeAsync(20)
+
+ const prefetchQueryClient = new QueryClient({
+ defaultOptions: {
+ dehydrate: {
+ shouldDehydrateQuery: () => true,
+ },
+ },
+ })
+ prefetchQueryClient.prefetchQuery({
+ queryKey: ['promise'],
+ queryFn: () =>
+ sleep(10).then(() => Promise.reject(new Error('Query failed'))),
+ })
+
+ const dehydratedState = dehydrate(prefetchQueryClient)
+
+ // Mimic what React/our synchronous thenable does for already rejected promises
+ // @ts-expect-error
+ dehydratedState.queries[0].promise.status = 'failure'
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: ['promise'],
+ queryFn: () => sleep(20).then(() => ['new']),
+ })
+ return (
+
+
{data}
+
+ )
+ }
+
+ const rendered = render(
+
+
+
+
+ ,
+ )
+
+ expect(rendered.getByText('existing')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(21)
+ expect(rendered.getByText('new')).toBeInTheDocument()
+
+ hydrateSpy.mockRestore()
+ prefetchQueryClient.clear()
+ clientQueryClient.clear()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/QueryClientProvider.test.tsx b/packages/preact-query/src/__tests__/QueryClientProvider.test.tsx
new file mode 100644
index 0000000000..bd5f584bbc
--- /dev/null
+++ b/packages/preact-query/src/__tests__/QueryClientProvider.test.tsx
@@ -0,0 +1,165 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
+import { render } from '@testing-library/preact'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import {
+ QueryCache,
+ QueryClient,
+ QueryClientProvider,
+ useQuery,
+ useQueryClient,
+} from '..'
+
+describe('QueryClientProvider', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ test('sets a specific cache for all queries to use', async () => {
+ const key = queryKey()
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({ queryCache })
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: () => sleep(10).then(() => 'test'),
+ })
+
+ return (
+
+
{data}
+
+ )
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('test')).toBeInTheDocument()
+
+ expect(queryCache.find({ queryKey: key })).toBeDefined()
+ })
+
+ test('allows multiple caches to be partitioned', async () => {
+ const key1 = queryKey()
+ const key2 = queryKey()
+
+ const queryCache1 = new QueryCache()
+ const queryCache2 = new QueryCache()
+
+ const queryClient1 = new QueryClient({ queryCache: queryCache1 })
+ const queryClient2 = new QueryClient({ queryCache: queryCache2 })
+
+ function Page1() {
+ const { data } = useQuery({
+ queryKey: key1,
+ queryFn: () => sleep(10).then(() => 'test1'),
+ })
+
+ return (
+
+
{data}
+
+ )
+ }
+ function Page2() {
+ const { data } = useQuery({
+ queryKey: key2,
+ queryFn: () => sleep(10).then(() => 'test2'),
+ })
+
+ return (
+
+
{data}
+
+ )
+ }
+
+ const rendered = render(
+ <>
+
+
+
+
+
+
+ >,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('test1')).toBeInTheDocument()
+ expect(rendered.getByText('test2')).toBeInTheDocument()
+
+ expect(queryCache1.find({ queryKey: key1 })).toBeDefined()
+ expect(queryCache1.find({ queryKey: key2 })).not.toBeDefined()
+ expect(queryCache2.find({ queryKey: key1 })).not.toBeDefined()
+ expect(queryCache2.find({ queryKey: key2 })).toBeDefined()
+ })
+
+ test("uses defaultOptions for queries when they don't provide their own config", async () => {
+ const key = queryKey()
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({
+ queryCache,
+ defaultOptions: {
+ queries: {
+ gcTime: Infinity,
+ },
+ },
+ })
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: () => sleep(10).then(() => 'test'),
+ })
+
+ return (
+
+
{data}
+
+ )
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('test')).toBeInTheDocument()
+
+ expect(queryCache.find({ queryKey: key })).toBeDefined()
+ expect(queryCache.find({ queryKey: key })?.options.gcTime).toBe(Infinity)
+ })
+
+ describe('useQueryClient', () => {
+ test('should throw an error if no query client has been set', () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ function Page() {
+ useQueryClient()
+ return null
+ }
+
+ expect(() => render( )).toThrow(
+ 'No QueryClient set, use QueryClientProvider to set one',
+ )
+
+ consoleMock.mockRestore()
+ })
+ })
+})
diff --git a/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx
new file mode 100644
index 0000000000..043fb3ef25
--- /dev/null
+++ b/packages/preact-query/src/__tests__/QueryResetErrorBoundary.test.tsx
@@ -0,0 +1,867 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent } from '@testing-library/preact'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import {
+ QueryCache,
+ QueryClient,
+ QueryErrorResetBoundary,
+ useQueries,
+ useQuery,
+ useSuspenseQueries,
+ useSuspenseQuery,
+} from '..'
+import { renderWithClient } from './utils'
+import { useEffect, useState } from 'preact/hooks'
+import { Suspense } from 'preact/compat'
+import { ErrorBoundary } from './ErrorBoundary'
+
+describe('QueryErrorResetBoundary', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({ queryCache })
+
+ describe('useQuery', () => {
+ it('should retry fetch if the reset error boundary has been reset', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+ const key = queryKey()
+
+ let succeed = false
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ if (!succeed) throw new Error('Error')
+ return 'data'
+ }),
+ retry: false,
+ throwOnError: true,
+ })
+
+ return {data}
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+ )}
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ succeed = true
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data')).toBeInTheDocument()
+
+ consoleMock.mockRestore()
+ })
+
+ it('should not throw error if query is disabled', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+ const key = queryKey()
+
+ let succeed = false
+
+ function Page() {
+ const { data, status } = useQuery({
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ if (!succeed) throw new Error('Error')
+ return 'data'
+ }),
+ retry: false,
+ enabled: !succeed,
+ throwOnError: true,
+ })
+
+ return (
+
+
status: {status}
+
{data}
+
+ )
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+ )}
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ succeed = true
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('status: error')).toBeInTheDocument()
+
+ consoleMock.mockRestore()
+ })
+
+ it('should not throw error if query is disabled, and refetch if query becomes enabled again', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ const key = queryKey()
+
+ let succeed = false
+
+ function Page() {
+ const [enabled, setEnabled] = useState(false)
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ if (!succeed) throw new Error('Error')
+ return 'data'
+ }),
+ retry: false,
+ enabled,
+ throwOnError: true,
+ })
+
+ useEffect(() => {
+ setEnabled(true)
+ }, [])
+
+ return {data}
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+ )}
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ succeed = true
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data')).toBeInTheDocument()
+
+ consoleMock.mockRestore()
+ })
+
+ it('should throw error if query is disabled and manually refetch', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ const key = queryKey()
+
+ function Page() {
+ const { data, refetch, status, fetchStatus } = useQuery({
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => Promise.reject(new Error('Error'))),
+ retry: false,
+ enabled: false,
+ throwOnError: true,
+ })
+
+ return (
+
+
refetch()}>refetch
+
+ status: {status}, fetchStatus: {fetchStatus}
+
+
{data}
+
+ )
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+ )}
+ ,
+ )
+
+ expect(
+ rendered.getByText('status: pending, fetchStatus: idle'),
+ ).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(
+ rendered.getByText('status: pending, fetchStatus: idle'),
+ ).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /refetch/i }))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+
+ consoleMock.mockRestore()
+ })
+
+ it('should not retry fetch if the reset error boundary has not been reset', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ const key = queryKey()
+
+ let succeed = false
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ if (!succeed) throw new Error('Error')
+ return 'data'
+ }),
+ retry: false,
+ throwOnError: true,
+ })
+
+ return {data}
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {() => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+ )}
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ succeed = true
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+
+ consoleMock.mockRestore()
+ })
+
+ it('should retry fetch if the reset error boundary has been reset and the query contains data from a previous fetch', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ const key = queryKey()
+
+ let succeed = false
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ if (!succeed) throw new Error('Error')
+ return 'data'
+ }),
+ retry: false,
+ throwOnError: true,
+ initialData: 'initial',
+ })
+
+ return {data}
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+ )}
+ ,
+ )
+
+ expect(rendered.getByText('initial')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ succeed = true
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data')).toBeInTheDocument()
+
+ consoleMock.mockRestore()
+ })
+
+ it('should not retry fetch if the reset error boundary has not been reset after a previous reset', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ const key = queryKey()
+
+ let succeed = false
+ let shouldReset = false
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ if (!succeed) throw new Error('Error')
+ return 'data'
+ }),
+ retry: false,
+ throwOnError: true,
+ })
+
+ return {data}
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ {
+ if (shouldReset) {
+ reset()
+ }
+ }}
+ fallbackRender={({ resetErrorBoundary }) => (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+ )}
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ succeed = false
+ shouldReset = true
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ succeed = true
+ shouldReset = false
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+
+ succeed = true
+ shouldReset = true
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data')).toBeInTheDocument()
+
+ consoleMock.mockRestore()
+ })
+
+ it('should throw again on error after the reset error boundary has been reset', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ const key = queryKey()
+ let fetchCount = 0
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ fetchCount++
+ throw new Error('Error')
+ }),
+ retry: false,
+ throwOnError: true,
+ })
+
+ return {data}
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+ )}
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+
+ expect(fetchCount).toBe(3)
+
+ consoleMock.mockRestore()
+ })
+
+ it('should never render the component while the query is in error state', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ const key = queryKey()
+ let fetchCount = 0
+ let renders = 0
+
+ function Page() {
+ const { data } = useSuspenseQuery({
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ fetchCount++
+ if (fetchCount > 2) return 'data'
+ throw new Error('Error')
+ }),
+ retry: false,
+ })
+
+ renders++
+
+ return {data}
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+ loading}>
+
+
+
+ )}
+ ,
+ )
+
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByText('retry'))
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByText('retry'))
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('data')).toBeInTheDocument()
+
+ expect(fetchCount).toBe(3)
+ expect(renders).toBe(1)
+
+ consoleMock.mockRestore()
+ })
+
+ it('should render children', () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ function Page() {
+ return (
+
+ page
+
+ )
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+
+ ,
+ )
+
+ expect(rendered.queryByText('page')).not.toBeNull()
+
+ consoleMock.mockRestore()
+ })
+
+ it('should show error boundary when using tracked queries even though we do not track the error field', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ const key = queryKey()
+
+ let succeed = false
+
+ function Page() {
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ if (!succeed) throw new Error('Error')
+ return 'data'
+ }),
+ retry: false,
+ throwOnError: true,
+ })
+
+ return {data}
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+ )}
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ succeed = true
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data')).toBeInTheDocument()
+
+ consoleMock.mockRestore()
+ })
+ })
+
+ describe('useQueries', () => {
+ it('should retry fetch if the reset error boundary has been reset', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+ const key = queryKey()
+
+ let succeed = false
+
+ function Page() {
+ const [{ data }] = useQueries({
+ queries: [
+ {
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ if (!succeed) throw new Error('Error')
+ return 'data'
+ }),
+ retry: false,
+ throwOnError: true,
+ retryOnMount: true,
+ },
+ ],
+ })
+
+ return {data}
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+ )}
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ succeed = true
+
+ fireEvent.click(rendered.getByText('retry'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data')).toBeInTheDocument()
+
+ consoleMock.mockRestore()
+ })
+
+ it('with suspense should retry fetch if the reset error boundary has been reset', async () => {
+ const key = queryKey()
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ let succeed = false
+
+ function Page() {
+ const [{ data }] = useSuspenseQueries({
+ queries: [
+ {
+ queryKey: key,
+ queryFn: () =>
+ sleep(10).then(() => {
+ if (!succeed) throw new Error('Error')
+ return 'data'
+ }),
+ retry: false,
+ retryOnMount: true,
+ },
+ ],
+ })
+
+ return {data}
+ }
+
+ const rendered = renderWithClient(
+ queryClient,
+
+ {({ reset }) => (
+ (
+
+
error boundary
+
{
+ resetErrorBoundary()
+ }}
+ >
+ retry
+
+
+ )}
+ >
+
+
+
+
+ )}
+ ,
+ )
+
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('error boundary')).toBeInTheDocument()
+ expect(rendered.getByText('retry')).toBeInTheDocument()
+
+ succeed = true
+
+ fireEvent.click(rendered.getByText('retry'))
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('data')).toBeInTheDocument()
+
+ consoleMock.mockRestore()
+ })
+ })
+})
diff --git a/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx b/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx
new file mode 100644
index 0000000000..ae9bc5d63b
--- /dev/null
+++ b/packages/preact-query/src/__tests__/fine-grained-persister.test.tsx
@@ -0,0 +1,179 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ PERSISTER_KEY_PREFIX,
+ experimental_createQueryPersister,
+} from '@tanstack/query-persist-client-core'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import { QueryCache, QueryClient, hashKey, useQuery } from '..'
+import { renderWithClient } from './utils'
+import { useState } from 'preact/hooks'
+
+describe('fine grained persister', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({ queryCache })
+
+ it('should restore query state from persister and not refetch', async () => {
+ const key = queryKey()
+ const hash = hashKey(key)
+ const spy = vi.fn(() => Promise.resolve('Works from queryFn'))
+
+ const mapStorage = new Map()
+ const storage = {
+ getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)),
+ setItem: (itemKey: string, value: unknown) => {
+ mapStorage.set(itemKey, value)
+ return Promise.resolve()
+ },
+ removeItem: (itemKey: string) => {
+ mapStorage.delete(itemKey)
+ return Promise.resolve()
+ },
+ }
+
+ await storage.setItem(
+ `${PERSISTER_KEY_PREFIX}-${hash}`,
+ JSON.stringify({
+ buster: '',
+ queryHash: hash,
+ queryKey: key,
+ state: {
+ dataUpdatedAt: Date.now(),
+ data: 'Works from persister',
+ },
+ }),
+ )
+
+ function Test() {
+ const [_, setRef] = useState()
+
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: spy,
+ persister: experimental_createQueryPersister({
+ storage,
+ }).persisterFn,
+ staleTime: 5000,
+ })
+
+ return setRef(value)}>{data}
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Works from persister')).toBeInTheDocument()
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should restore query state from persister and refetch', async () => {
+ const key = queryKey()
+ const hash = hashKey(key)
+ const spy = vi.fn(async () => {
+ await sleep(5)
+
+ return 'Works from queryFn'
+ })
+
+ const mapStorage = new Map()
+ const storage = {
+ getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)),
+ setItem: (itemKey: string, value: unknown) => {
+ mapStorage.set(itemKey, value)
+ return Promise.resolve()
+ },
+ removeItem: (itemKey: string) => {
+ mapStorage.delete(itemKey)
+ return Promise.resolve()
+ },
+ }
+
+ await storage.setItem(
+ `${PERSISTER_KEY_PREFIX}-${hash}`,
+ JSON.stringify({
+ buster: '',
+ queryHash: hash,
+ queryKey: key,
+ state: {
+ dataUpdatedAt: Date.now(),
+ data: 'Works from persister',
+ },
+ }),
+ )
+
+ function Test() {
+ const [_, setRef] = useState()
+
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: spy,
+ persister: experimental_createQueryPersister({
+ storage,
+ }).persisterFn,
+ })
+
+ return setRef(value)}>{data}
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Works from persister')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(6)
+ expect(rendered.getByText('Works from queryFn')).toBeInTheDocument()
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+
+ it('should store query state to persister after fetch', async () => {
+ const key = queryKey()
+ const hash = hashKey(key)
+ const spy = vi.fn(() => Promise.resolve('Works from queryFn'))
+
+ const mapStorage = new Map()
+ const storage = {
+ getItem: (itemKey: string) => Promise.resolve(mapStorage.get(itemKey)),
+ setItem: (itemKey: string, value: unknown) => {
+ mapStorage.set(itemKey, value)
+ return Promise.resolve()
+ },
+ removeItem: (itemKey: string) => {
+ mapStorage.delete(itemKey)
+ return Promise.resolve()
+ },
+ }
+
+ function Test() {
+ const [_, setRef] = useState()
+
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: spy,
+ persister: experimental_createQueryPersister({
+ storage,
+ }).persisterFn,
+ })
+
+ return setRef(value)}>{data}
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Works from queryFn')).toBeInTheDocument()
+ expect(spy).toHaveBeenCalledTimes(1)
+
+ const storedItem = await storage.getItem(`${PERSISTER_KEY_PREFIX}-${hash}`)
+ expect(JSON.parse(storedItem)).toMatchObject({
+ state: {
+ data: 'Works from queryFn',
+ },
+ })
+ })
+})
diff --git a/packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx b/packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx
new file mode 100644
index 0000000000..a1d97bf092
--- /dev/null
+++ b/packages/preact-query/src/__tests__/infiniteQueryOptions.test-d.tsx
@@ -0,0 +1,251 @@
+import { assertType, describe, expectTypeOf, it, test } from 'vitest'
+import { QueryClient, dataTagSymbol, skipToken } from '@tanstack/query-core'
+import { infiniteQueryOptions } from '../infiniteQueryOptions'
+import { useInfiniteQuery } from '../useInfiniteQuery'
+import { useSuspenseInfiniteQuery } from '../useSuspenseInfiniteQuery'
+import { useQuery } from '../useQuery'
+import type {
+ DataTag,
+ InfiniteData,
+ InitialDataFunction,
+} from '@tanstack/query-core'
+
+describe('infiniteQueryOptions', () => {
+ it('should not allow excess properties', () => {
+ assertType(
+ infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve('data'),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ // @ts-expect-error this is a good error, because stallTime does not exist!
+ stallTime: 1000,
+ }),
+ )
+ })
+ it('should infer types for callbacks', () => {
+ infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve('data'),
+ staleTime: 1000,
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ select: (data) => {
+ expectTypeOf(data).toEqualTypeOf>()
+ },
+ })
+ })
+ it('should work when passed to useInfiniteQuery', () => {
+ const options = infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve('string'),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+
+ const { data } = useInfiniteQuery(options)
+
+ // known issue: type of pageParams is unknown when returned from useInfiniteQuery
+ expectTypeOf(data).toEqualTypeOf<
+ InfiniteData | undefined
+ >()
+ })
+ it('should work when passed to useSuspenseInfiniteQuery', () => {
+ const options = infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve('string'),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+
+ const { data } = useSuspenseInfiniteQuery(options)
+
+ expectTypeOf(data).toEqualTypeOf>()
+ })
+ it('should work when passed to fetchInfiniteQuery', async () => {
+ const options = infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve('string'),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+
+ const data = await new QueryClient().fetchInfiniteQuery(options)
+
+ expectTypeOf(data).toEqualTypeOf>()
+ })
+ it('should tag the queryKey with the result type of the QueryFn', () => {
+ const { queryKey } = infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve('string'),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+
+ expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>()
+ })
+ it('should tag the queryKey even if no promise is returned', () => {
+ const { queryKey } = infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => 'string',
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+
+ expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>()
+ })
+ it('should tag the queryKey with the result type of the QueryFn if select is used', () => {
+ const { queryKey } = infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve('string'),
+ select: (data) => data.pages,
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+
+ expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf>()
+ })
+ it('should return the proper type when passed to getQueryData', () => {
+ const { queryKey } = infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve('string'),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+
+ const queryClient = new QueryClient()
+ const data = queryClient.getQueryData(queryKey)
+
+ expectTypeOf(data).toEqualTypeOf<
+ InfiniteData | undefined
+ >()
+ })
+ it('should properly type when passed to setQueryData', () => {
+ const { queryKey } = infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve('string'),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+
+ const queryClient = new QueryClient()
+ const data = queryClient.setQueryData(queryKey, (prev) => {
+ expectTypeOf(prev).toEqualTypeOf<
+ InfiniteData | undefined
+ >()
+ return prev
+ })
+
+ expectTypeOf(data).toEqualTypeOf<
+ InfiniteData | undefined
+ >()
+ })
+ it('should throw a type error when using queryFn with skipToken in a suspense query', () => {
+ const options = infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn:
+ Math.random() > 0.5 ? skipToken : () => Promise.resolve('string'),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+ // @ts-expect-error TS2345
+ const { data } = useSuspenseInfiniteQuery(options)
+ expectTypeOf(data).toEqualTypeOf>()
+ })
+
+ test('should not be allowed to be passed to non-infinite query functions', () => {
+ const queryClient = new QueryClient()
+ const options = infiniteQueryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve('string'),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+ assertType(
+ // @ts-expect-error cannot pass infinite options to non-infinite query functions
+ useQuery(options),
+ )
+ assertType(
+ // @ts-expect-error cannot pass infinite options to non-infinite query functions
+ queryClient.ensureQueryData(options),
+ )
+ assertType(
+ // @ts-expect-error cannot pass infinite options to non-infinite query functions
+ queryClient.fetchQuery(options),
+ )
+ assertType(
+ // @ts-expect-error cannot pass infinite options to non-infinite query functions
+ queryClient.prefetchQuery(options),
+ )
+ })
+
+ test('allow optional initialData function', () => {
+ const initialData: { example: boolean } | undefined = { example: true }
+ const queryOptions = infiniteQueryOptions({
+ queryKey: ['example'],
+ queryFn: () => initialData,
+ initialData: initialData
+ ? () => ({ pages: [initialData], pageParams: [] })
+ : undefined,
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+ expectTypeOf(queryOptions.initialData).toMatchTypeOf<
+ | InitialDataFunction>
+ | InfiniteData<{ example: boolean }, number>
+ | undefined
+ >()
+ })
+
+ test('allow optional initialData object', () => {
+ const initialData: { example: boolean } | undefined = { example: true }
+ const queryOptions = infiniteQueryOptions({
+ queryKey: ['example'],
+ queryFn: () => initialData,
+ initialData: initialData
+ ? { pages: [initialData], pageParams: [] }
+ : undefined,
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+ expectTypeOf(queryOptions.initialData).toMatchTypeOf<
+ | InitialDataFunction>
+ | InfiniteData<{ example: boolean }, number>
+ | undefined
+ >()
+ })
+
+ it('should return a custom query key type', () => {
+ type MyQueryKey = [Array, { type: 'foo' }]
+
+ const options = infiniteQueryOptions({
+ queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
+ queryFn: () => Promise.resolve(1),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+
+ expectTypeOf(options.queryKey).toEqualTypeOf<
+ DataTag, Error>
+ >()
+ })
+
+ it('should return a custom query key type with datatag', () => {
+ type MyQueryKey = DataTag<
+ [Array, { type: 'foo' }],
+ number,
+ Error & { myMessage: string }
+ >
+
+ const options = infiniteQueryOptions({
+ queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
+ queryFn: () => Promise.resolve(1),
+ getNextPageParam: () => 1,
+ initialPageParam: 1,
+ })
+
+ expectTypeOf(options.queryKey).toEqualTypeOf<
+ DataTag, Error & { myMessage: string }>
+ >()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx b/packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx
new file mode 100644
index 0000000000..3e876fd5d0
--- /dev/null
+++ b/packages/preact-query/src/__tests__/infiniteQueryOptions.test.tsx
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+
+import { infiniteQueryOptions } from '../infiniteQueryOptions'
+import type { UseInfiniteQueryOptions } from '../types'
+
+describe('infiniteQueryOptions', () => {
+ it('should return the object received as a parameter without any modification.', () => {
+ const object: UseInfiniteQueryOptions = {
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ getNextPageParam: () => null,
+ initialPageParam: null,
+ }
+
+ expect(infiniteQueryOptions(object)).toStrictEqual(object)
+ })
+})
diff --git a/packages/preact-query/src/__tests__/mutationOptions.test-d.tsx b/packages/preact-query/src/__tests__/mutationOptions.test-d.tsx
new file mode 100644
index 0000000000..2988426d65
--- /dev/null
+++ b/packages/preact-query/src/__tests__/mutationOptions.test-d.tsx
@@ -0,0 +1,217 @@
+import { assertType, describe, expectTypeOf, it } from 'vitest'
+import { QueryClient } from '@tanstack/query-core'
+import { useIsMutating, useMutation, useMutationState } from '..'
+import { mutationOptions } from '../mutationOptions'
+import type {
+ DefaultError,
+ MutationFunctionContext,
+ MutationState,
+ WithRequired,
+} from '@tanstack/query-core'
+import type { UseMutationOptions, UseMutationResult } from '../types'
+
+describe('mutationOptions', () => {
+ it('should not allow excess properties', () => {
+ // @ts-expect-error this is a good error, because onMutates does not exist!
+ mutationOptions({
+ mutationFn: () => Promise.resolve(5),
+ mutationKey: ['key'],
+ onMutates: 1000,
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ })
+ })
+
+ it('should infer types for callbacks', () => {
+ mutationOptions({
+ mutationFn: () => Promise.resolve(5),
+ mutationKey: ['key'],
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ })
+ })
+
+ it('should infer types for onError callback', () => {
+ mutationOptions({
+ mutationFn: () => {
+ throw new Error('fail')
+ },
+ mutationKey: ['key'],
+ onError: (error) => {
+ expectTypeOf(error).toEqualTypeOf()
+ },
+ })
+ })
+
+ it('should infer types for variables', () => {
+ mutationOptions({
+ mutationFn: (vars) => {
+ expectTypeOf(vars).toEqualTypeOf<{ id: string }>()
+ return Promise.resolve(5)
+ },
+ mutationKey: ['with-vars'],
+ })
+ })
+
+ it('should infer result type correctly', () => {
+ mutationOptions({
+ mutationFn: () => Promise.resolve(5),
+ mutationKey: ['key'],
+ onMutate: () => {
+ return { name: 'onMutateResult' }
+ },
+ onSuccess: (_data, _variables, onMutateResult) => {
+ expectTypeOf(onMutateResult).toEqualTypeOf<{ name: string }>()
+ },
+ })
+ })
+
+ it('should infer context type correctly', () => {
+ mutationOptions({
+ mutationFn: (_variables, context) => {
+ expectTypeOf(context).toEqualTypeOf()
+ return Promise.resolve(5)
+ },
+ mutationKey: ['key'],
+ onMutate: (_variables, context) => {
+ expectTypeOf(context).toEqualTypeOf()
+ },
+ onSuccess: (_data, _variables, _onMutateResult, context) => {
+ expectTypeOf(context).toEqualTypeOf()
+ },
+ onError: (_error, _variables, _onMutateResult, context) => {
+ expectTypeOf(context).toEqualTypeOf()
+ },
+ onSettled: (_data, _error, _variables, _onMutateResult, context) => {
+ expectTypeOf(context).toEqualTypeOf()
+ },
+ })
+ })
+
+ it('should error if mutationFn return type mismatches TData', () => {
+ assertType(
+ mutationOptions({
+ // @ts-expect-error this is a good error, because return type is string, not number
+ mutationFn: async () => Promise.resolve('wrong return'),
+ }),
+ )
+ })
+
+ it('should allow mutationKey to be omitted', () => {
+ return mutationOptions({
+ mutationFn: () => Promise.resolve(123),
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ })
+ })
+
+ it('should infer all types when not explicitly provided', () => {
+ expectTypeOf(
+ mutationOptions({
+ mutationFn: (id: string) => Promise.resolve(id.length),
+ mutationKey: ['key'],
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ }),
+ ).toEqualTypeOf<
+ WithRequired<
+ UseMutationOptions,
+ 'mutationKey'
+ >
+ >()
+ expectTypeOf(
+ mutationOptions({
+ mutationFn: (id: string) => Promise.resolve(id.length),
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ }),
+ ).toEqualTypeOf<
+ Omit, 'mutationKey'>
+ >()
+ })
+
+ it('should infer types when used with useMutation', () => {
+ const mutation = useMutation(
+ mutationOptions({
+ mutationKey: ['key'],
+ mutationFn: () => Promise.resolve('data'),
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ }),
+ )
+ expectTypeOf(mutation).toEqualTypeOf<
+ UseMutationResult
+ >()
+
+ useMutation(
+ // should allow when used with useMutation without mutationKey
+ mutationOptions({
+ mutationFn: () => Promise.resolve('data'),
+ onSuccess: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ }),
+ )
+ })
+
+ it('should infer types when used with useIsMutating', () => {
+ const isMutating = useIsMutating(
+ mutationOptions({
+ mutationKey: ['key'],
+ mutationFn: () => Promise.resolve(5),
+ }),
+ )
+ expectTypeOf(isMutating).toEqualTypeOf()
+
+ useIsMutating(
+ // @ts-expect-error filters should have mutationKey
+ mutationOptions({
+ mutationFn: () => Promise.resolve(5),
+ }),
+ )
+ })
+
+ it('should infer types when used with queryClient.isMutating', () => {
+ const queryClient = new QueryClient()
+
+ const isMutating = queryClient.isMutating(
+ mutationOptions({
+ mutationKey: ['key'],
+ mutationFn: () => Promise.resolve(5),
+ }),
+ )
+ expectTypeOf(isMutating).toEqualTypeOf()
+
+ queryClient.isMutating(
+ // @ts-expect-error filters should have mutationKey
+ mutationOptions({
+ mutationFn: () => Promise.resolve(5),
+ }),
+ )
+ })
+
+ it('should infer types when used with useMutationState', () => {
+ const mutationState = useMutationState({
+ filters: mutationOptions({
+ mutationKey: ['key'],
+ mutationFn: () => Promise.resolve(5),
+ }),
+ })
+ expectTypeOf(mutationState).toEqualTypeOf<
+ Array>
+ >()
+
+ useMutationState({
+ // @ts-expect-error filters should have mutationKey
+ filters: mutationOptions({
+ mutationFn: () => Promise.resolve(5),
+ }),
+ })
+ })
+})
diff --git a/packages/preact-query/src/__tests__/mutationOptions.test.tsx b/packages/preact-query/src/__tests__/mutationOptions.test.tsx
new file mode 100644
index 0000000000..ac08a3b553
--- /dev/null
+++ b/packages/preact-query/src/__tests__/mutationOptions.test.tsx
@@ -0,0 +1,526 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { QueryClient } from '@tanstack/query-core'
+import { sleep } from '@tanstack/query-test-utils'
+import { fireEvent } from '@testing-library/preact'
+import { mutationOptions } from '../mutationOptions'
+import { useIsMutating, useMutation, useMutationState } from '..'
+import { renderWithClient } from './utils'
+import type { MutationState } from '@tanstack/query-core'
+
+describe('mutationOptions', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should return the object received as a parameter without any modification (with mutationKey in mutationOptions)', () => {
+ const object = {
+ mutationKey: ['key'],
+ mutationFn: () => sleep(10).then(() => 5),
+ } as const
+
+ expect(mutationOptions(object)).toStrictEqual(object)
+ })
+
+ it('should return the object received as a parameter without any modification (without mutationKey in mutationOptions)', () => {
+ const object = {
+ mutationFn: () => sleep(10).then(() => 5),
+ } as const
+
+ expect(mutationOptions(object)).toStrictEqual(object)
+ })
+
+ it('should return the number of fetching mutations when used with useIsMutating (with mutationKey in mutationOptions)', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+ const mutationOpts = mutationOptions({
+ mutationKey: ['key'],
+ mutationFn: () => sleep(50).then(() => 'data'),
+ })
+
+ function IsMutating() {
+ const isMutating = useIsMutating()
+
+ isMutatingArray.push(isMutating)
+
+ return null
+ }
+
+ function Mutation() {
+ const { mutate } = useMutation(mutationOpts)
+
+ return (
+
+ mutate()}>mutate
+
+ )
+ }
+
+ function Page() {
+ return (
+
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ expect(isMutatingArray[0]).toEqual(0)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(isMutatingArray[1]).toEqual(1)
+ await vi.advanceTimersByTimeAsync(51)
+ expect(isMutatingArray[2]).toEqual(0)
+ expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
+ })
+
+ it('should return the number of fetching mutations when used with useIsMutating (without mutationKey in mutationOptions)', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+ const mutationOpts = mutationOptions({
+ mutationFn: () => sleep(50).then(() => 'data'),
+ })
+
+ function IsMutating() {
+ const isMutating = useIsMutating()
+
+ isMutatingArray.push(isMutating)
+
+ return null
+ }
+
+ function Mutation() {
+ const { mutate } = useMutation(mutationOpts)
+
+ return (
+
+ mutate()}>mutate
+
+ )
+ }
+
+ function Page() {
+ return (
+
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ expect(isMutatingArray[0]).toEqual(0)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(isMutatingArray[1]).toEqual(1)
+ await vi.advanceTimersByTimeAsync(51)
+ expect(isMutatingArray[2]).toEqual(0)
+ expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
+ })
+
+ it('should return the number of fetching mutations when used with useIsMutating', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+ const mutationOpts1 = mutationOptions({
+ mutationKey: ['key'],
+ mutationFn: () => sleep(50).then(() => 'data1'),
+ })
+ const mutationOpts2 = mutationOptions({
+ mutationFn: () => sleep(50).then(() => 'data2'),
+ })
+
+ function IsMutating() {
+ const isMutating = useIsMutating()
+
+ isMutatingArray.push(isMutating)
+
+ return null
+ }
+
+ function Mutation() {
+ const { mutate: mutate1 } = useMutation(mutationOpts1)
+ const { mutate: mutate2 } = useMutation(mutationOpts2)
+
+ return (
+
+ mutate1()}>mutate1
+ mutate2()}>mutate2
+
+ )
+ }
+
+ function Page() {
+ return (
+
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
+ fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
+ expect(isMutatingArray[0]).toEqual(0)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(isMutatingArray[1]).toEqual(2)
+ await vi.advanceTimersByTimeAsync(51)
+ expect(isMutatingArray[2]).toEqual(0)
+ expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
+ })
+
+ it('should return the number of fetching mutations when used with useIsMutating (filter mutationOpts1.mutationKey)', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+ const mutationOpts1 = mutationOptions({
+ mutationKey: ['key'],
+ mutationFn: () => sleep(50).then(() => 'data1'),
+ })
+ const mutationOpts2 = mutationOptions({
+ mutationFn: () => sleep(50).then(() => 'data2'),
+ })
+
+ function IsMutating() {
+ const isMutating = useIsMutating({
+ mutationKey: mutationOpts1.mutationKey,
+ })
+
+ isMutatingArray.push(isMutating)
+
+ return null
+ }
+
+ function Mutation() {
+ const { mutate: mutate1 } = useMutation(mutationOpts1)
+ const { mutate: mutate2 } = useMutation(mutationOpts2)
+
+ return (
+
+ mutate1()}>mutate1
+ mutate2()}>mutate2
+
+ )
+ }
+
+ function Page() {
+ return (
+
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
+ fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
+ expect(isMutatingArray[0]).toEqual(0)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(isMutatingArray[1]).toEqual(1)
+ await vi.advanceTimersByTimeAsync(51)
+ expect(isMutatingArray[2]).toEqual(0)
+ expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
+ })
+
+ it('should return the number of fetching mutations when used with queryClient.isMutating (with mutationKey in mutationOptions)', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+ const mutationOpts = mutationOptions({
+ mutationKey: ['mutation'],
+ mutationFn: () => sleep(500).then(() => 'data'),
+ })
+
+ function Mutation() {
+ const isMutating = queryClient.isMutating(mutationOpts)
+ const { mutate } = useMutation(mutationOpts)
+
+ isMutatingArray.push(isMutating)
+
+ return (
+
+ mutate()}>mutate
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ expect(isMutatingArray[0]).toEqual(0)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(isMutatingArray[1]).toEqual(1)
+ await vi.advanceTimersByTimeAsync(501)
+ expect(isMutatingArray[2]).toEqual(0)
+ expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
+ })
+
+ it('should return the number of fetching mutations when used with queryClient.isMutating (without mutationKey in mutationOptions)', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+ const mutationOpts = mutationOptions({
+ mutationFn: () => sleep(500).then(() => 'data'),
+ })
+
+ function Mutation() {
+ const isMutating = queryClient.isMutating()
+ const { mutate } = useMutation(mutationOpts)
+
+ isMutatingArray.push(isMutating)
+
+ return (
+
+ mutate()}>mutate
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ expect(isMutatingArray[0]).toEqual(0)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(isMutatingArray[1]).toEqual(1)
+ await vi.advanceTimersByTimeAsync(501)
+ expect(isMutatingArray[2]).toEqual(0)
+ expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
+ })
+
+ it('should return the number of fetching mutations when used with queryClient.isMutating', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+ const mutationOpts1 = mutationOptions({
+ mutationKey: ['mutation'],
+ mutationFn: () => sleep(500).then(() => 'data1'),
+ })
+ const mutationOpts2 = mutationOptions({
+ mutationFn: () => sleep(500).then(() => 'data2'),
+ })
+
+ function Mutation() {
+ const isMutating = queryClient.isMutating()
+ const { mutate: mutate1 } = useMutation(mutationOpts1)
+ const { mutate: mutate2 } = useMutation(mutationOpts2)
+
+ isMutatingArray.push(isMutating)
+
+ return (
+
+ mutate1()}>mutate1
+ mutate2()}>mutate2
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
+ fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
+ expect(isMutatingArray[0]).toEqual(0)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(isMutatingArray[1]).toEqual(2)
+ await vi.advanceTimersByTimeAsync(501)
+ expect(isMutatingArray[2]).toEqual(0)
+ expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
+ })
+
+ it('should return the number of fetching mutations when used with queryClient.isMutating (filter mutationOpt1.mutationKey)', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+ const mutationOpts1 = mutationOptions({
+ mutationKey: ['mutation'],
+ mutationFn: () => sleep(500).then(() => 'data1'),
+ })
+ const mutationOpts2 = mutationOptions({
+ mutationFn: () => sleep(500).then(() => 'data2'),
+ })
+
+ function Mutation() {
+ const isMutating = queryClient.isMutating({
+ mutationKey: mutationOpts1.mutationKey,
+ })
+ const { mutate: mutate1 } = useMutation(mutationOpts1)
+ const { mutate: mutate2 } = useMutation(mutationOpts2)
+
+ isMutatingArray.push(isMutating)
+
+ return (
+
+ mutate1()}>mutate1
+ mutate2()}>mutate2
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
+ fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
+ expect(isMutatingArray[0]).toEqual(0)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(isMutatingArray[1]).toEqual(1)
+ await vi.advanceTimersByTimeAsync(501)
+ expect(isMutatingArray[2]).toEqual(0)
+ expect(isMutatingArray[isMutatingArray.length - 1]).toEqual(0)
+ })
+
+ it('should return the number of fetching mutations when used with useMutationState (with mutationKey in mutationOptions)', async () => {
+ const mutationStateArray: Array<
+ MutationState
+ > = []
+ const queryClient = new QueryClient()
+ const mutationOpts = mutationOptions({
+ mutationKey: ['mutation'],
+ mutationFn: () => sleep(10).then(() => 'data'),
+ })
+
+ function Mutation() {
+ const { mutate } = useMutation(mutationOpts)
+ const data = useMutationState({
+ filters: { mutationKey: mutationOpts.mutationKey, status: 'success' },
+ })
+
+ mutationStateArray.push(...data)
+
+ return (
+
+ mutate()}>mutate
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(mutationStateArray.length).toEqual(0)
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(mutationStateArray.length).toEqual(1)
+ expect(mutationStateArray[0]?.data).toEqual('data')
+ })
+
+ it('should return the number of fetching mutations when used with useMutationState (without mutationKey in mutationOptions)', async () => {
+ const mutationStateArray: Array<
+ MutationState
+ > = []
+ const queryClient = new QueryClient()
+ const mutationOpts = mutationOptions({
+ mutationFn: () => sleep(10).then(() => 'data'),
+ })
+
+ function Mutation() {
+ const { mutate } = useMutation(mutationOpts)
+ const data = useMutationState({
+ filters: { status: 'success' },
+ })
+
+ mutationStateArray.push(...data)
+
+ return (
+
+ mutate()}>mutate
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(mutationStateArray.length).toEqual(0)
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(mutationStateArray.length).toEqual(1)
+ expect(mutationStateArray[0]?.data).toEqual('data')
+ })
+
+ it('should return the number of fetching mutations when used with useMutationState', async () => {
+ const mutationStateArray: Array<
+ MutationState
+ > = []
+ const queryClient = new QueryClient()
+ const mutationOpts1 = mutationOptions({
+ mutationKey: ['mutation'],
+ mutationFn: () => sleep(10).then(() => 'data1'),
+ })
+ const mutationOpts2 = mutationOptions({
+ mutationFn: () => sleep(10).then(() => 'data2'),
+ })
+
+ function Mutation() {
+ const { mutate: mutate1 } = useMutation(mutationOpts1)
+ const { mutate: mutate2 } = useMutation(mutationOpts2)
+ const data = useMutationState({
+ filters: { status: 'success' },
+ })
+
+ mutationStateArray.push(...data)
+
+ return (
+
+ mutate1()}>mutate1
+ mutate2()}>mutate2
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(mutationStateArray.length).toEqual(0)
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
+ fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(mutationStateArray.length).toEqual(2)
+ expect(mutationStateArray[0]?.data).toEqual('data1')
+ expect(mutationStateArray[1]?.data).toEqual('data2')
+ })
+
+ it('should return the number of fetching mutations when used with useMutationState (filter mutationOpt1.mutationKey)', async () => {
+ const mutationStateArray: Array<
+ MutationState
+ > = []
+ const queryClient = new QueryClient()
+ const mutationOpts1 = mutationOptions({
+ mutationKey: ['mutation'],
+ mutationFn: () => sleep(10).then(() => 'data1'),
+ })
+ const mutationOpts2 = mutationOptions({
+ mutationFn: () => sleep(10).then(() => 'data2'),
+ })
+
+ function Mutation() {
+ const { mutate: mutate1 } = useMutation(mutationOpts1)
+ const { mutate: mutate2 } = useMutation(mutationOpts2)
+ const data = useMutationState({
+ filters: { mutationKey: mutationOpts1.mutationKey, status: 'success' },
+ })
+
+ mutationStateArray.push(...data)
+
+ return (
+
+ mutate1()}>mutate1
+ mutate2()}>mutate2
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(mutationStateArray.length).toEqual(0)
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
+ fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(mutationStateArray.length).toEqual(1)
+ expect(mutationStateArray[0]?.data).toEqual('data1')
+ expect(mutationStateArray[1]).toBeFalsy()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/queryOptions.test-d.tsx b/packages/preact-query/src/__tests__/queryOptions.test-d.tsx
new file mode 100644
index 0000000000..aac63737eb
--- /dev/null
+++ b/packages/preact-query/src/__tests__/queryOptions.test-d.tsx
@@ -0,0 +1,286 @@
+import { assertType, describe, expectTypeOf, it } from 'vitest'
+import {
+ QueriesObserver,
+ QueryClient,
+ dataTagSymbol,
+ skipToken,
+} from '@tanstack/query-core'
+import { queryOptions } from '../queryOptions'
+import { useQuery } from '../useQuery'
+import { useQueries } from '../useQueries'
+import { useSuspenseQuery } from '../useSuspenseQuery'
+import type { AnyUseQueryOptions } from '../types'
+import type {
+ DataTag,
+ InitialDataFunction,
+ QueryObserverResult,
+} from '@tanstack/query-core'
+
+describe('queryOptions', () => {
+ it('should not allow excess properties', () => {
+ assertType(
+ queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ // @ts-expect-error this is a good error, because stallTime does not exist!
+ stallTime: 1000,
+ }),
+ )
+ })
+ it('should infer types for callbacks', () => {
+ queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ staleTime: 1000,
+ select: (data) => {
+ expectTypeOf(data).toEqualTypeOf()
+ },
+ })
+ })
+ it('should work when passed to useQuery', () => {
+ const options = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ const { data } = useQuery(options)
+ expectTypeOf(data).toEqualTypeOf()
+ })
+ it('should work when passed to useSuspenseQuery', () => {
+ const options = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ const { data } = useSuspenseQuery(options)
+ expectTypeOf(data).toEqualTypeOf()
+ })
+
+ it('should work when passed to fetchQuery', async () => {
+ const options = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ const data = await new QueryClient().fetchQuery(options)
+ expectTypeOf(data).toEqualTypeOf()
+ })
+ it('should work when passed to useQueries', () => {
+ const options = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ const [{ data }] = useQueries({
+ queries: [options],
+ })
+
+ expectTypeOf(data).toEqualTypeOf()
+ })
+ it('should tag the queryKey with the result type of the QueryFn', () => {
+ const { queryKey } = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf()
+ })
+ it('should tag the queryKey even if no promise is returned', () => {
+ const { queryKey } = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => 5,
+ })
+
+ expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf()
+ })
+ it('should tag the queryKey with unknown if there is no queryFn', () => {
+ const { queryKey } = queryOptions({
+ queryKey: ['key'],
+ })
+
+ expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf()
+ })
+ it('should tag the queryKey with the result type of the QueryFn if select is used', () => {
+ const { queryKey } = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ select: (data) => data.toString(),
+ })
+
+ expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf()
+ })
+ it('should return the proper type when passed to getQueryData', () => {
+ const { queryKey } = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ const queryClient = new QueryClient()
+ const data = queryClient.getQueryData(queryKey)
+ expectTypeOf(data).toEqualTypeOf()
+ })
+ it('should return the proper type when passed to getQueryState', () => {
+ const { queryKey } = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ const queryClient = new QueryClient()
+ const state = queryClient.getQueryState(queryKey)
+ expectTypeOf(state?.data).toEqualTypeOf()
+ })
+ it('should properly type updaterFn when passed to setQueryData', () => {
+ const { queryKey } = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ const queryClient = new QueryClient()
+ const data = queryClient.setQueryData(queryKey, (prev) => {
+ expectTypeOf(prev).toEqualTypeOf()
+ return prev
+ })
+ expectTypeOf(data).toEqualTypeOf()
+ })
+ it('should properly type value when passed to setQueryData', () => {
+ const { queryKey } = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ const queryClient = new QueryClient()
+
+ // @ts-expect-error value should be a number
+ queryClient.setQueryData(queryKey, '5')
+ // @ts-expect-error value should be a number
+ queryClient.setQueryData(queryKey, () => '5')
+
+ const data = queryClient.setQueryData(queryKey, 5)
+ expectTypeOf(data).toEqualTypeOf()
+ })
+
+ it('should infer even if there is a conditional skipToken', () => {
+ const options = queryOptions({
+ queryKey: ['key'],
+ queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
+ })
+
+ const queryClient = new QueryClient()
+ const data = queryClient.getQueryData(options.queryKey)
+ expectTypeOf(data).toEqualTypeOf()
+ })
+
+ it('should infer to unknown if we disable a query with just a skipToken', () => {
+ const options = queryOptions({
+ queryKey: ['key'],
+ queryFn: skipToken,
+ })
+
+ const queryClient = new QueryClient()
+ const data = queryClient.getQueryData(options.queryKey)
+ expectTypeOf(data).toEqualTypeOf()
+ })
+
+ it('should throw a type error when using queryFn with skipToken in a suspense query', () => {
+ const options = queryOptions({
+ queryKey: ['key'],
+ queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
+ })
+ // @ts-expect-error TS2345
+ const { data } = useSuspenseQuery(options)
+ expectTypeOf(data).toEqualTypeOf()
+ })
+
+ it('should return the proper type when passed to QueriesObserver', () => {
+ const options = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ const queryClient = new QueryClient()
+ const queriesObserver = new QueriesObserver(queryClient, [options])
+ expectTypeOf(queriesObserver).toEqualTypeOf<
+ QueriesObserver>
+ >()
+ })
+
+ it('should allow undefined response in initialData', () => {
+ assertType((id: string | null) =>
+ queryOptions({
+ queryKey: ['todo', id],
+ queryFn: () =>
+ Promise.resolve({
+ id: '1',
+ title: 'Do Laundry',
+ }),
+ initialData: () =>
+ !id
+ ? undefined
+ : {
+ id,
+ title: 'Initial Data',
+ },
+ }),
+ )
+ })
+
+ it('should allow optional initialData object', () => {
+ const testFn = (id?: string) => {
+ const options = queryOptions({
+ queryKey: ['test'],
+ queryFn: () => Promise.resolve('something string'),
+ initialData: id ? 'initial string' : undefined,
+ })
+ expectTypeOf(options.initialData).toMatchTypeOf<
+ InitialDataFunction | string | undefined
+ >()
+ }
+ testFn('id')
+ testFn()
+ })
+
+ it('should be passable to UseQueryOptions', () => {
+ function somethingWithQueryOptions(
+ options: TQueryOpts,
+ ) {
+ return options.queryKey
+ }
+
+ const options = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(1),
+ })
+
+ assertType(somethingWithQueryOptions(options))
+ })
+
+ it('should return a custom query key type', () => {
+ type MyQueryKey = [Array, { type: 'foo' }]
+
+ const options = queryOptions({
+ queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
+ queryFn: () => Promise.resolve(1),
+ })
+
+ expectTypeOf(options.queryKey).toEqualTypeOf<
+ DataTag
+ >()
+ })
+
+ it('should return a custom query key type with datatag', () => {
+ type MyQueryKey = DataTag<
+ [Array, { type: 'foo' }],
+ number,
+ Error & { myMessage: string }
+ >
+
+ const options = queryOptions({
+ queryKey: [['key'], { type: 'foo' }] as MyQueryKey,
+ queryFn: () => Promise.resolve(1),
+ })
+
+ expectTypeOf(options.queryKey).toEqualTypeOf<
+ DataTag
+ >()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/queryOptions.test.tsx b/packages/preact-query/src/__tests__/queryOptions.test.tsx
new file mode 100644
index 0000000000..28e539690b
--- /dev/null
+++ b/packages/preact-query/src/__tests__/queryOptions.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+import { queryOptions } from '../queryOptions'
+import type { UseQueryOptions } from '../types'
+
+describe('queryOptions', () => {
+ it('should return the object received as a parameter without any modification.', () => {
+ const object: UseQueryOptions = {
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ } as const
+
+ expect(queryOptions(object)).toStrictEqual(object)
+ })
+})
diff --git a/packages/preact-query/src/__tests__/ssr-hydration.test.tsx b/packages/preact-query/src/__tests__/ssr-hydration.test.tsx
new file mode 100644
index 0000000000..dfc75367d5
--- /dev/null
+++ b/packages/preact-query/src/__tests__/ssr-hydration.test.tsx
@@ -0,0 +1,269 @@
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import { renderToString } from 'preact-render-to-string'
+import { hydrate as preactHydrate, VNode } from 'preact'
+import {
+ QueryCache,
+ QueryClient,
+ QueryClientProvider,
+ dehydrate,
+ hydrate,
+ useQuery,
+} from '..'
+import { setIsServer } from './utils'
+import { act } from '@testing-library/preact'
+
+const ReactHydrate = (element: VNode, container: Element) => {
+ let root: any
+ act(() => {
+ root = preactHydrate(element, container)
+ })
+ return () => {
+ root.unmount()
+ }
+}
+
+async function fetchData(value: TData, ms?: number): Promise {
+ await vi.advanceTimersByTimeAsync(ms || 1)
+ return value
+}
+
+function PrintStateComponent({ componentName, result }: any): any {
+ return `${componentName} - status:${result.status} fetching:${result.isFetching} data:${result.data}`
+}
+
+describe('Server side rendering with de/rehydration', () => {
+ let previousIsReactActEnvironment: unknown
+ beforeAll(() => {
+ // @ts-expect-error we expect IS_REACT_ACT_ENVIRONMENT to exist
+ previousIsReactActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ vi.useFakeTimers()
+ })
+
+ afterAll(() => {
+ // @ts-expect-error we expect IS_REACT_ACT_ENVIRONMENT to exist
+ globalThis.IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment
+ vi.useRealTimers()
+ })
+
+ it('should not mismatch on success', async () => {
+ const consoleMock = vi.spyOn(console, 'error')
+ consoleMock.mockImplementation(() => undefined)
+
+ const fetchDataSuccess = vi.fn(fetchData)
+
+ // -- Shared part --
+ function SuccessComponent() {
+ const result = useQuery({
+ queryKey: ['success'],
+ queryFn: () => fetchDataSuccess('success!'),
+ })
+ return (
+
+ )
+ }
+
+ // -- Server part --
+ setIsServer(true)
+
+ const prefetchCache = new QueryCache()
+ const prefetchClient = new QueryClient({
+ queryCache: prefetchCache,
+ })
+ await prefetchClient.prefetchQuery({
+ queryKey: ['success'],
+ queryFn: () => fetchDataSuccess('success'),
+ })
+ const dehydratedStateServer = dehydrate(prefetchClient)
+ const renderCache = new QueryCache()
+ const renderClient = new QueryClient({
+ queryCache: renderCache,
+ })
+ hydrate(renderClient, dehydratedStateServer)
+ const markup = renderToString(
+
+
+ ,
+ )
+ const stringifiedState = JSON.stringify(dehydratedStateServer)
+ renderClient.clear()
+ setIsServer(false)
+
+ const expectedMarkup =
+ 'SuccessComponent - status:success fetching:true data:success'
+
+ expect(markup).toBe(expectedMarkup)
+ expect(fetchDataSuccess).toHaveBeenCalledTimes(1)
+
+ // -- Client part --
+ const el = document.createElement('div')
+ el.innerHTML = markup
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({ queryCache })
+ hydrate(queryClient, JSON.parse(stringifiedState))
+
+ const unmount = ReactHydrate(
+
+
+ ,
+ el,
+ )
+
+ // Check that we have no React hydration mismatches
+ expect(consoleMock).toHaveBeenCalledTimes(0)
+
+ expect(fetchDataSuccess).toHaveBeenCalledTimes(2)
+ expect(el.innerHTML).toBe(expectedMarkup)
+
+ unmount()
+ queryClient.clear()
+ consoleMock.mockRestore()
+ })
+
+ it('should not mismatch on error', async () => {
+ const consoleMock = vi.spyOn(console, 'error')
+ consoleMock.mockImplementation(() => undefined)
+
+ const fetchDataError = vi.fn(() => {
+ throw new Error('fetchDataError')
+ })
+
+ // -- Shared part --
+ function ErrorComponent() {
+ const result = useQuery({
+ queryKey: ['error'],
+ queryFn: () => fetchDataError(),
+ retry: false,
+ })
+ return (
+
+ )
+ }
+
+ // -- Server part --
+ setIsServer(true)
+ const prefetchCache = new QueryCache()
+ const prefetchClient = new QueryClient({
+ queryCache: prefetchCache,
+ })
+ await prefetchClient.prefetchQuery({
+ queryKey: ['error'],
+ queryFn: () => fetchDataError(),
+ })
+ const dehydratedStateServer = dehydrate(prefetchClient)
+ const renderCache = new QueryCache()
+ const renderClient = new QueryClient({
+ queryCache: renderCache,
+ })
+ hydrate(renderClient, dehydratedStateServer)
+ const markup = renderToString(
+
+
+ ,
+ )
+ const stringifiedState = JSON.stringify(dehydratedStateServer)
+ renderClient.clear()
+ setIsServer(false)
+
+ const expectedMarkup =
+ 'ErrorComponent - status:pending fetching:true data:undefined'
+
+ expect(markup).toBe(expectedMarkup)
+
+ // -- Client part --
+ const el = document.createElement('div')
+ el.innerHTML = markup
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({ queryCache })
+ hydrate(queryClient, JSON.parse(stringifiedState))
+
+ const unmount = ReactHydrate(
+
+
+ ,
+ el,
+ )
+
+ expect(consoleMock).toHaveBeenCalledTimes(0)
+ expect(fetchDataError).toHaveBeenCalledTimes(2)
+ expect(el.innerHTML).toBe(expectedMarkup)
+ await vi.advanceTimersByTimeAsync(50)
+ expect(fetchDataError).toHaveBeenCalledTimes(2)
+ expect(el.innerHTML).toBe(
+ 'ErrorComponent - status:error fetching:false data:undefined',
+ )
+
+ unmount()
+ queryClient.clear()
+ consoleMock.mockRestore()
+ })
+
+ it('should not mismatch on queries that were not prefetched', async () => {
+ const consoleMock = vi.spyOn(console, 'error')
+ consoleMock.mockImplementation(() => undefined)
+
+ const fetchDataSuccess = vi.fn(fetchData)
+
+ // -- Shared part --
+ function SuccessComponent() {
+ const result = useQuery({
+ queryKey: ['success'],
+ queryFn: () => fetchDataSuccess('success!'),
+ })
+ return (
+
+ )
+ }
+
+ // -- Server part --
+ setIsServer(true)
+
+ const prefetchClient = new QueryClient()
+ const dehydratedStateServer = dehydrate(prefetchClient)
+ const renderClient = new QueryClient()
+ hydrate(renderClient, dehydratedStateServer)
+ const markup = renderToString(
+
+
+ ,
+ )
+ const stringifiedState = JSON.stringify(dehydratedStateServer)
+ renderClient.clear()
+ setIsServer(false)
+
+ const expectedMarkup =
+ 'SuccessComponent - status:pending fetching:true data:undefined'
+
+ expect(markup).toBe(expectedMarkup)
+
+ // -- Client part --
+ const el = document.createElement('div')
+ el.innerHTML = markup
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({ queryCache })
+ hydrate(queryClient, JSON.parse(stringifiedState))
+
+ const unmount = ReactHydrate(
+
+
+ ,
+ el,
+ )
+
+ // Check that we have no React hydration mismatches
+ expect(consoleMock).toHaveBeenCalledTimes(0)
+ expect(fetchDataSuccess).toHaveBeenCalledTimes(1)
+ expect(el.innerHTML).toBe(expectedMarkup)
+ await vi.advanceTimersByTimeAsync(50)
+ expect(fetchDataSuccess).toHaveBeenCalledTimes(1)
+ expect(el.innerHTML).toBe(
+ 'SuccessComponent - status:success fetching:false data:success!',
+ )
+
+ unmount()
+ queryClient.clear()
+ consoleMock.mockRestore()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/ssr.test.tsx b/packages/preact-query/src/__tests__/ssr.test.tsx
new file mode 100644
index 0000000000..56769d3afb
--- /dev/null
+++ b/packages/preact-query/src/__tests__/ssr.test.tsx
@@ -0,0 +1,176 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import {
+ QueryCache,
+ QueryClient,
+ QueryClientProvider,
+ useInfiniteQuery,
+ useQuery,
+} from '..'
+import { setIsServer } from './utils'
+import { renderToString } from 'preact-render-to-string'
+import { useState } from 'preact/hooks'
+
+describe('Server Side Rendering', () => {
+ setIsServer(true)
+
+ let queryCache: QueryCache
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ queryCache = new QueryCache()
+ queryClient = new QueryClient({ queryCache })
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should not trigger fetch', () => {
+ const key = queryKey()
+ const queryFn = vi.fn(() => sleep(10).then(() => 'data'))
+
+ function Page() {
+ const query = useQuery({ queryKey: key, queryFn })
+
+ const content = `status ${query.status}`
+
+ return (
+
+ )
+ }
+
+ const markup = renderToString(
+
+
+ ,
+ )
+
+ expect(markup).toContain('status pending')
+ expect(queryFn).toHaveBeenCalledTimes(0)
+
+ queryCache.clear()
+ })
+
+ it('should add prefetched data to cache', async () => {
+ const key = queryKey()
+
+ const promise = queryClient.fetchQuery({
+ queryKey: key,
+ queryFn: () => sleep(10).then(() => 'data'),
+ })
+ await vi.advanceTimersByTimeAsync(10)
+
+ const data = await promise
+
+ expect(data).toBe('data')
+ expect(queryCache.find({ queryKey: key })?.state.data).toBe('data')
+
+ queryCache.clear()
+ })
+
+ it('should return existing data from the cache', async () => {
+ const key = queryKey()
+ const queryFn = vi.fn(() => sleep(10).then(() => 'data'))
+
+ function Page() {
+ const query = useQuery({ queryKey: key, queryFn })
+
+ const content = `status ${query.status}`
+
+ return (
+
+ )
+ }
+
+ queryClient.prefetchQuery({ queryKey: key, queryFn })
+ await vi.advanceTimersByTimeAsync(10)
+
+ const markup = renderToString(
+
+
+ ,
+ )
+
+ expect(markup).toContain('status success')
+ expect(queryFn).toHaveBeenCalledTimes(1)
+
+ queryCache.clear()
+ })
+
+ it('should add initialData to the cache', () => {
+ const key = queryKey()
+
+ function Page() {
+ const [page, setPage] = useState(1)
+ const { data } = useQuery({
+ queryKey: [key, page],
+ queryFn: () => sleep(10).then(() => page),
+ initialData: 1,
+ })
+
+ return (
+
+
{data}
+ setPage(page + 1)}>next
+
+ )
+ }
+
+ renderToString(
+
+
+ ,
+ )
+
+ const keys = queryCache.getAll().map((query) => query.queryKey)
+
+ expect(keys).toEqual([[key, 1]])
+
+ queryCache.clear()
+ })
+
+ it('useInfiniteQuery should return the correct state', async () => {
+ const key = queryKey()
+ const queryFn = vi.fn(() => sleep(10).then(() => 'page 1'))
+
+ function Page() {
+ const query = useInfiniteQuery({
+ queryKey: key,
+ queryFn,
+ getNextPageParam: () => undefined,
+ initialPageParam: 0,
+ })
+ return (
+
+ {query.data?.pages.map((page) => (
+ {page}
+ ))}
+
+ )
+ }
+
+ queryClient.prefetchInfiniteQuery({
+ queryKey: key,
+ queryFn,
+ initialPageParam: 0,
+ })
+ await vi.advanceTimersByTimeAsync(10)
+
+ const markup = renderToString(
+
+
+ ,
+ )
+
+ expect(markup).toContain('page 1')
+ expect(queryFn).toHaveBeenCalledTimes(1)
+
+ queryCache.clear()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/suspense.test.tsx b/packages/preact-query/src/__tests__/suspense.test.tsx
new file mode 100644
index 0000000000..7c6bc5ca9b
--- /dev/null
+++ b/packages/preact-query/src/__tests__/suspense.test.tsx
@@ -0,0 +1,184 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { render } from '@testing-library/preact'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import { QueryClient, QueryClientProvider, useSuspenseQuery } from '..'
+import type { QueryKey } from '..'
+import { Suspense } from 'preact/compat'
+
+function renderWithSuspense(client: QueryClient, ui: React.ReactNode) {
+ return render(
+
+ {ui}
+ ,
+ )
+}
+
+function createTestQuery(options: {
+ fetchCount: { count: number }
+ queryKey: QueryKey
+ staleTime?: number | (() => number)
+}) {
+ return function TestComponent() {
+ const { data } = useSuspenseQuery({
+ queryKey: options.queryKey,
+ queryFn: () =>
+ sleep(10).then(() => {
+ options.fetchCount.count++
+ return 'data'
+ }),
+ staleTime: options.staleTime,
+ })
+ return data: {data}
+ }
+}
+
+describe('Suspense Timer Tests', () => {
+ let queryClient: QueryClient
+ let fetchCount: { count: number }
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ })
+ fetchCount = { count: 0 }
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should enforce minimum staleTime of 1000ms when using suspense with number', async () => {
+ const TestComponent = createTestQuery({
+ fetchCount,
+ queryKey: ['test'],
+ staleTime: 10,
+ })
+
+ const rendered = renderWithSuspense(queryClient, )
+
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('data: data')).toBeInTheDocument()
+
+ rendered.rerender(
+
+
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(10)
+
+ expect(fetchCount.count).toBe(1)
+ })
+
+ it('should enforce minimum staleTime of 1000ms when using suspense with function', async () => {
+ const TestComponent = createTestQuery({
+ fetchCount,
+ queryKey: ['test-func'],
+ staleTime: () => 10,
+ })
+
+ const rendered = renderWithSuspense(queryClient, )
+
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('data: data')).toBeInTheDocument()
+
+ rendered.rerender(
+
+
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(10)
+
+ expect(fetchCount.count).toBe(1)
+ })
+
+ it('should respect staleTime when value is greater than 1000ms', async () => {
+ const TestComponent = createTestQuery({
+ fetchCount,
+ queryKey: queryKey(),
+ staleTime: 2000,
+ })
+
+ const rendered = renderWithSuspense(queryClient, )
+
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('data: data')).toBeInTheDocument()
+
+ rendered.rerender(
+
+
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(1500)
+
+ expect(fetchCount.count).toBe(1)
+ })
+
+ it('should enforce minimum staleTime when undefined is provided', async () => {
+ const TestComponent = createTestQuery({
+ fetchCount,
+ queryKey: queryKey(),
+ staleTime: undefined,
+ })
+
+ const rendered = renderWithSuspense(queryClient, )
+
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('data: data')).toBeInTheDocument()
+
+ rendered.rerender(
+
+
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(500)
+
+ expect(fetchCount.count).toBe(1)
+ })
+
+ it('should respect staleTime when function returns value greater than 1000ms', async () => {
+ const TestComponent = createTestQuery({
+ fetchCount,
+ queryKey: queryKey(),
+ staleTime: () => 3000,
+ })
+
+ const rendered = renderWithSuspense(queryClient, )
+
+ expect(rendered.getByText('loading')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('data: data')).toBeInTheDocument()
+
+ rendered.rerender(
+
+
+
+
+ ,
+ )
+
+ await vi.advanceTimersByTimeAsync(2000)
+
+ expect(fetchCount.count).toBe(1)
+ })
+})
diff --git a/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx b/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx
new file mode 100644
index 0000000000..a231d20600
--- /dev/null
+++ b/packages/preact-query/src/__tests__/useInfiniteQuery.test-d.tsx
@@ -0,0 +1,142 @@
+import { describe, expectTypeOf, it } from 'vitest'
+import { QueryClient } from '@tanstack/query-core'
+import { useInfiniteQuery } from '../useInfiniteQuery'
+import type { InfiniteData } from '@tanstack/query-core'
+
+describe('pageParam', () => {
+ it('initialPageParam should define type of param passed to queryFunctionContext', () => {
+ useInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: ({ pageParam }) => {
+ expectTypeOf(pageParam).toEqualTypeOf()
+ },
+ initialPageParam: 1,
+ getNextPageParam: () => undefined,
+ })
+ })
+
+ it('direction should be passed to queryFn of useInfiniteQuery', () => {
+ useInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: ({ direction }) => {
+ expectTypeOf(direction).toEqualTypeOf<'forward' | 'backward'>()
+ },
+ initialPageParam: 1,
+ getNextPageParam: () => undefined,
+ })
+ })
+
+ it('initialPageParam should define type of param passed to queryFunctionContext for fetchInfiniteQuery', () => {
+ const queryClient = new QueryClient()
+ queryClient.fetchInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: ({ pageParam }) => {
+ expectTypeOf(pageParam).toEqualTypeOf()
+ },
+ initialPageParam: 1,
+ })
+ })
+
+ it('initialPageParam should define type of param passed to queryFunctionContext for prefetchInfiniteQuery', () => {
+ const queryClient = new QueryClient()
+ queryClient.prefetchInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: ({ pageParam }) => {
+ expectTypeOf(pageParam).toEqualTypeOf()
+ },
+ initialPageParam: 1,
+ })
+ })
+})
+describe('select', () => {
+ it('should still return paginated data if no select result', () => {
+ const infiniteQuery = useInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: ({ pageParam }) => {
+ return pageParam * 5
+ },
+ initialPageParam: 1,
+ getNextPageParam: () => undefined,
+ })
+
+ // TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now
+ expectTypeOf(infiniteQuery.data).toEqualTypeOf<
+ InfiniteData | undefined
+ >()
+ })
+
+ it('should be able to transform data to arbitrary result', () => {
+ const infiniteQuery = useInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: ({ pageParam }) => {
+ return pageParam * 5
+ },
+ initialPageParam: 1,
+ getNextPageParam: () => undefined,
+ select: (data) => {
+ expectTypeOf(data).toEqualTypeOf>()
+ return 'selected' as const
+ },
+ })
+
+ expectTypeOf(infiniteQuery.data).toEqualTypeOf<'selected' | undefined>()
+ })
+})
+describe('getNextPageParam / getPreviousPageParam', () => {
+ it('should get typed params', () => {
+ const infiniteQuery = useInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: ({ pageParam }) => {
+ return String(pageParam)
+ },
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
+ expectTypeOf(lastPage).toEqualTypeOf()
+ expectTypeOf(allPages).toEqualTypeOf>()
+ expectTypeOf(lastPageParam).toEqualTypeOf()
+ expectTypeOf(allPageParams).toEqualTypeOf>()
+ return undefined
+ },
+ getPreviousPageParam: (
+ firstPage,
+ allPages,
+ firstPageParam,
+ allPageParams,
+ ) => {
+ expectTypeOf(firstPage).toEqualTypeOf()
+ expectTypeOf(allPages).toEqualTypeOf>()
+ expectTypeOf(firstPageParam).toEqualTypeOf()
+ expectTypeOf(allPageParams).toEqualTypeOf>()
+ return undefined
+ },
+ })
+
+ // TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now
+ expectTypeOf(infiniteQuery.data).toEqualTypeOf<
+ InfiniteData | undefined
+ >()
+ })
+})
+
+describe('error booleans', () => {
+ it('should not be permanently `false`', () => {
+ const {
+ isFetchNextPageError,
+ isFetchPreviousPageError,
+ isLoadingError,
+ isRefetchError,
+ } = useInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: ({ pageParam }) => {
+ return pageParam * 5
+ },
+ initialPageParam: 1,
+ getNextPageParam: () => undefined,
+ })
+
+ expectTypeOf(isFetchNextPageError).toEqualTypeOf()
+ expectTypeOf(isFetchPreviousPageError).toEqualTypeOf()
+ expectTypeOf(isLoadingError).toEqualTypeOf()
+ expectTypeOf(isRefetchError).toEqualTypeOf()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx b/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx
new file mode 100644
index 0000000000..27e070f898
--- /dev/null
+++ b/packages/preact-query/src/__tests__/useInfiniteQuery.test.tsx
@@ -0,0 +1,1860 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render } from '@testing-library/preact'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
+import {
+ QueryCache,
+ QueryClient,
+ QueryClientProvider,
+ keepPreviousData,
+ useInfiniteQuery,
+} from '..'
+import { renderWithClient, setActTimeout } from './utils'
+import type {
+ InfiniteData,
+ QueryFunctionContext,
+ UseInfiniteQueryResult,
+} from '..'
+import type { Mock } from 'vitest'
+import { Suspense } from 'preact/compat'
+
+interface Result {
+ items: Array
+ nextId?: number
+ prevId?: number
+ ts: number
+}
+
+const pageSize = 10
+
+const fetchItems = async (
+ page: number,
+ ts: number,
+ noNext?: boolean,
+ noPrev?: boolean,
+): Promise => {
+ await sleep(10)
+ return {
+ items: [...new Array(10)].fill(null).map((_, d) => page * pageSize + d),
+ nextId: noNext ? undefined : page + 1,
+ prevId: noPrev ? undefined : page - 1,
+ ts,
+ }
+}
+
+describe('useInfiniteQuery', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({
+ queryCache,
+ defaultOptions: {
+ queries: {
+ experimental_prefetchInRender: true,
+ },
+ },
+ })
+
+ it('should return the correct states for a successful query', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
+ getNextPageParam: (lastPage) => lastPage + 1,
+ initialPageParam: 0,
+ })
+ states.push(state)
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+
+ expect(states.length).toBe(2)
+ expect(states[0]).toEqual({
+ data: undefined,
+ dataUpdatedAt: 0,
+ error: null,
+ errorUpdatedAt: 0,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ fetchNextPage: expect.any(Function),
+ fetchPreviousPage: expect.any(Function),
+ hasNextPage: false,
+ hasPreviousPage: false,
+ isError: false,
+ isFetched: false,
+ isFetchedAfterMount: false,
+ isFetching: true,
+ isPaused: false,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isLoading: true,
+ isPending: true,
+ isInitialLoading: true,
+ isLoadingError: false,
+ isPlaceholderData: false,
+ isRefetchError: false,
+ isRefetching: false,
+ isStale: true,
+ isSuccess: false,
+ isEnabled: true,
+ refetch: expect.any(Function),
+ status: 'pending',
+ fetchStatus: 'fetching',
+ promise: expect.any(Promise),
+ })
+ expect(states[1]).toEqual({
+ data: { pages: [0], pageParams: [0] },
+ dataUpdatedAt: expect.any(Number),
+ error: null,
+ errorUpdatedAt: 0,
+ failureCount: 0,
+ failureReason: null,
+ errorUpdateCount: 0,
+ fetchNextPage: expect.any(Function),
+ fetchPreviousPage: expect.any(Function),
+ hasNextPage: true,
+ hasPreviousPage: false,
+ isError: false,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isFetching: false,
+ isPaused: false,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isLoading: false,
+ isPending: false,
+ isInitialLoading: false,
+ isLoadingError: false,
+ isPlaceholderData: false,
+ isRefetchError: false,
+ isRefetching: false,
+ isStale: true,
+ isSuccess: true,
+ isEnabled: true,
+ refetch: expect.any(Function),
+ status: 'success',
+ fetchStatus: 'idle',
+ promise: expect.any(Promise),
+ })
+ })
+
+ it('should not throw when fetchNextPage returns an error', async () => {
+ const key = queryKey()
+ let noThrow = false
+
+ function Page() {
+ const start = 1
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) =>
+ sleep(10).then(() => {
+ if (pageParam === 2) throw new Error('error')
+ return pageParam
+ }),
+ retry: 1,
+ retryDelay: 10,
+ getNextPageParam: (lastPage) => lastPage + 1,
+ initialPageParam: start,
+ })
+
+ const { fetchNextPage } = state
+
+ useEffect(() => {
+ setActTimeout(() => {
+ fetchNextPage()
+ .then(() => {
+ noThrow = true
+ })
+ .catch(() => undefined)
+ }, 20)
+ }, [fetchNextPage])
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(50)
+ expect(noThrow).toBe(true)
+ })
+
+ it('should keep the previous data when placeholderData is set', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const [order, setOrder] = useState('desc')
+
+ const state = useInfiniteQuery({
+ queryKey: [key, order],
+ queryFn: ({ pageParam }) =>
+ sleep(10).then(() => `${pageParam}-${order}`),
+ getNextPageParam: () => 1,
+ initialPageParam: 0,
+ placeholderData: keepPreviousData,
+ notifyOnChangeProps: 'all',
+ })
+
+ states.push(state)
+
+ return (
+
+
state.fetchNextPage()}>fetchNextPage
+
setOrder('asc')}>order
+
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {String(state.isFetching)}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 0-desc')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /fetchNextPage/i }))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 0-desc,1-desc')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /order/i }))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 0-asc')).toBeInTheDocument()
+ expect(rendered.getByText('isFetching: false')).toBeInTheDocument()
+
+ expect(states.length).toBe(6)
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ isFetching: true,
+ isFetchingNextPage: false,
+ isSuccess: false,
+ isPlaceholderData: false,
+ })
+ expect(states[1]).toMatchObject({
+ data: { pages: ['0-desc'] },
+ isFetching: false,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ isPlaceholderData: false,
+ })
+ expect(states[2]).toMatchObject({
+ data: { pages: ['0-desc'] },
+ isFetching: true,
+ isFetchingNextPage: true,
+ isSuccess: true,
+ isPlaceholderData: false,
+ })
+ expect(states[3]).toMatchObject({
+ data: { pages: ['0-desc', '1-desc'] },
+ isFetching: false,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ isPlaceholderData: false,
+ })
+ // Set state
+ expect(states[4]).toMatchObject({
+ data: { pages: ['0-desc', '1-desc'] },
+ isFetching: true,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ isPlaceholderData: true,
+ })
+ expect(states[5]).toMatchObject({
+ data: { pages: ['0-asc'] },
+ isFetching: false,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ isPlaceholderData: false,
+ })
+ })
+
+ it('should be able to select a part of the data', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: () => sleep(10).then(() => ({ count: 1 })),
+ select: (data) => ({
+ pages: data.pages.map((x) => `count: ${x.count}`),
+ pageParams: data.pageParams,
+ }),
+ getNextPageParam: () => undefined,
+ initialPageParam: 0,
+ })
+ states.push(state)
+
+ return {state.data?.pages.join(',')}
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('count: 1')).toBeInTheDocument()
+
+ expect(states.length).toBe(2)
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ isSuccess: false,
+ })
+ expect(states[1]).toMatchObject({
+ data: { pages: ['count: 1'] },
+ isSuccess: true,
+ })
+ })
+
+ it('should be able to select a new result and not cause infinite renders', async () => {
+ const key = queryKey()
+ const states: Array<
+ UseInfiniteQueryResult>
+ > = []
+ let selectCalled = 0
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: () => sleep(10).then(() => ({ count: 1 })),
+ select: useCallback((data: InfiniteData<{ count: number }>) => {
+ selectCalled++
+ return {
+ pages: data.pages.map((x) => ({ ...x, id: Math.random() })),
+ pageParams: data.pageParams,
+ }
+ }, []),
+ getNextPageParam: () => undefined,
+ initialPageParam: 0,
+ })
+ states.push(state)
+
+ return (
+
+ {state.data?.pages.map((page) => (
+
count: {page.count}
+ ))}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('count: 1')).toBeInTheDocument()
+
+ expect(states.length).toBe(2)
+ expect(selectCalled).toBe(1)
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ isSuccess: false,
+ })
+ expect(states[1]).toMatchObject({
+ data: { pages: [{ count: 1 }] },
+ isSuccess: true,
+ })
+ })
+
+ it('should be able to reverse the data', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
+ select: (data) => ({
+ pages: [...data.pages].reverse(),
+ pageParams: [...data.pageParams].reverse(),
+ }),
+ notifyOnChangeProps: 'all',
+ getNextPageParam: () => 1,
+ initialPageParam: 0,
+ })
+
+ states.push(state)
+
+ return (
+
+
state.fetchNextPage()}>fetchNextPage
+
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {state.isFetching}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 0')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /fetchNextPage/i }))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 1,0')).toBeInTheDocument()
+
+ expect(states.length).toBe(4)
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ isSuccess: false,
+ })
+ expect(states[1]).toMatchObject({
+ data: { pages: [0] },
+ isSuccess: true,
+ })
+ expect(states[2]).toMatchObject({
+ data: { pages: [0] },
+ isSuccess: true,
+ })
+ expect(states[3]).toMatchObject({
+ data: { pages: [1, 0] },
+ isSuccess: true,
+ })
+ })
+
+ it('should be able to fetch a previous page', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const start = 10
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
+ initialPageParam: start,
+ getNextPageParam: (lastPage) => lastPage + 1,
+ getPreviousPageParam: (firstPage) => firstPage - 1,
+ notifyOnChangeProps: 'all',
+ })
+
+ states.push(state)
+
+ return (
+
+
data: {state.data?.pages.join(',') ?? null}
+
state.fetchPreviousPage()}>
+ fetch previous page
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 10')).toBeInTheDocument()
+
+ fireEvent.click(
+ rendered.getByRole('button', { name: /fetch previous page/i }),
+ )
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 9,10')).toBeInTheDocument()
+
+ expect(states.length).toBe(4)
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ isFetching: true,
+ isFetchingNextPage: false,
+ isFetchingPreviousPage: false,
+ isSuccess: false,
+ })
+ expect(states[1]).toMatchObject({
+ data: { pages: [10] },
+ hasNextPage: true,
+ hasPreviousPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ isFetchingPreviousPage: false,
+ isSuccess: true,
+ })
+ expect(states[2]).toMatchObject({
+ data: { pages: [10] },
+ hasNextPage: true,
+ hasPreviousPage: true,
+ isFetching: true,
+ isFetchingNextPage: false,
+ isFetchingPreviousPage: true,
+ isSuccess: true,
+ })
+ expect(states[3]).toMatchObject({
+ data: { pages: [9, 10] },
+ hasNextPage: true,
+ hasPreviousPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ isFetchingPreviousPage: false,
+ isSuccess: true,
+ })
+ })
+
+ it('should be able to refetch when providing page params automatically', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
+ initialPageParam: 10,
+ getPreviousPageParam: (firstPage) => firstPage - 1,
+ getNextPageParam: (lastPage) => lastPage + 1,
+ notifyOnChangeProps: 'all',
+ })
+
+ states.push(state)
+
+ return (
+
+
state.fetchNextPage()}>fetchNextPage
+
state.fetchPreviousPage()}>
+ fetchPreviousPage
+
+
state.refetch()}>refetch
+
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {String(state.isFetching)}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 10')).toBeInTheDocument()
+ fireEvent.click(rendered.getByRole('button', { name: /fetchNextPage/i }))
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 10,11')).toBeInTheDocument()
+ fireEvent.click(
+ rendered.getByRole('button', { name: /fetchPreviousPage/i }),
+ )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 9,10,11')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /refetch/i }))
+ expect(rendered.getByText('isFetching: false')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(31)
+ expect(states.length).toBe(8)
+ // Initial fetch
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ isFetching: true,
+ isFetchingNextPage: false,
+ isRefetching: false,
+ })
+ // Initial fetch done
+ expect(states[1]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: false,
+ isFetchingNextPage: false,
+ isRefetching: false,
+ })
+ // Fetch next page
+ expect(states[2]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: true,
+ isFetchingNextPage: true,
+ isRefetching: false,
+ })
+ // Fetch next page done
+ expect(states[3]).toMatchObject({
+ data: { pages: [10, 11] },
+ isFetching: false,
+ isFetchingNextPage: false,
+ isRefetching: false,
+ })
+ // Fetch previous page
+ expect(states[4]).toMatchObject({
+ data: { pages: [10, 11] },
+ isFetching: true,
+ isFetchingNextPage: false,
+ isFetchingPreviousPage: true,
+ isRefetching: false,
+ })
+ // Fetch previous page done
+ expect(states[5]).toMatchObject({
+ data: { pages: [9, 10, 11] },
+ isFetching: false,
+ isFetchingNextPage: false,
+ isFetchingPreviousPage: false,
+ isRefetching: false,
+ })
+ // Refetch
+ expect(states[6]).toMatchObject({
+ data: { pages: [9, 10, 11] },
+ isFetching: true,
+ isFetchingNextPage: false,
+ isFetchingPreviousPage: false,
+ isRefetching: true,
+ })
+ // Refetch done
+ expect(states[7]).toMatchObject({
+ data: { pages: [9, 10, 11] },
+ isFetching: false,
+ isFetchingNextPage: false,
+ isFetchingPreviousPage: false,
+ isRefetching: false,
+ })
+ })
+
+ it('should return the correct states when refetch fails', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+ let isRefetch = false
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) =>
+ sleep(10).then(() => {
+ if (isRefetch) throw new Error()
+ return pageParam
+ }),
+ initialPageParam: 10,
+ getPreviousPageParam: (firstPage) => firstPage - 1,
+ getNextPageParam: (lastPage) => lastPage + 1,
+ notifyOnChangeProps: 'all',
+ retry: false,
+ })
+
+ states.push(state)
+
+ return (
+
+
{
+ isRefetch = true
+ state.refetch()
+ }}
+ >
+ refetch
+
+
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {String(state.isFetching)}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 10')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /refetch/i }))
+ expect(rendered.getByText('isFetching: false')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(states.length).toBe(4)
+ // Initial fetch
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ isFetching: true,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isRefetchError: false,
+ isRefetching: false,
+ })
+ // Initial fetch done
+ expect(states[1]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: false,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isRefetchError: false,
+ isRefetching: false,
+ })
+ // Refetch
+ expect(states[2]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: true,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isRefetchError: false,
+ isRefetching: true,
+ })
+ // Refetch failed
+ expect(states[3]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: false,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isRefetchError: true,
+ isRefetching: false,
+ })
+ })
+
+ it('should return the correct states when fetchNextPage fails', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) =>
+ sleep(10).then(() => {
+ if (pageParam !== 10) throw new Error()
+ return pageParam
+ }),
+ initialPageParam: 10,
+ getPreviousPageParam: (firstPage) => firstPage - 1,
+ getNextPageParam: (lastPage) => lastPage + 1,
+ notifyOnChangeProps: 'all',
+ retry: false,
+ })
+
+ states.push(state)
+
+ return (
+
+
state.fetchNextPage()}>fetchNextPage
+
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {String(state.isFetching)}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 10')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /fetchNextPage/i }))
+ expect(rendered.getByText('isFetching: false')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(states.length).toBe(4)
+ // Initial fetch
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ isFetching: true,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isRefetchError: false,
+ isRefetching: false,
+ })
+ // Initial fetch done
+ expect(states[1]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: false,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isRefetchError: false,
+ isRefetching: false,
+ })
+ // Fetch next page
+ expect(states[2]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: true,
+ isFetchNextPageError: false,
+ isFetchingNextPage: true,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isRefetchError: false,
+ isRefetching: false,
+ })
+ // Fetch next page failed
+ expect(states[3]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: false,
+ isFetchNextPageError: true,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isRefetchError: false,
+ isRefetching: false,
+ })
+ })
+
+ it('should return the correct states when fetchPreviousPage fails', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) =>
+ sleep(10).then(() => {
+ if (pageParam !== 10) throw new Error()
+ return pageParam
+ }),
+ initialPageParam: 10,
+ getPreviousPageParam: (firstPage) => firstPage - 1,
+ getNextPageParam: (lastPage) => lastPage + 1,
+ notifyOnChangeProps: 'all',
+ retry: false,
+ })
+
+ states.push(state)
+
+ return (
+
+
state.fetchPreviousPage()}>
+ fetchPreviousPage
+
+
data: {state.data?.pages.join(',') ?? 'null'}
+
isFetching: {String(state.isFetching)}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 10')).toBeInTheDocument()
+
+ fireEvent.click(
+ rendered.getByRole('button', { name: /fetchPreviousPage/i }),
+ )
+ expect(rendered.getByText('isFetching: false')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(states.length).toBe(4)
+ // Initial fetch
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ isFetching: true,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isRefetchError: false,
+ isRefetching: false,
+ })
+ // Initial fetch done
+ expect(states[1]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: false,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: false,
+ isRefetchError: false,
+ isRefetching: false,
+ })
+ // Fetch previous page
+ expect(states[2]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: true,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: false,
+ isFetchingPreviousPage: true,
+ isRefetchError: false,
+ isRefetching: false,
+ })
+ // Fetch previous page failed
+ expect(states[3]).toMatchObject({
+ data: { pages: [10] },
+ isFetching: false,
+ isFetchNextPageError: false,
+ isFetchingNextPage: false,
+ isFetchPreviousPageError: true,
+ isFetchingPreviousPage: false,
+ isRefetchError: false,
+ isRefetching: false,
+ })
+ })
+
+ it('should silently cancel any ongoing fetch when fetching more', async () => {
+ const key = queryKey()
+
+ function Page() {
+ const start = 10
+ const { data, fetchNextPage, refetch, status, fetchStatus } =
+ useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) => sleep(50).then(() => pageParam),
+ initialPageParam: start,
+ getNextPageParam: (lastPage) => lastPage + 1,
+ })
+
+ return (
+
+
fetchNextPage()}>fetchNextPage
+
refetch()}>refetch
+
data: {JSON.stringify(data)}
+
+ status: {status}, {fetchStatus}
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(51)
+ expect(rendered.getByText('status: success, idle')).toBeInTheDocument()
+ expect(
+ rendered.getByText('data: {"pages":[10],"pageParams":[10]}'),
+ ).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /refetch/i }))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('status: success, fetching')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /fetchNextPage/i }))
+ await vi.advanceTimersByTimeAsync(51)
+ expect(rendered.getByText('status: success, idle')).toBeInTheDocument()
+ expect(
+ rendered.getByText('data: {"pages":[10,11],"pageParams":[10,11]}'),
+ ).toBeInTheDocument()
+ })
+
+ it('should silently cancel an ongoing fetchNextPage request when another fetchNextPage is invoked', async () => {
+ const key = queryKey()
+ const start = 10
+ const onAborts: Array) => any>> = []
+ const abortListeners: Array) => any>> = []
+ const fetchPage = vi.fn<
+ (context: QueryFunctionContext) => Promise
+ >(async ({ pageParam, signal }) => {
+ const onAbort = vi.fn()
+ const abortListener = vi.fn()
+ onAborts.push(onAbort)
+ abortListeners.push(abortListener)
+ signal.onabort = onAbort
+ signal.addEventListener('abort', abortListener)
+ await sleep(50)
+ return pageParam
+ })
+
+ function Page() {
+ const { fetchNextPage } = useInfiniteQuery({
+ queryKey: key,
+ queryFn: fetchPage,
+ initialPageParam: start,
+ getNextPageParam: (lastPage) => lastPage + 1,
+ })
+
+ useEffect(() => {
+ setActTimeout(() => {
+ fetchNextPage()
+ }, 100)
+ setActTimeout(() => {
+ fetchNextPage()
+ }, 110)
+ }, [fetchNextPage])
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(160)
+
+ const expectedCallCount = 3
+ expect(fetchPage).toBeCalledTimes(expectedCallCount)
+ expect(onAborts).toHaveLength(expectedCallCount)
+ expect(abortListeners).toHaveLength(expectedCallCount)
+
+ let callIndex = 0
+ const firstCtx = fetchPage.mock.calls[callIndex]![0]
+ expect(firstCtx.pageParam).toEqual(start)
+ expect(firstCtx.queryKey).toEqual(key)
+ expect(firstCtx.signal).toBeInstanceOf(AbortSignal)
+ expect(firstCtx.signal.aborted).toBe(false)
+ expect(onAborts[callIndex]).not.toHaveBeenCalled()
+ expect(abortListeners[callIndex]).not.toHaveBeenCalled()
+
+ callIndex = 1
+ const secondCtx = fetchPage.mock.calls[callIndex]![0]
+ expect(secondCtx.pageParam).toBe(11)
+ expect(secondCtx.queryKey).toEqual(key)
+ expect(secondCtx.signal).toBeInstanceOf(AbortSignal)
+ expect(secondCtx.signal.aborted).toBe(true)
+ expect(onAborts[callIndex]).toHaveBeenCalledTimes(1)
+ expect(abortListeners[callIndex]).toHaveBeenCalledTimes(1)
+
+ callIndex = 2
+ const thirdCtx = fetchPage.mock.calls[callIndex]![0]
+ expect(thirdCtx.pageParam).toBe(11)
+ expect(thirdCtx.queryKey).toEqual(key)
+ expect(thirdCtx.signal).toBeInstanceOf(AbortSignal)
+ expect(thirdCtx.signal.aborted).toBe(false)
+ expect(onAborts[callIndex]).not.toHaveBeenCalled()
+ expect(abortListeners[callIndex]).not.toHaveBeenCalled()
+ })
+
+ it('should not cancel an ongoing fetchNextPage request when another fetchNextPage is invoked if `cancelRefetch: false` is used', async () => {
+ const key = queryKey()
+ const start = 10
+ const onAborts: Array) => any>> = []
+ const abortListeners: Array) => any>> = []
+ const fetchPage = vi.fn<
+ (context: QueryFunctionContext) => Promise
+ >(async ({ pageParam, signal }) => {
+ const onAbort = vi.fn()
+ const abortListener = vi.fn()
+ onAborts.push(onAbort)
+ abortListeners.push(abortListener)
+ signal.onabort = onAbort
+ signal.addEventListener('abort', abortListener)
+ await sleep(50)
+ return pageParam
+ })
+
+ function Page() {
+ const { fetchNextPage } = useInfiniteQuery({
+ queryKey: key,
+ queryFn: fetchPage,
+ initialPageParam: start,
+ getNextPageParam: (lastPage) => lastPage + 1,
+ })
+
+ useEffect(() => {
+ setActTimeout(() => {
+ fetchNextPage()
+ }, 100)
+ setActTimeout(() => {
+ fetchNextPage({ cancelRefetch: false })
+ }, 110)
+ }, [fetchNextPage])
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(160)
+
+ const expectedCallCount = 2
+ expect(fetchPage).toBeCalledTimes(expectedCallCount)
+ expect(onAborts).toHaveLength(expectedCallCount)
+ expect(abortListeners).toHaveLength(expectedCallCount)
+
+ let callIndex = 0
+ const firstCtx = fetchPage.mock.calls[callIndex]![0]
+ expect(firstCtx.pageParam).toEqual(start)
+ expect(firstCtx.queryKey).toEqual(key)
+ expect(firstCtx.signal).toBeInstanceOf(AbortSignal)
+ expect(firstCtx.signal.aborted).toBe(false)
+ expect(onAborts[callIndex]).not.toHaveBeenCalled()
+ expect(abortListeners[callIndex]).not.toHaveBeenCalled()
+
+ callIndex = 1
+ const secondCtx = fetchPage.mock.calls[callIndex]![0]
+ expect(secondCtx.pageParam).toBe(11)
+ expect(secondCtx.queryKey).toEqual(key)
+ expect(secondCtx.signal).toBeInstanceOf(AbortSignal)
+ expect(secondCtx.signal.aborted).toBe(false)
+ expect(onAborts[callIndex]).not.toHaveBeenCalled()
+ expect(abortListeners[callIndex]).not.toHaveBeenCalled()
+ })
+
+ it('should keep fetching first page when not loaded yet and triggering fetch more', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const start = 10
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) => sleep(50).then(() => pageParam),
+ initialPageParam: start,
+ getNextPageParam: (lastPage) => lastPage + 1,
+ notifyOnChangeProps: 'all',
+ })
+
+ states.push(state)
+
+ const { fetchNextPage } = state
+
+ useEffect(() => {
+ setActTimeout(() => {
+ fetchNextPage()
+ }, 10)
+ }, [fetchNextPage])
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(60)
+
+ expect(states.length).toBe(2)
+ expect(states[0]).toMatchObject({
+ hasNextPage: false,
+ data: undefined,
+ isFetching: true,
+ isFetchingNextPage: false,
+ isSuccess: false,
+ })
+ expect(states[1]).toMatchObject({
+ hasNextPage: true,
+ data: { pages: [10] },
+ isFetching: false,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ })
+ })
+
+ it('should stop fetching additional pages when the component is unmounted and AbortSignal is consumed', async () => {
+ const key = queryKey()
+ let fetches = 0
+
+ const initialData = { pages: [1, 2, 3, 4], pageParams: [0, 1, 2, 3] }
+
+ function List() {
+ useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) =>
+ sleep(50).then(() => {
+ fetches++
+ return pageParam * 10
+ }),
+ initialData,
+ initialPageParam: 0,
+ getNextPageParam: (_, allPages) => {
+ return allPages.length === 4 ? undefined : allPages.length
+ },
+ })
+
+ return null
+ }
+
+ function Page() {
+ const [show, setShow] = useState(true)
+
+ useEffect(() => {
+ setActTimeout(() => {
+ setShow(false)
+ }, 75)
+ }, [])
+
+ return show ?
: null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(125)
+
+ expect(fetches).toBe(2)
+ expect(queryClient.getQueryState(key)).toMatchObject({
+ data: initialData,
+ status: 'success',
+ error: null,
+ })
+ })
+
+ it('should be able to set new pages with the query client', async () => {
+ const key = queryKey()
+
+ let multiplier = 1
+
+ function Page() {
+ const [firstPage, setFirstPage] = useState(0)
+
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) =>
+ sleep(10).then(() => multiplier * pageParam),
+ getNextPageParam: (lastPage) => lastPage + 1,
+ initialPageParam: firstPage,
+ })
+
+ return (
+
+
{
+ queryClient.setQueryData(key, {
+ pages: [7, 8],
+ pageParams: [7, 8],
+ })
+ setFirstPage(7)
+ }}
+ >
+ setPages
+
+
state.refetch()}>refetch
+
data: {JSON.stringify(state.data)}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(
+ rendered.getByText('data: {"pages":[0],"pageParams":[0]}'),
+ ).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /setPages/i }))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(
+ rendered.getByText('data: {"pages":[7,8],"pageParams":[7,8]}'),
+ ).toBeInTheDocument()
+
+ multiplier = 2
+
+ fireEvent.click(rendered.getByRole('button', { name: /refetch/i }))
+ await vi.advanceTimersByTimeAsync(21)
+ expect(
+ rendered.getByText('data: {"pages":[14,30],"pageParams":[7,15]}'),
+ ).toBeInTheDocument()
+ })
+
+ // it('should only refetch the first page when initialData is provided', async () => {
+ // vi.useRealTimers()
+
+ // const key = queryKey()
+
+ // const renderStream =
+ // createRenderStream>>()
+
+ // function Page() {
+ // const state = useInfiniteQuery({
+ // queryKey: key,
+ // queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
+ // initialData: { pages: [1], pageParams: [1] },
+ // getNextPageParam: (lastPage) => lastPage + 1,
+ // initialPageParam: 0,
+ // notifyOnChangeProps: 'all',
+ // })
+
+ // renderStream.replaceSnapshot(state)
+
+ // return (
+ // state.fetchNextPage()}>fetchNextPage
+ // )
+ // }
+
+ // const rendered = await renderStream.render(
+ //
+ //
+ // ,
+ // )
+
+ // {
+ // const { snapshot } = await renderStream.takeRender()
+ // expect(snapshot).toMatchObject({
+ // data: { pages: [1] },
+ // hasNextPage: true,
+ // isFetching: true,
+ // isFetchingNextPage: false,
+ // isSuccess: true,
+ // })
+ // }
+
+ // {
+ // const { snapshot } = await renderStream.takeRender()
+ // expect(snapshot).toMatchObject({
+ // data: { pages: [1] },
+ // hasNextPage: true,
+ // isFetching: false,
+ // isFetchingNextPage: false,
+ // isSuccess: true,
+ // })
+ // }
+
+ // fireEvent.click(rendered.getByText('fetchNextPage'))
+
+ // {
+ // const { snapshot } = await renderStream.takeRender()
+ // expect(snapshot).toMatchObject({
+ // data: { pages: [1] },
+ // hasNextPage: true,
+ // isFetching: true,
+ // isFetchingNextPage: true,
+ // isSuccess: true,
+ // })
+ // }
+ // {
+ // const { snapshot } = await renderStream.takeRender()
+ // expect(snapshot).toMatchObject({
+ // data: { pages: [1, 2] },
+ // hasNextPage: true,
+ // isFetching: false,
+ // isFetchingNextPage: false,
+ // isSuccess: true,
+ // })
+ // }
+ // })
+
+ it('should set hasNextPage to false if getNextPageParam returns undefined', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
+ getNextPageParam: () => undefined,
+ initialPageParam: 1,
+ })
+
+ states.push(state)
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+
+ expect(states.length).toBe(2)
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ hasNextPage: false,
+ isFetching: true,
+ isFetchingNextPage: false,
+ isSuccess: false,
+ })
+ expect(states[1]).toMatchObject({
+ data: { pages: [1] },
+ hasNextPage: false,
+ isFetching: false,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ })
+ })
+
+ it('should compute hasNextPage correctly using initialData', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
+ initialData: { pages: [10], pageParams: [10] },
+ getNextPageParam: (lastPage) => (lastPage === 10 ? 11 : undefined),
+ initialPageParam: 10,
+ })
+
+ states.push(state)
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+
+ expect(states.length).toBe(2)
+ expect(states[0]).toMatchObject({
+ data: { pages: [10] },
+ hasNextPage: true,
+ isFetching: true,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ })
+ expect(states[1]).toMatchObject({
+ data: { pages: [10] },
+ hasNextPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ })
+ })
+
+ it('should compute hasNextPage correctly for falsy getFetchMore return value using initialData', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
+ initialPageParam: 10,
+ initialData: { pages: [10], pageParams: [10] },
+ getNextPageParam: () => undefined,
+ })
+
+ states.push(state)
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+
+ expect(states.length).toBe(2)
+ expect(states[0]).toMatchObject({
+ data: { pages: [10] },
+ hasNextPage: false,
+ isFetching: true,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ })
+ expect(states[1]).toMatchObject({
+ data: { pages: [10] },
+ hasNextPage: false,
+ isFetching: false,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ })
+ })
+
+ it('should not use selected data when computing hasNextPage', async () => {
+ const key = queryKey()
+ const states: Array>> = []
+
+ function Page() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
+ getNextPageParam: (lastPage) => (lastPage === 1 ? 2 : undefined),
+ select: (data) => ({
+ pages: data.pages.map((x) => x.toString()),
+ pageParams: data.pageParams,
+ }),
+ initialPageParam: 1,
+ })
+
+ states.push(state)
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+
+ expect(states.length).toBe(2)
+ expect(states[0]).toMatchObject({
+ data: undefined,
+ hasNextPage: false,
+ isFetching: true,
+ isFetchingNextPage: false,
+ isSuccess: false,
+ })
+ expect(states[1]).toMatchObject({
+ data: { pages: ['1'] },
+ hasNextPage: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ isSuccess: true,
+ })
+ })
+
+ it('should build fresh cursors on refetch', async () => {
+ const key = queryKey()
+
+ const genItems = (size: number) =>
+ [...new Array(size)].fill(null).map((_, d) => d)
+ const items = genItems(15)
+ const limit = 3
+
+ const fetchItemsWithLimit = (cursor = 0, ts: number) =>
+ sleep(10).then(() => ({
+ nextId: cursor + limit,
+ items: items.slice(cursor, cursor + limit),
+ ts,
+ }))
+
+ function Page() {
+ const fetchCountRef = useRef(0)
+ const {
+ status,
+ data,
+ error,
+ isFetchingNextPage,
+ fetchNextPage,
+ hasNextPage,
+ refetch,
+ } = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) =>
+ fetchItemsWithLimit(pageParam, fetchCountRef.current++),
+ initialPageParam: 0,
+ getNextPageParam: (lastPage) => lastPage.nextId,
+ })
+
+ return (
+
+
Pagination
+ {status === 'pending' ? (
+ 'Loading...'
+ ) : status === 'error' ? (
+
Error: {error.message}
+ ) : (
+ <>
+
Data:
+ {data.pages.map((page, i) => (
+
+
+ Page {i}: {page.ts}
+
+
+ {page.items.map((item) => (
+
Item: {item}
+ ))}
+
+
+ ))}
+
+ fetchNextPage()}
+ disabled={!hasNextPage || isFetchingNextPage}
+ >
+ {isFetchingNextPage
+ ? 'Loading more...'
+ : hasNextPage
+ ? 'Load More'
+ : 'Nothing more to load'}
+
+ refetch()}>Refetch
+ {
+ // Imagine that this mutation happens somewhere else
+ // makes an actual network request
+ // and calls invalidateQueries in an onSuccess
+ items.splice(4, 1)
+ queryClient.invalidateQueries({ queryKey: key })
+ }}
+ >
+ Remove item
+
+
+
{!isFetchingNextPage ? 'Background Updating...' : null}
+ >
+ )}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('Loading...')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('Item: 2')).toBeInTheDocument()
+ expect(rendered.getByText('Page 0: 0')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByText('Load More'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Loading more...')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('Item: 5')).toBeInTheDocument()
+ expect(rendered.getByText('Page 0: 0')).toBeInTheDocument()
+ expect(rendered.getByText('Page 1: 1')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByText('Load More'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Loading more...')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('Item: 8')).toBeInTheDocument()
+ expect(rendered.getByText('Page 0: 0')).toBeInTheDocument()
+ expect(rendered.getByText('Page 1: 1')).toBeInTheDocument()
+ expect(rendered.getByText('Page 2: 2')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByText('Refetch'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Background Updating...')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(31)
+ expect(rendered.getByText('Item: 8')).toBeInTheDocument()
+ expect(rendered.getByText('Page 0: 3')).toBeInTheDocument()
+ expect(rendered.getByText('Page 1: 4')).toBeInTheDocument()
+ expect(rendered.getByText('Page 2: 5')).toBeInTheDocument()
+ // ensure that Item: 4 is rendered before removing it
+ expect(rendered.queryAllByText('Item: 4')).toHaveLength(1)
+
+ // remove Item: 4
+ fireEvent.click(rendered.getByText('Remove item'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Background Updating...')).toBeInTheDocument()
+ // ensure that an additional item is rendered (it means that cursors were properly rebuilt)
+ await vi.advanceTimersByTimeAsync(31)
+ expect(rendered.getByText('Item: 9')).toBeInTheDocument()
+ expect(rendered.getByText('Page 0: 6')).toBeInTheDocument()
+ expect(rendered.getByText('Page 1: 7')).toBeInTheDocument()
+ expect(rendered.getByText('Page 2: 8')).toBeInTheDocument()
+ // ensure that Item: 4 is no longer rendered
+ expect(rendered.queryAllByText('Item: 4')).toHaveLength(0)
+ })
+
+ it('should compute hasNextPage correctly for falsy getFetchMore return value on refetching', async () => {
+ const key = queryKey()
+ const MAX = 2
+
+ function Page() {
+ const fetchCountRef = useRef(0)
+ const [isRemovedLastPage, setIsRemovedLastPage] = useState(false)
+ const {
+ status,
+ data,
+ error,
+ isFetching,
+ isFetchingNextPage,
+ fetchNextPage,
+ hasNextPage,
+ refetch,
+ } = useInfiniteQuery({
+ queryKey: key,
+ queryFn: ({ pageParam }) =>
+ fetchItems(
+ pageParam,
+ fetchCountRef.current++,
+ pageParam === MAX || (pageParam === MAX - 1 && isRemovedLastPage),
+ ),
+ getNextPageParam: (lastPage) => lastPage.nextId,
+ initialPageParam: 0,
+ })
+
+ return (
+
+
Pagination
+ {status === 'pending' ? (
+ 'Loading...'
+ ) : status === 'error' ? (
+
Error: {error.message}
+ ) : (
+ <>
+
Data:
+ {data.pages.map((page, i) => (
+
+
+ Page {i}: {page.ts}
+
+
+ {page.items.map((item) => (
+
Item: {item}
+ ))}
+
+
+ ))}
+
+ fetchNextPage()}
+ disabled={!hasNextPage || isFetchingNextPage}
+ >
+ {isFetchingNextPage
+ ? 'Loading more...'
+ : hasNextPage
+ ? 'Load More'
+ : 'Nothing more to load'}
+
+ refetch()}>Refetch
+ setIsRemovedLastPage(true)}>
+ Remove Last Page
+
+
+
+ {isFetching && !isFetchingNextPage
+ ? 'Background Updating...'
+ : null}
+
+ >
+ )}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('Loading...')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('Item: 9')).toBeInTheDocument()
+ expect(rendered.getByText('Page 0: 0')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByText('Load More'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Loading more...')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('Item: 19')).toBeInTheDocument()
+ expect(rendered.getByText('Page 0: 0')).toBeInTheDocument()
+ expect(rendered.getByText('Page 1: 1')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByText('Load More'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Loading more...')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('Item: 29')).toBeInTheDocument()
+ expect(rendered.getByText('Page 0: 0')).toBeInTheDocument()
+ expect(rendered.getByText('Page 1: 1')).toBeInTheDocument()
+ expect(rendered.getByText('Page 2: 2')).toBeInTheDocument()
+ expect(rendered.getByText('Nothing more to load')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByText('Remove Last Page'))
+ fireEvent.click(rendered.getByText('Refetch'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Background Updating...')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(21)
+ expect(rendered.queryByText('Item: 29')).not.toBeInTheDocument()
+ expect(rendered.getByText('Page 0: 3')).toBeInTheDocument()
+ expect(rendered.getByText('Page 1: 4')).toBeInTheDocument()
+ expect(rendered.queryByText('Page 2: 5')).not.toBeInTheDocument()
+ expect(rendered.getByText('Nothing more to load')).toBeInTheDocument()
+ })
+
+ it('should cancel the query function when there are no more subscriptions', () => {
+ const key = queryKey()
+ let cancelFn: Mock = vi.fn()
+
+ const queryFn = ({ signal }: { signal?: AbortSignal }) => {
+ const promise = new Promise((resolve, reject) => {
+ cancelFn = vi.fn(() => reject('Cancelled'))
+ signal?.addEventListener('abort', cancelFn)
+ sleep(1000).then(() => resolve('OK'))
+ })
+
+ return promise
+ }
+
+ function Inner() {
+ const state = useInfiniteQuery({
+ queryKey: key,
+ queryFn,
+ getNextPageParam: () => undefined,
+ initialPageParam: 0,
+ })
+ return (
+
+
Status: {state.status}
+
+ )
+ }
+
+ function Page() {
+ const [isVisible, setIsVisible] = useState(true)
+
+ return (
+ <>
+ setIsVisible(false)}>hide
+ {isVisible && }
+ {isVisible ? 'visible' : 'hidden'}
+ >
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('visible')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: 'hide' }))
+ expect(rendered.getByText('hidden')).toBeInTheDocument()
+
+ expect(cancelFn).toHaveBeenCalled()
+ })
+
+ it('should use provided custom queryClient', async () => {
+ const key = queryKey()
+ const queryFn = () => sleep(10).then(() => 'custom client')
+
+ function Page() {
+ const { data } = useInfiniteQuery(
+ {
+ queryKey: key,
+ queryFn,
+ getNextPageParam: () => undefined,
+ initialPageParam: 0,
+ },
+ queryClient,
+ )
+
+ return data: {data?.pages[0]}
+ }
+
+ const rendered = render( )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: custom client')).toBeInTheDocument()
+ })
+
+ // it('should work with use()', async () => {
+ // vi.useRealTimers()
+
+ // const key = queryKey()
+
+ // const renderStream = createRenderStream({ snapshotDOM: true })
+
+ // function Loading() {
+ // useTrackRenders()
+ // return <>loading...>
+ // }
+
+ // function MyComponent() {
+ // useTrackRenders()
+ // const fetchCountRef = useRef(0)
+ // const query = useInfiniteQuery({
+ // queryFn: ({ pageParam }) =>
+ // fetchItems(pageParam, fetchCountRef.current++),
+ // getNextPageParam: (lastPage) => lastPage.nextId,
+ // initialPageParam: 0,
+ // queryKey: key,
+ // })
+ // const data = use(query.promise)
+ // return (
+ // <>
+ // {data.pages.map((page, index) => (
+ //
+ //
+ //
Page: {index + 1}
+ //
+ // {page.items.map((item) => (
+ // Item: {item}
+ // ))}
+ //
+ // ))}
+ // query.fetchNextPage()}>fetchNextPage
+ // >
+ // )
+ // }
+
+ // function Page() {
+ // useTrackRenders()
+ // return (
+ // }>
+ //
+ //
+ // )
+ // }
+
+ // const rendered = await renderStream.render(
+ //
+ //
+ // ,
+ // )
+
+ // {
+ // const { renderedComponents, withinDOM } = await renderStream.takeRender()
+ // withinDOM().getByText('loading...')
+ // expect(renderedComponents).toEqual([Page, Loading])
+ // }
+
+ // {
+ // const { renderedComponents, withinDOM } = await renderStream.takeRender()
+ // withinDOM().getByText('Page: 1')
+ // withinDOM().getByText('Item: 1')
+ // expect(renderedComponents).toEqual([MyComponent])
+ // }
+
+ // // click button
+ // rendered.getByRole('button', { name: 'fetchNextPage' }).click()
+
+ // {
+ // const { renderedComponents, withinDOM } = await renderStream.takeRender()
+ // withinDOM().getByText('Page: 1')
+ // expect(renderedComponents).toEqual([MyComponent])
+ // }
+ // })
+})
diff --git a/packages/preact-query/src/__tests__/useIsFetching.test.tsx b/packages/preact-query/src/__tests__/useIsFetching.test.tsx
new file mode 100644
index 0000000000..614b70d962
--- /dev/null
+++ b/packages/preact-query/src/__tests__/useIsFetching.test.tsx
@@ -0,0 +1,246 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render } from '@testing-library/preact'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import { QueryCache, QueryClient, useIsFetching, useQuery } from '..'
+import { renderWithClient, setActTimeout } from './utils'
+import { useEffect, useState } from 'preact/hooks'
+
+describe('useIsFetching', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ // See https://github.com/tannerlinsley/react-query/issues/105
+ it('should update as queries start and stop fetching', async () => {
+ const queryClient = new QueryClient()
+ const key = queryKey()
+
+ function IsFetching() {
+ const isFetching = useIsFetching()
+
+ return isFetching: {isFetching}
+ }
+
+ function Query() {
+ const [ready, setReady] = useState(false)
+
+ useQuery({
+ queryKey: key,
+ queryFn: () => sleep(50).then(() => 'test'),
+ enabled: ready,
+ })
+
+ return setReady(true)}>setReady
+ }
+
+ function Page() {
+ return (
+
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /setReady/i }))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(51)
+ expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
+ })
+
+ it('should not update state while rendering', async () => {
+ const queryClient = new QueryClient()
+
+ const key1 = queryKey()
+ const key2 = queryKey()
+
+ const isFetchingArray: Array = []
+
+ function IsFetching() {
+ const isFetching = useIsFetching()
+
+ isFetchingArray.push(isFetching)
+
+ return null
+ }
+
+ function FirstQuery() {
+ useQuery({
+ queryKey: key1,
+ queryFn: () => sleep(100).then(() => 'data1'),
+ })
+
+ return null
+ }
+
+ function SecondQuery() {
+ useQuery({
+ queryKey: key2,
+ queryFn: () => sleep(100).then(() => 'data2'),
+ })
+
+ return null
+ }
+
+ function Page() {
+ const [renderSecond, setRenderSecond] = useState(false)
+
+ useEffect(() => {
+ setActTimeout(() => {
+ setRenderSecond(true)
+ }, 50)
+ }, [])
+
+ return (
+ <>
+
+
+ {renderSecond && }
+ >
+ )
+ }
+
+ renderWithClient(queryClient, )
+
+ expect(isFetchingArray[0]).toEqual(0)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(isFetchingArray[1]).toEqual(1)
+ await vi.advanceTimersByTimeAsync(50)
+ expect(isFetchingArray[2]).toEqual(1)
+ await vi.advanceTimersByTimeAsync(1)
+ expect(isFetchingArray[3]).toEqual(2)
+ await vi.advanceTimersByTimeAsync(50)
+ expect(isFetchingArray[4]).toEqual(1)
+ await vi.advanceTimersByTimeAsync(50)
+ expect(isFetchingArray[5]).toEqual(0)
+
+ expect(isFetchingArray).toEqual([0, 1, 1, 2, 1, 0])
+ })
+
+ it('should be able to filter', async () => {
+ const queryClient = new QueryClient()
+ const key1 = queryKey()
+ const key2 = queryKey()
+
+ const isFetchingArray: Array = []
+
+ function One() {
+ useQuery({
+ queryKey: key1,
+ queryFn: () => sleep(10).then(() => 'test1'),
+ })
+
+ return null
+ }
+
+ function Two() {
+ useQuery({
+ queryKey: key2,
+ queryFn: () => sleep(20).then(() => 'test2'),
+ })
+
+ return null
+ }
+
+ function Page() {
+ const [started, setStarted] = useState(false)
+ const isFetching = useIsFetching({ queryKey: key1 })
+
+ isFetchingArray.push(isFetching)
+
+ return (
+
+
setStarted(true)}>setStarted
+
isFetching: {isFetching}
+ {started ? (
+ <>
+
+
+ >
+ ) : null}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /setStarted/i }))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
+
+ // at no point should we have isFetching: 2
+ expect(isFetchingArray).toEqual(expect.not.arrayContaining([2]))
+ })
+
+ it('should show the correct fetching state when mounted after a query', async () => {
+ const queryClient = new QueryClient()
+ const key = queryKey()
+
+ function Page() {
+ useQuery({
+ queryKey: key,
+ queryFn: () => sleep(10).then(() => 'test'),
+ })
+
+ const isFetching = useIsFetching()
+
+ return (
+
+
isFetching: {isFetching}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
+ })
+
+ it('should use provided custom queryClient', async () => {
+ const onSuccess = vi.fn()
+
+ const queryCache = new QueryCache({ onSuccess })
+ const queryClient = new QueryClient({ queryCache })
+ const key = queryKey()
+
+ function Page() {
+ useQuery(
+ {
+ queryKey: key,
+ queryFn: () => sleep(10).then(() => 'test'),
+ },
+ queryClient,
+ )
+
+ const isFetching = useIsFetching({}, queryClient)
+
+ return (
+
+
isFetching: {isFetching}
+
+ )
+ }
+
+ const rendered = render( )
+
+ expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
+ expect(onSuccess).toHaveBeenCalledOnce()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/useMutation.test.tsx b/packages/preact-query/src/__tests__/useMutation.test.tsx
new file mode 100644
index 0000000000..26f4344f8c
--- /dev/null
+++ b/packages/preact-query/src/__tests__/useMutation.test.tsx
@@ -0,0 +1,1182 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render } from '@testing-library/preact'
+import { ErrorBoundary } from './ErrorBoundary'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import { MutationCache, QueryCache, QueryClient, useMutation } from '..'
+import {
+ mockOnlineManagerIsOnline,
+ renderWithClient,
+ setActTimeout,
+} from './utils'
+import type { UseMutationResult } from '../types'
+import { useEffect, useState } from 'preact/hooks'
+
+describe('useMutation', () => {
+ let queryCache: QueryCache
+ let mutationCache: MutationCache
+ let queryClient: QueryClient
+
+ beforeEach(() => {
+ queryCache = new QueryCache()
+ mutationCache = new MutationCache()
+ queryClient = new QueryClient({
+ queryCache,
+ mutationCache,
+ })
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should be able to reset `data`', async () => {
+ function Page() {
+ const {
+ mutate,
+ data = 'empty',
+ reset,
+ } = useMutation({ mutationFn: () => Promise.resolve('mutation') })
+
+ return (
+
+
{data}
+ reset()}>reset
+ mutate()}>mutate
+
+ )
+ }
+
+ const { getByRole } = renderWithClient(queryClient, )
+
+ expect(getByRole('heading').textContent).toBe('empty')
+
+ fireEvent.click(getByRole('button', { name: /mutate/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(getByRole('heading').textContent).toBe('mutation')
+
+ fireEvent.click(getByRole('button', { name: /reset/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(getByRole('heading').textContent).toBe('empty')
+ })
+
+ it('should be able to reset `error`', async () => {
+ function Page() {
+ const { mutate, error, reset } = useMutation({
+ mutationFn: () => {
+ const err = new Error('Expected mock error. All is well!')
+ err.stack = ''
+ return Promise.reject(err)
+ },
+ })
+
+ return (
+
+ {error &&
{error.message} }
+ reset()}>reset
+ mutate()}>mutate
+
+ )
+ }
+
+ const { getByRole, queryByRole } = renderWithClient(queryClient, )
+
+ expect(queryByRole('heading')).toBeNull()
+
+ fireEvent.click(getByRole('button', { name: /mutate/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(getByRole('heading').textContent).toBe(
+ 'Expected mock error. All is well!',
+ )
+
+ fireEvent.click(getByRole('button', { name: /reset/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(queryByRole('heading')).toBeNull()
+ })
+
+ it('should be able to call `onSuccess` and `onSettled` after each successful mutate', async () => {
+ let count = 0
+ const onSuccessMock = vi.fn()
+ const onSettledMock = vi.fn()
+
+ function Page() {
+ const { mutate } = useMutation({
+ mutationFn: (vars: { count: number }) => Promise.resolve(vars.count),
+
+ onSuccess: (data) => {
+ onSuccessMock(data)
+ },
+ onSettled: (data) => {
+ onSettledMock(data)
+ },
+ })
+
+ return (
+
+
{count}
+ mutate({ count: ++count })}>mutate
+
+ )
+ }
+
+ const { getByRole } = renderWithClient(queryClient, )
+
+ expect(getByRole('heading').textContent).toBe('0')
+
+ fireEvent.click(getByRole('button', { name: /mutate/i }))
+ fireEvent.click(getByRole('button', { name: /mutate/i }))
+ fireEvent.click(getByRole('button', { name: /mutate/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(getByRole('heading').textContent).toBe('3')
+ expect(onSuccessMock).toHaveBeenCalledTimes(3)
+
+ expect(onSuccessMock).toHaveBeenCalledWith(1)
+ expect(onSuccessMock).toHaveBeenCalledWith(2)
+ expect(onSuccessMock).toHaveBeenCalledWith(3)
+
+ expect(onSettledMock).toHaveBeenCalledTimes(3)
+
+ expect(onSettledMock).toHaveBeenCalledWith(1)
+ expect(onSettledMock).toHaveBeenCalledWith(2)
+ expect(onSettledMock).toHaveBeenCalledWith(3)
+ })
+
+ it('should set correct values for `failureReason` and `failureCount` on multiple mutate calls', async () => {
+ let count = 0
+ type Value = { count: number }
+
+ const mutateFn = vi.fn<(value: Value) => Promise>()
+
+ mutateFn.mockImplementationOnce(() => {
+ return Promise.reject(new Error('Error test Jonas'))
+ })
+
+ mutateFn.mockImplementation(async (value) => {
+ await sleep(10)
+ return Promise.resolve(value)
+ })
+
+ function Page() {
+ const { mutate, failureCount, failureReason, data, status } = useMutation(
+ { mutationFn: mutateFn },
+ )
+
+ return (
+
+
Data {data?.count}
+ Status {status}
+ Failed {failureCount} times
+ Failed because {failureReason?.message ?? 'null'}
+ mutate({ count: ++count })}>mutate
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('Data')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Status error')).toBeInTheDocument()
+ expect(rendered.getByText('Failed 1 times')).toBeInTheDocument()
+ expect(
+ rendered.getByText('Failed because Error test Jonas'),
+ ).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('Status pending')).toBeInTheDocument()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('Status success')).toBeInTheDocument()
+ expect(rendered.getByText('Data 2')).toBeInTheDocument()
+ expect(rendered.getByText('Failed 0 times')).toBeInTheDocument()
+ expect(rendered.getByText('Failed because null')).toBeInTheDocument()
+ })
+
+ it('should be able to call `onError` and `onSettled` after each failed mutate', async () => {
+ const onErrorMock = vi.fn()
+ const onSettledMock = vi.fn()
+ let count = 0
+
+ function Page() {
+ const { mutate } = useMutation({
+ mutationFn: (vars: { count: number }) => {
+ const error = new Error(
+ `Expected mock error. All is well! ${vars.count}`,
+ )
+ error.stack = ''
+ return Promise.reject(error)
+ },
+ onError: (error: Error) => {
+ onErrorMock(error.message)
+ },
+ onSettled: (_data, error) => {
+ onSettledMock(error?.message)
+ },
+ })
+
+ return (
+
+
{count}
+ mutate({ count: ++count })}>mutate
+
+ )
+ }
+
+ const { getByRole } = renderWithClient(queryClient, )
+
+ expect(getByRole('heading').textContent).toBe('0')
+
+ fireEvent.click(getByRole('button', { name: /mutate/i }))
+ fireEvent.click(getByRole('button', { name: /mutate/i }))
+ fireEvent.click(getByRole('button', { name: /mutate/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(getByRole('heading').textContent).toBe('3')
+ expect(onErrorMock).toHaveBeenCalledTimes(3)
+ expect(onErrorMock).toHaveBeenCalledWith(
+ 'Expected mock error. All is well! 1',
+ )
+ expect(onErrorMock).toHaveBeenCalledWith(
+ 'Expected mock error. All is well! 2',
+ )
+ expect(onErrorMock).toHaveBeenCalledWith(
+ 'Expected mock error. All is well! 3',
+ )
+
+ expect(onSettledMock).toHaveBeenCalledTimes(3)
+ expect(onSettledMock).toHaveBeenCalledWith(
+ 'Expected mock error. All is well! 1',
+ )
+ expect(onSettledMock).toHaveBeenCalledWith(
+ 'Expected mock error. All is well! 2',
+ )
+ expect(onSettledMock).toHaveBeenCalledWith(
+ 'Expected mock error. All is well! 3',
+ )
+ })
+
+ it('should be able to override the useMutation success callbacks', async () => {
+ const callbacks: Array = []
+
+ function Page() {
+ const { mutateAsync } = useMutation({
+ mutationFn: (text: string) => Promise.resolve(text),
+ onSuccess: () => {
+ callbacks.push('useMutation.onSuccess')
+ return Promise.resolve()
+ },
+ onSettled: () => {
+ callbacks.push('useMutation.onSettled')
+ return Promise.resolve()
+ },
+ })
+
+ useEffect(() => {
+ setActTimeout(async () => {
+ try {
+ const result = await mutateAsync('todo', {
+ onSuccess: () => {
+ callbacks.push('mutateAsync.onSuccess')
+ return Promise.resolve()
+ },
+ onSettled: () => {
+ callbacks.push('mutateAsync.onSettled')
+ return Promise.resolve()
+ },
+ })
+ callbacks.push(`mutateAsync.result:${result}`)
+ } catch {}
+ }, 10)
+ }, [mutateAsync])
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(10)
+
+ expect(callbacks).toEqual([
+ 'useMutation.onSuccess',
+ 'useMutation.onSettled',
+ 'mutateAsync.onSuccess',
+ 'mutateAsync.onSettled',
+ 'mutateAsync.result:todo',
+ ])
+ })
+
+ it('should be able to override the error callbacks when using mutateAsync', async () => {
+ const callbacks: Array = []
+
+ function Page() {
+ const { mutateAsync } = useMutation({
+ mutationFn: async (_text: string) => Promise.reject(new Error('oops')),
+ onError: () => {
+ callbacks.push('useMutation.onError')
+ return Promise.resolve()
+ },
+ onSettled: () => {
+ callbacks.push('useMutation.onSettled')
+ return Promise.resolve()
+ },
+ })
+
+ useEffect(() => {
+ setActTimeout(async () => {
+ try {
+ await mutateAsync('todo', {
+ onError: () => {
+ callbacks.push('mutateAsync.onError')
+ return Promise.resolve()
+ },
+ onSettled: () => {
+ callbacks.push('mutateAsync.onSettled')
+ return Promise.resolve()
+ },
+ })
+ } catch (error) {
+ callbacks.push(`mutateAsync.error:${(error as Error).message}`)
+ }
+ }, 10)
+ }, [mutateAsync])
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(10)
+
+ expect(callbacks).toEqual([
+ 'useMutation.onError',
+ 'useMutation.onSettled',
+ 'mutateAsync.onError',
+ 'mutateAsync.onSettled',
+ 'mutateAsync.error:oops',
+ ])
+ })
+
+ it('should be able to use mutation defaults', async () => {
+ const key = queryKey()
+
+ queryClient.setMutationDefaults(key, {
+ mutationFn: async (text: string) => {
+ await sleep(10)
+ return text
+ },
+ })
+
+ const states: Array> = []
+
+ function Page() {
+ const state = useMutation({ mutationKey: key })
+
+ states.push(state)
+
+ const { mutate } = state
+
+ useEffect(() => {
+ setActTimeout(() => {
+ mutate('todo')
+ }, 10)
+ }, [mutate])
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(21)
+
+ expect(states.length).toBe(3)
+ expect(states[0]).toMatchObject({ data: undefined, isPending: false })
+ expect(states[1]).toMatchObject({ data: undefined, isPending: true })
+ expect(states[2]).toMatchObject({ data: 'todo', isPending: false })
+ })
+
+ it('should be able to retry a failed mutation', async () => {
+ let count = 0
+
+ function Page() {
+ const { mutate } = useMutation({
+ mutationFn: (_text: string) => {
+ count++
+ return Promise.reject(new Error('oops'))
+ },
+ retry: 1,
+ retryDelay: 5,
+ })
+
+ useEffect(() => {
+ setActTimeout(() => {
+ mutate('todo')
+ }, 10)
+ }, [mutate])
+
+ return null
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(15)
+
+ expect(count).toBe(2)
+ })
+
+ it('should not retry mutations while offline', async () => {
+ const onlineMock = mockOnlineManagerIsOnline(false)
+
+ let count = 0
+
+ function Page() {
+ const mutation = useMutation({
+ mutationFn: (_text: string) => {
+ count++
+ return Promise.reject(new Error('oops'))
+ },
+ retry: 1,
+ retryDelay: 5,
+ })
+
+ return (
+
+
mutation.mutate('todo')}>mutate
+
+ error:{' '}
+ {mutation.error instanceof Error ? mutation.error.message : 'null'},
+ status: {mutation.status}, isPaused: {String(mutation.isPaused)}
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(
+ rendered.getByText('error: null, status: idle, isPaused: false'),
+ ).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(
+ rendered.getByText('error: null, status: pending, isPaused: true'),
+ ).toBeInTheDocument()
+
+ expect(count).toBe(0)
+
+ onlineMock.mockReturnValue(true)
+ queryClient.getMutationCache().resumePausedMutations()
+
+ await vi.advanceTimersByTimeAsync(6)
+ expect(
+ rendered.getByText('error: oops, status: error, isPaused: false'),
+ ).toBeInTheDocument()
+
+ expect(count).toBe(2)
+ onlineMock.mockRestore()
+ })
+
+ it('should call onMutate even if paused', async () => {
+ const onlineMock = mockOnlineManagerIsOnline(false)
+ const onMutate = vi.fn()
+ let count = 0
+
+ function Page() {
+ const mutation = useMutation({
+ mutationFn: async (_text: string) => {
+ count++
+ await sleep(10)
+ return count
+ },
+ onMutate,
+ })
+
+ return (
+
+
mutation.mutate('todo')}>mutate
+
+ data: {mutation.data ?? 'null'}, status: {mutation.status},
+ isPaused: {String(mutation.isPaused)}
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(
+ rendered.getByText('data: null, status: idle, isPaused: false'),
+ ).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(
+ rendered.getByText('data: null, status: pending, isPaused: true'),
+ ).toBeInTheDocument()
+
+ expect(onMutate).toHaveBeenCalledTimes(1)
+ expect(onMutate).toHaveBeenCalledWith('todo', {
+ client: queryClient,
+ meta: undefined,
+ mutationKey: undefined,
+ })
+
+ onlineMock.mockReturnValue(true)
+ queryClient.getMutationCache().resumePausedMutations()
+ await vi.advanceTimersByTimeAsync(11)
+ expect(
+ rendered.getByText('data: 1, status: success, isPaused: false'),
+ ).toBeInTheDocument()
+
+ expect(onMutate).toHaveBeenCalledTimes(1)
+ expect(count).toBe(1)
+
+ onlineMock.mockRestore()
+ })
+
+ it('should optimistically go to paused state if offline', async () => {
+ const onlineMock = mockOnlineManagerIsOnline(false)
+ let count = 0
+ const states: Array = []
+
+ function Page() {
+ const mutation = useMutation({
+ mutationFn: async (_text: string) => {
+ count++
+ await sleep(10)
+ return count
+ },
+ })
+
+ states.push(`${mutation.status}, ${mutation.isPaused}`)
+
+ return (
+
+
mutation.mutate('todo')}>mutate
+
+ data: {mutation.data ?? 'null'}, status: {mutation.status},
+ isPaused: {String(mutation.isPaused)}
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(
+ rendered.getByText('data: null, status: idle, isPaused: false'),
+ ).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(
+ rendered.getByText('data: null, status: pending, isPaused: true'),
+ ).toBeInTheDocument()
+
+ // no intermediate 'pending, false' state is expected because we don't start mutating!
+ expect(states[0]).toBe('idle, false')
+ expect(states[1]).toBe('pending, true')
+
+ onlineMock.mockReturnValue(true)
+ queryClient.getMutationCache().resumePausedMutations()
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(
+ rendered.getByText('data: 1, status: success, isPaused: false'),
+ ).toBeInTheDocument()
+
+ onlineMock.mockRestore()
+ })
+
+ it('should be able to retry a mutation when online', async () => {
+ const onlineMock = mockOnlineManagerIsOnline(false)
+ const key = queryKey()
+
+ let count = 0
+
+ function Page() {
+ const state = useMutation({
+ mutationKey: key,
+ mutationFn: async (_text: string) => {
+ await sleep(10)
+ count++
+ return count > 1
+ ? Promise.resolve(`data${count}`)
+ : Promise.reject(new Error('oops'))
+ },
+ retry: 1,
+ retryDelay: 5,
+ networkMode: 'offlineFirst',
+ })
+
+ return (
+
+
state.mutate('todo')}>mutate
+
status: {state.status}
+
isPaused: {String(state.isPaused)}
+
data: {state.data ?? 'null'}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('status: idle')).toBeInTheDocument()
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ await vi.advanceTimersByTimeAsync(16)
+ expect(rendered.getByText('isPaused: true')).toBeInTheDocument()
+
+ expect(
+ queryClient.getMutationCache().findAll({ mutationKey: key }).length,
+ ).toBe(1)
+ expect(
+ queryClient.getMutationCache().findAll({ mutationKey: key })[0]?.state,
+ ).toMatchObject({
+ status: 'pending',
+ isPaused: true,
+ failureCount: 1,
+ failureReason: new Error('oops'),
+ })
+
+ onlineMock.mockReturnValue(true)
+ queryClient.getMutationCache().resumePausedMutations()
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: data2')).toBeInTheDocument()
+
+ expect(
+ queryClient.getMutationCache().findAll({ mutationKey: key })[0]?.state,
+ ).toMatchObject({
+ status: 'success',
+ isPaused: false,
+ failureCount: 0,
+ failureReason: null,
+ data: 'data2',
+ })
+
+ onlineMock.mockRestore()
+ })
+
+ // eslint-disable-next-line vitest/expect-expect
+ it('should not change state if unmounted', () => {
+ function Mutates() {
+ const { mutate } = useMutation({ mutationFn: () => sleep(10) })
+ return mutate()}>mutate
+ }
+ function Page() {
+ const [mounted, setMounted] = useState(true)
+ return (
+
+ setMounted(false)}>unmount
+ {mounted && }
+
+ )
+ }
+
+ const { getByText } = renderWithClient(queryClient, )
+ fireEvent.click(getByText('mutate'))
+ fireEvent.click(getByText('unmount'))
+ })
+
+ it('should be able to throw an error when throwOnError is set to true', async () => {
+ const err = new Error('Expected mock error. All is well!')
+ err.stack = ''
+
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+ function Page() {
+ const { mutate } = useMutation({
+ mutationFn: () => {
+ return Promise.reject(err)
+ },
+ throwOnError: true,
+ })
+
+ return (
+
+ mutate()}>mutate
+
+ )
+ }
+
+ const { getByText, queryByText } = renderWithClient(
+ queryClient,
+ (
+
+ error
+
+ )}
+ >
+
+ ,
+ )
+
+ fireEvent.click(getByText('mutate'))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(queryByText('error')).not.toBeNull()
+
+ expect(consoleMock.mock.calls[0]?.[1]).toBe(err)
+
+ consoleMock.mockRestore()
+ })
+
+ it('should be able to throw an error when throwOnError is a function that returns true', async () => {
+ const consoleMock = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+ let boundary = false
+ function Page() {
+ const { mutate, error } = useMutation({
+ mutationFn: () => {
+ const err = new Error('mock error')
+ err.stack = ''
+ return Promise.reject(err)
+ },
+ throwOnError: () => {
+ return boundary
+ },
+ })
+
+ return (
+
+ mutate()}>mutate
+ {error && error.message}
+
+ )
+ }
+
+ const { getByText, queryByText } = renderWithClient(
+ queryClient,
+ (
+
+ error boundary
+
+ )}
+ >
+
+ ,
+ )
+
+ // first error goes to component
+ fireEvent.click(getByText('mutate'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(queryByText('mock error')).not.toBeNull()
+
+ // second error goes to boundary
+ boundary = true
+ fireEvent.click(getByText('mutate'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(queryByText('error boundary')).not.toBeNull()
+ consoleMock.mockRestore()
+ })
+
+ it('should pass meta to mutation', async () => {
+ const errorMock = vi.fn()
+ const successMock = vi.fn()
+
+ const queryClientMutationMeta = new QueryClient({
+ mutationCache: new MutationCache({
+ onSuccess: (_, __, ___, mutation) => {
+ successMock(mutation.meta?.metaSuccessMessage)
+ },
+ onError: (_, __, ___, mutation) => {
+ errorMock(mutation.meta?.metaErrorMessage)
+ },
+ }),
+ })
+
+ const metaSuccessMessage = 'mutation succeeded'
+ const metaErrorMessage = 'mutation failed'
+
+ function Page() {
+ const { mutate: succeed, isSuccess } = useMutation({
+ mutationFn: () => Promise.resolve(''),
+ meta: { metaSuccessMessage },
+ })
+ const { mutate: error, isError } = useMutation({
+ mutationFn: () => {
+ return Promise.reject(new Error(''))
+ },
+ meta: { metaErrorMessage },
+ })
+
+ return (
+
+
succeed()}>succeed
+
error()}>error
+ {isSuccess &&
successTest
}
+ {isError &&
errorTest
}
+
+ )
+ }
+
+ const { getByText, queryByText } = renderWithClient(
+ queryClientMutationMeta,
+ ,
+ )
+
+ fireEvent.click(getByText('succeed'))
+ fireEvent.click(getByText('error'))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(queryByText('successTest')).not.toBeNull()
+ expect(queryByText('errorTest')).not.toBeNull()
+
+ expect(successMock).toHaveBeenCalledTimes(1)
+ expect(successMock).toHaveBeenCalledWith(metaSuccessMessage)
+ expect(errorMock).toHaveBeenCalledTimes(1)
+ expect(errorMock).toHaveBeenCalledWith(metaErrorMessage)
+ })
+
+ it('should call cache callbacks when unmounted', async () => {
+ const onSuccess = vi.fn()
+ const onSuccessMutate = vi.fn()
+ const onSettled = vi.fn()
+ const onSettledMutate = vi.fn()
+ const mutationKey = queryKey()
+ let count = 0
+
+ function Page() {
+ const [show, setShow] = useState(true)
+ return (
+
+ setShow(false)}>hide
+ {show && }
+
+ )
+ }
+
+ function Component() {
+ const mutation = useMutation({
+ mutationFn: async (_text: string) => {
+ count++
+ await sleep(10)
+ return count
+ },
+ mutationKey,
+ gcTime: 0,
+ onSuccess,
+ onSettled,
+ })
+
+ return (
+
+
+ mutation.mutate('todo', {
+ onSuccess: onSuccessMutate,
+ onSettled: onSettledMutate,
+ })
+ }
+ >
+ mutate
+
+
+ data: {mutation.data ?? 'null'}, status: {mutation.status},
+ isPaused: {String(mutation.isPaused)}
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(
+ rendered.getByText('data: null, status: idle, isPaused: false'),
+ ).toBeInTheDocument()
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ fireEvent.click(rendered.getByRole('button', { name: /hide/i }))
+
+ await vi.advanceTimersByTimeAsync(10)
+ expect(
+ queryClient.getMutationCache().findAll({ mutationKey }),
+ ).toHaveLength(0)
+
+ expect(count).toBe(1)
+
+ expect(onSuccess).toHaveBeenCalledTimes(1)
+ expect(onSettled).toHaveBeenCalledTimes(1)
+ expect(onSuccessMutate).toHaveBeenCalledTimes(0)
+ expect(onSettledMutate).toHaveBeenCalledTimes(0)
+ })
+
+ it('should call mutate callbacks only for the last observer', async () => {
+ const onSuccess = vi.fn()
+ const onSuccessMutate = vi.fn()
+ const onSettled = vi.fn()
+ const onSettledMutate = vi.fn()
+ let count = 0
+
+ function Page() {
+ const mutation = useMutation({
+ mutationFn: async (text: string) => {
+ count++
+ const result = `result-${text}`
+ await sleep(10)
+ return result
+ },
+ onSuccess,
+ onSettled,
+ })
+
+ return (
+
+
+ mutation.mutate('todo1', {
+ onSuccess: onSuccessMutate,
+ onSettled: onSettledMutate,
+ })
+ }
+ >
+ mutate1
+
+
+ mutation.mutate('todo2', {
+ onSuccess: onSuccessMutate,
+ onSettled: onSettledMutate,
+ })
+ }
+ >
+ mutate2
+
+
+ data: {mutation.data ?? 'null'}, status: {mutation.status}
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('data: null, status: idle')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
+ fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(
+ rendered.getByText('data: result-todo2, status: success'),
+ ).toBeInTheDocument()
+
+ expect(count).toBe(2)
+
+ expect(onSuccess).toHaveBeenCalledTimes(2)
+ expect(onSuccess).toHaveBeenNthCalledWith(
+ 1,
+ 'result-todo1',
+ 'todo1',
+ undefined,
+ {
+ client: queryClient,
+ meta: undefined,
+ mutationKey: undefined,
+ },
+ )
+ expect(onSuccess).toHaveBeenNthCalledWith(
+ 2,
+ 'result-todo2',
+ 'todo2',
+ undefined,
+ {
+ client: queryClient,
+ meta: undefined,
+ mutationKey: undefined,
+ },
+ )
+ expect(onSettled).toHaveBeenCalledTimes(2)
+ expect(onSuccessMutate).toHaveBeenCalledTimes(1)
+ expect(onSuccessMutate).toHaveBeenCalledWith(
+ 'result-todo2',
+ 'todo2',
+ undefined,
+ {
+ client: queryClient,
+ meta: undefined,
+ mutationKey: undefined,
+ },
+ )
+ expect(onSettledMutate).toHaveBeenCalledTimes(1)
+ expect(onSettledMutate).toHaveBeenCalledWith(
+ 'result-todo2',
+ null,
+ 'todo2',
+ undefined,
+ {
+ client: queryClient,
+ meta: undefined,
+ mutationKey: undefined,
+ },
+ )
+ })
+
+ it('should go to error state if onSuccess callback errors', async () => {
+ const error = new Error('error from onSuccess')
+ const onError = vi.fn()
+
+ function Page() {
+ const mutation = useMutation({
+ mutationFn: async (_text: string) => {
+ await sleep(10)
+ return 'result'
+ },
+ onSuccess: () => Promise.reject(error),
+ onError,
+ })
+
+ return (
+
+
mutation.mutate('todo')}>mutate
+
status: {mutation.status}
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('status: idle')).toBeInTheDocument()
+
+ rendered.getByRole('button', { name: /mutate/i }).click()
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('status: error')).toBeInTheDocument()
+
+ expect(onError).toHaveBeenCalledWith(error, 'todo', undefined, {
+ client: queryClient,
+ meta: undefined,
+ mutationKey: undefined,
+ })
+ })
+
+ it('should go to error state if onError callback errors', async () => {
+ const error = new Error('error from onError')
+ const mutateFnError = new Error('mutateFnError')
+
+ function Page() {
+ const mutation = useMutation({
+ mutationFn: async (_text: string) => {
+ await sleep(10)
+ throw mutateFnError
+ },
+ onError: () => Promise.reject(error),
+ })
+
+ return (
+
+
mutation.mutate('todo')}>mutate
+
+ error:{' '}
+ {mutation.error instanceof Error ? mutation.error.message : 'null'},
+ status: {mutation.status}
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('error: null, status: idle')).toBeInTheDocument()
+
+ rendered.getByRole('button', { name: /mutate/i }).click()
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(
+ rendered.getByText('error: mutateFnError, status: error'),
+ ).toBeInTheDocument()
+ })
+
+ it('should go to error state if onSettled callback errors', async () => {
+ const error = new Error('error from onSettled')
+ const mutateFnError = new Error('mutateFnError')
+ const onError = vi.fn()
+
+ function Page() {
+ const mutation = useMutation({
+ mutationFn: async (_text: string) => {
+ await sleep(10)
+ throw mutateFnError
+ },
+ onSettled: () => Promise.reject(error),
+ onError,
+ })
+
+ return (
+
+
mutation.mutate('todo')}>mutate
+
+ error:{' '}
+ {mutation.error instanceof Error ? mutation.error.message : 'null'},
+ status: {mutation.status}
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('error: null, status: idle')).toBeInTheDocument()
+
+ rendered.getByRole('button', { name: /mutate/i }).click()
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(
+ rendered.getByText('error: mutateFnError, status: error'),
+ ).toBeInTheDocument()
+ expect(onError).toHaveBeenCalledWith(mutateFnError, 'todo', undefined, {
+ client: queryClient,
+ meta: undefined,
+ mutationKey: undefined,
+ })
+ })
+
+ it('should use provided custom queryClient', async () => {
+ function Page() {
+ const mutation = useMutation(
+ {
+ mutationFn: async (text: string) => {
+ return Promise.resolve(text)
+ },
+ },
+ queryClient,
+ )
+
+ return (
+
+
mutation.mutate('custom client')}>
+ mutate
+
+
+ data: {mutation.data ?? 'null'}, status: {mutation.status}
+
+
+ )
+ }
+
+ const rendered = render( )
+
+ expect(rendered.getByText('data: null, status: idle')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(
+ rendered.getByText('data: custom client, status: success'),
+ ).toBeInTheDocument()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/useMutationState.test-d.tsx b/packages/preact-query/src/__tests__/useMutationState.test-d.tsx
new file mode 100644
index 0000000000..795995aa44
--- /dev/null
+++ b/packages/preact-query/src/__tests__/useMutationState.test-d.tsx
@@ -0,0 +1,23 @@
+import { describe, expectTypeOf, it } from 'vitest'
+import { useMutationState } from '../useMutationState'
+import type { MutationState, MutationStatus } from '@tanstack/query-core'
+
+describe('useMutationState', () => {
+ it('should default to QueryState', () => {
+ const result = useMutationState({
+ filters: { status: 'pending' },
+ })
+
+ expectTypeOf(result).toEqualTypeOf<
+ Array>
+ >()
+ })
+ it('should infer with select', () => {
+ const result = useMutationState({
+ filters: { status: 'pending' },
+ select: (mutation) => mutation.state.status,
+ })
+
+ expectTypeOf(result).toEqualTypeOf>()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/useMutationState.test.tsx b/packages/preact-query/src/__tests__/useMutationState.test.tsx
new file mode 100644
index 0000000000..8a00db36fd
--- /dev/null
+++ b/packages/preact-query/src/__tests__/useMutationState.test.tsx
@@ -0,0 +1,238 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render } from '@testing-library/preact'
+import { sleep } from '@tanstack/query-test-utils'
+import { QueryClient, useIsMutating, useMutation, useMutationState } from '..'
+import { renderWithClient } from './utils'
+import { useEffect } from 'preact/hooks'
+
+describe('useIsMutating', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should return the number of fetching mutations', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+
+ function IsMutating() {
+ const isMutating = useIsMutating()
+
+ isMutatingArray.push(isMutating)
+
+ return null
+ }
+
+ function Mutations() {
+ const { mutate: mutate1 } = useMutation({
+ mutationKey: ['mutation1'],
+ mutationFn: () => sleep(50).then(() => 'data'),
+ })
+ const { mutate: mutate2 } = useMutation({
+ mutationKey: ['mutation2'],
+ mutationFn: () => sleep(10).then(() => 'data'),
+ })
+
+ return (
+
+ mutate1()}>mutate1
+ mutate2()}>mutate2
+
+ )
+ }
+
+ function Page() {
+ return (
+
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
+ await vi.advanceTimersByTimeAsync(10)
+ fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
+
+ // we don't really care if this yields
+ // [ +0, 1, 2, +0 ]
+ // or
+ // [ +0, 1, 2, 1, +0 ]
+ // our batching strategy might yield different results
+
+ await vi.advanceTimersByTimeAsync(41)
+ expect(isMutatingArray[0]).toEqual(0)
+ expect(isMutatingArray[1]).toEqual(1)
+ expect(isMutatingArray[2]).toEqual(2)
+ expect(isMutatingArray[3]).toEqual(1)
+ expect(isMutatingArray[4]).toEqual(0)
+
+ expect(isMutatingArray).toEqual([0, 1, 2, 1, 0])
+ })
+
+ it('should filter correctly by mutationKey', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+
+ function IsMutating() {
+ const isMutating = useIsMutating({ mutationKey: ['mutation1'] })
+ isMutatingArray.push(isMutating)
+ return null
+ }
+
+ function Page() {
+ const { mutate: mutate1 } = useMutation({
+ mutationKey: ['mutation1'],
+ mutationFn: () => sleep(100).then(() => 'data'),
+ })
+ const { mutate: mutate2 } = useMutation({
+ mutationKey: ['mutation2'],
+ mutationFn: () => sleep(100).then(() => 'data'),
+ })
+
+ useEffect(() => {
+ mutate1()
+ mutate2()
+ }, [mutate1, mutate2])
+
+ return
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(101)
+ expect(isMutatingArray).toEqual([0, 1, 0])
+ })
+
+ it('should filter correctly by predicate', async () => {
+ const isMutatingArray: Array = []
+ const queryClient = new QueryClient()
+
+ function IsMutating() {
+ const isMutating = useIsMutating({
+ predicate: (mutation) =>
+ mutation.options.mutationKey?.[0] === 'mutation1',
+ })
+ isMutatingArray.push(isMutating)
+ return null
+ }
+
+ function Page() {
+ const { mutate: mutate1 } = useMutation({
+ mutationKey: ['mutation1'],
+ mutationFn: () => sleep(100).then(() => 'data'),
+ })
+ const { mutate: mutate2 } = useMutation({
+ mutationKey: ['mutation2'],
+ mutationFn: () => sleep(100).then(() => 'data'),
+ })
+
+ useEffect(() => {
+ mutate1()
+ mutate2()
+ }, [mutate1, mutate2])
+
+ return
+ }
+
+ renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(101)
+ expect(isMutatingArray).toEqual([0, 1, 0])
+ })
+
+ it('should use provided custom queryClient', async () => {
+ const queryClient = new QueryClient()
+
+ function Page() {
+ const isMutating = useIsMutating({}, queryClient)
+ const { mutate } = useMutation(
+ {
+ mutationKey: ['mutation1'],
+ mutationFn: () => sleep(10).then(() => 'data'),
+ },
+ queryClient,
+ )
+
+ useEffect(() => {
+ mutate()
+ }, [mutate])
+
+ return (
+
+
mutating: {isMutating}
+
+ )
+ }
+
+ const rendered = render( )
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(rendered.getByText('mutating: 1')).toBeInTheDocument()
+ })
+})
+
+describe('useMutationState', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should return variables after calling mutate', async () => {
+ const queryClient = new QueryClient()
+ const variables: Array> = []
+ const mutationKey = ['mutation']
+
+ function Variables() {
+ variables.push(
+ useMutationState({
+ filters: { mutationKey, status: 'pending' },
+ select: (mutation) => mutation.state.variables,
+ }),
+ )
+
+ return null
+ }
+
+ function Mutate() {
+ const { mutate, data } = useMutation({
+ mutationKey,
+ mutationFn: (input: number) => sleep(150).then(() => 'data' + input),
+ })
+
+ return (
+
+ data: {data ?? 'null'}
+ mutate(1)}>mutate
+
+ )
+ }
+
+ function Page() {
+ return (
+
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('data: null')).toBeInTheDocument()
+
+ fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
+ await vi.advanceTimersByTimeAsync(151)
+ expect(rendered.getByText('data: data1')).toBeInTheDocument()
+
+ expect(variables).toEqual([[], [1], []])
+ })
+})
diff --git a/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx
new file mode 100644
index 0000000000..03af450c93
--- /dev/null
+++ b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test-d.tsx
@@ -0,0 +1,60 @@
+import { assertType, describe, expectTypeOf, it } from 'vitest'
+import { usePrefetchInfiniteQuery } from '..'
+
+describe('usePrefetchInfiniteQuery', () => {
+ it('should return nothing', () => {
+ const result = usePrefetchInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ initialPageParam: 1,
+ getNextPageParam: () => 1,
+ })
+
+ expectTypeOf(result).toEqualTypeOf()
+ })
+
+ it('should require initialPageParam and getNextPageParam', () => {
+ assertType(
+ // @ts-expect-error TS2345
+ usePrefetchInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ }),
+ )
+ })
+
+ it('should not allow refetchInterval, enabled or throwOnError options', () => {
+ assertType(
+ usePrefetchInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ initialPageParam: 1,
+ getNextPageParam: () => 1,
+ // @ts-expect-error TS2353
+ refetchInterval: 1000,
+ }),
+ )
+
+ assertType(
+ usePrefetchInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ initialPageParam: 1,
+ getNextPageParam: () => 1,
+ // @ts-expect-error TS2353
+ enabled: true,
+ }),
+ )
+
+ assertType(
+ usePrefetchInfiniteQuery({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ initialPageParam: 1,
+ getNextPageParam: () => 1,
+ // @ts-expect-error TS2353
+ throwOnError: true,
+ }),
+ )
+ })
+})
diff --git a/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx
new file mode 100644
index 0000000000..5a0b8fb0e7
--- /dev/null
+++ b/packages/preact-query/src/__tests__/usePrefetchInfiniteQuery.test.tsx
@@ -0,0 +1,202 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, fireEvent } from '@testing-library/preact'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import {
+ QueryCache,
+ QueryClient,
+ usePrefetchInfiniteQuery,
+ useSuspenseInfiniteQuery,
+} from '..'
+import { renderWithClient } from './utils'
+import type { InfiniteData, UseSuspenseInfiniteQueryOptions } from '..'
+import type { Mock } from 'vitest'
+import { Suspense } from 'preact/compat'
+import { VNode } from 'preact'
+
+const generateInfiniteQueryOptions = (
+ data: Array<{ data: string; currentPage: number; totalPages: number }>,
+) => {
+ let currentPage = 0
+
+ return {
+ queryFn: vi
+ .fn<(...args: Array) => Promise<(typeof data)[number]>>()
+ .mockImplementation(async () => {
+ const currentPageData = data[currentPage]
+ if (!currentPageData) {
+ throw new Error('No data defined for page ' + currentPage)
+ }
+
+ await sleep(10)
+ currentPage++
+
+ return currentPageData
+ }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage: (typeof data)[number]) =>
+ lastPage.currentPage === lastPage.totalPages
+ ? undefined
+ : lastPage.currentPage + 1,
+ }
+}
+
+describe('usePrefetchInfiniteQuery', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({ queryCache })
+
+ const Fallback = vi.fn().mockImplementation(() => Loading...
)
+
+ function Suspended(props: {
+ queryOpts: UseSuspenseInfiniteQueryOptions<
+ T,
+ Error,
+ InfiniteData,
+ Array,
+ any
+ >
+ renderPage: (page: T) => VNode
+ }) {
+ const state = useSuspenseInfiniteQuery(props.queryOpts)
+
+ return (
+
+ {state.data.pages.map((page, index) => (
+
{props.renderPage(page)}
+ ))}
+
state.fetchNextPage()}>Next Page
+
+ )
+ }
+
+ it('should prefetch an infinite query if query state does not exist', async () => {
+ const data = [
+ { data: 'Do you fetch on render?', currentPage: 1, totalPages: 3 },
+ { data: 'Or do you render as you fetch?', currentPage: 2, totalPages: 3 },
+ {
+ data: 'Either way, Tanstack Query helps you!',
+ currentPage: 3,
+ totalPages: 3,
+ },
+ ]
+
+ const queryOpts = {
+ queryKey: queryKey(),
+ ...generateInfiniteQueryOptions(data),
+ }
+
+ function App() {
+ usePrefetchInfiniteQuery({ ...queryOpts, pages: data.length })
+
+ return (
+ }>
+ data: {page.data}
}
+ />
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(30)
+ rendered.getByText('data: Do you fetch on render?')
+ fireEvent.click(rendered.getByText('Next Page'))
+ expect(
+ rendered.getByText('data: Or do you render as you fetch?'),
+ ).toBeInTheDocument()
+ fireEvent.click(rendered.getByText('Next Page'))
+ expect(
+ rendered.getByText('data: Either way, Tanstack Query helps you!'),
+ ).toBeInTheDocument()
+ expect(Fallback).toHaveBeenCalledTimes(1)
+ expect(queryOpts.queryFn).toHaveBeenCalledTimes(3)
+ })
+
+ it('should not display fallback if the query cache is already populated', async () => {
+ const queryOpts = {
+ queryKey: queryKey(),
+ ...generateInfiniteQueryOptions([
+ { data: 'Prefetch rocks!', currentPage: 1, totalPages: 3 },
+ { data: 'No waterfalls, boy!', currentPage: 2, totalPages: 3 },
+ { data: 'Tanstack Query #ftw', currentPage: 3, totalPages: 3 },
+ ]),
+ }
+
+ queryClient.prefetchInfiniteQuery({ ...queryOpts, pages: 3 })
+ await vi.advanceTimersByTimeAsync(30)
+ ;(queryOpts.queryFn as Mock).mockClear()
+
+ function App() {
+ usePrefetchInfiniteQuery(queryOpts)
+
+ return (
+ }>
+ data: {page.data}
}
+ />
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('data: Prefetch rocks!')).toBeInTheDocument()
+ fireEvent.click(rendered.getByText('Next Page'))
+ expect(rendered.getByText('data: No waterfalls, boy!')).toBeInTheDocument()
+ fireEvent.click(rendered.getByText('Next Page'))
+ expect(rendered.getByText('data: Tanstack Query #ftw')).toBeInTheDocument()
+ expect(queryOpts.queryFn).not.toHaveBeenCalled()
+ expect(Fallback).not.toHaveBeenCalled()
+ })
+
+ it('should not create an endless loop when using inside a suspense boundary', async () => {
+ const queryOpts = {
+ queryKey: queryKey(),
+ ...generateInfiniteQueryOptions([
+ { data: 'Infinite Page 1', currentPage: 1, totalPages: 3 },
+ { data: 'Infinite Page 2', currentPage: 1, totalPages: 3 },
+ { data: 'Infinite Page 3', currentPage: 1, totalPages: 3 },
+ ]),
+ }
+
+ function Prefetch({ children }: { children: VNode }) {
+ usePrefetchInfiniteQuery(queryOpts)
+ return <>{children}>
+ }
+
+ function App() {
+ return (
+ >}>
+
+ data: {page.data}
}
+ />
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(10)
+ rendered.getByText('data: Infinite Page 1')
+ fireEvent.click(rendered.getByText('Next Page'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: Infinite Page 2')).toBeInTheDocument()
+ fireEvent.click(rendered.getByText('Next Page'))
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: Infinite Page 3')).toBeInTheDocument()
+ expect(queryOpts.queryFn).toHaveBeenCalledTimes(3)
+ })
+})
diff --git a/packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx b/packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx
new file mode 100644
index 0000000000..09dbaf18c1
--- /dev/null
+++ b/packages/preact-query/src/__tests__/usePrefetchQuery.test-d.tsx
@@ -0,0 +1,59 @@
+import { assertType, describe, expectTypeOf, it } from 'vitest'
+import { skipToken, usePrefetchQuery } from '..'
+
+describe('usePrefetchQuery', () => {
+ it('should return nothing', () => {
+ const result = usePrefetchQuery({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ })
+
+ expectTypeOf(result).toEqualTypeOf()
+ })
+
+ it('should not allow refetchInterval, enabled or throwOnError options', () => {
+ assertType(
+ usePrefetchQuery({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ // @ts-expect-error TS2345
+ refetchInterval: 1000,
+ }),
+ )
+
+ assertType(
+ usePrefetchQuery({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ // @ts-expect-error TS2345
+ enabled: true,
+ }),
+ )
+
+ assertType(
+ usePrefetchQuery({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(5),
+ // @ts-expect-error TS2345
+ throwOnError: true,
+ }),
+ )
+ })
+
+ it('should not allow skipToken in queryFn', () => {
+ assertType(
+ usePrefetchQuery({
+ queryKey: ['key'],
+ // @ts-expect-error
+ queryFn: skipToken,
+ }),
+ )
+ assertType(
+ usePrefetchQuery({
+ queryKey: ['key'],
+ // @ts-expect-error
+ queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
+ }),
+ )
+ })
+})
diff --git a/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx b/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx
new file mode 100644
index 0000000000..5804893bee
--- /dev/null
+++ b/packages/preact-query/src/__tests__/usePrefetchQuery.test.tsx
@@ -0,0 +1,291 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, fireEvent } from '@testing-library/preact'
+import { ErrorBoundary } from './ErrorBoundary'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import {
+ QueryCache,
+ QueryClient,
+ usePrefetchQuery,
+ useQueryErrorResetBoundary,
+ useSuspenseQuery,
+} from '..'
+import { renderWithClient } from './utils'
+
+import type { UseSuspenseQueryOptions } from '..'
+import { Suspense } from 'preact/compat'
+import { VNode } from 'preact'
+
+const generateQueryFn = (data: string) =>
+ vi
+ .fn<(...args: Array) => Promise>()
+ .mockImplementation(async () => {
+ await sleep(10)
+
+ return data
+ })
+
+describe('usePrefetchQuery', () => {
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({ queryCache })
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ function Suspended(props: {
+ queryOpts: UseSuspenseQueryOptions>
+ children?: VNode
+ }) {
+ const state = useSuspenseQuery(props.queryOpts)
+
+ return (
+
+
data: {String(state.data)}
+ {props.children}
+
+ )
+ }
+
+ it('should prefetch query if query state does not exist', async () => {
+ const queryOpts = {
+ queryKey: queryKey(),
+ queryFn: generateQueryFn('prefetchQuery'),
+ }
+
+ const componentQueryOpts = {
+ ...queryOpts,
+ queryFn: generateQueryFn('useSuspenseQuery'),
+ }
+
+ function App() {
+ usePrefetchQuery(queryOpts)
+
+ return (
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('data: prefetchQuery')).toBeInTheDocument()
+ expect(queryOpts.queryFn).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not prefetch query if query state exists', async () => {
+ const queryOpts = {
+ queryKey: queryKey(),
+ queryFn: generateQueryFn('The usePrefetchQuery hook is smart!'),
+ }
+
+ function App() {
+ usePrefetchQuery(queryOpts)
+
+ return (
+
+
+
+ )
+ }
+
+ queryClient.fetchQuery(queryOpts)
+ await vi.advanceTimersByTimeAsync(10)
+ queryOpts.queryFn.mockClear()
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.queryByText('fetching: true')).not.toBeInTheDocument()
+ expect(
+ rendered.getByText('data: The usePrefetchQuery hook is smart!'),
+ ).toBeInTheDocument()
+ expect(queryOpts.queryFn).not.toHaveBeenCalled()
+ })
+
+ it('should let errors fall through and not refetch failed queries', async () => {
+ const consoleMock = vi.spyOn(console, 'error')
+ consoleMock.mockImplementation(() => undefined)
+ const queryFn = generateQueryFn('Not an error')
+
+ const queryOpts = {
+ queryKey: queryKey(),
+ queryFn,
+ }
+
+ queryFn.mockImplementationOnce(async () => {
+ await sleep(10)
+
+ throw new Error('Oops! Server error!')
+ })
+
+ function App() {
+ usePrefetchQuery(queryOpts)
+
+ return (
+ Oops!
}>
+
+
+
+
+ )
+ }
+
+ queryClient.prefetchQuery(queryOpts)
+ await vi.advanceTimersByTimeAsync(10)
+ queryFn.mockClear()
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('Oops!')).toBeInTheDocument()
+ expect(rendered.queryByText('data: Not an error')).not.toBeInTheDocument()
+ expect(queryOpts.queryFn).not.toHaveBeenCalled()
+
+ consoleMock.mockRestore()
+ })
+
+ it('should not create an endless loop when using inside a suspense boundary', async () => {
+ const queryFn = generateQueryFn('prefetchedQuery')
+
+ const queryOpts = {
+ queryKey: queryKey(),
+ queryFn,
+ }
+
+ function Prefetch({ children }: { children: VNode }) {
+ usePrefetchQuery(queryOpts)
+ return <>{children}>
+ }
+
+ function App() {
+ return (
+ >}>
+
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+ await vi.advanceTimersByTimeAsync(10)
+ expect(rendered.getByText('data: prefetchedQuery')).toBeInTheDocument()
+ expect(queryOpts.queryFn).toHaveBeenCalledTimes(1)
+ })
+
+ it('should be able to recover from errors and try fetching again', async () => {
+ const consoleMock = vi.spyOn(console, 'error')
+ consoleMock.mockImplementation(() => undefined)
+ const queryFn = generateQueryFn('This is fine :dog: :fire:')
+
+ const queryOpts = {
+ queryKey: queryKey(),
+ queryFn,
+ }
+
+ queryFn.mockImplementationOnce(async () => {
+ await sleep(10)
+
+ throw new Error('Oops! Server error!')
+ })
+
+ function App() {
+ const { reset } = useQueryErrorResetBoundary()
+ usePrefetchQuery(queryOpts)
+
+ return (
+ (
+
+ )}
+ >
+
+
+
+
+ )
+ }
+
+ queryClient.prefetchQuery(queryOpts)
+ await vi.advanceTimersByTimeAsync(10)
+ queryFn.mockClear()
+
+ const rendered = renderWithClient(queryClient, )
+
+ expect(rendered.getByText('Oops!')).toBeInTheDocument()
+ fireEvent.click(rendered.getByText('Try again'))
+ await vi.advanceTimersByTimeAsync(10)
+ expect(
+ rendered.getByText('data: This is fine :dog: :fire:'),
+ ).toBeInTheDocument()
+ expect(queryOpts.queryFn).toHaveBeenCalledTimes(1)
+ consoleMock.mockRestore()
+ })
+
+ it('should not create a suspense waterfall if prefetch is fired', async () => {
+ const firstQueryOpts = {
+ queryKey: queryKey(),
+ queryFn: generateQueryFn('Prefetch is nice!'),
+ }
+
+ const secondQueryOpts = {
+ queryKey: queryKey(),
+ queryFn: generateQueryFn('Prefetch is really nice!!'),
+ }
+
+ const thirdQueryOpts = {
+ queryKey: queryKey(),
+ queryFn: generateQueryFn('Prefetch does not create waterfalls!!'),
+ }
+
+ const Fallback = vi.fn().mockImplementation(() => Loading...
)
+
+ function App() {
+ usePrefetchQuery(firstQueryOpts)
+ usePrefetchQuery(secondQueryOpts)
+ usePrefetchQuery(thirdQueryOpts)
+
+ return (
+ }>
+
+
+
+
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+ expect(
+ queryClient.getQueryState(firstQueryOpts.queryKey)?.fetchStatus,
+ ).toBe('fetching')
+ expect(
+ queryClient.getQueryState(secondQueryOpts.queryKey)?.fetchStatus,
+ ).toBe('fetching')
+ expect(
+ queryClient.getQueryState(thirdQueryOpts.queryKey)?.fetchStatus,
+ ).toBe('fetching')
+ expect(rendered.getByText('Loading...')).toBeInTheDocument()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(10)
+ })
+ expect(rendered.getByText('data: Prefetch is nice!')).toBeInTheDocument()
+ expect(
+ rendered.getByText('data: Prefetch is really nice!!'),
+ ).toBeInTheDocument()
+ expect(
+ rendered.getByText('data: Prefetch does not create waterfalls!!'),
+ ).toBeInTheDocument()
+ expect(Fallback).toHaveBeenCalledTimes(1)
+ expect(firstQueryOpts.queryFn).toHaveBeenCalledTimes(1)
+ expect(secondQueryOpts.queryFn).toHaveBeenCalledTimes(1)
+ expect(thirdQueryOpts.queryFn).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/preact-query/src/__tests__/useQueries.test-d.tsx b/packages/preact-query/src/__tests__/useQueries.test-d.tsx
new file mode 100644
index 0000000000..9aaeb45dc2
--- /dev/null
+++ b/packages/preact-query/src/__tests__/useQueries.test-d.tsx
@@ -0,0 +1,170 @@
+import { describe, expectTypeOf, it } from 'vitest'
+import { skipToken } from '..'
+import { useQueries } from '../useQueries'
+import { queryOptions } from '../queryOptions'
+import type { OmitKeyof } from '..'
+import type { UseQueryOptions, UseQueryResult } from '../types'
+
+describe('UseQueries config object overload', () => {
+ it('TData should always be defined when initialData is provided as an object', () => {
+ const query1 = {
+ queryKey: ['key1'],
+ queryFn: () => {
+ return {
+ wow: true,
+ }
+ },
+ initialData: {
+ wow: false,
+ },
+ }
+
+ const query2 = {
+ queryKey: ['key2'],
+ queryFn: () => 'Query Data',
+ initialData: 'initial data',
+ }
+
+ const query3 = {
+ queryKey: ['key2'],
+ queryFn: () => 'Query Data',
+ }
+
+ const queryResults = useQueries({ queries: [query1, query2, query3] })
+
+ const query1Data = queryResults[0].data
+ const query2Data = queryResults[1].data
+ const query3Data = queryResults[2].data
+
+ expectTypeOf(query1Data).toEqualTypeOf<{ wow: boolean }>()
+ expectTypeOf(query2Data).toEqualTypeOf()
+ expectTypeOf(query3Data).toEqualTypeOf()
+ })
+
+ it('TData should be defined when passed through queryOptions', () => {
+ const options = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => {
+ return {
+ wow: true,
+ }
+ },
+ initialData: {
+ wow: true,
+ },
+ })
+ const queryResults = useQueries({ queries: [options] })
+
+ const data = queryResults[0].data
+
+ expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
+ })
+
+ it('should be possible to define a different TData than TQueryFnData using select with queryOptions spread into useQuery', () => {
+ const query1 = queryOptions({
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(1),
+ select: (data) => data > 1,
+ })
+
+ const query2 = {
+ queryKey: ['key'],
+ queryFn: () => Promise.resolve(1),
+ select: (data: number) => data > 1,
+ }
+
+ const queryResults = useQueries({ queries: [query1, query2] })
+ const query1Data = queryResults[0].data
+ const query2Data = queryResults[1].data
+
+ expectTypeOf(query1Data).toEqualTypeOf()
+ expectTypeOf(query2Data).toEqualTypeOf()
+ })
+
+ it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => {
+ const queryResults = useQueries({
+ queries: [
+ {
+ queryKey: ['key'],
+ queryFn: () => {
+ return {
+ wow: true,
+ }
+ },
+ initialData: () => undefined as { wow: boolean } | undefined,
+ },
+ ],
+ })
+
+ const data = queryResults[0].data
+
+ expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>()
+ })
+
+ describe('custom hook', () => {
+ it('should allow custom hooks using UseQueryOptions', () => {
+ type Data = string
+
+ const useCustomQueries = (
+ options?: OmitKeyof, 'queryKey' | 'queryFn'>,
+ ) => {
+ return useQueries({
+ queries: [
+ {
+ ...options,
+ queryKey: ['todos-key'],
+ queryFn: () => Promise.resolve('data'),
+ },
+ ],
+ })
+ }
+
+ const queryResults = useCustomQueries()
+ const data = queryResults[0].data
+
+ expectTypeOf(data).toEqualTypeOf()
+ })
+ })
+
+ it('TData should have correct type when conditional skipToken is passed', () => {
+ const queryResults = useQueries({
+ queries: [
+ {
+ queryKey: ['withSkipToken'],
+ queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5),
+ },
+ ],
+ })
+
+ const firstResult = queryResults[0]
+
+ expectTypeOf(firstResult).toEqualTypeOf>()
+ expectTypeOf(firstResult.data).toEqualTypeOf()
+ })
+
+ it('should return correct data for dynamic queries with mixed result types', () => {
+ const Queries1 = {
+ get: () =>
+ queryOptions({
+ queryKey: ['key1'],
+ queryFn: () => Promise.resolve(1),
+ }),
+ }
+ const Queries2 = {
+ get: () =>
+ queryOptions({
+ queryKey: ['key2'],
+ queryFn: () => Promise.resolve(true),
+ }),
+ }
+
+ const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() }))
+ const result = useQueries({
+ queries: [...queries1List, { ...Queries2.get() }],
+ })
+
+ expectTypeOf(result).toEqualTypeOf<
+ [...Array>, UseQueryResult]
+ >()
+ })
+})
diff --git a/packages/preact-query/src/__tests__/useQueries.test.tsx b/packages/preact-query/src/__tests__/useQueries.test.tsx
new file mode 100644
index 0000000000..0b83765cf8
--- /dev/null
+++ b/packages/preact-query/src/__tests__/useQueries.test.tsx
@@ -0,0 +1,1814 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ expectTypeOf,
+ it,
+ vi,
+} from 'vitest'
+import { fireEvent, render } from '@testing-library/preact'
+import { ErrorBoundary } from './ErrorBoundary'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import {
+ QueryCache,
+ QueryClient,
+ queryOptions,
+ skipToken,
+ useQueries,
+} from '..'
+import { renderWithClient } from './utils'
+import type {
+ QueryFunction,
+ QueryKey,
+ QueryObserverResult,
+ UseQueryOptions,
+ UseQueryResult,
+} from '..'
+import type { QueryFunctionContext } from '@tanstack/query-core'
+import { useCallback, useEffect, useState } from 'preact/hooks'
+
+describe('useQueries', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({ queryCache })
+
+ it('should return the correct states', async () => {
+ const key1 = queryKey()
+ const key2 = queryKey()
+ const results: Array> = []
+
+ function Page() {
+ const result = useQueries({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: async () => {
+ await sleep(10)
+ return 1
+ },
+ },
+ {
+ queryKey: key2,
+ queryFn: async () => {
+ await sleep(200)
+ return 2
+ },
+ },
+ ],
+ })
+ results.push(result)
+
+ return (
+
+
+ data1: {String(result[0].data ?? 'null')}, data2:{' '}
+ {String(result[1].data ?? 'null')}
+
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(201)
+ expect(rendered.getByText('data1: 1, data2: 2')).toBeInTheDocument()
+
+ expect(results.length).toBe(3)
+ expect(results[0]).toMatchObject([{ data: undefined }, { data: undefined }])
+ expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }])
+ expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }])
+ })
+
+ it('should track results', async () => {
+ const key1 = queryKey()
+ const results: Array> = []
+ let count = 0
+
+ function Page() {
+ const result = useQueries({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: async () => {
+ await sleep(10)
+ count++
+ return count
+ },
+ },
+ ],
+ })
+ results.push(result)
+
+ return (
+
+
data: {String(result[0].data ?? 'null')}
+
result[0].refetch()}>refetch
+
+ )
+ }
+
+ const rendered = renderWithClient(queryClient, )
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 1')).toBeInTheDocument()
+
+ expect(results.length).toBe(2)
+ expect(results[0]).toMatchObject([{ data: undefined }])
+ expect(results[1]).toMatchObject([{ data: 1 }])
+
+ fireEvent.click(rendered.getByRole('button', { name: /refetch/i }))
+
+ await vi.advanceTimersByTimeAsync(11)
+ expect(rendered.getByText('data: 2')).toBeInTheDocument()
+
+ // only one render for data update, no render for isFetching transition
+ expect(results.length).toBe(3)
+
+ expect(results[2]).toMatchObject([{ data: 2 }])
+ })
+
+ it('handles type parameter - tuple of tuples', () => {
+ const key1 = queryKey()
+ const key2 = queryKey()
+ const key3 = queryKey()
+
+ // @ts-expect-error (Page component is not rendered)
+ function Page() {
+ const result1 = useQueries<
+ [[number], [string], [Array, boolean]]
+ >({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 1,
+ },
+ {
+ queryKey: key2,
+ queryFn: () => 'string',
+ },
+ {
+ queryKey: key3,
+ queryFn: () => ['string[]'],
+ },
+ ],
+ })
+ expectTypeOf(result1[0]).toEqualTypeOf>()
+ expectTypeOf(result1[1]).toEqualTypeOf>()
+ expectTypeOf(result1[2]).toEqualTypeOf<
+ UseQueryResult, boolean>
+ >()
+ expectTypeOf(result1[0].data).toEqualTypeOf()
+ expectTypeOf(result1[1].data).toEqualTypeOf()
+ expectTypeOf(result1[2].data).toEqualTypeOf | undefined>()
+ expectTypeOf(result1[2].error).toEqualTypeOf()
+
+ // TData (3rd element) takes precedence over TQueryFnData (1st element)
+ const result2 = useQueries<
+ [[string, unknown, string], [string, unknown, number]]
+ >({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return a.toLowerCase()
+ },
+ },
+ {
+ queryKey: key2,
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return parseInt(a)
+ },
+ },
+ ],
+ })
+ expectTypeOf(result2[0]).toEqualTypeOf>()
+ expectTypeOf(result2[1]).toEqualTypeOf>()
+ expectTypeOf(result2[0].data).toEqualTypeOf()
+ expectTypeOf(result2[1].data).toEqualTypeOf()
+
+ // types should be enforced
+ useQueries<[[string, unknown, string], [string, boolean, number]]>({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return a.toLowerCase()
+ },
+ placeholderData: 'string',
+ // @ts-expect-error (initialData: string)
+ initialData: 123,
+ },
+ {
+ queryKey: key2,
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return parseInt(a)
+ },
+ placeholderData: 'string',
+ // @ts-expect-error (initialData: string)
+ initialData: 123,
+ },
+ ],
+ })
+
+ // field names should be enforced
+ useQueries<[[string]]>({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ // @ts-expect-error (invalidField)
+ someInvalidField: [],
+ },
+ ],
+ })
+ }
+ })
+
+ it('handles type parameter - tuple of objects', () => {
+ const key1 = queryKey()
+ const key2 = queryKey()
+ const key3 = queryKey()
+
+ // @ts-expect-error (Page component is not rendered)
+ function Page() {
+ const result1 = useQueries<
+ [
+ { queryFnData: number },
+ { queryFnData: string },
+ { queryFnData: Array; error: boolean },
+ ]
+ >({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 1,
+ },
+ {
+ queryKey: key2,
+ queryFn: () => 'string',
+ },
+ {
+ queryKey: key3,
+ queryFn: () => ['string[]'],
+ },
+ ],
+ })
+ expectTypeOf(result1[0]).toEqualTypeOf>()
+ expectTypeOf(result1[1]).toEqualTypeOf>()
+ expectTypeOf(result1[2]).toEqualTypeOf<
+ UseQueryResult, boolean>
+ >()
+ expectTypeOf(result1[0].data).toEqualTypeOf()
+ expectTypeOf(result1[1].data).toEqualTypeOf()
+ expectTypeOf(result1[2].data).toEqualTypeOf | undefined>()
+ expectTypeOf(result1[2].error).toEqualTypeOf()
+
+ // TData (data prop) takes precedence over TQueryFnData (queryFnData prop)
+ const result2 = useQueries<
+ [
+ { queryFnData: string; data: string },
+ { queryFnData: string; data: number },
+ ]
+ >({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return a.toLowerCase()
+ },
+ },
+ {
+ queryKey: key2,
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return parseInt(a)
+ },
+ },
+ ],
+ })
+ expectTypeOf(result2[0]).toEqualTypeOf>()
+ expectTypeOf(result2[1]).toEqualTypeOf>()
+ expectTypeOf(result2[0].data).toEqualTypeOf()
+ expectTypeOf(result2[1].data).toEqualTypeOf()
+
+ // can pass only TData (data prop) although TQueryFnData will be left unknown
+ const result3 = useQueries<[{ data: string }, { data: number }]>({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return a as string
+ },
+ },
+ {
+ queryKey: key2,
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return a as number
+ },
+ },
+ ],
+ })
+ expectTypeOf(result3[0]).toEqualTypeOf>()
+ expectTypeOf(result3[1]).toEqualTypeOf>()
+ expectTypeOf(result3[0].data).toEqualTypeOf()
+ expectTypeOf(result3[1].data).toEqualTypeOf()
+
+ // types should be enforced
+ useQueries<
+ [
+ { queryFnData: string; data: string },
+ { queryFnData: string; data: number; error: boolean },
+ ]
+ >({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return a.toLowerCase()
+ },
+ placeholderData: 'string',
+ // @ts-expect-error (initialData: string)
+ initialData: 123,
+ },
+ {
+ queryKey: key2,
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return parseInt(a)
+ },
+ placeholderData: 'string',
+ // @ts-expect-error (initialData: string)
+ initialData: 123,
+ },
+ ],
+ })
+
+ // field names should be enforced
+ useQueries<[{ queryFnData: string }]>({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ // @ts-expect-error (invalidField)
+ someInvalidField: [],
+ },
+ ],
+ })
+ }
+ })
+
+ it('correctly returns types when passing through queryOptions', () => {
+ // @ts-expect-error (Page component is not rendered)
+ function Page() {
+ // data and results types are correct when using queryOptions
+ const result4 = useQueries({
+ queries: [
+ queryOptions({
+ queryKey: ['key1'],
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return a.toLowerCase()
+ },
+ }),
+ queryOptions({
+ queryKey: ['key2'],
+ queryFn: () => 'string',
+ select: (a) => {
+ expectTypeOf(a).toEqualTypeOf()
+ return parseInt(a)
+ },
+ }),
+ ],
+ })
+ expectTypeOf(result4[0]).toEqualTypeOf>()
+ expectTypeOf(result4[1]).toEqualTypeOf>()
+ expectTypeOf(result4[0].data).toEqualTypeOf()
+ expectTypeOf(result4[1].data).toEqualTypeOf()
+ }
+ })
+
+ it('handles array literal without type parameter to infer result type', () => {
+ const key1 = queryKey()
+ const key2 = queryKey()
+ const key3 = queryKey()
+ const key4 = queryKey()
+ const key5 = queryKey()
+
+ type BizError = { code: number }
+ const throwOnError = (_error: BizError) => true
+
+ // @ts-expect-error (Page component is not rendered)
+ function Page() {
+ // Array.map preserves TQueryFnData
+ const result1 = useQueries({
+ queries: Array(50).map((_, i) => ({
+ queryKey: ['key', i] as const,
+ queryFn: () => i + 10,
+ })),
+ })
+ expectTypeOf(result1).toEqualTypeOf<
+ Array>
+ >()
+ if (result1[0]) {
+ expectTypeOf(result1[0].data).toEqualTypeOf()
+ }
+
+ // Array.map preserves TError
+ const result1_err = useQueries({
+ queries: Array(50).map((_, i) => ({
+ queryKey: ['key', i] as const,
+ queryFn: () => i + 10,
+ throwOnError,
+ })),
+ })
+ expectTypeOf(result1_err).toEqualTypeOf<
+ Array>
+ >()
+ if (result1_err[0]) {
+ expectTypeOf(result1_err[0].data).toEqualTypeOf()
+ expectTypeOf(result1_err[0].error).toEqualTypeOf()
+ }
+
+ // Array.map preserves TData
+ const result2 = useQueries({
+ queries: Array(50).map((_, i) => ({
+ queryKey: ['key', i] as const,
+ queryFn: () => i + 10,
+ select: (data: number) => data.toString(),
+ })),
+ })
+ expectTypeOf(result2).toEqualTypeOf<
+ Array>
+ >()
+
+ const result2_err = useQueries({
+ queries: Array(50).map((_, i) => ({
+ queryKey: ['key', i] as const,
+ queryFn: () => i + 10,
+ select: (data: number) => data.toString(),
+ throwOnError,
+ })),
+ })
+ expectTypeOf(result2_err).toEqualTypeOf<
+ Array>
+ >()
+
+ const result3 = useQueries({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 1,
+ },
+ {
+ queryKey: key2,
+ queryFn: () => 'string',
+ },
+ {
+ queryKey: key3,
+ queryFn: () => ['string[]'],
+ select: () => 123,
+ },
+ {
+ queryKey: key5,
+ queryFn: () => 'string',
+ throwOnError,
+ },
+ ],
+ })
+ expectTypeOf(result3[0]).toEqualTypeOf>()
+ expectTypeOf(result3[1]).toEqualTypeOf>()
+ expectTypeOf(result3[2]).toEqualTypeOf>()
+ expectTypeOf(result3[0].data).toEqualTypeOf()
+ expectTypeOf(result3[1].data).toEqualTypeOf()
+ expectTypeOf(result3[3].data).toEqualTypeOf()
+ // select takes precedence over queryFn
+ expectTypeOf(result3[2].data).toEqualTypeOf()
+ // infer TError from throwOnError
+ expectTypeOf(result3[3].error).toEqualTypeOf()
+
+ // initialData/placeholderData are enforced
+ useQueries({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ placeholderData: 'string',
+ // @ts-expect-error (initialData: string)
+ initialData: 123,
+ },
+ {
+ queryKey: key2,
+ queryFn: () => 123,
+ // @ts-expect-error (placeholderData: number)
+ placeholderData: 'string',
+ initialData: 123,
+ },
+ ],
+ })
+
+ // select and throwOnError params are "indirectly" enforced
+ useQueries({
+ queries: [
+ // unfortunately TS will not suggest the type for you
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ },
+ // however you can add a type to the callback
+ {
+ queryKey: key2,
+ queryFn: () => 'string',
+ },
+ // the type you do pass is enforced
+ {
+ queryKey: key3,
+ queryFn: () => 'string',
+ },
+ {
+ queryKey: key4,
+ queryFn: () => 'string',
+ select: (a: string) => parseInt(a),
+ },
+ {
+ queryKey: key5,
+ queryFn: () => 'string',
+ throwOnError,
+ },
+ ],
+ })
+
+ // callbacks are also indirectly enforced with Array.map
+ useQueries({
+ queries: Array(50).map((_, i) => ({
+ queryKey: ['key', i] as const,
+ queryFn: () => i + 10,
+ select: (data: number) => data.toString(),
+ })),
+ })
+ useQueries({
+ queries: Array(50).map((_, i) => ({
+ queryKey: ['key', i] as const,
+ queryFn: () => i + 10,
+ select: (data: number) => data.toString(),
+ })),
+ })
+
+ // results inference works when all the handlers are defined
+ const result4 = useQueries({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ },
+ {
+ queryKey: key2,
+ queryFn: () => 'string',
+ },
+ {
+ queryKey: key4,
+ queryFn: () => 'string',
+ select: (a: string) => parseInt(a),
+ },
+ {
+ queryKey: key5,
+ queryFn: () => 'string',
+ select: (a: string) => parseInt(a),
+ throwOnError,
+ },
+ ],
+ })
+ expectTypeOf(result4[0]).toEqualTypeOf>()
+ expectTypeOf(result4[1]).toEqualTypeOf>()
+ expectTypeOf(result4[2]).toEqualTypeOf>()
+ expectTypeOf(result4[3]).toEqualTypeOf>()
+
+ // handles when queryFn returns a Promise
+ const result5 = useQueries({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => Promise.resolve('string'),
+ },
+ ],
+ })
+ expectTypeOf(result5[0]).toEqualTypeOf>()
+
+ // Array as const does not throw error
+ const result6 = useQueries({
+ queries: [
+ {
+ queryKey: ['key1'],
+ queryFn: () => 'string',
+ },
+ {
+ queryKey: ['key1'],
+ queryFn: () => 123,
+ },
+ {
+ queryKey: key5,
+ queryFn: () => 'string',
+ throwOnError,
+ },
+ ],
+ } as const)
+ expectTypeOf(result6[0]).toEqualTypeOf>()
+ expectTypeOf(result6[1]).toEqualTypeOf>()
+ expectTypeOf(result6[2]).toEqualTypeOf>()
+
+ // field names should be enforced - array literal
+ useQueries({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ // @ts-expect-error (invalidField)
+ someInvalidField: [],
+ },
+ ],
+ })
+
+ // field names should be enforced - Array.map() result
+ useQueries({
+ // @ts-expect-error (invalidField)
+ queries: Array(10).map(() => ({
+ someInvalidField: '',
+ })),
+ })
+
+ // field names should be enforced - array literal
+ useQueries({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () => 'string',
+ // @ts-expect-error (invalidField)
+ someInvalidField: [],
+ },
+ ],
+ })
+
+ // supports queryFn using fetch() to return Promise - Array.map() result
+ useQueries({
+ queries: Array(50).map((_, i) => ({
+ queryKey: ['key', i] as const,
+ queryFn: () =>
+ fetch('return Promise').then((resp) => resp.json()),
+ })),
+ })
+
+ // supports queryFn using fetch() to return Promise - array literal
+ useQueries({
+ queries: [
+ {
+ queryKey: key1,
+ queryFn: () =>
+ fetch('return Promise').then((resp) => resp.json()),
+ },
+ ],
+ })
+ }
+ })
+
+ it('handles strongly typed queryFn factories and useQueries wrappers', () => {
+ // QueryKey + queryFn factory
+ type QueryKeyA = ['queryA']
+ const getQueryKeyA = (): QueryKeyA => ['queryA']
+ type GetQueryFunctionA = () => QueryFunction
+ const getQueryFunctionA: GetQueryFunctionA = () => () => {
+ return Promise.resolve(1)
+ }
+ type SelectorA = (data: number) => [number, string]
+ const getSelectorA = (): SelectorA => (data) => [data, data.toString()]
+
+ type QueryKeyB = ['queryB', string]
+ const getQueryKeyB = (id: string): QueryKeyB => ['queryB', id]
+ type GetQueryFunctionB = () => QueryFunction
+ const getQueryFunctionB: GetQueryFunctionB = () => () => {
+ return Promise.resolve('1')
+ }
+ type SelectorB = (data: string) => [string, number]
+ const getSelectorB = (): SelectorB => (data) => [data, +data]
+
+ // Wrapper with strongly typed array-parameter
+ function useWrappedQueries<
+ TQueryFnData,
+ TError,
+ TData,
+ TQueryKey extends QueryKey,
+ >(queries: Array>) {
+ return useQueries({
+ queries: queries.map(
+ // no need to type the mapped query
+ (query) => {
+ const { queryFn: fn, queryKey: key } = query
+ expectTypeOf(fn).toEqualTypeOf<
+ | typeof skipToken
+ | QueryFunction
+ | undefined
+ >()
+ return {
+ queryKey: key,
+ queryFn:
+ fn && fn !== skipToken
+ ? (ctx: QueryFunctionContext) => {
+ // eslint-disable-next-line vitest/valid-expect
+ expectTypeOf