(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..52cb80a0be
--- /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('Preact 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('PreactQueryCacheProvider 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..0cef635d87
--- /dev/null
+++ b/packages/preact-query/src/__tests__/ssr-hydration.test.tsx
@@ -0,0 +1,266 @@
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import { renderToString } from 'preact-render-to-string'
+import { hydrate as preactHydrate, render, VNode } from 'preact'
+import {
+ QueryCache,
+ QueryClient,
+ QueryClientProvider,
+ dehydrate,
+ hydrate,
+ useQuery,
+} from '..'
+import { setIsServer } from './utils'
+import { act } from '@testing-library/preact'
+
+const PreactHydrate = (element: VNode, container: Element) => {
+ act(() => {
+ preactHydrate(element, container)
+ })
+ // To unmount in Preact, you render null into the same container
+ return () => {
+ act(() => {
+ render(null, container)
+ })
+ }
+}
+
+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', () => {
+ beforeAll(() => {
+ vi.useFakeTimers()
+ })
+
+ afterAll(() => {
+ 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 = PreactHydrate(
+
+
+ ,
+ 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 = PreactHydrate(
+
+
+ ,
+ 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 = PreactHydrate(
+
+
+ ,
+ 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..c75425e4af
--- /dev/null
+++ b/packages/preact-query/src/__tests__/suspense.test.tsx
@@ -0,0 +1,185 @@
+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'
+import { ComponentChildren } from 'preact'
+
+function renderWithSuspense(client: QueryClient, ui: ComponentChildren) {
+ 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