snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
autofix.ci / autofix (push) Failing after 4s
Release / Release (push) Has been skipped
GitHub Actions Security Analysis / Run zizmor (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:13:08 +05:00
commit 3a013ccdfe
3570 changed files with 490194 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import {
HydrationBoundary,
createQuery,
setQueryClientContext,
} from '../../src/index.js'
import type { DehydratedState } from '@tanstack/query-core'
type Props = {
queryClient: QueryClient
dehydratedState: DehydratedState
queryFn: () => Promise<string>
}
let { queryClient, dehydratedState, queryFn }: Props = $props()
setQueryClientContext(queryClient)
const query = createQuery(() => ({
queryKey: ['string'],
queryFn,
}))
</script>
<HydrationBoundary
state={dehydratedState}
options={undefined}
queryClient={undefined}
>
<div>data: {query.data ?? 'undefined'}</div>
</HydrationBoundary>

View File

@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/svelte'
import { QueryClient, dehydrate, noop } from '@tanstack/query-core'
import { sleep } from '@tanstack/query-test-utils'
import Base from './Base.svelte'
describe('HydrationBoundary', () => {
let queryClient: QueryClient
let stringifiedState: string
beforeEach(async () => {
vi.useFakeTimers()
queryClient = new QueryClient()
const dehydrateClient = new QueryClient()
void dehydrateClient
.query({
queryKey: ['string'],
queryFn: () => sleep(10).then(() => 'stringCached'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(10)
const dehydrated = dehydrate(dehydrateClient)
stringifiedState = JSON.stringify(dehydrated)
dehydrateClient.clear()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('should hydrate queries to the cache on context', async () => {
const dehydratedState = JSON.parse(stringifiedState)
const rendered = render(Base, {
props: {
queryClient,
dehydratedState,
queryFn: () => sleep(20).then(() => 'string'),
},
})
expect(rendered.getByText('data: stringCached')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(20)
expect(rendered.getByText('data: string')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { QueryClientProvider } from '../../src/index.js'
import Child from './Child.svelte'
import type { QueryClient } from '@tanstack/query-core'
type Props = {
queryClient: QueryClient
}
let { queryClient }: Props = $props()
</script>
<QueryClientProvider client={queryClient}>
<Child />
</QueryClientProvider>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { createQuery } from '../../src/index.js'
import { sleep } from '@tanstack/query-test-utils'
const query = createQuery(() => ({
queryKey: ['hello'],
queryFn: () => sleep(10).then(() => 'test'),
}))
</script>
<div>Data: {query.data ?? 'undefined'}</div>

View File

@@ -0,0 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/svelte'
import { QueryClient } from '@tanstack/query-core'
import Base from './Base.svelte'
describe('QueryClientProvider', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('should set a specific cache for all queries to use', async () => {
const queryCache = queryClient.getQueryCache()
const rendered = render(Base, {
props: {
queryClient,
},
})
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('Data: test')).toBeInTheDocument()
expect(queryCache.find({ queryKey: ['hello'] })?.state.data).toBe('test')
})
})

View File

@@ -0,0 +1,244 @@
import { flushSync } from 'svelte'
import { describe, expect, it } from 'vitest'
import { createRawRef } from '../src/containers.svelte.js'
import { withEffectRoot } from './utils.svelte.js'
describe('createRawRef', () => {
it('should create a reactive reference', () => {
const [ref, update] = createRawRef({ a: 1, b: 2 })
expect(ref).toEqual({ a: 1, b: 2 })
update({ a: 3, b: 4 })
expect(ref).toEqual({ a: 3, b: 4 })
ref.a = 5
expect(ref).toEqual({ a: 5, b: 4 })
})
it('should handle nested objects', () => {
const [ref, update] = createRawRef<{ a: any }>({ a: { b: { c: 1 } } })
expect(ref).toEqual({ a: { b: { c: 1 } } })
// update with same structure
update({ a: { b: { c: 2 } } })
expect(ref).toEqual({ a: { b: { c: 2 } } })
ref.a.b.c = 3
expect(ref).toEqual({ a: { b: { c: 3 } } })
// update with different structure should wipe out everything below the first level
update({ a: { b: 3 } })
expect(ref).toEqual({ a: { b: 3 } })
})
it('should remove properties when a new object is assigned', () => {
const [ref, update] = createRawRef<Record<string, number>>({
a: 1,
b: 2,
})
expect(ref).toEqual({ a: 1, b: 2 })
update({ a: 3 })
expect(ref).toEqual({ a: 3 })
})
it(
'should not break reactivity when removing keys',
withEffectRoot(() => {
const [ref, update] = createRawRef<Record<string, number>>({ a: 1, b: 2 })
const states: Array<number | undefined> = []
$effect(() => {
states.push(ref.b)
})
// these flushSync calls force the effect to run and push the value to the states array
flushSync()
update({ a: 3 }) // should remove b, and should rerun the effect
flushSync()
update({ a: 3, b: 4 }) // should add b back, and should rerun the effect
flushSync()
delete ref.b // should remove b, and should rerun the effect
flushSync()
delete ref.a // should remove a, and should _not_ rerun the effect
expect(states).toEqual([2, undefined, 4, undefined])
}),
)
it(
'should correctly trap calls to `in`',
withEffectRoot(() => {
const [ref, update] = createRawRef<Record<string, number>>({
a: 1,
b: 2,
})
expect('b' in ref).toBe(true)
delete ref.b
expect('b' in ref).toBe(false)
update({})
expect('a' in ref).toBe(false)
update({ a: 1, b: 2 })
expect('b' in ref).toBe(true)
expect('a' in ref).toBe(true)
}),
)
it('should correctly trap calls to `ownKeys`', () => {
const [ref, update] = createRawRef<Record<string, number>>({
a: 1,
b: 2,
})
expect(Object.keys(ref)).toEqual(['a', 'b'])
delete ref.b
expect(Reflect.ownKeys(ref)).toEqual(['a'])
update({})
expect(Object.keys(ref)).toEqual([])
update({ a: 1, b: 2 })
expect(Object.keys(ref)).toEqual(['a', 'b'])
})
it('should correctly trap calls to `getOwnPropertyDescriptor`', () => {
const [ref, update] = createRawRef<Record<string, number>>({
a: 1,
b: 2,
})
expect(Reflect.getOwnPropertyDescriptor(ref, 'b')).toEqual({
configurable: true,
enumerable: true,
get: expect.any(Function),
set: expect.any(Function),
})
delete ref.b
expect(Reflect.getOwnPropertyDescriptor(ref, 'b')).toEqual(undefined)
update({})
expect(Reflect.getOwnPropertyDescriptor(ref, 'a')).toEqual(undefined)
update({ a: 1, b: 2 })
expect(Reflect.getOwnPropertyDescriptor(ref, 'a')).toEqual({
configurable: true,
enumerable: true,
get: expect.any(Function),
set: expect.any(Function),
})
expect(Reflect.getOwnPropertyDescriptor(ref, 'b')).toEqual({
configurable: true,
enumerable: true,
get: expect.any(Function),
set: expect.any(Function),
})
})
it('should lazily access values when using `update`', () => {
let aAccessed = false
let bAccessed = false
const [ref, update] = createRawRef({
get a() {
aAccessed = true
return 1
},
get b() {
bAccessed = true
return 2
},
})
expect(aAccessed).toBe(false)
expect(bAccessed).toBe(false)
expect(ref.a).toBe(1)
expect(aAccessed).toBe(true)
expect(bAccessed).toBe(false)
aAccessed = false
bAccessed = false
update({
get a() {
aAccessed = true
return 2
},
get b() {
bAccessed = true
return 3
},
})
expect(aAccessed).toBe(false)
expect(bAccessed).toBe(false)
expect(ref.a).toBe(2)
expect(aAccessed).toBe(true)
expect(bAccessed).toBe(false)
})
it('should handle arrays', () => {
const [ref, update] = createRawRef([1, 2, 3])
expect(ref).toEqual([1, 2, 3])
ref[0] = 4
expect(ref).toEqual([4, 2, 3])
update([5, 6])
expect(ref).toEqual([5, 6])
update([7, 8, 9])
expect(ref).toEqual([7, 8, 9])
})
it('should return `false` when deleting a property that does not exist', () => {
const [ref] = createRawRef<Record<string, number>>({ a: 1, b: 2 })
expect(Reflect.deleteProperty(ref, 'c')).toBe(false)
expect(ref).toEqual({ a: 1, b: 2 })
})
it('should handle shrinking an array by more than one entry at once', () => {
// Regression for #10341: createQueries crashed with
// `TypeError: can't delete property 'N': proxy deleteProperty handler returned false`
// when two or more items were removed from the reactive array in a single update.
const [ref, update] = createRawRef([1, 2, 3, 4, 5])
expect(ref).toEqual([1, 2, 3, 4, 5])
update([1, 2])
expect(ref).toEqual([1, 2])
update([1, 2, 3, 4])
expect(ref).toEqual([1, 2, 3, 4])
update([])
expect(ref).toEqual([])
})
it('should behave like a regular object when not using `update`', () => {
const [ref] = createRawRef<Record<string, unknown>>({ a: 1, b: 2 })
expect(ref).toEqual({ a: 1, b: 2 })
ref.a = 3
expect(ref).toEqual({ a: 3, b: 2 })
ref.b = 4
expect(ref).toEqual({ a: 3, b: 4 })
ref.c = 5
expect(ref).toEqual({ a: 3, b: 4, c: 5 })
ref.fn = () => 6
expect(ref).toEqual({ a: 3, b: 4, c: 5, fn: expect.any(Function) })
expect((ref.fn as () => number)()).toBe(6)
})
})

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import { getQueryClientContext } from '../../src/index.js'
getQueryClientContext()
</script>

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/svelte'
import { getIsRestoringContext } from '../../src/index.js'
import Base from './Base.svelte'
describe('getQueryClientContext', () => {
it('should throw when called without a client in context', () => {
expect(() => render(Base)).toThrow(
'No QueryClient was found in Svelte context. Did you forget to wrap your component with QueryClientProvider?',
)
})
})
describe('getIsRestoringContext', () => {
it('should not throw when called outside of a component', () => {
expect(() => getIsRestoringContext()).not.toThrow()
})
})

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { untrack } from 'svelte'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { createInfiniteQuery } from '../../src/index.js'
import { setQueryClientContext } from '../../src/context.js'
import type { QueryClient, QueryObserverResult } from '@tanstack/query-core'
type Props = {
queryClient: QueryClient
states: { value: Array<QueryObserverResult> }
}
let { queryClient, states }: Props = $props()
setQueryClientContext(queryClient)
const query = createInfiniteQuery(() => ({
queryKey: queryKey(),
queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
getNextPageParam: (lastPage) => lastPage + 1,
initialPageParam: 0,
}))
$effect(() => {
// @ts-expect-error
states.value = [...untrack(() => states.value), $state.snapshot(query)]
})
</script>
<div>Status: {query.status}</div>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { QueryClient } from '@tanstack/query-core'
import { createInfiniteQuery } from '../../src/index.js'
import { setQueryClientContext } from '../../src/context.js'
import { sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
}
let { queryClient }: Props = $props()
const queryKey = ['test']
let firstPage = $state(0)
setQueryClientContext(queryClient)
const query = createInfiniteQuery(() => ({
queryKey: queryKey,
queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
getNextPageParam: (lastPage) => lastPage + 1,
initialPageParam: firstPage,
}))
</script>
<button
onclick={() => {
queryClient.setQueryData(queryKey, {
pages: [7, 8],
pageParams: [7, 8],
})
firstPage = 7
}}
>
setPages
</button>
<div>Data: {JSON.stringify(query.data)}</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { untrack } from 'svelte'
import { createInfiniteQuery } from '../../src/index.js'
import { setQueryClientContext } from '../../src/context.js'
import type { QueryClient, QueryObserverResult } from '@tanstack/query-core'
import { queryKey, sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
states: { value: Array<QueryObserverResult> }
}
let { queryClient, states }: Props = $props()
setQueryClientContext(queryClient)
const query = createInfiniteQuery(() => ({
queryKey: queryKey(),
queryFn: ({ pageParam }) => sleep(10).then(() => pageParam),
getNextPageParam: (lastPage) => lastPage + 1,
initialPageParam: 0,
initialData: { pages: [0], pageParams: [0] },
}))
$effect(() => {
// @ts-expect-error
states.value = [...untrack(() => states.value), $state.snapshot(query)]
})
</script>
<div>Status: {query.status}</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { untrack } from 'svelte'
import { createInfiniteQuery } from '../../src/index.js'
import { setQueryClientContext } from '../../src/context.js'
import type { QueryClient, QueryObserverResult } from '@tanstack/query-core'
import { queryKey, sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
states: { value: Array<QueryObserverResult> }
}
let { queryClient, states }: Props = $props()
setQueryClientContext(queryClient)
const query = createInfiniteQuery(() => ({
queryKey: queryKey(),
queryFn: () => sleep(10).then(() => ({ count: 1 })),
select: (data) => ({
pages: data.pages.map((x) => `count: ${x.count}`),
pageParams: data.pageParams,
}),
getNextPageParam: () => undefined,
initialPageParam: 0,
}))
$effect(() => {
// @ts-expect-error
states.value = [...untrack(() => states.value), $state.snapshot(query)]
})
</script>
<div>{query.data?.pages.join(',')}</div>

View File

@@ -0,0 +1,174 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render } from '@testing-library/svelte'
import { QueryClient } from '@tanstack/query-core'
import { ref } from '../utils.svelte.js'
import Base from './Base.svelte'
import Select from './Select.svelte'
import ChangeClient from './ChangeClient.svelte'
import InitialData from './InitialData.svelte'
import type { QueryObserverResult } from '@tanstack/query-core'
describe('createInfiniteQuery', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('should return the correct states for a successful query', async () => {
let states = ref<Array<QueryObserverResult>>([])
const rendered = render(Base, {
props: {
queryClient,
states,
},
})
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('Status: success')).toBeInTheDocument()
expect(states.value).toHaveLength(2)
expect(states.value[0]).toEqual({
data: undefined,
dataUpdatedAt: 0,
error: null,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
fetchNextPage: expect.any(Function),
fetchPreviousPage: expect.any(Function),
hasNextPage: false,
hasPreviousPage: false,
isError: false,
isFetched: false,
isFetchedAfterMount: false,
isFetching: true,
isPaused: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isFetchPreviousPageError: false,
isFetchingPreviousPage: false,
isLoading: true,
isPending: true,
isInitialLoading: true,
isLoadingError: false,
isPlaceholderData: false,
isRefetchError: false,
isRefetching: false,
isStale: true,
isSuccess: false,
isEnabled: true,
refetch: expect.any(Function),
status: 'pending',
fetchStatus: 'fetching',
})
expect(states.value[1]).toEqual({
data: { pages: [0], pageParams: [0] },
dataUpdatedAt: expect.any(Number),
error: null,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
fetchNextPage: expect.any(Function),
fetchPreviousPage: expect.any(Function),
hasNextPage: true,
hasPreviousPage: false,
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isPaused: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isFetchPreviousPageError: false,
isFetchingPreviousPage: false,
isLoading: false,
isPending: false,
isInitialLoading: false,
isLoadingError: false,
isPlaceholderData: false,
isRefetchError: false,
isRefetching: false,
isStale: true,
isSuccess: true,
isEnabled: true,
refetch: expect.any(Function),
status: 'success',
fetchStatus: 'idle',
})
})
it('should render with initialData and no pending state', async () => {
let states = ref<Array<QueryObserverResult>>([])
const rendered = render(InitialData, {
props: {
queryClient,
states,
},
})
expect(rendered.getByText('Status: success')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(10)
expect(states.value.every((state) => state.status === 'success')).toBe(true)
expect(states.value[0]?.data).toEqual({ pages: [0], pageParams: [0] })
})
it('should be able to select a part of the data', async () => {
let states = ref<Array<QueryObserverResult>>([])
const rendered = render(Select, {
props: {
queryClient,
states,
},
})
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('count: 1')).toBeInTheDocument()
expect(states.value).toHaveLength(2)
expect(states.value[0]).toMatchObject({
data: undefined,
isSuccess: false,
})
expect(states.value[1]).toMatchObject({
data: { pages: ['count: 1'] },
isSuccess: true,
})
})
it('should be able to set new pages with the query client', async () => {
const rendered = render(ChangeClient, {
props: {
queryClient,
},
})
await vi.advanceTimersByTimeAsync(10)
expect(
rendered.getByText('Data: {"pages":[0],"pageParams":[0]}'),
).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /setPages/i }))
await vi.advanceTimersByTimeAsync(10)
expect(
rendered.getByText('Data: {"pages":[7,8],"pageParams":[7,8]}'),
).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,227 @@
import { describe, expectTypeOf, it } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import { queryKey } from '@tanstack/query-test-utils'
import { createInfiniteQuery, infiniteQueryOptions } from '../../src/index.js'
import type { InfiniteData } from '@tanstack/query-core'
describe('createInfiniteQuery', () => {
describe('pageParam', () => {
it('initialPageParam should define type of param passed to queryFunctionContext', () => {
createInfiniteQuery(() => ({
queryKey: queryKey(),
queryFn: ({ pageParam }) => {
expectTypeOf(pageParam).toEqualTypeOf<number>()
},
initialPageParam: 1,
getNextPageParam: () => undefined,
}))
})
it('direction should be passed to queryFn of createInfiniteQuery', () => {
createInfiniteQuery(() => ({
queryKey: queryKey(),
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()
// grandfathered direct test
// eslint-disable-next-line no-restricted-syntax
queryClient.fetchInfiniteQuery({
queryKey: queryKey(),
queryFn: ({ pageParam }) => {
expectTypeOf(pageParam).toEqualTypeOf<number>()
},
initialPageParam: 1,
})
})
it('initialPageParam should define type of param passed to queryFunctionContext for prefetchInfiniteQuery', () => {
const queryClient = new QueryClient()
// grandfathered direct test
// eslint-disable-next-line no-restricted-syntax
queryClient.prefetchInfiniteQuery({
queryKey: queryKey(),
queryFn: ({ pageParam }) => {
expectTypeOf(pageParam).toEqualTypeOf<number>()
},
initialPageParam: 1,
})
})
})
describe('initialData', () => {
it('TData should always be defined when initialData is provided', () => {
const { data } = createInfiniteQuery(() => ({
queryKey: queryKey(),
queryFn: ({ pageParam }) => {
return pageParam * 5
},
initialPageParam: 1,
getNextPageParam: () => undefined,
initialData: { pages: [5], pageParams: [1] },
}))
// TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now
expectTypeOf(data).toEqualTypeOf<InfiniteData<number, unknown>>()
})
it('TData should always be defined when initialData is provided through infiniteQueryOptions', () => {
const options = infiniteQueryOptions({
queryKey: queryKey(),
queryFn: ({ pageParam }: { pageParam: number }) => {
return pageParam * 5
},
initialPageParam: 1,
getNextPageParam: () => undefined,
initialData: { pages: [5], pageParams: [1] },
})
const { data } = createInfiniteQuery(() => options)
// known issue: type of pageParams is unknown when returned from createInfiniteQuery
expectTypeOf(data).toEqualTypeOf<InfiniteData<number, unknown>>()
})
it('TData should have undefined in the union when initialData is NOT provided', () => {
const { data } = createInfiniteQuery(() => ({
queryKey: queryKey(),
queryFn: ({ pageParam }) => {
return pageParam * 5
},
initialPageParam: 1,
getNextPageParam: () => undefined,
}))
expectTypeOf(data).toEqualTypeOf<
InfiniteData<number, unknown> | undefined
>()
})
})
describe('select', () => {
it('should still return paginated data if no select result', () => {
const infiniteQuery = createInfiniteQuery(() => ({
queryKey: queryKey(),
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<number, unknown> | undefined
>()
})
it('should be able to transform data to arbitrary result', () => {
const infiniteQuery = createInfiniteQuery(() => ({
queryKey: queryKey(),
queryFn: ({ pageParam }) => {
return pageParam * 5
},
initialPageParam: 1,
getNextPageParam: () => undefined,
select: (data) => {
expectTypeOf(data).toEqualTypeOf<InfiniteData<number, number>>()
return 'selected' as const
},
}))
expectTypeOf(infiniteQuery.data).toEqualTypeOf<'selected' | undefined>()
})
})
describe('getNextPageParam / getPreviousPageParam', () => {
it('should get typed params', () => {
const infiniteQuery = createInfiniteQuery(() => ({
queryKey: queryKey(),
queryFn: ({ pageParam }) => {
return String(pageParam)
},
initialPageParam: 1,
getNextPageParam: (
lastPage,
allPages,
lastPageParam,
allPageParams,
) => {
expectTypeOf(lastPage).toEqualTypeOf<string>()
expectTypeOf(allPages).toEqualTypeOf<Array<string>>()
expectTypeOf(lastPageParam).toEqualTypeOf<number>()
expectTypeOf(allPageParams).toEqualTypeOf<Array<number>>()
return undefined
},
getPreviousPageParam: (
firstPage,
allPages,
firstPageParam,
allPageParams,
) => {
expectTypeOf(firstPage).toEqualTypeOf<string>()
expectTypeOf(allPages).toEqualTypeOf<Array<string>>()
expectTypeOf(firstPageParam).toEqualTypeOf<number>()
expectTypeOf(allPageParams).toEqualTypeOf<Array<number>>()
return undefined
},
}))
// TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now
expectTypeOf(infiniteQuery.data).toEqualTypeOf<
InfiniteData<string, unknown> | undefined
>()
})
})
describe('error booleans', () => {
it('should not be permanently `false`', () => {
const {
isFetchNextPageError,
isFetchPreviousPageError,
isLoadingError,
isRefetchError,
} = createInfiniteQuery(() => ({
queryKey: queryKey(),
queryFn: ({ pageParam }) => {
return pageParam * 5
},
initialPageParam: 1,
getNextPageParam: () => undefined,
}))
expectTypeOf(isFetchNextPageError).toEqualTypeOf<boolean>()
expectTypeOf(isFetchPreviousPageError).toEqualTypeOf<boolean>()
expectTypeOf(isLoadingError).toEqualTypeOf<boolean>()
expectTypeOf(isRefetchError).toEqualTypeOf<boolean>()
})
})
describe('queryClient', () => {
it('should accept queryClient as second argument', () => {
const queryClient = new QueryClient()
const infiniteQuery = createInfiniteQuery(
() => ({
queryKey: queryKey(),
queryFn: ({ pageParam }) => {
return pageParam * 5
},
initialPageParam: 1,
getNextPageParam: () => undefined,
}),
() => queryClient,
)
// TODO: Order of generics prevents pageParams to be typed correctly. Using `unknown` for now
expectTypeOf(infiniteQuery.data).toEqualTypeOf<
InfiniteData<number, unknown> | undefined
>()
})
})
})

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { createMutation, setQueryClientContext } from '../../src/index.js'
import { sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
onSuccessPerCall: (...args: Array<unknown>) => void
}
const { queryClient, onSuccessPerCall }: Props = $props()
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
}))
</script>
<button
onclick={() => mutation.mutate('Todo 1', { onSuccess: onSuccessPerCall })}
>
mutate1
</button>
<button
onclick={() => mutation.mutate('Todo 2', { onSuccess: onSuccessPerCall })}
>
mutate2
</button>
<div>data: {mutation.data ?? 'null'}, status: {mutation.status}</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { createMutation, setQueryClientContext } from '../../src/index.js'
type Props = {
queryClient: QueryClient
mutationFn: (value: { count: number }) => Promise<{ count: number }>
}
let { queryClient, mutationFn }: Props = $props()
let count = $state(0)
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({ mutationFn }))
</script>
<button onclick={() => mutation.mutate({ count: ++count })}>Mutate</button>
<div>Data: {mutation.data?.count ?? 'undefined'}</div>
<div>Status: {mutation.status}</div>
<div>Failure Count: {mutation.failureCount}</div>
<div>Failure Reason: {mutation.failureReason ?? 'undefined'}</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { QueryClient, QueryKey } from '@tanstack/query-core'
import { createMutation, setQueryClientContext } from '../../src/index.js'
import { sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
queryKey: QueryKey
}
const { queryClient, queryKey }: Props = $props()
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({
mutationFn: () => sleep(10).then(() => 'mutated'),
onSuccess: (_data, _variables, _onMutateResult, context) => {
context.client.invalidateQueries({ queryKey })
},
}))
</script>
<button onclick={() => mutation.mutate()}>Mutate</button>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { QueryClient, QueryKey } from '@tanstack/query-core'
import { createMutation, setQueryClientContext } from '../../src/index.js'
import { sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
queryKey: QueryKey
}
const { queryClient, queryKey }: Props = $props()
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({
mutationFn: (_text: string, context) =>
sleep(10).then(() => context.client.getQueryData(queryKey)),
}))
</script>
<button onclick={() => mutation.mutate('todo')}>Mutate</button>
<div>data: {mutation.data}</div>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { createMutation, setQueryClientContext } from '../../src/index.js'
import { sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
queryKey: Array<string>
shouldSucceed: boolean
}
const { queryClient, queryKey, shouldSucceed }: Props = $props()
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({
mutationFn: (newTodo: string) =>
shouldSucceed
? sleep(10).then(() => newTodo)
: sleep(10).then(() => Promise.reject(new Error('Some error'))),
onMutate: async (newTodo: string) => {
await queryClient.cancelQueries({ queryKey })
const previousTodos = queryClient.getQueryData<Array<string>>(queryKey)
queryClient.setQueryData<Array<string>>(queryKey, (old) => [
...(old ?? []),
newTodo,
])
return { previousTodos }
},
onError: (_err, _newTodo, onMutateResult) => {
queryClient.setQueryData(queryKey, onMutateResult?.previousTodos)
},
}))
</script>
<button onclick={() => mutation.mutate('Todo 2')}>add</button>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { createMutation, setQueryClientContext } from '../../src/index.js'
import { sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
strategy: 'all' | 'allSettled'
failOn?: string
}
const { queryClient, strategy, failOn }: Props = $props()
let result = $state('idle')
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({
mutationFn: (file: string) =>
sleep(10).then(() => {
if (file === failOn) {
throw new Error('upload failed')
}
return `uploaded: ${file}`
}),
retry: false,
}))
async function uploadAll() {
if (strategy === 'all') {
try {
const results = await Promise.all([
mutation.mutateAsync('file1'),
mutation.mutateAsync('file2'),
mutation.mutateAsync('file3'),
])
result = results.join(', ')
} catch (error) {
result = `error: ${(error as Error).message}`
}
} else {
const results = await Promise.allSettled([
mutation.mutateAsync('file1'),
mutation.mutateAsync('file2'),
mutation.mutateAsync('file3'),
])
result = results
.map((r) =>
r.status === 'fulfilled' ? r.value : `error: ${r.reason.message}`,
)
.join(', ')
}
}
</script>
<button onclick={uploadAll}>upload all</button>
<div>result: {result}</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { createMutation, setQueryClientContext } from '../../src/index.js'
import { sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
perCallOnSuccess: (...args: Array<unknown>) => void
}
const { queryClient, perCallOnSuccess }: Props = $props()
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
}))
</script>
<button
onclick={() => mutation.mutate('todo', { onSuccess: perCallOnSuccess })}
>
Mutate
</button>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { createMutation, setQueryClientContext } from '../../src/index.js'
import { sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
}
let { queryClient }: Props = $props()
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({
mutationFn: () =>
sleep(10).then(() => Promise.reject(new Error('Expected mock error'))),
}))
</script>
<button onclick={() => mutation.reset()}>Reset</button>
<button onclick={() => mutation.mutate()}>Mutate</button>
<div>Error: {mutation.error?.message ?? 'undefined'}</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { createMutation, setQueryClientContext } from '../../src/index.js'
import { sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
onSuccessMock: (data: number) => void
onSettledMock: (data: number | undefined) => void
}
const { queryClient, onSettledMock, onSuccessMock }: Props = $props()
let count = $state(0)
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({
mutationFn: (vars: { count: number }) => sleep(10).then(() => vars.count),
onSuccess: (data) => {
onSuccessMock(data)
},
onSettled: (data) => {
onSettledMock(data)
},
}))
</script>
<button onclick={() => mutation.mutate({ count: ++count })}>Mutate</button>
<div>Count: {count}</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { MutationKey, QueryClient } from '@tanstack/query-core'
import { createMutation, setQueryClientContext } from '../../src/index.js'
import { sleep } from '@tanstack/query-test-utils'
type Props = {
queryClient: QueryClient
mutationKey?: MutationKey
onSuccessMock: (...args: Array<unknown>) => void
}
const { queryClient, mutationKey, onSuccessMock }: Props = $props()
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({
mutationKey,
mutationFn: (text: string) => sleep(10).then(() => text.toUpperCase()),
onMutate: (text: string) => ({ startedWith: text }),
onSuccess: onSuccessMock,
}))
</script>
<button onclick={() => mutation.mutate('todo')}>Mutate</button>

View File

@@ -0,0 +1,706 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushSync } from 'svelte'
import { fireEvent, render } from '@testing-library/svelte'
import { QueryClient, noop } from '@tanstack/query-core'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { createMutation } from '../../src/index.js'
import { withEffectRoot } from '../utils.svelte.js'
import Reset from './Reset.svelte'
import Success from './Success.svelte'
import Failure from './Failure.svelte'
import OptimisticUpdate from './OptimisticUpdate.svelte'
import SuccessContext from './SuccessContext.svelte'
import InvalidateFromContext from './InvalidateFromContext.svelte'
import PerCallSuccess from './PerCallSuccess.svelte'
import MutationFnContext from './MutationFnContext.svelte'
import ParallelMutateAsync from './ParallelMutateAsync.svelte'
import ConcurrentMutate from './ConcurrentMutate.svelte'
describe('createMutation', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('should be able to reset `error`', async () => {
const rendered = render(Reset, {
props: { queryClient },
})
expect(rendered.queryByText('Error: undefined')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('Error: Expected mock error')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /Reset/i }))
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('Error: undefined')).toBeInTheDocument()
})
it(
'should call mutate callbacks when createMutation has no callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
}),
() => queryClient,
)
mutation.mutate('todo', {
onSuccess: () => callbacks.push('mutate.onSuccess'),
onSettled: () => callbacks.push('mutate.onSettled'),
})
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual(['mutate.onSuccess', 'mutate.onSettled'])
}),
)
it(
'should call mutate error callbacks when createMutation has no callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (_text: string) =>
sleep(10).then(() => Promise.reject(new Error('oops'))),
}),
() => queryClient,
)
mutation.mutate('todo', {
onError: () => callbacks.push('mutate.onError'),
onSettled: () => callbacks.push('mutate.onSettled'),
})
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual(['mutate.onError', 'mutate.onSettled'])
}),
)
it(
'should call only mutate onSuccess when createMutation has no callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
}),
() => queryClient,
)
mutation.mutate('todo', {
onSuccess: () => callbacks.push('mutate.onSuccess'),
})
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual(['mutate.onSuccess'])
}),
)
it(
'should call only mutate onError when createMutation has no callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (_text: string) =>
sleep(10).then(() => Promise.reject(new Error('oops'))),
}),
() => queryClient,
)
mutation.mutate('todo', {
onError: () => callbacks.push('mutate.onError'),
})
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual(['mutate.onError'])
}),
)
it(
'should call only mutate onSettled when createMutation has no callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
}),
() => queryClient,
)
mutation.mutate('todo', {
onSettled: () => callbacks.push('mutate.onSettled'),
})
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual(['mutate.onSettled'])
}),
)
it(
'should call mutateAsync callbacks when createMutation has no callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
}),
() => queryClient,
)
mutation.mutateAsync('todo', {
onSuccess: () => callbacks.push('mutateAsync.onSuccess'),
onSettled: () => callbacks.push('mutateAsync.onSettled'),
})
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual([
'mutateAsync.onSuccess',
'mutateAsync.onSettled',
])
}),
)
it(
'should call mutateAsync error callbacks when createMutation has no callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (_text: string) =>
sleep(10).then(() => Promise.reject(new Error('oops'))),
}),
() => queryClient,
)
mutation
.mutateAsync('todo', {
onError: () => callbacks.push('mutateAsync.onError'),
onSettled: () => callbacks.push('mutateAsync.onSettled'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual([
'mutateAsync.onError',
'mutateAsync.onSettled',
])
}),
)
it(
'should call only mutateAsync onSuccess when createMutation has no callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
}),
() => queryClient,
)
mutation.mutateAsync('todo', {
onSuccess: () => callbacks.push('mutateAsync.onSuccess'),
})
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual(['mutateAsync.onSuccess'])
}),
)
it(
'should call only mutateAsync onError when createMutation has no callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (_text: string) =>
sleep(10).then(() => Promise.reject(new Error('oops'))),
}),
() => queryClient,
)
mutation
.mutateAsync('todo', {
onError: () => callbacks.push('mutateAsync.onError'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual(['mutateAsync.onError'])
}),
)
it(
'should call only mutateAsync onSettled when createMutation has no callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
}),
() => queryClient,
)
mutation.mutateAsync('todo', {
onSettled: () => callbacks.push('mutateAsync.onSettled'),
})
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual(['mutateAsync.onSettled'])
}),
)
it('should be able to call `onSuccess` and `onSettled` after each successful mutate', async () => {
const onSuccessMock = vi.fn()
const onSettledMock = vi.fn()
const rendered = render(Success, {
props: {
queryClient,
onSuccessMock,
onSettledMock,
},
})
expect(rendered.queryByText('Count: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
await vi.advanceTimersByTimeAsync(11)
expect(rendered.queryByText('Count: 3')).toBeInTheDocument()
expect(onSuccessMock).toHaveBeenCalledTimes(3)
expect(onSuccessMock).toHaveBeenNthCalledWith(1, 1)
expect(onSuccessMock).toHaveBeenNthCalledWith(2, 2)
expect(onSuccessMock).toHaveBeenNthCalledWith(3, 3)
expect(onSettledMock).toHaveBeenCalledTimes(3)
expect(onSettledMock).toHaveBeenNthCalledWith(1, 1)
expect(onSettledMock).toHaveBeenNthCalledWith(2, 2)
expect(onSettledMock).toHaveBeenNthCalledWith(3, 3)
})
it('should set correct values for `failureReason` and `failureCount` on multiple mutate calls', async () => {
type Value = { count: number }
const mutationFn = vi.fn<(value: Value) => Promise<Value>>()
mutationFn.mockImplementationOnce(() =>
sleep(20).then(() => Promise.reject(`Expected mock error`)),
)
mutationFn.mockImplementation((value) => sleep(10).then(() => value))
const rendered = render(Failure, {
props: {
queryClient,
mutationFn,
},
})
expect(rendered.queryByText('Data: undefined')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
expect(rendered.getByText('Data: undefined')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(21)
expect(rendered.getByText('Status: error')).toBeInTheDocument()
expect(rendered.getByText('Failure Count: 1')).toBeInTheDocument()
expect(
rendered.getByText('Failure Reason: Expected mock error'),
).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('Status: pending')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('Status: success')).toBeInTheDocument()
expect(rendered.getByText('Data: 2')).toBeInTheDocument()
expect(rendered.getByText('Failure Count: 0')).toBeInTheDocument()
expect(rendered.getByText('Failure Reason: undefined')).toBeInTheDocument()
})
it(
'should be able to call `onSuccess` callback after successful mutateAsync',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
onSuccess: () => callbacks.push('useMutation.onSuccess'),
}),
() => queryClient,
)
mutation.mutateAsync('todo', {
onSuccess: () => callbacks.push('mutateAsync.onSuccess'),
})
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual([
'useMutation.onSuccess',
'mutateAsync.onSuccess',
])
}),
)
it(
'should be able to call `onError` callback after failed mutateAsync',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (_text: string) =>
sleep(10).then(() => Promise.reject(new Error('oops'))),
onError: () => callbacks.push('useMutation.onError'),
}),
() => queryClient,
)
mutation
.mutateAsync('todo', {
onError: () => callbacks.push('mutateAsync.onError'),
})
.catch(noop)
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual(['useMutation.onError', 'mutateAsync.onError'])
}),
)
it(
'should be able to call `onSettled` callback after mutateAsync',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
onSettled: () => callbacks.push('useMutation.onSettled'),
}),
() => queryClient,
)
mutation.mutateAsync('todo', {
onSettled: () => callbacks.push('mutateAsync.onSettled'),
})
await vi.advanceTimersByTimeAsync(10)
expect(callbacks).toEqual([
'useMutation.onSettled',
'mutateAsync.onSettled',
])
}),
)
it(
'should be able to override the useMutation success callbacks',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (text: string) => sleep(10).then(() => text),
onSuccess: () =>
sleep(10).then(() => callbacks.push('useMutation.onSuccess')),
onSettled: () =>
sleep(10).then(() => callbacks.push('useMutation.onSettled')),
}),
() => queryClient,
)
mutation
.mutateAsync('todo', {
onSuccess: () => callbacks.push('mutateAsync.onSuccess'),
onSettled: () => callbacks.push('mutateAsync.onSettled'),
})
.then((result) => callbacks.push(`mutateAsync.result:${result}`))
await vi.advanceTimersByTimeAsync(30)
expect(callbacks).toEqual([
'useMutation.onSuccess',
'useMutation.onSettled',
'mutateAsync.onSuccess',
'mutateAsync.onSettled',
'mutateAsync.result:todo',
])
}),
)
it(
'should be able to override the error callbacks when using mutateAsync',
withEffectRoot(async () => {
const callbacks: Array<string> = []
const mutation = createMutation(
() => ({
mutationFn: (_text: string) =>
sleep(10).then(() => Promise.reject(new Error('oops'))),
onError: () =>
sleep(10).then(() => callbacks.push('useMutation.onError')),
onSettled: () =>
sleep(10).then(() => callbacks.push('useMutation.onSettled')),
}),
() => queryClient,
)
mutation
.mutateAsync('todo', {
onError: () => callbacks.push('mutateAsync.onError'),
onSettled: () => callbacks.push('mutateAsync.onSettled'),
})
.catch((error) =>
callbacks.push(`mutateAsync.error:${(error as Error).message}`),
)
await vi.advanceTimersByTimeAsync(30)
expect(callbacks).toEqual([
'useMutation.onError',
'useMutation.onSettled',
'mutateAsync.onError',
'mutateAsync.onSettled',
'mutateAsync.error:oops',
])
}),
)
it(
'should recreate observer when queryClient changes',
withEffectRoot(async () => {
const queryClient1 = new QueryClient()
const queryClient2 = new QueryClient()
let activeClient = $state(queryClient1)
const mutation = createMutation(
() => ({
mutationFn: (params: string) => sleep(10).then(() => params),
}),
() => activeClient,
)
mutation.mutate('first')
await vi.advanceTimersByTimeAsync(11)
expect(mutation.status).toBe('success')
expect(mutation.data).toBe('first')
activeClient = queryClient2
flushSync()
expect(mutation.status).toBe('idle')
expect(mutation.data).toBeUndefined()
}),
)
it('should update the cache in onMutate and roll back via onMutateResult in onError', async () => {
const key = queryKey()
queryClient.setQueryData<Array<string>>(key, ['Todo 1'])
const rendered = render(OptimisticUpdate, {
props: { queryClient, queryKey: key, shouldSucceed: false },
})
fireEvent.click(rendered.getByRole('button', { name: /add/i }))
// The optimistic value lands after onMutate's first await, so flush
// microtasks before asserting.
await vi.advanceTimersByTimeAsync(0)
expect(queryClient.getQueryData(key)).toEqual(['Todo 1', 'Todo 2'])
await vi.advanceTimersByTimeAsync(11)
expect(queryClient.getQueryData(key)).toEqual(['Todo 1'])
})
it('should keep the optimistic update in place when the mutation succeeds', async () => {
const key = queryKey()
queryClient.setQueryData<Array<string>>(key, ['Todo 1'])
const rendered = render(OptimisticUpdate, {
props: { queryClient, queryKey: key, shouldSucceed: true },
})
fireEvent.click(rendered.getByRole('button', { name: /add/i }))
await vi.advanceTimersByTimeAsync(11)
expect(queryClient.getQueryData(key)).toEqual(['Todo 1', 'Todo 2'])
})
it('should pass a non-undefined onMutateResult alongside context to onSuccess', async () => {
const onSuccessMock = vi.fn()
const rendered = render(SuccessContext, {
props: { queryClient, onSuccessMock },
})
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
await vi.advanceTimersByTimeAsync(10)
expect(onSuccessMock).toHaveBeenCalledTimes(1)
const [data, variables, onMutateResult, context] =
onSuccessMock.mock.calls[0]!
expect(data).toBe('TODO')
expect(variables).toBe('todo')
expect(onMutateResult).toEqual({ startedWith: 'todo' })
expect(context.client).toBe(queryClient)
expect(context.meta).toBeUndefined()
expect(context.mutationKey).toBeUndefined()
})
it('should include mutationKey in the context passed to hook-level callbacks', async () => {
const onSuccessMock = vi.fn()
const rendered = render(SuccessContext, {
props: { queryClient, mutationKey: ['todos', 'add'], onSuccessMock },
})
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
await vi.advanceTimersByTimeAsync(10)
expect(onSuccessMock).toHaveBeenCalledTimes(1)
expect(onSuccessMock.mock.calls[0]?.[3].mutationKey).toEqual([
'todos',
'add',
])
})
it('should give mutationFn the same QueryClient instance via context', async () => {
const key = queryKey()
queryClient.setQueryData(key, 'tag-from-this-client')
const rendered = render(MutationFnContext, {
props: { queryClient, queryKey: key },
})
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('data: tag-from-this-client')).toBeInTheDocument()
})
it('should let onSuccess invalidate queries via context.client without a useQueryClient() closure', async () => {
const key = queryKey()
queryClient.setQueryData(key, 'data')
const rendered = render(InvalidateFromContext, {
props: { queryClient, queryKey: key },
})
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false)
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
await vi.advanceTimersByTimeAsync(10)
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true)
})
it('should give a per-call onSuccess the same QueryClient instance via context', async () => {
const perCallOnSuccess = vi.fn()
const rendered = render(PerCallSuccess, {
props: { queryClient, perCallOnSuccess },
})
fireEvent.click(rendered.getByRole('button', { name: /Mutate/i }))
await vi.advanceTimersByTimeAsync(10)
expect(perCallOnSuccess).toHaveBeenCalledTimes(1)
expect(perCallOnSuccess.mock.calls[0]?.[3].client).toBe(queryClient)
})
it('should be able to run multiple mutateAsync calls in parallel with Promise.all', async () => {
const rendered = render(ParallelMutateAsync, {
props: { queryClient, strategy: 'all' },
})
fireEvent.click(rendered.getByRole('button', { name: /upload all/i }))
await vi.advanceTimersByTimeAsync(11)
expect(
rendered.getByText(
'result: uploaded: file1, uploaded: file2, uploaded: file3',
),
).toBeInTheDocument()
})
it('should handle Promise.all rejection when one parallel mutateAsync call fails', async () => {
const rendered = render(ParallelMutateAsync, {
props: { queryClient, strategy: 'all', failOn: 'file2' },
})
fireEvent.click(rendered.getByRole('button', { name: /upload all/i }))
await vi.advanceTimersByTimeAsync(11)
expect(
rendered.getByText('result: error: upload failed'),
).toBeInTheDocument()
})
it('should handle partial failure in parallel mutateAsync calls with Promise.allSettled', async () => {
const rendered = render(ParallelMutateAsync, {
props: { queryClient, strategy: 'allSettled', failOn: 'file2' },
})
fireEvent.click(rendered.getByRole('button', { name: /upload all/i }))
await vi.advanceTimersByTimeAsync(11)
expect(
rendered.getByText(
'result: uploaded: file1, error: upload failed, uploaded: file3',
),
).toBeInTheDocument()
})
it('should only fire the per-call onSuccess for the last mutate() call', async () => {
const onSuccessPerCall = vi.fn()
const rendered = render(ConcurrentMutate, {
props: { queryClient, onSuccessPerCall },
})
expect(rendered.getByText('data: null, status: idle')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
await vi.advanceTimersByTimeAsync(11)
expect(
rendered.getByText('data: Todo 2, status: success'),
).toBeInTheDocument()
expect(onSuccessPerCall).toHaveBeenCalledTimes(1)
expect(onSuccessPerCall).toHaveBeenCalledWith(
'Todo 2',
'Todo 2',
undefined,
expect.anything(),
)
})
})

View File

@@ -0,0 +1,203 @@
import { describe, expectTypeOf, it } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import { createMutation } from '../../src/index.js'
import type {
DefaultError,
MutationFunctionContext,
MutationKey,
} from '@tanstack/query-core'
import type { CreateMutationResult } from '../../src/types.js'
describe('createMutation', () => {
it('should infer TData from mutationFn return type', () => {
const mutation = createMutation(() => ({
mutationFn: () => Promise.resolve('data'),
}))
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
expectTypeOf(mutation.error).toEqualTypeOf<DefaultError | null>()
})
it('should infer TVariables from mutationFn parameter', () => {
const mutation = createMutation(() => ({
mutationFn: (vars: { id: string }) => Promise.resolve(vars.id),
}))
expectTypeOf(mutation.mutate).toBeCallableWith({ id: '1' })
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
})
it('should infer TOnMutateResult from onMutate return type', () => {
createMutation(() => ({
mutationFn: () => Promise.resolve('data'),
onMutate: () => {
return { token: 'abc' }
},
onSuccess: (_data, _variables, onMutateResult) => {
expectTypeOf(onMutateResult).toEqualTypeOf<{ token: string }>()
},
onError: (_error, _variables, onMutateResult) => {
expectTypeOf(onMutateResult).toEqualTypeOf<
{ token: string } | undefined
>()
},
}))
})
it('should allow explicit generic types', () => {
const mutation = createMutation<string, Error, { id: number }>(() => ({
mutationFn: (vars) => {
expectTypeOf(vars).toEqualTypeOf<{ id: number }>()
return Promise.resolve('result')
},
}))
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
expectTypeOf(mutation.error).toEqualTypeOf<Error | null>()
})
it('should return correct CreateMutationResult type', () => {
const mutation = createMutation(() => ({
mutationFn: () => Promise.resolve(42),
}))
expectTypeOf(mutation).toEqualTypeOf<
CreateMutationResult<number, DefaultError, void, unknown>
>()
})
it('should type mutateAsync with correct return type', () => {
const mutation = createMutation(() => ({
mutationFn: (id: string) => Promise.resolve(id.length),
}))
expectTypeOf(mutation.mutateAsync).toBeCallableWith('test')
expectTypeOf(mutation.mutateAsync('test')).toEqualTypeOf<Promise<number>>()
})
it('should default TVariables to void when mutationFn has no parameters', () => {
const mutation = createMutation(() => ({
mutationFn: () => Promise.resolve('data'),
}))
expectTypeOf(mutation.mutate).toBeCallableWith()
})
it('should allow calling mutate with no arguments when TVariables is optional undefinable', () => {
const mutation = createMutation(() => ({
mutationFn: (_variables: number | undefined) => Promise.resolve(1),
}))
expectTypeOf(mutation.mutate).toBeCallableWith()
expectTypeOf(mutation.mutateAsync).toBeCallableWith()
})
it('should infer custom TError type', () => {
class CustomError extends Error {
code: number
constructor(code: number) {
super()
this.code = code
}
}
const mutation = createMutation<string, CustomError>(() => ({
mutationFn: () => Promise.resolve('data'),
}))
expectTypeOf(mutation.error).toEqualTypeOf<CustomError | null>()
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
})
it('should infer types for onSettled callback', () => {
createMutation(() => ({
mutationFn: () => Promise.resolve(42),
onSettled: (data, error, _variables, _onMutateResult) => {
expectTypeOf(data).toEqualTypeOf<number | undefined>()
expectTypeOf(error).toEqualTypeOf<DefaultError | null>()
},
}))
})
it('should infer custom TError in onError callback', () => {
class CustomError extends Error {
code: number
constructor(code: number) {
super()
this.code = code
}
}
createMutation<string, CustomError>(() => ({
mutationFn: () => Promise.resolve('data'),
onError: (error) => {
expectTypeOf(error).toEqualTypeOf<CustomError>()
},
}))
})
it('should accept queryClient as second argument', () => {
const queryClient = new QueryClient()
const mutation = createMutation(
() => ({
mutationFn: () => Promise.resolve('data'),
}),
() => queryClient,
)
expectTypeOf(mutation.data).toEqualTypeOf<string | undefined>()
})
it('should type context as the last argument for mutationFn and every hook-level callback', () => {
createMutation(() => ({
mutationFn: (_variables, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
expectTypeOf(context.client).toEqualTypeOf<QueryClient>()
return Promise.resolve('data')
},
onMutate: (_variables, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
onSuccess: (_data, _variables, _onMutateResult, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
onError: (_error, _variables, _onMutateResult, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
}))
})
it('should type context as the last argument for every per-call mutate option', () => {
const mutation = createMutation(() => ({
mutationFn: () => Promise.resolve('data'),
}))
mutation.mutate(undefined, {
onSuccess: (_data, _variables, _onMutateResult, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
onError: (_error, _variables, _onMutateResult, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
})
})
it('should type context.mutationKey as MutationKey', () => {
createMutation(() => ({
mutationKey: ['todos', 'add'] as const,
mutationFn: () => Promise.resolve('data'),
onSuccess: (_data, _variables, _onMutateResult, context) => {
expectTypeOf(context.mutationKey).toEqualTypeOf<
MutationKey | undefined
>()
},
}))
})
})

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { queryKey } from '@tanstack/query-test-utils'
import {
setIsRestoringContext,
setQueryClientContext,
} from '../../src/context.js'
import { createQueries } from '../../src/index.js'
type Props = {
queryClient: QueryClient
queryFn1: () => Promise<string>
queryFn2: () => Promise<string>
}
let { queryClient, queryFn1, queryFn2 }: Props = $props()
setQueryClientContext(queryClient)
setIsRestoringContext({ current: true })
const result = createQueries(() => ({
queries: [
{ queryKey: queryKey(), queryFn: queryFn1 },
{ queryKey: queryKey(), queryFn: queryFn2 },
],
}))
</script>
<div>
<div data-testid="status1">{result[0].status}</div>
<div data-testid="status2">{result[1].status}</div>
<div data-testid="fetchStatus1">{result[0].fetchStatus}</div>
<div data-testid="fetchStatus2">{result[1].fetchStatus}</div>
<div data-testid="data1">{result[0].data ?? 'undefined'}</div>
<div data-testid="data2">{result[1].data ?? 'undefined'}</div>
</div>

View File

@@ -0,0 +1,377 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/svelte'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { QueryClient, createQueries } from '../../src/index.js'
import { promiseWithResolvers, ref, withEffectRoot } from '../utils.svelte.js'
import IsRestoring from './IsRestoring.svelte'
import type { CreateQueryResult } from '../../src/index.js'
describe('createQueries', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it(
'should return the correct states',
withEffectRoot(async () => {
const key1 = queryKey()
const key2 = queryKey()
const results: Array<Array<CreateQueryResult>> = []
const { promise: promise1, resolve: resolve1 } = promiseWithResolvers()
const { promise: promise2, resolve: resolve2 } = promiseWithResolvers()
const result = createQueries(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => promise1,
},
{
queryKey: key2,
queryFn: () => promise2,
},
],
}),
() => queryClient,
)
$effect(() => {
results.push([{ ...result[0] }, { ...result[1] }])
})
resolve1(1)
await vi.advanceTimersByTimeAsync(0)
expect(result[0].data).toBe(1)
resolve2(2)
await vi.advanceTimersByTimeAsync(0)
expect(result[1].data).toBe(2)
expect(results.length).toBe(3)
expect(results[0]).toMatchObject([
{ data: undefined },
{ data: undefined },
])
expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }])
expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }])
}),
)
it(
'should track results',
withEffectRoot(async () => {
const key1 = queryKey()
const results: Array<Array<CreateQueryResult>> = []
let count = 0
const result = createQueries(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => Promise.resolve(++count),
},
],
}),
() => queryClient,
)
$effect(() => {
results.push([result[0]])
})
await vi.advanceTimersByTimeAsync(0)
expect(result[0].data).toBe(1)
expect(results.length).toBe(2)
expect(results[0]).toMatchObject([{ data: undefined }])
expect(results[1]).toMatchObject([{ data: 1 }])
// Trigger refetch
result[0].refetch()
await vi.advanceTimersByTimeAsync(0)
expect(result[0].data).toBe(2)
// Only one render for data update, no render for isFetching transition
expect(results.length).toBe(3)
expect(results[2]).toMatchObject([{ data: 2 }])
}),
)
it(
'should track queries added to an initially empty array',
withEffectRoot(async () => {
const key1 = queryKey()
const queries = ref<
Array<{ queryKey: Array<string>; queryFn: () => Promise<string> }>
>([])
const results: Array<Array<CreateQueryResult>> = []
const result = createQueries(
() => ({ queries: queries.value }),
() => queryClient,
)
$effect(() => {
results.push(result.map((res) => ({ ...res })))
})
await vi.advanceTimersByTimeAsync(0)
expect(results).toMatchObject([[]])
queries.value = [
{ queryKey: key1, queryFn: () => sleep(10).then(() => 'data1') },
]
await vi.advanceTimersByTimeAsync(10)
expect(result[0]?.data).toBe('data1')
expect(results.at(-1)).toMatchObject([
{ status: 'success', data: 'data1' },
])
}),
)
it(
'should combine queries',
withEffectRoot(async () => {
const key1 = queryKey()
const key2 = queryKey()
const { promise: promise1, resolve: resolve1 } =
promiseWithResolvers<string>()
const { promise: promise2, resolve: resolve2 } =
promiseWithResolvers<string>()
const queries = createQueries(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => promise1,
},
{
queryKey: key2,
queryFn: () => promise2,
},
],
combine: (results) => {
return {
combined: true,
res: results
.flatMap((res) => (res.data ? [res.data] : []))
.join(','),
}
},
}),
() => queryClient,
)
// Initially both queries are loading
expect(queries).toEqual({
combined: true,
res: '',
})
// Resolve the first query
resolve1('first result')
await vi.advanceTimersByTimeAsync(0)
expect(queries.res).toBe('first result')
// Resolve the second query
resolve2('second result')
await vi.advanceTimersByTimeAsync(0)
expect(queries.res).toBe('first result,second result')
expect(queries).toEqual({
combined: true,
res: 'first result,second result',
})
}),
)
it(
'should track property access through combine function',
withEffectRoot(async () => {
const key1 = queryKey()
const key2 = queryKey()
let count = 0
const results: Array<unknown> = []
const { promise: promise1, resolve: resolve1 } =
promiseWithResolvers<string>()
const { promise: promise2, resolve: resolve2 } =
promiseWithResolvers<string>()
const { promise: promise3, resolve: resolve3 } =
promiseWithResolvers<string>()
const { promise: promise4, resolve: resolve4 } =
promiseWithResolvers<string>()
const queries = createQueries(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => (count === 0 ? promise1 : promise3),
},
{
queryKey: key2,
queryFn: () => (count === 0 ? promise2 : promise4),
},
],
combine: (queryResults) => {
return {
combined: true,
refetch: () =>
Promise.all(queryResults.map((res) => res.refetch())),
res: queryResults
.flatMap((res) => (res.data ? [res.data] : []))
.join(','),
}
},
}),
() => queryClient,
)
$effect(() => {
results.push({ ...queries })
})
// Initially both queries are loading
await vi.advanceTimersByTimeAsync(0)
expect(results[0]).toStrictEqual({
combined: true,
refetch: expect.any(Function),
res: '',
})
// Resolve the first query
resolve1('first result ' + count)
await vi.advanceTimersByTimeAsync(0)
expect(queries.res).toBe('first result 0')
expect(results[1]).toStrictEqual({
combined: true,
refetch: expect.any(Function),
res: 'first result 0',
})
// Resolve the second query
resolve2('second result ' + count)
await vi.advanceTimersByTimeAsync(0)
expect(queries.res).toBe('first result 0,second result 0')
expect(results[2]).toStrictEqual({
combined: true,
refetch: expect.any(Function),
res: 'first result 0,second result 0',
})
// Increment count and refetch
count++
queries.refetch()
// Resolve the refetched queries
resolve3('first result ' + count)
resolve4('second result ' + count)
await vi.advanceTimersByTimeAsync(0)
expect(queries.res).toBe('first result 1,second result 1')
const length = results.length
expect(results.at(-1)).toStrictEqual({
combined: true,
refetch: expect.any(Function),
res: 'first result 1,second result 1',
})
// Refetch again but with the same data
await queries.refetch()
// No further re-render because data didn't change
expect(results.length).toBe(length)
}),
)
it('should not fetch for the duration of the restoring period when isRestoring is true', async () => {
const queryFn1 = vi.fn(() => sleep(10).then(() => 'data1'))
const queryFn2 = vi.fn(() => sleep(10).then(() => 'data2'))
const rendered = render(IsRestoring, {
props: { queryClient, queryFn1, queryFn2 },
})
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByTestId('status1')).toHaveTextContent('pending')
expect(rendered.getByTestId('status2')).toHaveTextContent('pending')
expect(rendered.getByTestId('fetchStatus1')).toHaveTextContent('idle')
expect(rendered.getByTestId('fetchStatus2')).toHaveTextContent('idle')
expect(rendered.getByTestId('data1')).toHaveTextContent('undefined')
expect(rendered.getByTestId('data2')).toHaveTextContent('undefined')
expect(queryFn1).toHaveBeenCalledTimes(0)
expect(queryFn2).toHaveBeenCalledTimes(0)
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByTestId('status1')).toHaveTextContent('pending')
expect(rendered.getByTestId('status2')).toHaveTextContent('pending')
expect(rendered.getByTestId('fetchStatus1')).toHaveTextContent('idle')
expect(rendered.getByTestId('fetchStatus2')).toHaveTextContent('idle')
expect(rendered.getByTestId('data1')).toHaveTextContent('undefined')
expect(rendered.getByTestId('data2')).toHaveTextContent('undefined')
expect(queryFn1).toHaveBeenCalledTimes(0)
expect(queryFn2).toHaveBeenCalledTimes(0)
})
it('should not fetch queries with different durations for the duration of the restoring period when isRestoring is true', async () => {
const queryFn1 = vi.fn(() => sleep(10).then(() => 'data1'))
const queryFn2 = vi.fn(() => sleep(20).then(() => 'data2'))
const rendered = render(IsRestoring, {
props: { queryClient, queryFn1, queryFn2 },
})
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByTestId('status1')).toHaveTextContent('pending')
expect(rendered.getByTestId('status2')).toHaveTextContent('pending')
expect(rendered.getByTestId('fetchStatus1')).toHaveTextContent('idle')
expect(rendered.getByTestId('fetchStatus2')).toHaveTextContent('idle')
expect(rendered.getByTestId('data1')).toHaveTextContent('undefined')
expect(rendered.getByTestId('data2')).toHaveTextContent('undefined')
expect(queryFn1).toHaveBeenCalledTimes(0)
expect(queryFn2).toHaveBeenCalledTimes(0)
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByTestId('status1')).toHaveTextContent('pending')
expect(rendered.getByTestId('status2')).toHaveTextContent('pending')
expect(rendered.getByTestId('fetchStatus1')).toHaveTextContent('idle')
expect(rendered.getByTestId('fetchStatus2')).toHaveTextContent('idle')
expect(rendered.getByTestId('data1')).toHaveTextContent('undefined')
expect(rendered.getByTestId('data2')).toHaveTextContent('undefined')
expect(queryFn1).toHaveBeenCalledTimes(0)
expect(queryFn2).toHaveBeenCalledTimes(0)
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByTestId('status1')).toHaveTextContent('pending')
expect(rendered.getByTestId('status2')).toHaveTextContent('pending')
expect(rendered.getByTestId('fetchStatus1')).toHaveTextContent('idle')
expect(rendered.getByTestId('fetchStatus2')).toHaveTextContent('idle')
expect(rendered.getByTestId('data1')).toHaveTextContent('undefined')
expect(rendered.getByTestId('data2')).toHaveTextContent('undefined')
expect(queryFn1).toHaveBeenCalledTimes(0)
expect(queryFn2).toHaveBeenCalledTimes(0)
})
})

View File

@@ -0,0 +1,684 @@
import { describe, expectTypeOf, it } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import { queryKey } from '@tanstack/query-test-utils'
import { createQueries, queryOptions } from '../../src/index.js'
import type {
CreateQueryOptions,
CreateQueryResult,
QueryFunction,
QueryFunctionContext,
QueryKey,
skipToken,
} from '../../src/index.js'
describe('createQueries', () => {
it('should return correct data for dynamic queries with mixed result types', () => {
const key1 = queryKey()
const key2 = queryKey()
const Queries1 = {
get: () =>
queryOptions({
queryKey: key1,
queryFn: () => Promise.resolve(1),
}),
}
const Queries2 = {
get: () =>
queryOptions({
queryKey: key2,
queryFn: () => Promise.resolve(true),
}),
}
const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() }))
const result = createQueries(() => ({
queries: [...queries1List, { ...Queries2.get() }],
}))
expectTypeOf(result).toEqualTypeOf<
[
...Array<CreateQueryResult<number, Error>>,
CreateQueryResult<boolean, Error>,
]
>()
})
it('should handle type parameter - tuple of tuples', () => {
const queryClient = new QueryClient()
const key1 = queryKey()
const key2 = queryKey()
const key3 = queryKey()
const result1 = createQueries<
[[number], [string], [Array<string>, boolean]]
>(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 1,
},
{
queryKey: key2,
queryFn: () => 'string',
},
{
queryKey: key3,
queryFn: () => ['string[]'],
},
],
}),
() => queryClient,
)
expectTypeOf(result1[0]).toEqualTypeOf<CreateQueryResult<number, unknown>>()
expectTypeOf(result1[1]).toEqualTypeOf<CreateQueryResult<string, unknown>>()
expectTypeOf(result1[2]).toEqualTypeOf<
CreateQueryResult<Array<string>, boolean>
>()
expectTypeOf(result1[0].data).toEqualTypeOf<number | undefined>()
expectTypeOf(result1[1].data).toEqualTypeOf<string | undefined>()
expectTypeOf(result1[2].data).toEqualTypeOf<Array<string> | undefined>()
expectTypeOf(result1[2].error).toEqualTypeOf<boolean | null>()
// TData (3rd element) takes precedence over TQueryFnData (1st element)
const result2 = createQueries<
[[string, unknown, string], [string, unknown, number]]
>(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 'string',
select: (a) => {
expectTypeOf(a).toEqualTypeOf<string>()
return a.toLowerCase()
},
},
{
queryKey: key2,
queryFn: () => 'string',
select: (a) => {
expectTypeOf(a).toEqualTypeOf<string>()
return parseInt(a)
},
},
],
}),
() => queryClient,
)
expectTypeOf(result2[0]).toEqualTypeOf<CreateQueryResult<string, unknown>>()
expectTypeOf(result2[1]).toEqualTypeOf<CreateQueryResult<number, unknown>>()
expectTypeOf(result2[0].data).toEqualTypeOf<string | undefined>()
expectTypeOf(result2[1].data).toEqualTypeOf<number | undefined>()
// types should be enforced
createQueries<[[string, unknown, string], [string, boolean, number]]>(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 'string',
select: (a) => {
expectTypeOf(a).toEqualTypeOf<string>()
return a.toLowerCase()
},
placeholderData: 'string',
// @ts-expect-error (initialData: string)
initialData: 123,
},
{
queryKey: key2,
queryFn: () => 'string',
select: (a) => {
expectTypeOf(a).toEqualTypeOf<string>()
return parseInt(a)
},
placeholderData: 'string',
// @ts-expect-error (initialData: string)
initialData: 123,
},
],
}),
() => queryClient,
)
// field names should be enforced
createQueries<[[string]]>(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 'string',
},
],
}),
() => queryClient,
)
})
it('should handle type parameter - tuple of objects', () => {
const queryClient = new QueryClient()
const key1 = queryKey()
const key2 = queryKey()
const key3 = queryKey()
const result1 = createQueries<
[
{ queryFnData: number },
{ queryFnData: string },
{ queryFnData: Array<string>; error: boolean },
]
>(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 1,
},
{
queryKey: key2,
queryFn: () => 'string',
},
{
queryKey: key3,
queryFn: () => ['string[]'],
},
],
}),
() => queryClient,
)
expectTypeOf(result1[0]).toEqualTypeOf<CreateQueryResult<number, unknown>>()
expectTypeOf(result1[1]).toEqualTypeOf<CreateQueryResult<string, unknown>>()
expectTypeOf(result1[2]).toEqualTypeOf<
CreateQueryResult<Array<string>, boolean>
>()
expectTypeOf(result1[0].data).toEqualTypeOf<number | undefined>()
expectTypeOf(result1[1].data).toEqualTypeOf<string | undefined>()
expectTypeOf(result1[2].data).toEqualTypeOf<Array<string> | undefined>()
expectTypeOf(result1[2].error).toEqualTypeOf<boolean | null>()
// TData (data prop) takes precedence over TQueryFnData (queryFnData prop)
const result2 = createQueries<
[
{ queryFnData: string; data: string },
{ queryFnData: string; data: number },
]
>(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 'string',
select: (a) => {
expectTypeOf(a).toEqualTypeOf<string>()
return a.toLowerCase()
},
},
{
queryKey: key2,
queryFn: () => 'string',
select: (a) => {
expectTypeOf(a).toEqualTypeOf<string>()
return parseInt(a)
},
},
],
}),
() => queryClient,
)
expectTypeOf(result2[0]).toEqualTypeOf<CreateQueryResult<string, unknown>>()
expectTypeOf(result2[1]).toEqualTypeOf<CreateQueryResult<number, unknown>>()
expectTypeOf(result2[0].data).toEqualTypeOf<string | undefined>()
expectTypeOf(result2[1].data).toEqualTypeOf<number | undefined>()
// can pass only TData (data prop) although TQueryFnData will be left unknown
const result3 = createQueries<[{ data: string }, { data: number }]>(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 'string',
select: (a) => {
expectTypeOf(a).toEqualTypeOf<unknown>()
return a as string
},
},
{
queryKey: key2,
queryFn: () => 'string',
select: (a) => {
expectTypeOf(a).toEqualTypeOf<unknown>()
return a as number
},
},
],
}),
() => queryClient,
)
expectTypeOf(result3[0]).toEqualTypeOf<CreateQueryResult<string, unknown>>()
expectTypeOf(result3[1]).toEqualTypeOf<CreateQueryResult<number, unknown>>()
expectTypeOf(result3[0].data).toEqualTypeOf<string | undefined>()
expectTypeOf(result3[1].data).toEqualTypeOf<number | undefined>()
// types should be enforced
createQueries<
[
{ queryFnData: string; data: string },
{ queryFnData: string; data: number; error: boolean },
]
>(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 'string',
select: (a) => {
expectTypeOf(a).toEqualTypeOf<string>()
return a.toLowerCase()
},
placeholderData: 'string',
// @ts-expect-error (initialData: string)
initialData: 123,
},
{
queryKey: key2,
queryFn: () => 'string',
select: (a) => {
expectTypeOf(a).toEqualTypeOf<string>()
return parseInt(a)
},
placeholderData: 'string',
// @ts-expect-error (initialData: string)
initialData: 123,
},
],
}),
() => queryClient,
)
// field names should be enforced
createQueries<[{ queryFnData: string }]>(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 'string',
},
],
}),
() => queryClient,
)
})
it('should handle array literal without type parameter to infer result type', () => {
const queryClient = new QueryClient()
const key1 = queryKey()
const key2 = queryKey()
const key3 = queryKey()
const key4 = queryKey()
// Array.map preserves TQueryFnData
const result1 = createQueries(
() => ({
queries: Array(50).map((_, i) => ({
queryKey: ['key', i] as const,
queryFn: () => i + 10,
})),
}),
() => queryClient,
)
expectTypeOf(result1).toEqualTypeOf<
Array<CreateQueryResult<number, Error>>
>()
if (result1[0]) {
expectTypeOf(result1[0].data).toEqualTypeOf<number | undefined>()
}
// Array.map preserves TData
const result2 = createQueries(
() => ({
queries: Array(50).map((_, i) => ({
queryKey: ['key', i] as const,
queryFn: () => i + 10,
select: (data: number) => data.toString(),
})),
}),
() => queryClient,
)
expectTypeOf(result2).toEqualTypeOf<
Array<CreateQueryResult<string, Error>>
>()
const result3 = createQueries(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 1,
},
{
queryKey: key2,
queryFn: () => 'string',
},
{
queryKey: key3,
queryFn: () => ['string[]'],
select: () => 123,
},
],
}),
() => queryClient,
)
expectTypeOf(result3[0]).toEqualTypeOf<CreateQueryResult<number, Error>>()
expectTypeOf(result3[1]).toEqualTypeOf<CreateQueryResult<string, Error>>()
expectTypeOf(result3[2]).toEqualTypeOf<CreateQueryResult<number, Error>>()
expectTypeOf(result3[0].data).toEqualTypeOf<number | undefined>()
expectTypeOf(result3[1].data).toEqualTypeOf<string | undefined>()
// select takes precedence over queryFn
expectTypeOf(result3[2].data).toEqualTypeOf<number | undefined>()
// initialData/placeholderData are enforced
createQueries(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 'string',
placeholderData: 'string',
// @ts-expect-error (initialData: string)
initialData: 123,
},
{
queryKey: key2,
queryFn: () => 123,
// @ts-expect-error (placeholderData: number)
placeholderData: 'string',
initialData: 123,
},
],
}),
() => queryClient,
)
// select params are "indirectly" enforced
createQueries(
() => ({
queries: [
// unfortunately TS will not suggest the type for you
{
queryKey: key1,
queryFn: () => 'string',
},
// however you can add a type to the callback
{
queryKey: key2,
queryFn: () => 'string',
},
// the type you do pass is enforced
{
queryKey: key3,
queryFn: () => 'string',
},
{
queryKey: key4,
queryFn: () => 'string',
select: (a: string) => parseInt(a),
},
],
}),
() => queryClient,
)
// callbacks are also indirectly enforced with Array.map
createQueries(
() => ({
queries: Array(50).map((_, i) => ({
queryKey: ['key', i] as const,
queryFn: () => i + 10,
select: (data: number) => data.toString(),
})),
}),
() => queryClient,
)
// results inference works when all the handlers are defined
const result4 = createQueries(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 'string',
},
{
queryKey: key2,
queryFn: () => 'string',
},
{
queryKey: key4,
queryFn: () => 'string',
select: (a: string) => parseInt(a),
},
],
}),
() => queryClient,
)
expectTypeOf(result4[0]).toEqualTypeOf<CreateQueryResult<string, Error>>()
expectTypeOf(result4[1]).toEqualTypeOf<CreateQueryResult<string, Error>>()
expectTypeOf(result4[2]).toEqualTypeOf<CreateQueryResult<number, Error>>()
// handles when queryFn returns a Promise
const result5 = createQueries(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => Promise.resolve('string'),
},
],
}),
() => queryClient,
)
expectTypeOf(result5[0]).toEqualTypeOf<CreateQueryResult<string, Error>>()
// Array as const does not throw error
const result6 = createQueries(
() =>
({
queries: [
{
queryKey: ['key1'],
queryFn: () => 'string',
},
{
queryKey: ['key1'],
queryFn: () => 123,
},
],
}) as const,
() => queryClient,
)
expectTypeOf(result6[0]).toEqualTypeOf<CreateQueryResult<string, Error>>()
expectTypeOf(result6[1]).toEqualTypeOf<CreateQueryResult<number, Error>>()
// field names should be enforced - array literal
createQueries(
() => ({
queries: [
{
queryKey: key1,
queryFn: () => 'string',
},
],
}),
() => queryClient,
)
// field names should be enforced - Array.map() result
createQueries(
() => ({
// @ts-expect-error (invalidField)
queries: Array(10).map(() => ({
someInvalidField: '',
})),
}),
() => queryClient,
)
// supports queryFn using fetch() to return Promise<any> - Array.map() result
createQueries(
() => ({
queries: Array(50).map((_, i) => ({
queryKey: ['key', i] as const,
queryFn: () =>
fetch('return Promise<any>').then((resp) => resp.json()),
})),
}),
() => queryClient,
)
// supports queryFn using fetch() to return Promise<any> - array literal
createQueries(
() => ({
queries: [
{
queryKey: key1,
queryFn: () =>
fetch('return Promise<any>').then((resp) => resp.json()),
},
],
}),
() => queryClient,
)
})
it('should handle strongly typed queryFn factories and createQueries wrappers', () => {
const queryClient = new QueryClient()
// QueryKey + queryFn factory
type QueryKeyA = ['queryA']
const getQueryKeyA = (): QueryKeyA => ['queryA']
type GetQueryFunctionA = () => QueryFunction<number, QueryKeyA>
const getQueryFunctionA: GetQueryFunctionA = () => () => {
return 1
}
type SelectorA = (data: number) => [number, string]
const getSelectorA = (): SelectorA => (data) => [data, data.toString()]
type QueryKeyB = ['queryB', string]
const getQueryKeyB = (id: string): QueryKeyB => ['queryB', id]
type GetQueryFunctionB = () => QueryFunction<string, QueryKeyB>
const getQueryFunctionB: GetQueryFunctionB = () => () => {
return '1'
}
type SelectorB = (data: string) => [string, number]
const getSelectorB = (): SelectorB => (data) => [data, +data]
// Wrapper with strongly typed array-parameter
function useWrappedQueries<
TQueryFnData,
TError,
TData,
TQueryKey extends QueryKey,
>(
queries: Array<
CreateQueryOptions<TQueryFnData, TError, TData, TQueryKey>
>,
) {
return createQueries(
() => ({
queries: queries.map(
// no need to type the mapped query
(query) => {
const { queryFn: fn, queryKey: key } = query
expectTypeOf(fn).toEqualTypeOf<
| typeof skipToken
| QueryFunction<TQueryFnData, TQueryKey, never>
| undefined
>()
return {
queryKey: key,
queryFn: fn
? (ctx: QueryFunctionContext<TQueryKey>) => {
// eslint-disable-next-line vitest/valid-expect
expectTypeOf<TQueryKey>(ctx.queryKey)
return (
fn as QueryFunction<TQueryFnData, TQueryKey>
).call({}, ctx)
}
: undefined,
}
},
),
}),
() => queryClient,
)
}
const result = createQueries(
() => ({
queries: [
{
queryKey: getQueryKeyA(),
queryFn: getQueryFunctionA(),
},
{
queryKey: getQueryKeyB('id'),
queryFn: getQueryFunctionB(),
},
],
}),
() => queryClient,
)
expectTypeOf(result[0]).toEqualTypeOf<CreateQueryResult<number, Error>>()
expectTypeOf(result[1]).toEqualTypeOf<CreateQueryResult<string, Error>>()
const withSelector = createQueries(
() => ({
queries: [
{
queryKey: getQueryKeyA(),
queryFn: getQueryFunctionA(),
select: getSelectorA(),
},
{
queryKey: getQueryKeyB('id'),
queryFn: getQueryFunctionB(),
select: getSelectorB(),
},
],
}),
() => queryClient,
)
expectTypeOf(withSelector[0]).toEqualTypeOf<
CreateQueryResult<[number, string], Error>
>()
expectTypeOf(withSelector[1]).toEqualTypeOf<
CreateQueryResult<[string, number], Error>
>()
const withWrappedQueries = useWrappedQueries(
Array(10).map(() => ({
queryKey: getQueryKeyA(),
queryFn: getQueryFunctionA(),
select: getSelectorA(),
})),
)
expectTypeOf(withWrappedQueries).toEqualTypeOf<
Array<CreateQueryResult<number, Error>>
>()
})
})

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import type { QueryClient } from '@tanstack/query-core'
import { createQuery, setQueryClientContext } from '../../src/index.js'
import type { Accessor, CreateQueryOptions } from '../../src/index.js'
type Props = {
queryClient: QueryClient
options: Accessor<CreateQueryOptions>
}
let { queryClient, options }: Props = $props()
setQueryClientContext(queryClient)
// QueryClientProvider normally mounts/unmounts the client; since this fixture
// sets the context directly, do it manually so focus/online refetching works
onMount(() => queryClient.mount())
onDestroy(() => queryClient.unmount())
const query = createQuery(options)
</script>
<button onclick={() => query.refetch()}>refetch</button>
<button onclick={() => query.refetch({ cancelRefetch: false })}>
refetch no cancel
</button>
<div data-testid="status">{query.status}</div>
<div data-testid="fetchStatus">{query.fetchStatus}</div>
<div data-testid="data">{query.data ?? 'undefined'}</div>
<div data-testid="dataUpdatedAt">{query.dataUpdatedAt}</div>
<div data-testid="error">
{(query.error as Error | null)?.message ?? 'null'}
</div>
<div data-testid="isFetched">{query.isFetched}</div>
<div data-testid="isFetchedAfterMount">{query.isFetchedAfterMount}</div>
<div data-testid="isStale">{query.isStale}</div>
<div data-testid="isFetching">{query.isFetching}</div>
<div data-testid="isSuccess">{query.isSuccess}</div>
<div data-testid="isPlaceholderData">{query.isPlaceholderData}</div>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { createQuery, setQueryClientContext } from '../../src/index.js'
import type { CreateQueryOptions } from '../../src/index.js'
type Props = {
queryClient: QueryClient
startCount?: number
options: (count: number) => CreateQueryOptions<number, Error>
}
let { queryClient, startCount = 0, options }: Props = $props()
let count = $state(startCount)
setQueryClientContext(queryClient)
const query = createQuery<number, Error>(() => options(count))
</script>
<button onclick={() => count++}>increment</button>
<button onclick={() => query.refetch()}>refetch</button>
<div data-testid="data">{query.data ?? 'undefined'}</div>
<div data-testid="status">{query.status}</div>
<div data-testid="fetchStatus">{query.fetchStatus}</div>
<div data-testid="isFetching">{query.isFetching}</div>
<div data-testid="isSuccess">{query.isSuccess}</div>
<div data-testid="isPlaceholderData">{query.isPlaceholderData}</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { queryKey } from '@tanstack/query-test-utils'
import {
setIsRestoringContext,
setQueryClientContext,
} from '../../src/context.js'
import { createQuery } from '../../src/index.js'
type Props = {
queryClient: QueryClient
queryFn: () => Promise<string>
}
let { queryClient, queryFn }: Props = $props()
setQueryClientContext(queryClient)
setIsRestoringContext({ current: true })
const query = createQuery(() => ({
queryKey: queryKey(),
queryFn,
}))
</script>
<div>
<div data-testid="status">{query.status}</div>
<div data-testid="fetchStatus">{query.fetchStatus}</div>
<div data-testid="data">{query.data ?? 'undefined'}</div>
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { createQuery, setQueryClientContext } from '../../src/index.js'
import type { Accessor, CreateQueryOptions } from '../../src/index.js'
type Data = { name: string }
type Props = {
queryClient: QueryClient
options: Accessor<CreateQueryOptions<Data, Error, string>>
}
let { queryClient, options }: Props = $props()
setQueryClientContext(queryClient)
const query = createQuery<Data, Error, string>(options)
</script>
<div data-testid="status">{query.status}</div>
<div data-testid="data">{query.data ?? 'undefined'}</div>
<div data-testid="error">
{(query.error as Error | null)?.message ?? 'null'}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import type { QueryClient } from '@tanstack/query-core'
import { createQuery, setQueryClientContext } from '../../src/index.js'
import type { Accessor, CreateQueryOptions } from '../../src/index.js'
type Props = {
queryClient: QueryClient
options1: Accessor<CreateQueryOptions>
options2: Accessor<CreateQueryOptions>
}
let { queryClient, options1, options2 }: Props = $props()
setQueryClientContext(queryClient)
onMount(() => queryClient.mount())
onDestroy(() => queryClient.unmount())
const query1 = createQuery(options1)
const query2 = createQuery(options2)
</script>
<button onclick={() => query1.refetch()}>refetch1</button>
<div data-testid="status1">{query1.status}</div>
<div data-testid="fetchStatus1">{query1.fetchStatus}</div>
<div data-testid="data1">{query1.data ?? 'undefined'}</div>
<div data-testid="isStale1">{query1.isStale}</div>
<div data-testid="status2">{query2.status}</div>
<div data-testid="fetchStatus2">{query2.fetchStatus}</div>
<div data-testid="data2">{query2.data ?? 'undefined'}</div>
<div data-testid="isStale2">{query2.isStale}</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
import { describe, expectTypeOf, it } from 'vitest'
import { queryKey } from '@tanstack/query-test-utils'
import { createQuery, queryOptions } from '../../src/index.js'
describe('createQuery', () => {
it('should return the correct states for a successful query', () => {
const key = queryKey()
const query = createQuery<string, Error>(() => ({
queryKey: key,
queryFn: () => Promise.resolve('test'),
}))
if (query.isPending) {
expectTypeOf(query.data).toEqualTypeOf<undefined>()
expectTypeOf(query.error).toEqualTypeOf<null>()
} else if (query.isLoadingError) {
expectTypeOf(query.data).toEqualTypeOf<undefined>()
expectTypeOf(query.error).toEqualTypeOf<Error>()
} else {
expectTypeOf(query.data).toEqualTypeOf<string>()
expectTypeOf(query.error).toEqualTypeOf<Error | null>()
}
})
describe('initialData', () => {
describe('Config object overload', () => {
it('TData should always be defined when initialData is provided as an object', () => {
const key = queryKey()
const { data } = createQuery(() => ({
queryKey: key,
queryFn: () => ({ wow: true }),
initialData: { wow: true },
}))
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
})
it('TData should be defined when passed through queryOptions', () => {
const key = queryKey()
const options = queryOptions({
queryKey: key,
queryFn: () => ({ wow: true }),
initialData: { wow: true },
})
const { data } = createQuery(() => options)
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
})
it('TData should have undefined in the union when initialData is NOT provided', () => {
const key = queryKey()
const { data } = createQuery(() => ({
queryKey: key,
queryFn: () => ({ wow: true }),
}))
expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>()
})
it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => {
const key = queryKey()
const { data } = createQuery(() => ({
queryKey: key,
queryFn: () => ({ wow: true }),
initialData: () => undefined as { wow: boolean } | undefined,
}))
expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>()
})
})
describe('Query key overload', () => {
it('TData should always be defined when initialData is provided', () => {
const key = queryKey()
const { data } = createQuery(() => ({
queryKey: key,
queryFn: () => ({ wow: true }),
initialData: { wow: true },
}))
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
})
it('TData should have undefined in the union when initialData is NOT provided', () => {
const key = queryKey()
const { data } = createQuery(() => ({
queryKey: key,
queryFn: () => ({ wow: true }),
}))
expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>()
})
})
describe('Query key and func', () => {
it('TData should always be defined when initialData is provided', () => {
const key = queryKey()
const { data } = createQuery(() => ({
queryKey: key,
queryFn: () => ({ wow: true }),
initialData: { wow: true },
}))
expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>()
})
it('TData should have undefined in the union when initialData is NOT provided', () => {
const key = queryKey()
const { data } = createQuery(() => ({
queryKey: key,
queryFn: () => ({ wow: true }),
}))
expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>()
})
})
})
describe('generic queryFn', () => {
it('should infer the result type from a generic query function', () => {
const key = queryKey()
function queryFn<T = string>(): Promise<T> {
return Promise.resolve({} as T)
}
const query = createQuery(() => ({
queryKey: key,
queryFn: () => queryFn(),
}))
expectTypeOf(query.data).toEqualTypeOf<string | undefined>()
expectTypeOf(query.error).toEqualTypeOf<Error | null>()
})
})
})

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { infiniteQueryOptions } from '../src/index.js'
import type { CreateInfiniteQueryOptions } from '../src/types.js'
describe('infiniteQueryOptions', () => {
it('should return the object received as a parameter without any modification.', () => {
const object: CreateInfiniteQueryOptions = {
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
getNextPageParam: () => null,
initialPageParam: null,
} as const
expect(infiniteQueryOptions(object)).toBe(object)
})
})

View File

@@ -0,0 +1,138 @@
import { assertType, describe, expectTypeOf, it } from 'vitest'
import { QueryClient, skipToken } from '@tanstack/query-core'
import { queryKey } from '@tanstack/query-test-utils'
import { createInfiniteQuery, infiniteQueryOptions } from '../src/index.js'
import type { InfiniteData } from '@tanstack/query-core'
describe('infiniteQueryOptions', () => {
it('should not allow excess properties', () => {
const key = queryKey()
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', () => {
const key = queryKey()
infiniteQueryOptions({
queryKey: key,
queryFn: () => Promise.resolve('data'),
staleTime: 1000,
getNextPageParam: () => 1,
initialPageParam: 1,
select: (data) => {
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
},
})
})
it('should work when passed to createInfiniteQuery', () => {
const key = queryKey()
const options = infiniteQueryOptions({
queryKey: key,
queryFn: () => Promise.resolve('string'),
getNextPageParam: () => 1,
initialPageParam: 1,
})
const query = createInfiniteQuery(() => options)
// known issue: type of pageParams is unknown when returned from useInfiniteQuery
expectTypeOf(query.data).toEqualTypeOf<
InfiniteData<string, unknown> | undefined
>()
})
it('should work when passed to createInfiniteQuery with initialData', () => {
const key = queryKey()
const options = infiniteQueryOptions({
queryKey: key,
queryFn: () => Promise.resolve('string'),
getNextPageParam: () => 1,
initialPageParam: 1,
initialData: { pages: ['string'], pageParams: [1] },
})
const query = createInfiniteQuery(() => options)
// known issue: type of pageParams is unknown when returned from createInfiniteQuery
expectTypeOf(query.data).toEqualTypeOf<InfiniteData<string, unknown>>()
})
it('should work when passed to infiniteQuery', async () => {
const options = infiniteQueryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve('string'),
getNextPageParam: () => 1,
initialPageParam: 1,
})
const data = await new QueryClient().infiniteQuery(options)
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
})
it('should work when passed to infiniteQuery with select', async () => {
const options = infiniteQueryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve('string'),
getNextPageParam: () => 1,
initialPageParam: 1,
select: (data) => data.pages,
})
const data = await new QueryClient().infiniteQuery(options)
expectTypeOf(data).toEqualTypeOf<Array<string>>()
})
it('should work when passed to infiniteQuery with enabled: false', async () => {
const options = infiniteQueryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve('string'),
getNextPageParam: () => 1,
initialPageParam: 1,
enabled: false,
})
const data = await new QueryClient().infiniteQuery(options)
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
})
it('should work when passed to infiniteQuery with skipToken', async () => {
const options = infiniteQueryOptions({
queryKey: ['key'],
queryFn: skipToken,
getNextPageParam: () => 1,
initialPageParam: 1,
})
const data = await new QueryClient().infiniteQuery(options)
expectTypeOf(data).toEqualTypeOf<InfiniteData<unknown, number>>()
})
it('should work when passed to fetchInfiniteQuery', async () => {
const key = queryKey()
const options = infiniteQueryOptions({
queryKey: key,
queryFn: () => Promise.resolve('string'),
getNextPageParam: () => 1,
initialPageParam: 1,
})
// grandfathered direct test
// eslint-disable-next-line no-restricted-syntax
const data = await new QueryClient().fetchInfiniteQuery(options)
expectTypeOf(data).toEqualTypeOf<InfiniteData<string, number>>()
})
})

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import {
createMutation,
setQueryClientContext,
useIsMutating,
useMutationState,
} from '../../src/index.js'
import type {
Accessor,
CreateMutationOptions,
MutationStateOptions,
} from '../../src/index.js'
import type { MutationFilters } from '@tanstack/query-core'
type Props = {
queryClient: QueryClient
mutationOpts: Accessor<CreateMutationOptions<string>>
isMutatingFilters?: MutationFilters
mutationStateOpts?: MutationStateOptions
}
let {
queryClient,
mutationOpts,
isMutatingFilters,
mutationStateOpts,
}: Props = $props()
setQueryClientContext(queryClient)
const mutation = createMutation(mutationOpts)
const isMutating = useIsMutating(isMutatingFilters)
const mutationState = useMutationState(mutationStateOpts)
let clientIsMutating = $state(0)
$effect(() => {
const mutationCache = queryClient.getMutationCache()
clientIsMutating = isMutatingFilters
? queryClient.isMutating(isMutatingFilters)
: queryClient.isMutating()
const unsubscribe = mutationCache.subscribe(() => {
clientIsMutating = isMutatingFilters
? queryClient.isMutating(isMutatingFilters)
: queryClient.isMutating()
})
return unsubscribe
})
</script>
<button onclick={() => mutation.mutate()}>mutate</button>
<div>isMutating: {isMutating.current}</div>
<div>clientIsMutating: {clientIsMutating}</div>
<div>
mutationState: {JSON.stringify(mutationState.map((state) => state.data))}
</div>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import {
createMutation,
setQueryClientContext,
useIsMutating,
useMutationState,
} from '../../src/index.js'
import type {
Accessor,
CreateMutationOptions,
MutationStateOptions,
} from '../../src/index.js'
import type { MutationFilters } from '@tanstack/query-core'
type Props = {
queryClient: QueryClient
mutationOpts1: Accessor<CreateMutationOptions<string>>
mutationOpts2: Accessor<CreateMutationOptions<string>>
isMutatingFilters?: MutationFilters
mutationStateOpts?: MutationStateOptions
}
let {
queryClient,
mutationOpts1,
mutationOpts2,
isMutatingFilters,
mutationStateOpts,
}: Props = $props()
setQueryClientContext(queryClient)
const mutation1 = createMutation(mutationOpts1)
const mutation2 = createMutation(mutationOpts2)
const isMutating = useIsMutating(isMutatingFilters)
const mutationState = useMutationState(mutationStateOpts)
let clientIsMutating = $state(0)
$effect(() => {
const mutationCache = queryClient.getMutationCache()
clientIsMutating = isMutatingFilters
? queryClient.isMutating(isMutatingFilters)
: queryClient.isMutating()
const unsubscribe = mutationCache.subscribe(() => {
clientIsMutating = isMutatingFilters
? queryClient.isMutating(isMutatingFilters)
: queryClient.isMutating()
})
return unsubscribe
})
</script>
<button onclick={() => mutation1.mutate()}>mutate1</button>
<button onclick={() => mutation2.mutate()}>mutate2</button>
<div>isMutating: {isMutating.current}</div>
<div>clientIsMutating: {clientIsMutating}</div>
<div>
mutationState: {JSON.stringify(mutationState.map((state) => state.data))}
</div>

View File

@@ -0,0 +1,344 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render } from '@testing-library/svelte'
import { QueryClient } from '@tanstack/query-core'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { mutationOptions } from '../../src/index.js'
import Base from './Base.svelte'
import Multi from './Multi.svelte'
describe('mutationOptions', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
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)).toBe(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)).toBe(object)
})
it('should return the number of fetching mutations when used with useIsMutating (with mutationKey in mutationOptions)', async () => {
const key = queryKey()
const mutationOpts = mutationOptions({
mutationKey: key,
mutationFn: () => sleep(50).then(() => 'data'),
})
const rendered = render(Base, {
props: { queryClient, mutationOpts: () => mutationOpts },
})
expect(rendered.getByText('isMutating: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('isMutating: 1')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(50)
expect(rendered.getByText('isMutating: 0')).toBeInTheDocument()
})
it('should return the number of fetching mutations when used with useIsMutating (without mutationKey in mutationOptions)', async () => {
const mutationOpts = mutationOptions({
mutationFn: () => sleep(50).then(() => 'data'),
})
const rendered = render(Base, {
props: { queryClient, mutationOpts: () => mutationOpts },
})
expect(rendered.getByText('isMutating: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('isMutating: 1')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(50)
expect(rendered.getByText('isMutating: 0')).toBeInTheDocument()
})
it('should return the number of fetching mutations when used with useIsMutating', async () => {
const key = queryKey()
const mutationOpts1 = mutationOptions({
mutationKey: key,
mutationFn: () => sleep(50).then(() => 'data1'),
})
const mutationOpts2 = mutationOptions({
mutationFn: () => sleep(50).then(() => 'data2'),
})
const rendered = render(Multi, {
props: {
queryClient,
mutationOpts1: () => mutationOpts1,
mutationOpts2: () => mutationOpts2,
},
})
expect(rendered.getByText('isMutating: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('isMutating: 2')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(50)
expect(rendered.getByText('isMutating: 0')).toBeInTheDocument()
})
it('should return the number of fetching mutations when used with useIsMutating (filter mutationOpts1.mutationKey)', async () => {
const key = queryKey()
const mutationOpts1 = mutationOptions({
mutationKey: key,
mutationFn: () => sleep(50).then(() => 'data1'),
})
const mutationOpts2 = mutationOptions({
mutationFn: () => sleep(50).then(() => 'data2'),
})
const rendered = render(Multi, {
props: {
queryClient,
mutationOpts1: () => mutationOpts1,
mutationOpts2: () => mutationOpts2,
isMutatingFilters: { mutationKey: mutationOpts1.mutationKey },
},
})
expect(rendered.getByText('isMutating: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('isMutating: 1')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(50)
expect(rendered.getByText('isMutating: 0')).toBeInTheDocument()
})
it('should return the number of fetching mutations when used with queryClient.isMutating (with mutationKey in mutationOptions)', async () => {
const key = queryKey()
const mutationOpts = mutationOptions({
mutationKey: key,
mutationFn: () => sleep(500).then(() => 'data'),
})
const rendered = render(Base, {
props: {
queryClient,
mutationOpts: () => mutationOpts,
isMutatingFilters: { mutationKey: mutationOpts.mutationKey },
},
})
expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('clientIsMutating: 1')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(500)
expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument()
})
it('should return the number of fetching mutations when used with queryClient.isMutating (without mutationKey in mutationOptions)', async () => {
const mutationOpts = mutationOptions({
mutationFn: () => sleep(500).then(() => 'data'),
})
const rendered = render(Base, {
props: { queryClient, mutationOpts: () => mutationOpts },
})
expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('clientIsMutating: 1')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(500)
expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument()
})
it('should return the number of fetching mutations when used with queryClient.isMutating', async () => {
const key = queryKey()
const mutationOpts1 = mutationOptions({
mutationKey: key,
mutationFn: () => sleep(500).then(() => 'data1'),
})
const mutationOpts2 = mutationOptions({
mutationFn: () => sleep(500).then(() => 'data2'),
})
const rendered = render(Multi, {
props: {
queryClient,
mutationOpts1: () => mutationOpts1,
mutationOpts2: () => mutationOpts2,
},
})
expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('clientIsMutating: 2')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(500)
expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument()
})
it('should return the number of fetching mutations when used with queryClient.isMutating (filter mutationOpts1.mutationKey)', async () => {
const key = queryKey()
const mutationOpts1 = mutationOptions({
mutationKey: key,
mutationFn: () => sleep(500).then(() => 'data1'),
})
const mutationOpts2 = mutationOptions({
mutationFn: () => sleep(500).then(() => 'data2'),
})
const rendered = render(Multi, {
props: {
queryClient,
mutationOpts1: () => mutationOpts1,
mutationOpts2: () => mutationOpts2,
isMutatingFilters: { mutationKey: mutationOpts1.mutationKey },
},
})
expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('clientIsMutating: 1')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(500)
expect(rendered.getByText('clientIsMutating: 0')).toBeInTheDocument()
})
it('should return mutation states when used with useMutationState (with mutationKey in mutationOptions)', async () => {
const key = queryKey()
const mutationOpts = mutationOptions({
mutationKey: key,
mutationFn: () => sleep(10).then(() => 'data'),
})
const rendered = render(Base, {
props: {
queryClient,
mutationOpts: () => mutationOpts,
mutationStateOpts: {
filters: {
mutationKey: mutationOpts.mutationKey,
status: 'success',
},
},
},
})
expect(rendered.getByText('mutationState: []')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('mutationState: ["data"]')).toBeInTheDocument()
})
it('should return mutation states when used with useMutationState (without mutationKey in mutationOptions)', async () => {
const mutationOpts = mutationOptions({
mutationFn: () => sleep(10).then(() => 'data'),
})
const rendered = render(Base, {
props: {
queryClient,
mutationOpts: () => mutationOpts,
mutationStateOpts: {
filters: { status: 'success' },
},
},
})
expect(rendered.getByText('mutationState: []')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('mutationState: ["data"]')).toBeInTheDocument()
})
it('should return mutation states when used with useMutationState', async () => {
const key = queryKey()
const mutationOpts1 = mutationOptions({
mutationKey: key,
mutationFn: () => sleep(10).then(() => 'data1'),
})
const mutationOpts2 = mutationOptions({
mutationFn: () => sleep(10).then(() => 'data2'),
})
const rendered = render(Multi, {
props: {
queryClient,
mutationOpts1: () => mutationOpts1,
mutationOpts2: () => mutationOpts2,
mutationStateOpts: {
filters: { status: 'success' },
},
},
})
expect(rendered.getByText('mutationState: []')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
await vi.advanceTimersByTimeAsync(10)
expect(
rendered.getByText('mutationState: ["data1","data2"]'),
).toBeInTheDocument()
})
it('should return mutation states when used with useMutationState (filter mutationOpts1.mutationKey)', async () => {
const key = queryKey()
const mutationOpts1 = mutationOptions({
mutationKey: key,
mutationFn: () => sleep(10).then(() => 'data1'),
})
const mutationOpts2 = mutationOptions({
mutationFn: () => sleep(10).then(() => 'data2'),
})
const rendered = render(Multi, {
props: {
queryClient,
mutationOpts1: () => mutationOpts1,
mutationOpts2: () => mutationOpts2,
mutationStateOpts: {
filters: {
mutationKey: mutationOpts1.mutationKey,
status: 'success',
},
},
},
})
expect(rendered.getByText('mutationState: []')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate1/i }))
fireEvent.click(rendered.getByRole('button', { name: /mutate2/i }))
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('mutationState: ["data1"]')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,246 @@
import { assertType, describe, expectTypeOf, it } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import { queryKey } from '@tanstack/query-test-utils'
import {
createMutation,
mutationOptions,
useIsMutating,
useMutationState,
} from '../../src/index.js'
import type {
DefaultError,
MutationFunctionContext,
MutationState,
WithRequired,
} from '@tanstack/query-core'
import type {
CreateMutationOptions,
CreateMutationResult,
} from '../../src/types.js'
describe('mutationOptions', () => {
it('should not allow excess properties', () => {
const key = queryKey()
// @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<number>()
},
})
})
it('should infer types for callbacks', () => {
const key = queryKey()
mutationOptions({
mutationFn: () => Promise.resolve(5),
mutationKey: key,
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
})
})
it('should infer types for onError callback', () => {
const key = queryKey()
mutationOptions({
mutationFn: () => {
throw new Error('fail')
},
mutationKey: key,
onError: (error) => {
expectTypeOf(error).toEqualTypeOf<DefaultError>()
},
})
})
it('should infer types for variables', () => {
const key = queryKey()
mutationOptions<number, DefaultError, { id: string }>({
mutationFn: (vars) => {
expectTypeOf(vars).toEqualTypeOf<{ id: string }>()
return Promise.resolve(5)
},
mutationKey: key,
})
})
it('should infer result type correctly', () => {
const key = queryKey()
mutationOptions<number, DefaultError, void, { name: string }>({
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', () => {
const key = queryKey()
mutationOptions<number>({
mutationFn: (_variables, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
return Promise.resolve(5)
},
mutationKey: key,
onMutate: (_variables, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
onSuccess: (_data, _variables, _onMutateResult, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
onError: (_error, _variables, _onMutateResult, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
onSettled: (_data, _error, _variables, _onMutateResult, context) => {
expectTypeOf(context).toEqualTypeOf<MutationFunctionContext>()
},
})
})
it('should error if mutationFn return type mismatches TData', () => {
assertType(
mutationOptions<number>({
// @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<number>()
},
})
})
it('should infer all types when not explicitly provided', () => {
const key = queryKey()
expectTypeOf(
mutationOptions({
mutationFn: (id: string) => Promise.resolve(id.length),
mutationKey: key,
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
}),
).toEqualTypeOf<
WithRequired<
CreateMutationOptions<number, DefaultError, string>,
'mutationKey'
>
>()
expectTypeOf(
mutationOptions({
mutationFn: (id: string) => Promise.resolve(id.length),
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
}),
).toEqualTypeOf<
Omit<CreateMutationOptions<number, DefaultError, string>, 'mutationKey'>
>()
})
it('should work when used with createMutation', () => {
const key = queryKey()
const mutation = createMutation(() =>
mutationOptions({
mutationKey: key,
mutationFn: () => Promise.resolve('data'),
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<string>()
},
}),
)
expectTypeOf(mutation).toEqualTypeOf<
CreateMutationResult<string, DefaultError, void, unknown>
>()
createMutation(() =>
// should allow when used with createMutation without mutationKey
mutationOptions({
mutationFn: () => Promise.resolve('data'),
onSuccess: (data) => {
expectTypeOf(data).toEqualTypeOf<string>()
},
}),
)
})
it('should work when used with useIsMutating', () => {
const key = queryKey()
const isMutating = useIsMutating(
mutationOptions({
mutationKey: key,
mutationFn: () => Promise.resolve(5),
}),
)
expectTypeOf(isMutating.current).toEqualTypeOf<number>()
useIsMutating(
// @ts-expect-error filters should have mutationKey
mutationOptions({
mutationFn: () => Promise.resolve(5),
}),
)
})
it('should work when used with queryClient.isMutating', () => {
const key = queryKey()
const queryClient = new QueryClient()
const isMutating = queryClient.isMutating(
mutationOptions({
mutationKey: key,
mutationFn: () => Promise.resolve(5),
}),
)
expectTypeOf(isMutating).toEqualTypeOf<number>()
queryClient.isMutating(
// @ts-expect-error filters should have mutationKey
mutationOptions({
mutationFn: () => Promise.resolve(5),
}),
)
})
it('should work when used with useMutationState', () => {
const key = queryKey()
const mutationState = useMutationState({
filters: mutationOptions({
mutationKey: key,
mutationFn: () => Promise.resolve(5),
}),
})
expectTypeOf(mutationState).toEqualTypeOf<
Array<MutationState<unknown, Error, unknown, unknown>>
>()
useMutationState({
// @ts-expect-error filters should have mutationKey
filters: mutationOptions({
mutationFn: () => Promise.resolve(5),
}),
})
})
})

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { queryOptions } from '../src/index.js'
describe('queryOptions', () => {
it('should return the object received as a parameter without any modification.', () => {
const object = {
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
} as const
expect(queryOptions(object)).toBe(object)
})
})

View File

@@ -0,0 +1,271 @@
import { assertType, describe, expectTypeOf, it } from 'vitest'
import {
QueriesObserver,
QueryClient,
dataTagSymbol,
skipToken,
} from '@tanstack/query-core'
import { queryKey } from '@tanstack/query-test-utils'
import { createQueries, queryOptions } from '../src/index.js'
import type { QueryObserverResult } from '@tanstack/query-core'
// Regression test for exported queryOptions inference under declaration emit.
// TypeScript should be able to name the return type without expanding the
// internal data tag symbols into the consumer's .d.ts output.
export const exportedQueryOptions = queryOptions({
queryKey: ['invalid'],
})
describe('queryOptions', () => {
it('should not allow excess properties', () => {
const key = queryKey()
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', () => {
const key = queryKey()
queryOptions({
queryKey: key,
queryFn: () => Promise.resolve(5),
staleTime: 1000,
select: (data) => {
expectTypeOf(data).toEqualTypeOf<number>()
},
})
})
it('should work when passed to query', async () => {
const options = queryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
})
const data = await new QueryClient().query(options)
expectTypeOf(data).toEqualTypeOf<number>()
})
it('should work when passed to query with select', async () => {
const options = queryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
select: (data) => data.toString(),
})
const data = await new QueryClient().query(options)
expectTypeOf(data).toEqualTypeOf<string>()
})
it('should work when passed to query with enabled: false', async () => {
const options = queryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
enabled: false,
})
const data = await new QueryClient().query(options)
expectTypeOf(data).toEqualTypeOf<number>()
})
it('should work when passed to query with skipToken', async () => {
const options = queryOptions({
queryKey: ['key'],
queryFn: skipToken,
})
const data = await new QueryClient().query(options)
expectTypeOf(data).toEqualTypeOf<unknown>()
})
it('should work when passed to fetchQuery', async () => {
const key = queryKey()
const options = queryOptions({
queryKey: key,
queryFn: () => Promise.resolve(5),
})
// grandfathered direct test
// eslint-disable-next-line no-restricted-syntax
const data = await new QueryClient().fetchQuery(options)
expectTypeOf(data).toEqualTypeOf<number>()
})
it('should work when passed to createQueries', () => {
const key = queryKey()
const options = queryOptions({
queryKey: key,
queryFn: () => Promise.resolve(5),
})
const queries = createQueries(() => ({
queries: [options],
}))
expectTypeOf(queries[0].data).toEqualTypeOf<number | undefined>()
})
it('should tag the queryKey with the result type of the QueryFn', () => {
const key = queryKey()
const { queryKey: tagged } = queryOptions({
queryKey: key,
queryFn: () => Promise.resolve(5),
})
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<number>()
})
it('should tag the queryKey even if no promise is returned', () => {
const key = queryKey()
const { queryKey: tagged } = queryOptions({
queryKey: key,
queryFn: () => 5,
})
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<number>()
})
it('should tag the queryKey with unknown if there is no queryFn', () => {
const key = queryKey()
const { queryKey: tagged } = queryOptions({
queryKey: key,
})
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<unknown>()
})
it('should tag the queryKey with the result type of the QueryFn if select is used', () => {
const key = queryKey()
const { queryKey: tagged } = queryOptions({
queryKey: key,
queryFn: () => Promise.resolve(5),
select: (data) => data.toString(),
})
expectTypeOf(tagged[dataTagSymbol]).toEqualTypeOf<number>()
})
it('should return the proper type when passed to getQueryData', () => {
const key = queryKey()
const { queryKey: tagged } = queryOptions({
queryKey: key,
queryFn: () => Promise.resolve(5),
})
const queryClient = new QueryClient()
const data = queryClient.getQueryData(tagged)
expectTypeOf(data).toEqualTypeOf<number | undefined>()
})
it('should return the proper type when passed to getQueryState', () => {
const key = queryKey()
const { queryKey: tagged } = queryOptions({
queryKey: key,
queryFn: () => Promise.resolve(5),
})
const queryClient = new QueryClient()
const state = queryClient.getQueryState(tagged)
expectTypeOf(state?.data).toEqualTypeOf<number | undefined>()
})
it('should properly type updaterFn when passed to setQueryData', () => {
const key = queryKey()
const { queryKey: tagged } = queryOptions({
queryKey: key,
queryFn: () => Promise.resolve(5),
})
const queryClient = new QueryClient()
const data = queryClient.setQueryData(tagged, (prev) => {
expectTypeOf(prev).toEqualTypeOf<number | undefined>()
return prev
})
expectTypeOf(data).toEqualTypeOf<number | undefined>()
})
it('should properly type value when passed to setQueryData', () => {
const key = queryKey()
const { queryKey: tagged } = queryOptions({
queryKey: key,
queryFn: () => Promise.resolve(5),
})
const queryClient = new QueryClient()
// @ts-expect-error value should be a number
queryClient.setQueryData(tagged, '5')
// @ts-expect-error value should be a number
queryClient.setQueryData(tagged, () => '5')
const data = queryClient.setQueryData(tagged, 5)
expectTypeOf(data).toEqualTypeOf<number | undefined>()
})
it('should infer even if there is a conditional skipToken', () => {
const key = queryKey()
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<number | undefined>()
})
it('should infer to unknown if we disable a query with just a skipToken', () => {
const key = queryKey()
const options = queryOptions({
queryKey: key,
queryFn: skipToken,
})
const queryClient = new QueryClient()
const data = queryClient.getQueryData(options.queryKey)
expectTypeOf(data).toEqualTypeOf<unknown>()
})
it('should return the proper type when passed to QueriesObserver', () => {
const key = queryKey()
const options = queryOptions({
queryKey: key,
queryFn: () => Promise.resolve(5),
})
const queryClient = new QueryClient()
const queriesObserver = new QueriesObserver(queryClient, [options])
expectTypeOf(queriesObserver).toEqualTypeOf<
QueriesObserver<Array<QueryObserverResult>>
>()
})
it('should allow undefined response in initialData', () => {
const key = queryKey()
const options = (id: string | null) =>
queryOptions({
queryKey: [...key, id],
queryFn: () =>
Promise.resolve({
id: '1',
title: 'Do Laundry',
}),
initialData: () =>
!id
? undefined
: {
id,
title: 'Initial Data',
},
})
expectTypeOf(options(null).initialData).returns.toEqualTypeOf<
{ id: string; title: string } | undefined
>()
})
})

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { setQueryClientContext } from '../../src/context.js'
import { createQuery, useIsFetching } from '../../src/index.js'
type Props = {
queryClient: QueryClient
}
let { queryClient }: Props = $props()
setQueryClientContext(queryClient)
let ready = $state(false)
const query = createQuery(() => ({
queryKey: queryKey(),
queryFn: () => sleep(10).then(() => 'test'),
enabled: ready,
}))
const isFetching = useIsFetching()
</script>
<button onclick={() => (ready = true)}>setReady</button>
<div>isFetching: {isFetching.current}</div>
<div>Data: {query.data ?? 'undefined'}</div>

View File

@@ -0,0 +1,32 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render } from '@testing-library/svelte'
import { QueryClient } from '@tanstack/query-core'
import Base from './Base.svelte'
describe('useIsFetching', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('should update as queries start and stop fetching', async () => {
const rendered = render(Base, {
props: { queryClient },
})
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /setReady/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('isFetching: 1')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('isFetching: 0')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { setQueryClientContext } from '../../src/context.js'
import { createMutation, useIsMutating } from '../../src/index.js'
type Props = {
queryClient: QueryClient
}
let { queryClient }: Props = $props()
setQueryClientContext(queryClient)
const mutation = createMutation(() => ({
mutationKey: queryKey(),
mutationFn: () => sleep(10).then(() => 'data'),
}))
const isMutating = useIsMutating()
</script>
<button onclick={() => mutation.mutate()}>Trigger</button>
<div>isMutating: {isMutating.current}</div>

View File

@@ -0,0 +1,32 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render } from '@testing-library/svelte'
import { QueryClient } from '@tanstack/query-core'
import Base from './Base.svelte'
describe('useIsMutating', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('should update as queries start and stop mutating', async () => {
const rendered = render(Base, {
props: { queryClient },
})
expect(rendered.getByText('isMutating: 0')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /Trigger/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('isMutating: 1')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('isMutating: 0')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import {
createMutation,
setQueryClientContext,
useMutationState,
} from '../../src/index.js'
import type {
Accessor,
CreateMutationOptions,
MutationStateOptions,
} from '../../src/index.js'
type Props = {
queryClient: QueryClient
successMutationOpts: Accessor<CreateMutationOptions<string>>
errorMutationOpts: Accessor<CreateMutationOptions<string>>
mutationStateOpts?: MutationStateOptions
}
let {
queryClient,
successMutationOpts,
errorMutationOpts,
mutationStateOpts,
}: Props = $props()
setQueryClientContext(queryClient)
const successMutation = createMutation(successMutationOpts)
const errorMutation = createMutation(errorMutationOpts)
const mutationState = useMutationState(mutationStateOpts)
</script>
<button onclick={() => successMutation.mutate()}>Success</button>
<button onclick={() => errorMutation.mutate()}>Error</button>
<div>
Data: {JSON.stringify(mutationState.map((state) => state.status))}
</div>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import {
createMutation,
setQueryClientContext,
useMutationState,
} from '../../src/index.js'
import type {
Accessor,
CreateMutationOptions,
MutationStateOptions,
} from '../../src/index.js'
type Props = {
queryClient: QueryClient
mutationOpts: Accessor<CreateMutationOptions<string>>
mutationStateOpts: MutationStateOptions<string>
}
let { queryClient, mutationOpts, mutationStateOpts }: Props = $props()
setQueryClientContext(queryClient)
const mutation = createMutation(mutationOpts)
const variables = useMutationState(mutationStateOpts)
</script>
<button onclick={() => mutation.mutate()}>mutate</button>
<div>
Variables: {JSON.stringify(variables)}
</div>

View File

@@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render } from '@testing-library/svelte'
import { QueryClient } from '@tanstack/query-core'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import Base from './Base.svelte'
import Select from './Select.svelte'
import type { Mutation } from '@tanstack/query-core'
describe('useMutationState', () => {
let queryClient: QueryClient
beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
})
afterEach(() => {
queryClient.clear()
vi.useRealTimers()
})
it('should run few mutation functions and check from useMutationState', async () => {
const successKey = queryKey()
const errorKey = queryKey()
const successMutationFn = vi.fn(() => sleep(10).then(() => 'data'))
const errorMutationFn = vi
.fn()
.mockImplementation(() =>
sleep(20).then(() => Promise.reject(new Error('error'))),
)
const rendered = render(Base, {
props: {
queryClient,
successMutationOpts: () => ({
mutationKey: successKey,
mutationFn: successMutationFn,
}),
errorMutationOpts: () => ({
mutationKey: errorKey,
mutationFn: errorMutationFn,
}),
},
})
fireEvent.click(rendered.getByRole('button', { name: /Success/i }))
await vi.advanceTimersByTimeAsync(10)
expect(successMutationFn).toHaveBeenCalledTimes(1)
expect(rendered.getByText('Data: ["success"]')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /Error/i }))
await vi.advanceTimersByTimeAsync(20)
expect(errorMutationFn).toHaveBeenCalledTimes(1)
expect(rendered.getByText('Data: ["success","error"]')).toBeInTheDocument()
})
it('should select specific type of mutation ( i.e: error only )', async () => {
const successKey = queryKey()
const errorKey = queryKey()
const successMutationFn = vi.fn(() => sleep(10).then(() => 'data'))
const errorMutationFn = vi
.fn()
.mockImplementation(() =>
sleep(20).then(() => Promise.reject(new Error('error'))),
)
const rendered = render(Base, {
props: {
queryClient,
successMutationOpts: () => ({
mutationKey: successKey,
mutationFn: successMutationFn,
}),
errorMutationOpts: () => ({
mutationKey: errorKey,
mutationFn: errorMutationFn,
}),
mutationStateOpts: {
filters: { status: 'error' },
},
},
})
fireEvent.click(rendered.getByRole('button', { name: /Success/i }))
await vi.advanceTimersByTimeAsync(10)
expect(successMutationFn).toHaveBeenCalledTimes(1)
expect(rendered.getByText('Data: []')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /Error/i }))
await vi.advanceTimersByTimeAsync(20)
expect(errorMutationFn).toHaveBeenCalledTimes(1)
expect(rendered.getByText('Data: ["error"]')).toBeInTheDocument()
})
it('should return selected value when using select option', async () => {
const mutationKey = queryKey()
const rendered = render(Select, {
props: {
queryClient,
mutationOpts: () => ({
mutationKey,
mutationFn: () => sleep(10).then(() => 'data'),
}),
mutationStateOpts: {
filters: { mutationKey },
select: (mutation: Mutation) => mutation.state.status,
},
},
})
expect(rendered.getByText('Variables: []')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByText('Variables: ["pending"]')).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('Variables: ["success"]')).toBeInTheDocument()
})
it('should select specific mutation using mutation key', async () => {
const successKey = queryKey()
const errorKey = queryKey()
const successMutationFn = vi.fn(() => sleep(10).then(() => 'data'))
const errorMutationFn = vi
.fn()
.mockImplementation(() =>
sleep(20).then(() => Promise.reject(new Error('error'))),
)
const rendered = render(Base, {
props: {
queryClient,
successMutationOpts: () => ({
mutationKey: successKey,
mutationFn: successMutationFn,
}),
errorMutationOpts: () => ({
mutationKey: errorKey,
mutationFn: errorMutationFn,
}),
mutationStateOpts: {
filters: { mutationKey: successKey },
},
},
})
fireEvent.click(rendered.getByRole('button', { name: /Success/i }))
await vi.advanceTimersByTimeAsync(10)
expect(successMutationFn).toHaveBeenCalledTimes(1)
expect(rendered.getByText('Data: ["success"]')).toBeInTheDocument()
fireEvent.click(rendered.getByRole('button', { name: /Error/i }))
await vi.advanceTimersByTimeAsync(20)
expect(errorMutationFn).toHaveBeenCalledTimes(1)
expect(rendered.getByText('Data: ["success"]')).toBeInTheDocument()
})
it('should remove mutations that no longer match the filter', async () => {
const firstKey = queryKey()
const secondKey = queryKey()
const mutationFn = vi.fn(() => sleep(10).then(() => 'data'))
const rendered = render(Base, {
props: {
queryClient,
successMutationOpts: () => ({
mutationKey: firstKey,
mutationFn,
}),
errorMutationOpts: () => ({
mutationKey: secondKey,
mutationFn,
}),
mutationStateOpts: {
filters: { status: 'pending' },
},
},
})
fireEvent.click(rendered.getByRole('button', { name: /Success/i }))
fireEvent.click(rendered.getByRole('button', { name: /Error/i }))
await vi.advanceTimersByTimeAsync(0)
expect(
rendered.getByText('Data: ["pending","pending"]'),
).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('Data: []')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,24 @@
import { describe, expectTypeOf, it } from 'vitest'
import { useMutationState } from '../../src/index.js'
import type { MutationState, MutationStatus } from '@tanstack/query-core'
describe('useMutationState', () => {
it('should default to MutationState', () => {
const result = useMutationState({
filters: { status: 'pending' },
})
expectTypeOf(result).toEqualTypeOf<
Array<MutationState<unknown, Error, unknown, unknown>>
>()
})
it('should infer with select', () => {
const result = useMutationState({
filters: { status: 'pending' },
select: (mutation) => mutation.state.status,
})
expectTypeOf(result).toEqualTypeOf<Array<MutationStatus>>()
})
})

View File

@@ -0,0 +1,79 @@
import { flushSync } from 'svelte'
import { describe, expect, it, vi } from 'vitest'
import { watchChanges } from '../src/utils.svelte.js'
import { ref, withEffectRoot } from './utils.svelte.js'
describe('watchChanges', () => {
it(
'should skip the first run and only call the effect on subsequent changes',
withEffectRoot(() => {
const source = ref(0)
const effect = vi.fn()
watchChanges(() => source.value, 'pre', effect)
// first run only records previousValues, effect is not called
flushSync()
expect(effect).not.toHaveBeenCalled()
source.value = 1
flushSync()
expect(effect).toHaveBeenCalledExactlyOnceWith(1, 0)
}),
)
it(
'should run with the "post" flush timing',
withEffectRoot(() => {
const source = ref(0)
const effect = vi.fn()
watchChanges(() => source.value, 'post', effect)
flushSync()
expect(effect).not.toHaveBeenCalled()
source.value = 1
flushSync()
expect(effect).toHaveBeenCalledExactlyOnceWith(1, 0)
}),
)
it(
'should track an array of sources and pass arrays of values',
withEffectRoot(() => {
const a = ref(1)
const b = ref(2)
const effect = vi.fn()
watchChanges([() => a.value, () => b.value], 'pre', effect)
flushSync()
expect(effect).not.toHaveBeenCalled()
a.value = 10
flushSync()
expect(effect).toHaveBeenCalledExactlyOnceWith([10, 2], [1, 2])
}),
)
it(
'should run the returned cleanup before the next effect run',
withEffectRoot(() => {
const source = ref(0)
const cleanup = vi.fn()
const effect = vi.fn(() => cleanup)
watchChanges(() => source.value, 'pre', effect)
flushSync()
source.value = 1
flushSync()
expect(cleanup).not.toHaveBeenCalled()
source.value = 2
flushSync()
expect(cleanup).toHaveBeenCalledOnce()
}),
)
})

View File

@@ -0,0 +1,33 @@
export function ref<T>(initial: T) {
let value = $state(initial)
return {
get value() {
return value
},
set value(newValue) {
value = newValue
},
}
}
export function promiseWithResolvers<T>() {
let resolve: (value: T) => void
let reject: (reason?: any) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve: resolve!, reject: reject! }
}
export function withEffectRoot(fn: () => void | Promise<void>) {
return async () => {
let promise: void | Promise<void> = Promise.resolve()
const cleanup = $effect.root(() => {
promise = fn()
})
await promise
cleanup()
}
}