From 640679bbcaeb2aa31d3b00acec2ad21eba05e6d1 Mon Sep 17 00:00:00 2001 From: maxime Date: Fri, 3 May 2024 11:55:34 +0200 Subject: [PATCH] feat: upgraded with latest react-query release --- package-lock.json | 8 +- package.json | 2 +- src/lib/queries/client/QueryClient.ts | 100 +- .../client/queries/cache/QueryCache.ts | 5 +- .../queries/observer/QueryObserver.rq.test.ts | 1858 ++++++++--------- .../client/queries/observer/QueryObserver.ts | 2 +- .../queries/client/queries/observer/types.ts | 11 +- src/lib/queries/client/queries/query/Query.ts | 6 +- .../client/queries/query/query.rq.test.ts | 122 +- src/lib/queries/client/queries/types.ts | 11 +- src/lib/queries/client/queries/utils.ts | 8 +- .../react/mutations/useMutationState.test.tsx | 6 +- .../queries/QueryOptions.rq.types.test.ts | 191 -- .../queries/QueryOptions.rq.types.typeTest.ts | 176 ++ src/lib/queries/react/queries/queryOptions.ts | 28 +- src/lib/queries/react/queries/types.ts | 30 +- .../react/queries/useIsFetching.rq.test.tsx | 88 +- src/lib/utils/types.ts | 14 +- tsconfig.json | 27 +- 19 files changed, 1383 insertions(+), 1310 deletions(-) delete mode 100644 src/lib/queries/react/queries/QueryOptions.rq.types.test.ts create mode 100644 src/lib/queries/react/queries/QueryOptions.rq.types.typeTest.ts diff --git a/package-lock.json b/package-lock.json index c88eb88..9615f62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "rollup-plugin-node-externals": "^7.0.1", "rxjs": "^7.8.0", "semantic-release": "^23.0.2", - "typescript": "^5.0.4", + "typescript": "5.2.2", "vite": "^5.1.3", "vite-plugin-dts": "^3.6.3", "vitest": "^1.3.0" @@ -11764,9 +11764,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index f464c3e..7077ef3 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "rollup-plugin-node-externals": "^7.0.1", "rxjs": "^7.8.0", "semantic-release": "^23.0.2", - "typescript": "^5.0.4", + "typescript": "5.2.2", "vite": "^5.1.3", "vite-plugin-dts": "^3.6.3", "vitest": "^1.3.0" diff --git a/src/lib/queries/client/QueryClient.ts b/src/lib/queries/client/QueryClient.ts index 088696c..918d113 100644 --- a/src/lib/queries/client/QueryClient.ts +++ b/src/lib/queries/client/QueryClient.ts @@ -11,8 +11,7 @@ import { type FetchQueryOptions, type QueryFilters, type SetDataOptions, - type RefetchOptions, - type QueryOptions + type RefetchOptions } from "./queries/types" import { type RefetchQueryFilters, @@ -26,15 +25,22 @@ import { type DefaultedQueryObserverOptions, type QueryObserverOptions } from "./queries/observer/types" -import { functionalUpdate, hashQueryKeyByOptions } from "./queries/utils" -import { type NoInfer } from "../../utils/types" +import { + functionalUpdate, + hashQueryKeyByOptions, + skipToken +} from "./queries/utils" +import { type NoInfer, type OmitKeyof } from "../../utils/types" import { type QueryState } from "./queries/query/types" import { type CancelOptions } from "./queries/retryer/types" import { hashKey } from "./keys/hashKey" import { partialMatchKey } from "./keys/partialMatchKey" export interface DefaultOptions { - queries?: Omit, "suspense"> + queries?: OmitKeyof< + QueryObserverOptions, + "suspense" | "queryKey" + > mutations?: MutationObserverOptions } @@ -99,7 +105,8 @@ export class QueryClient { TQueryKey extends QueryKey = QueryKey, TPageParam = never >( - options?: + options: + | QueryObserverOptions | QueryObserverOptions< TQueryFnData, TError, @@ -122,7 +129,7 @@ export class QueryClient { TQueryData, TQueryKey > { - if (options?._defaulted) { + if (options._defaulted) { return options as DefaultedQueryObserverOptions< TQueryFnData, TError, @@ -134,7 +141,7 @@ export class QueryClient { const defaultedOptions = { ...this.#defaultOptions.queries, - ...(options?.queryKey && this.getQueryDefaults(options.queryKey)), + ...this.getQueryDefaults(options.queryKey), ...options, _defaulted: true } @@ -142,24 +149,28 @@ export class QueryClient { if (!defaultedOptions.queryHash) { defaultedOptions.queryHash = hashQueryKeyByOptions( defaultedOptions.queryKey, - defaultedOptions as QueryOptions + defaultedOptions ) } // dependent default values - if (typeof defaultedOptions.refetchOnReconnect === "undefined") { + if (defaultedOptions.refetchOnReconnect === undefined) { defaultedOptions.refetchOnReconnect = defaultedOptions.networkMode !== "always" } - if (typeof defaultedOptions.throwOnError === "undefined") { + if (defaultedOptions.throwOnError === undefined) { defaultedOptions.throwOnError = !!defaultedOptions.suspense } + if (!defaultedOptions.networkMode && defaultedOptions.persister) { + defaultedOptions.networkMode = "offlineFirst" + } + if ( - typeof defaultedOptions.networkMode === "undefined" && - defaultedOptions.persister + defaultedOptions.enabled !== true && + defaultedOptions.queryFn === skipToken ) { - defaultedOptions.networkMode = "offlineFirst" + defaultedOptions.enabled = false } return defaultedOptions as DefaultedQueryObserverOptions< @@ -178,13 +189,9 @@ export class QueryClient { TQueryKey extends QueryKey = QueryKey, TPageParam = never >( - options: FetchQueryOptions< - TQueryFnData, - TError, - TData, - TQueryKey, - TPageParam - > + options: + | FetchQueryOptions + | FetchQueryOptions ): Promise { const defaultedOptions = this.defaultQueryOptions(options) @@ -265,34 +272,27 @@ export class QueryClient { >(queryKey: TTaggedQueryKey): TInferredQueryFnData | undefined getQueryData(queryKey: QueryKey) { const options = this.defaultQueryOptions({ queryKey }) + return this.#queryCache.get(options.queryHash)?.state.data } setQueryData< TQueryFnData = unknown, - TaggedQueryKey extends QueryKey = QueryKey, - TInferredQueryFnData = TaggedQueryKey extends DataTag< + TTaggedQueryKey extends QueryKey = QueryKey, + TInferredQueryFnData = TTaggedQueryKey extends DataTag< unknown, infer TaggedValue > ? TaggedValue : TQueryFnData >( - queryKey: TaggedQueryKey, + queryKey: TTaggedQueryKey, updater: Updater< NoInfer | undefined, NoInfer | undefined >, options?: SetDataOptions ): TInferredQueryFnData | undefined { - const query = this.#queryCache.find({ queryKey }) - const prevData = query?.state.data - const data = functionalUpdate(updater, prevData) - - if (typeof data === "undefined") { - return undefined - } - const defaultedOptions = this.defaultQueryOptions< any, any, @@ -301,6 +301,16 @@ export class QueryClient { QueryKey >({ queryKey }) + const query = this.#queryCache.get( + defaultedOptions.queryHash + ) + const prevData = query?.state.data + const data = functionalUpdate(updater, prevData) + + if (data === undefined) { + return undefined + } + return this.#queryCache .build(this, defaultedOptions) .setData(data, { ...options, manual: true }) @@ -322,10 +332,21 @@ export class QueryClient { return result } - getQueryState( - queryKey: QueryKey - ): QueryState | undefined { - return this.#queryCache.find({ queryKey })?.state + getQueryState< + TQueryFnData = unknown, + TError = DefaultError, + TTaggedQueryKey extends QueryKey = QueryKey, + TInferredQueryFnData = TTaggedQueryKey extends DataTag< + unknown, + infer TaggedValue + > + ? TaggedValue + : TQueryFnData + >( + queryKey: TTaggedQueryKey + ): QueryState | undefined { + return this.#queryCache.find({ queryKey }) + ?.state } setMutationDefaults( @@ -352,10 +373,13 @@ export class QueryClient { getQueryDefaults( queryKey: QueryKey - ): QueryObserverOptions { + ): OmitKeyof, "queryKey"> { const defaults = [...this.#queryDefaults.values()] - let result: QueryObserverOptions = {} + let result: OmitKeyof< + QueryObserverOptions, + "queryKey" + > = {} defaults.forEach((queryDefault) => { if (partialMatchKey(queryKey, queryDefault.queryKey)) { diff --git a/src/lib/queries/client/queries/cache/QueryCache.ts b/src/lib/queries/client/queries/cache/QueryCache.ts index 2dc32ba..1398ff8 100644 --- a/src/lib/queries/client/queries/cache/QueryCache.ts +++ b/src/lib/queries/client/queries/cache/QueryCache.ts @@ -87,7 +87,10 @@ export class QueryCache { build( client: QueryClient, - options: QueryOptions, + options: WithRequired< + QueryOptions, + "queryKey" + >, state?: QueryState ): Query { const queryKey = options.queryKey ?? ([nanoid()] as unknown as TQueryKey) diff --git a/src/lib/queries/client/queries/observer/QueryObserver.rq.test.ts b/src/lib/queries/client/queries/observer/QueryObserver.rq.test.ts index b787de6..2873095 100644 --- a/src/lib/queries/client/queries/observer/QueryObserver.rq.test.ts +++ b/src/lib/queries/client/queries/observer/QueryObserver.rq.test.ts @@ -5,940 +5,940 @@ /* eslint-disable @typescript-eslint/no-confusing-void-expression */ /* eslint-disable @typescript-eslint/array-type */ import { - afterEach, - beforeEach, - describe, - expect, - expectTypeOf, - test, - vi, - } from 'vitest' - import { waitFor } from '@testing-library/react' -import { type QueryClient } from '../../QueryClient' -import { createQueryClient, sleep } from '../../../../../tests/utils' -import { queryKey } from '../../tests/utils' -import { QueryObserver } from './QueryObserver' -import { type QueryObserverResult } from './types' -import { focusManager } from '../../focusManager' - - describe('queryObserver', () => { - let queryClient: QueryClient - - beforeEach(() => { - queryClient = createQueryClient() - queryClient.mount() - }) - - afterEach(() => { - queryClient.clear() - }) - - test('should trigger a fetch when subscribed', async () => { - const key = queryKey() - const queryFn = vi.fn, string>().mockReturnValue('data') - const observer = new QueryObserver(queryClient, { queryKey: key, queryFn }) - const unsubscribe = observer.subscribe(() => undefined) - await sleep(1) - unsubscribe() - expect(queryFn).toHaveBeenCalledTimes(1) - }) - - test('should be able to read latest data after subscribing', async () => { - const key = queryKey() - queryClient.setQueryData(key, 'data') - const observer = new QueryObserver(queryClient, { - queryKey: key, - enabled: false, - }) - - const unsubscribe = observer.subscribe(vi.fn()) - - expect(observer.getCurrentResult()).toMatchObject({ - status: 'success', - data: 'data', - }) - - unsubscribe() - }) - - test('should be able to read latest data when re-subscribing (but not re-fetching)', async () => { - const key = queryKey() - let count = 0 - const observer = new QueryObserver(queryClient, { - queryKey: key, - staleTime: Infinity, - queryFn: async () => { - await sleep(10) - count++ - return 'data' - }, - }) - - let unsubscribe = observer.subscribe(vi.fn()) - - // unsubscribe before data comes in - unsubscribe() - expect(count).toBe(0) - expect(observer.getCurrentResult()).toMatchObject({ - status: 'pending', - fetchStatus: 'fetching', - data: undefined, - }) - - await waitFor(() => expect(count).toBe(1)) - - // re-subscribe after data comes in - unsubscribe = observer.subscribe(vi.fn()) - - expect(observer.getCurrentResult()).toMatchObject({ - status: 'success', - data: 'data', - }) - - unsubscribe() - }) - - test('should notify when switching query', async () => { - const key1 = queryKey() - const key2 = queryKey() - const results: Array = [] - const observer = new QueryObserver(queryClient, { - queryKey: key1, - queryFn: () => 1, - }) - const unsubscribe = observer.subscribe((result) => { - results.push(result) - }) - await sleep(1) - observer.setOptions({ queryKey: key2, queryFn: () => 2 }) - await sleep(1) - unsubscribe() - expect(results.length).toBe(4) - expect(results[0]).toMatchObject({ data: undefined, status: 'pending' }) - expect(results[1]).toMatchObject({ data: 1, status: 'success' }) - expect(results[2]).toMatchObject({ data: undefined, status: 'pending' }) - expect(results[3]).toMatchObject({ data: 2, status: 'success' }) - }) - - test('should be able to fetch with a selector', async () => { - const key = queryKey() - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => ({ count: 1 }), - select: (data) => ({ myCount: data.count }), - }) - let observerResult - const unsubscribe = observer.subscribe((result) => { - expectTypeOf>(result) - observerResult = result - }) - await sleep(1) - unsubscribe() - expect(observerResult).toMatchObject({ data: { myCount: 1 } }) - }) - - test('should be able to fetch with a selector using the fetch method', async () => { - const key = queryKey() - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => ({ count: 1 }), - select: (data) => ({ myCount: data.count }), - }) - const observerResult = await observer.refetch() - expectTypeOf<{ myCount: number } | undefined>(observerResult.data) - expect(observerResult.data).toMatchObject({ myCount: 1 }) - }) - - test('should be able to fetch with a selector and object syntax', async () => { - const key = queryKey() - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => ({ count: 1 }), - select: (data) => ({ myCount: data.count }), - }) - let observerResult - const unsubscribe = observer.subscribe((result) => { - observerResult = result - }) - await sleep(1) - unsubscribe() - expect(observerResult).toMatchObject({ data: { myCount: 1 } }) - }) - - test('should run the selector again if the data changed', async () => { - const key = queryKey() - let count = 0 - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => ({ count }), - select: (data) => { - count++ - return { myCount: data.count } - }, - }) - const observerResult1 = await observer.refetch() - const observerResult2 = await observer.refetch() - expect(count).toBe(2) - expect(observerResult1.data).toMatchObject({ myCount: 0 }) - expect(observerResult2.data).toMatchObject({ myCount: 1 }) - }) - - test('should run the selector again if the selector changed', async () => { - const key = queryKey() - let count = 0 - const results: Array = [] - const queryFn = () => ({ count: 1 }) - const select1 = (data: ReturnType) => { - count++ - return { myCount: data.count } - } - const select2 = (_data: ReturnType) => { + afterEach, + beforeEach, + describe, + expect, + expectTypeOf, + test, + vi +} from "vitest" +import { waitFor } from "@testing-library/react" +import { type QueryClient } from "../../QueryClient" +import { createQueryClient, sleep } from "../../../../../tests/utils" +import { queryKey } from "../../tests/utils" +import { QueryObserver } from "./QueryObserver" +import { type QueryObserverResult } from "./types" +import { focusManager } from "../../focusManager" + +describe("queryObserver", () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = createQueryClient() + queryClient.mount() + }) + + afterEach(() => { + queryClient.clear() + }) + + test("should trigger a fetch when subscribed", async () => { + const key = queryKey() + const queryFn = vi.fn, string>().mockReturnValue("data") + const observer = new QueryObserver(queryClient, { queryKey: key, queryFn }) + const unsubscribe = observer.subscribe(() => undefined) + await sleep(1) + unsubscribe() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + test("should be able to read latest data after subscribing", async () => { + const key = queryKey() + queryClient.setQueryData(key, "data") + const observer = new QueryObserver(queryClient, { + queryKey: key, + enabled: false + }) + + const unsubscribe = observer.subscribe(vi.fn()) + + expect(observer.getCurrentResult()).toMatchObject({ + status: "success", + data: "data" + }) + + unsubscribe() + }) + + test("should be able to read latest data when re-subscribing (but not re-fetching)", async () => { + const key = queryKey() + let count = 0 + const observer = new QueryObserver(queryClient, { + queryKey: key, + staleTime: Infinity, + queryFn: async () => { + await sleep(10) count++ - return { myCount: 99 } + return "data" } - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - select: select1, - }) - const unsubscribe = observer.subscribe((result) => { - results.push(result) - }) - await sleep(1) - observer.setOptions({ - queryKey: key, - queryFn, - select: select2, - }) - await sleep(1) - await observer.refetch() - unsubscribe() - expect(count).toBe(2) - expect(results.length).toBe(5) - expect(results[0]).toMatchObject({ - status: 'pending', - isFetching: true, - data: undefined, - }) - expect(results[1]).toMatchObject({ - status: 'success', - isFetching: false, - data: { myCount: 1 }, - }) - expect(results[2]).toMatchObject({ - status: 'success', - isFetching: false, - data: { myCount: 99 }, - }) - expect(results[3]).toMatchObject({ - status: 'success', - isFetching: true, - data: { myCount: 99 }, - }) - expect(results[4]).toMatchObject({ - status: 'success', - isFetching: false, - data: { myCount: 99 }, - }) - }) - - test('should not run the selector again if the data and selector did not change', async () => { - const key = queryKey() - let count = 0 - const results: Array = [] - const queryFn = () => ({ count: 1 }) - const select = (data: ReturnType) => { + }) + + let unsubscribe = observer.subscribe(vi.fn()) + + // unsubscribe before data comes in + unsubscribe() + expect(count).toBe(0) + expect(observer.getCurrentResult()).toMatchObject({ + status: "pending", + fetchStatus: "fetching", + data: undefined + }) + + await waitFor(() => expect(count).toBe(1)) + + // re-subscribe after data comes in + unsubscribe = observer.subscribe(vi.fn()) + + expect(observer.getCurrentResult()).toMatchObject({ + status: "success", + data: "data" + }) + + unsubscribe() + }) + + test("should notify when switching query", async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: Array = [] + const observer = new QueryObserver(queryClient, { + queryKey: key1, + queryFn: () => 1 + }) + const unsubscribe = observer.subscribe((result) => { + results.push(result) + }) + await sleep(1) + observer.setOptions({ queryKey: key2, queryFn: () => 2 }) + await sleep(1) + unsubscribe() + expect(results.length).toBe(4) + expect(results[0]).toMatchObject({ data: undefined, status: "pending" }) + expect(results[1]).toMatchObject({ data: 1, status: "success" }) + expect(results[2]).toMatchObject({ data: undefined, status: "pending" }) + expect(results[3]).toMatchObject({ data: 2, status: "success" }) + }) + + test("should be able to fetch with a selector", async () => { + const key = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => ({ count: 1 }), + select: (data) => ({ myCount: data.count }) + }) + let observerResult + const unsubscribe = observer.subscribe((result) => { + expectTypeOf>(result) + observerResult = result + }) + await sleep(1) + unsubscribe() + expect(observerResult).toMatchObject({ data: { myCount: 1 } }) + }) + + test("should be able to fetch with a selector using the fetch method", async () => { + const key = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => ({ count: 1 }), + select: (data) => ({ myCount: data.count }) + }) + const observerResult = await observer.refetch() + expectTypeOf<{ myCount: number } | undefined>(observerResult.data) + expect(observerResult.data).toMatchObject({ myCount: 1 }) + }) + + test("should be able to fetch with a selector and object syntax", async () => { + const key = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => ({ count: 1 }), + select: (data) => ({ myCount: data.count }) + }) + let observerResult + const unsubscribe = observer.subscribe((result) => { + observerResult = result + }) + await sleep(1) + unsubscribe() + expect(observerResult).toMatchObject({ data: { myCount: 1 } }) + }) + + test("should run the selector again if the data changed", async () => { + const key = queryKey() + let count = 0 + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => ({ count }), + select: (data) => { count++ return { myCount: data.count } } - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - select, - }) - const unsubscribe = observer.subscribe((result) => { - results.push(result) - }) - await sleep(1) - observer.setOptions({ - queryKey: key, - queryFn, - select, - }) - await sleep(1) - await observer.refetch() - unsubscribe() - expect(count).toBe(1) - expect(results.length).toBe(4) - expect(results[0]).toMatchObject({ - status: 'pending', - isFetching: true, - data: undefined, - }) - expect(results[1]).toMatchObject({ - status: 'success', - isFetching: false, - data: { myCount: 1 }, - }) - expect(results[2]).toMatchObject({ - status: 'success', - isFetching: true, - data: { myCount: 1 }, - }) - expect(results[3]).toMatchObject({ - status: 'success', - isFetching: false, - data: { myCount: 1 }, - }) - }) - - // test('should not run the selector again if the data did not change', async () => { - // const key = queryKey() - // let count = 0 - // const observer = new QueryObserver(queryClient, { - // queryKey: key, - // queryFn: () => ({ count: 1 }), - // select: (data) => { - // count++ - // return { myCount: data.count } - // }, - // }) - // const observerResult1 = await observer.refetch() - // const observerResult2 = await observer.refetch() - // expect(count).toBe(1) - // expect(observerResult1.data).toMatchObject({ myCount: 1 }) - // expect(observerResult2.data).toMatchObject({ myCount: 1 }) - // }) - - test('should always run the selector again if selector throws an error and selector is not referentially stable', async () => { - const key = queryKey() - const results: Array = [] - const queryFn = async () => { + }) + const observerResult1 = await observer.refetch() + const observerResult2 = await observer.refetch() + expect(count).toBe(2) + expect(observerResult1.data).toMatchObject({ myCount: 0 }) + expect(observerResult2.data).toMatchObject({ myCount: 1 }) + }) + + test("should run the selector again if the selector changed", async () => { + const key = queryKey() + let count = 0 + const results: Array = [] + const queryFn = () => ({ count: 1 }) + const select1 = (data: ReturnType) => { + count++ + return { myCount: data.count } + } + const select2 = (_data: ReturnType) => { + count++ + return { myCount: 99 } + } + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + select: select1 + }) + const unsubscribe = observer.subscribe((result) => { + results.push(result) + }) + await sleep(1) + observer.setOptions({ + queryKey: key, + queryFn, + select: select2 + }) + await sleep(1) + await observer.refetch() + unsubscribe() + expect(count).toBe(2) + expect(results.length).toBe(5) + expect(results[0]).toMatchObject({ + status: "pending", + isFetching: true, + data: undefined + }) + expect(results[1]).toMatchObject({ + status: "success", + isFetching: false, + data: { myCount: 1 } + }) + expect(results[2]).toMatchObject({ + status: "success", + isFetching: false, + data: { myCount: 99 } + }) + expect(results[3]).toMatchObject({ + status: "success", + isFetching: true, + data: { myCount: 99 } + }) + expect(results[4]).toMatchObject({ + status: "success", + isFetching: false, + data: { myCount: 99 } + }) + }) + + test("should not run the selector again if the data and selector did not change", async () => { + const key = queryKey() + let count = 0 + const results: Array = [] + const queryFn = () => ({ count: 1 }) + const select = (data: ReturnType) => { + count++ + return { myCount: data.count } + } + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + select + }) + const unsubscribe = observer.subscribe((result) => { + results.push(result) + }) + await sleep(1) + observer.setOptions({ + queryKey: key, + queryFn, + select + }) + await sleep(1) + await observer.refetch() + unsubscribe() + expect(count).toBe(1) + expect(results.length).toBe(4) + expect(results[0]).toMatchObject({ + status: "pending", + isFetching: true, + data: undefined + }) + expect(results[1]).toMatchObject({ + status: "success", + isFetching: false, + data: { myCount: 1 } + }) + expect(results[2]).toMatchObject({ + status: "success", + isFetching: true, + data: { myCount: 1 } + }) + expect(results[3]).toMatchObject({ + status: "success", + isFetching: false, + data: { myCount: 1 } + }) + }) + + // test('should not run the selector again if the data did not change', async () => { + // const key = queryKey() + // let count = 0 + // const observer = new QueryObserver(queryClient, { + // queryKey: key, + // queryFn: () => ({ count: 1 }), + // select: (data) => { + // count++ + // return { myCount: data.count } + // }, + // }) + // const observerResult1 = await observer.refetch() + // const observerResult2 = await observer.refetch() + // expect(count).toBe(1) + // expect(observerResult1.data).toMatchObject({ myCount: 1 }) + // expect(observerResult2.data).toMatchObject({ myCount: 1 }) + // }) + + test("should always run the selector again if selector throws an error and selector is not referentially stable", async () => { + const key = queryKey() + const results: Array = [] + const queryFn = async () => { + await sleep(10) + return { count: 1 } + } + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + select: () => { + throw new Error("selector error") + } + }) + const unsubscribe = observer.subscribe((result) => { + results.push(result) + }) + await sleep(50) + await observer.refetch() + unsubscribe() + expect(results[0]).toMatchObject({ + status: "pending", + isFetching: true, + data: undefined + }) + expect(results[1]).toMatchObject({ + status: "error", + isFetching: false, + data: undefined + }) + expect(results[2]).toMatchObject({ + status: "error", + isFetching: true, + data: undefined + }) + expect(results[3]).toMatchObject({ + status: "error", + isFetching: false, + data: undefined + }) + }) + + test("should return stale data if selector throws an error", async () => { + const key = queryKey() + const results: Array = [] + let shouldError = false + const error = new Error("select error") + const observer = new QueryObserver(queryClient, { + queryKey: key, + retry: 0, + queryFn: async () => { await sleep(10) - return { count: 1 } + return shouldError ? 2 : 1 + }, + select: (num) => { + if (shouldError) { + throw error + } + shouldError = true + return String(num) } - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - select: () => { - throw new Error('selector error') - }, - }) - const unsubscribe = observer.subscribe((result) => { - results.push(result) - }) - await sleep(50) - await observer.refetch() - unsubscribe() - expect(results[0]).toMatchObject({ - status: 'pending', - isFetching: true, - data: undefined, - }) - expect(results[1]).toMatchObject({ - status: 'error', - isFetching: false, - data: undefined, - }) - expect(results[2]).toMatchObject({ - status: 'error', - isFetching: true, - data: undefined, - }) - expect(results[3]).toMatchObject({ - status: 'error', - isFetching: false, - data: undefined, - }) - }) - - test('should return stale data if selector throws an error', async () => { - const key = queryKey() - const results: Array = [] - let shouldError = false - const error = new Error('select error') - const observer = new QueryObserver(queryClient, { - queryKey: key, - retry: 0, - queryFn: async () => { - await sleep(10) - return shouldError ? 2 : 1 - }, - select: (num) => { - if (shouldError) { - throw error - } - shouldError = true - return String(num) - }, - }) - - const unsubscribe = observer.subscribe((result) => { - results.push(result) - }) - await sleep(50) - await observer.refetch() - unsubscribe() - - expect(results[0]).toMatchObject({ - status: 'pending', - isFetching: true, - data: undefined, - error: null, - }) - expect(results[1]).toMatchObject({ - status: 'success', - isFetching: false, - data: '1', - error: null, - }) - expect(results[2]).toMatchObject({ - status: 'success', - isFetching: true, - data: '1', - error: null, - }) - expect(results[3]).toMatchObject({ - status: 'error', - isFetching: false, - data: '1', - error, - }) - }) - - // test('should structurally share the selector', async () => { - // const key = queryKey() - // let count = 0 - // const observer = new QueryObserver(queryClient, { - // queryKey: key, - // queryFn: () => ({ count: ++count }), - // select: () => ({ myCount: 1 }), - // }) - // const observerResult1 = await observer.refetch() - // const observerResult2 = await observer.refetch() - // expect(count).toBe(2) - // expect(observerResult1.data).toBe(observerResult2.data) - // }) - - test('should not trigger a fetch when subscribed and disabled', async () => { - const key = queryKey() - const queryFn = vi.fn, string>().mockReturnValue('data') - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - enabled: false, - }) - const unsubscribe = observer.subscribe(() => undefined) - await sleep(1) - unsubscribe() - expect(queryFn).toHaveBeenCalledTimes(0) - }) - - test('should not trigger a fetch when not subscribed', async () => { - const key = queryKey() - const queryFn = vi.fn, string>().mockReturnValue('data') - new QueryObserver(queryClient, { queryKey: key, queryFn }) - await sleep(1) - expect(queryFn).toHaveBeenCalledTimes(0) - }) - - // test('should be able to watch a query without defining a query function', async () => { - // const key = queryKey() - // const queryFn = vi.fn, string>().mockReturnValue('data') - // const callback = vi.fn() - // const observer = new QueryObserver(queryClient, { - // queryKey: key, - // enabled: false, - // }) - // const unsubscribe = observer.subscribe(callback) - // await queryClient.fetchQuery({ queryKey: key, queryFn }) - // unsubscribe() - // expect(queryFn).toHaveBeenCalledTimes(1) - // expect(callback).toHaveBeenCalledTimes(2) - // }) - - // test('should accept unresolved query config in update function', async () => { - // const key = queryKey() - // const queryFn = vi.fn, string>().mockReturnValue('data') - // const observer = new QueryObserver(queryClient, { - // queryKey: key, - // enabled: false, - // }) - // const results: Array> = [] - // const unsubscribe = observer.subscribe((x) => { - // results.push(x) - // }) - // observer.setOptions({ queryKey: key, enabled: false, staleTime: 10 }) - // await queryClient.fetchQuery({ queryKey: key, queryFn }) - // await sleep(100) - // unsubscribe() - // expect(queryFn).toHaveBeenCalledTimes(1) - // expect(results.length).toBe(3) - // expect(results[0]).toMatchObject({ isStale: true }) - // expect(results[1]).toMatchObject({ isStale: false }) - // expect(results[2]).toMatchObject({ isStale: true }) - // }) - - // test('should be able to handle multiple subscribers', async () => { - // const key = queryKey() - // const queryFn = vi.fn, string>().mockReturnValue('data') - // const observer = new QueryObserver(queryClient, { - // queryKey: key, - // enabled: false, - // }) - // const results1: Array> = [] - // const results2: Array> = [] - // const unsubscribe1 = observer.subscribe((x) => { - // results1.push(x) - // }) - // const unsubscribe2 = observer.subscribe((x) => { - // results2.push(x) - // }) - // await queryClient.fetchQuery({ queryKey: key, queryFn }) - // await sleep(50) - // unsubscribe1() - // unsubscribe2() - // expect(queryFn).toHaveBeenCalledTimes(1) - // expect(results1.length).toBe(2) - // expect(results2.length).toBe(2) - // expect(results1[0]).toMatchObject({ data: undefined }) - // expect(results1[1]).toMatchObject({ data: 'data' }) - // expect(results2[0]).toMatchObject({ data: undefined }) - // expect(results2[1]).toMatchObject({ data: 'data' }) - // }) - - test('should stop retry when unsubscribing', async () => { - const key = queryKey() - let count = 0 - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: async () => { - count++ - return await Promise.reject('reject') - }, - retry: 10, - retryDelay: 50, - }) - const unsubscribe = observer.subscribe(() => undefined) - await sleep(70) - unsubscribe() - await sleep(200) - expect(count).toBe(2) - }) - - test('should clear interval when unsubscribing to a refetchInterval query', async () => { - const key = queryKey() - let count = 0 - - const fetchData = async () => { + }) + + const unsubscribe = observer.subscribe((result) => { + results.push(result) + }) + await sleep(50) + await observer.refetch() + unsubscribe() + + expect(results[0]).toMatchObject({ + status: "pending", + isFetching: true, + data: undefined, + error: null + }) + expect(results[1]).toMatchObject({ + status: "success", + isFetching: false, + data: "1", + error: null + }) + expect(results[2]).toMatchObject({ + status: "success", + isFetching: true, + data: "1", + error: null + }) + expect(results[3]).toMatchObject({ + status: "error", + isFetching: false, + data: "1", + error + }) + }) + + // test('should structurally share the selector', async () => { + // const key = queryKey() + // let count = 0 + // const observer = new QueryObserver(queryClient, { + // queryKey: key, + // queryFn: () => ({ count: ++count }), + // select: () => ({ myCount: 1 }), + // }) + // const observerResult1 = await observer.refetch() + // const observerResult2 = await observer.refetch() + // expect(count).toBe(2) + // expect(observerResult1.data).toBe(observerResult2.data) + // }) + + test("should not trigger a fetch when subscribed and disabled", async () => { + const key = queryKey() + const queryFn = vi.fn, string>().mockReturnValue("data") + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + enabled: false + }) + const unsubscribe = observer.subscribe(() => undefined) + await sleep(1) + unsubscribe() + expect(queryFn).toHaveBeenCalledTimes(0) + }) + + test("should not trigger a fetch when not subscribed", async () => { + const key = queryKey() + const queryFn = vi.fn, string>().mockReturnValue("data") + new QueryObserver(queryClient, { queryKey: key, queryFn }) + await sleep(1) + expect(queryFn).toHaveBeenCalledTimes(0) + }) + + // test('should be able to watch a query without defining a query function', async () => { + // const key = queryKey() + // const queryFn = vi.fn, string>().mockReturnValue('data') + // const callback = vi.fn() + // const observer = new QueryObserver(queryClient, { + // queryKey: key, + // enabled: false, + // }) + // const unsubscribe = observer.subscribe(callback) + // await queryClient.fetchQuery({ queryKey: key, queryFn }) + // unsubscribe() + // expect(queryFn).toHaveBeenCalledTimes(1) + // expect(callback).toHaveBeenCalledTimes(2) + // }) + + // test('should accept unresolved query config in update function', async () => { + // const key = queryKey() + // const queryFn = vi.fn, string>().mockReturnValue('data') + // const observer = new QueryObserver(queryClient, { + // queryKey: key, + // enabled: false, + // }) + // const results: Array> = [] + // const unsubscribe = observer.subscribe((x) => { + // results.push(x) + // }) + // observer.setOptions({ queryKey: key, enabled: false, staleTime: 10 }) + // await queryClient.fetchQuery({ queryKey: key, queryFn }) + // await sleep(100) + // unsubscribe() + // expect(queryFn).toHaveBeenCalledTimes(1) + // expect(results.length).toBe(3) + // expect(results[0]).toMatchObject({ isStale: true }) + // expect(results[1]).toMatchObject({ isStale: false }) + // expect(results[2]).toMatchObject({ isStale: true }) + // }) + + // test('should be able to handle multiple subscribers', async () => { + // const key = queryKey() + // const queryFn = vi.fn, string>().mockReturnValue('data') + // const observer = new QueryObserver(queryClient, { + // queryKey: key, + // enabled: false, + // }) + // const results1: Array> = [] + // const results2: Array> = [] + // const unsubscribe1 = observer.subscribe((x) => { + // results1.push(x) + // }) + // const unsubscribe2 = observer.subscribe((x) => { + // results2.push(x) + // }) + // await queryClient.fetchQuery({ queryKey: key, queryFn }) + // await sleep(50) + // unsubscribe1() + // unsubscribe2() + // expect(queryFn).toHaveBeenCalledTimes(1) + // expect(results1.length).toBe(2) + // expect(results2.length).toBe(2) + // expect(results1[0]).toMatchObject({ data: undefined }) + // expect(results1[1]).toMatchObject({ data: 'data' }) + // expect(results2[0]).toMatchObject({ data: undefined }) + // expect(results2[1]).toMatchObject({ data: 'data' }) + // }) + + test("should stop retry when unsubscribing", async () => { + const key = queryKey() + let count = 0 + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { count++ - return await Promise.resolve('data') - } - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: fetchData, - gcTime: 0, - refetchInterval: 10, - }) - const unsubscribe = observer.subscribe(() => undefined) - expect(count).toBe(1) - await sleep(15) - expect(count).toBe(2) - unsubscribe() - await sleep(10) - expect(queryClient.getQueryCache().find({ queryKey: key })).toBeUndefined() - expect(count).toBe(2) - }) - - test('uses placeholderData as non-cache data when pending a query with no data', async () => { - const key = queryKey() - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => 'data', - placeholderData: 'placeholder', - }) - - expect(observer.getCurrentResult()).toMatchObject({ - status: 'success', - data: 'placeholder', - }) - - const results: Array> = [] - - const unsubscribe = observer.subscribe((x) => { - results.push(x) - }) - - await sleep(10) - unsubscribe() - - expect(results.length).toBe(2) - expect(results[0]).toMatchObject({ status: 'success', data: 'placeholder' }) - expect(results[1]).toMatchObject({ status: 'success', data: 'data' }) - }) - - test('should structurally share placeholder data', async () => { - const key = queryKey() - const observer = new QueryObserver(queryClient, { - queryKey: key, - enabled: false, - queryFn: () => 'data', - placeholderData: {}, - }) - - const firstData = observer.getCurrentResult().data - - observer.setOptions({ queryKey: key, placeholderData: {} }) - - const secondData = observer.getCurrentResult().data - - expect(firstData).toBe(secondData) - }) - - test('should throw an error if enabled option type is not valid', async () => { - const key = queryKey() - - expect( - () => - new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => 'data', - // @ts-expect-error - enabled: null, - }), - ).toThrowError('Expected enabled to be a boolean') - }) - - test('getCurrentQuery should return the current query', async () => { - const key = queryKey() - - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => 'data', - }) - - expect(observer.getCurrentQuery().queryKey).toEqual(key) - }) - - test('should throw an error if throwOnError option is true', async () => { - const key = queryKey() - - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: async () => await Promise.reject('error'), - retry: false, - }) - - let error: string | null = null - try { - await observer.refetch({ throwOnError: true }) - } catch (err) { - error = err as string - } - - expect(error).toEqual('error') - }) - - test('should not refetch in background if refetchIntervalInBackground is false', async () => { - const key = queryKey() - const queryFn = vi.fn, string>().mockReturnValue('data') - - focusManager.setFocused(false) - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - refetchIntervalInBackground: false, - refetchInterval: 10, - }) - - const unsubscribe = observer.subscribe(() => undefined) - await sleep(30) - - expect(queryFn).toHaveBeenCalledTimes(1) - - // Clean-up - unsubscribe() - focusManager.setFocused(true) - }) - - test('should not use replaceEqualDeep for select value when structuralSharing option is true', async () => { - const key = queryKey() - - const data = { value: 'data' } - const selectedData = { value: 'data' } - - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => data, - select: () => data, - }) - - const unsubscribe = observer.subscribe(() => undefined) - - await sleep(10) - expect(observer.getCurrentResult().data).toBe(data) - - observer.setOptions({ - queryKey: key, - queryFn: () => data, - structuralSharing: false, - select: () => selectedData, - }) - - await observer.refetch() - expect(observer.getCurrentResult().data).toBe(selectedData) - - unsubscribe() - }) - - test('should not use replaceEqualDeep for select value when structuralSharing option is true and placeholderData is defined', () => { - const key = queryKey() - - const data = { value: 'data' } - const selectedData1 = { value: 'data' } - const selectedData2 = { value: 'data' } - const placeholderData1 = { value: 'data' } - const placeholderData2 = { value: 'data' } - - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => data, - select: () => data, - }) - - observer.setOptions({ - queryKey: key, - queryFn: () => data, - select: () => { - return selectedData1 - }, - placeholderData: placeholderData1, - }) - - observer.setOptions({ - queryKey: key, - queryFn: () => data, - select: () => { - return selectedData2 - }, - placeholderData: placeholderData2, - structuralSharing: false, - }) - - expect(observer.getCurrentResult().data).toBe(selectedData2) - }) - - test('should not use an undefined value returned by select as placeholderData', () => { - const key = queryKey() - - const data = { value: 'data' } - const selectedData = { value: 'data' } - const placeholderData1 = { value: 'data' } - const placeholderData2 = { value: 'data' } - - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: () => data, - select: () => data, - }) - - observer.setOptions({ - queryKey: key, - queryFn: () => data, - select: () => { - return selectedData - }, - placeholderData: placeholderData1, - }) - - expect(observer.getCurrentResult().isPlaceholderData).toBe(true) - - observer.setOptions({ - queryKey: key, - queryFn: () => data, - // @ts-expect-error - select: () => undefined, - placeholderData: placeholderData2, - }) - - expect(observer.getCurrentResult().isPlaceholderData).toBe(false) - }) - - test('should pass the correct previous queryKey (from prevQuery) to placeholderData function params with select', async () => { - const results: Array = [] - const keys: Array | null> = [] - - const key1 = queryKey() - const key2 = queryKey() - - const data1 = { value: 'data1' } - const data2 = { value: 'data2' } - - const observer = new QueryObserver(queryClient, { - queryKey: key1, - queryFn: () => data1, - placeholderData: (prev, prevQuery) => { - keys.push(prevQuery?.queryKey || null) - return prev - }, - select: (data) => data.value, - }) - - const unsubscribe = observer.subscribe((result) => { - results.push(result) - }) - - await sleep(1) - - observer.setOptions({ - queryKey: key2, - queryFn: () => data2, - placeholderData: (prev, prevQuery) => { - keys.push(prevQuery?.queryKey || null) - return prev - }, - select: (data) => data.value, - }) - - await sleep(1) - unsubscribe() - expect(results.length).toBe(4) - expect(keys.length).toBe(3) - expect(keys[0]).toBe(null) // First Query - status: 'pending', fetchStatus: 'idle' - expect(keys[1]).toBe(null) // First Query - status: 'pending', fetchStatus: 'fetching' - expect(keys[2]).toBe(key1) // Second Query - status: 'pending', fetchStatus: 'fetching' - - expect(results[0]).toMatchObject({ - data: undefined, - status: 'pending', - fetchStatus: 'fetching', - }) // Initial fetch - expect(results[1]).toMatchObject({ - data: 'data1', - status: 'success', - fetchStatus: 'idle', - }) // Successful fetch - expect(results[2]).toMatchObject({ - data: 'data1', - status: 'success', - fetchStatus: 'fetching', - }) // Fetch for new key, but using previous data as placeholder - expect(results[3]).toMatchObject({ - data: 'data2', - status: 'success', - fetchStatus: 'idle', - }) // Successful fetch for new key - }) - - test('should pass the correct previous data to placeholderData function params when select function is used in conjunction', async () => { - const results: Array = [] - - const key1 = queryKey() - const key2 = queryKey() - - const data1 = { value: 'data1' } - const data2 = { value: 'data2' } - - const observer = new QueryObserver(queryClient, { - queryKey: key1, - queryFn: () => data1, - placeholderData: (prev) => prev, - select: (data) => data.value, - }) - - const unsubscribe = observer.subscribe((result) => { - results.push(result) - }) - - await sleep(1) - - observer.setOptions({ - queryKey: key2, - queryFn: () => data2, - placeholderData: (prev) => prev, - select: (data) => data.value, - }) - - await sleep(1) - unsubscribe() - - expect(results.length).toBe(4) - expect(results[0]).toMatchObject({ - data: undefined, - status: 'pending', - fetchStatus: 'fetching', - }) // Initial fetch - expect(results[1]).toMatchObject({ - data: 'data1', - status: 'success', - fetchStatus: 'idle', - }) // Successful fetch - expect(results[2]).toMatchObject({ - data: 'data1', - status: 'success', - fetchStatus: 'fetching', - }) // Fetch for new key, but using previous data as placeholder - expect(results[3]).toMatchObject({ - data: 'data2', - status: 'success', - fetchStatus: 'idle', - }) // Successful fetch for new key - }) - - test('setOptions should notify cache listeners', async () => { - const key = queryKey() - - const observer = new QueryObserver(queryClient, { - queryKey: key, - }) - - const spy = vi.fn() - const unsubscribe = queryClient.getQueryCache().subscribe(spy) - observer.setOptions({ queryKey: key, enabled: false }) - - expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ type: 'observerOptionsUpdated' }), - ) - - unsubscribe() - }) - - test('should be inferred as a correct result type', async () => { - const key = queryKey() - const data = { value: 'data' } - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn: async () => await Promise.resolve(data), - }) - - const result = observer.getCurrentResult() - - result.isPending && - expectTypeOf(result.data) && - expectTypeOf(result.error) && - expectTypeOf(result.isLoading) && - expectTypeOf<'pending'>(result.status) - - result.isLoading && - expectTypeOf(result.data) && - expectTypeOf(result.error) && - expectTypeOf(result.isPending) && - expectTypeOf<'pending'>(result.status) - - result.isLoadingError && - expectTypeOf(result.data) && - expectTypeOf(result.error) && - expectTypeOf<'error'>(result.status) - - result.isRefetchError && - expectTypeOf<{ value: string }>(result.data) && - expectTypeOf(result.error) && - expectTypeOf<'error'>(result.status) - - result.isSuccess && - expectTypeOf<{ value: string }>(result.data) && - expectTypeOf(result.error) && - expectTypeOf<'success'>(result.status) - }) - }) \ No newline at end of file + return await Promise.reject("reject") + }, + retry: 10, + retryDelay: 50 + }) + const unsubscribe = observer.subscribe(() => undefined) + await sleep(70) + unsubscribe() + await sleep(200) + expect(count).toBe(2) + }) + + test("should clear interval when unsubscribing to a refetchInterval query", async () => { + const key = queryKey() + let count = 0 + + const fetchData = async () => { + count++ + return await Promise.resolve("data") + } + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: fetchData, + gcTime: 0, + refetchInterval: 10 + }) + const unsubscribe = observer.subscribe(() => undefined) + expect(count).toBe(1) + await sleep(15) + expect(count).toBe(2) + unsubscribe() + await sleep(10) + expect(queryClient.getQueryCache().find({ queryKey: key })).toBeUndefined() + expect(count).toBe(2) + }) + + test("uses placeholderData as non-cache data when pending a query with no data", async () => { + const key = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => "data", + placeholderData: "placeholder" + }) + + expect(observer.getCurrentResult()).toMatchObject({ + status: "success", + data: "placeholder" + }) + + const results: Array> = [] + + const unsubscribe = observer.subscribe((x) => { + results.push(x) + }) + + await sleep(10) + unsubscribe() + + expect(results.length).toBe(2) + expect(results[0]).toMatchObject({ status: "success", data: "placeholder" }) + expect(results[1]).toMatchObject({ status: "success", data: "data" }) + }) + + test("should structurally share placeholder data", async () => { + const key = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key, + enabled: false, + queryFn: () => "data", + placeholderData: {} + }) + + const firstData = observer.getCurrentResult().data + + observer.setOptions({ queryKey: key, placeholderData: {} }) + + const secondData = observer.getCurrentResult().data + + expect(firstData).toBe(secondData) + }) + + test("should throw an error if enabled option type is not valid", async () => { + const key = queryKey() + + expect( + () => + new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => "data", + // @ts-expect-error + enabled: null + }) + ).toThrowError("Expected enabled to be a boolean") + }) + + test("getCurrentQuery should return the current query", async () => { + const key = queryKey() + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => "data" + }) + + expect(observer.getCurrentQuery().queryKey).toEqual(key) + }) + + test("should throw an error if throwOnError option is true", async () => { + const key = queryKey() + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => await Promise.reject("error"), + retry: false + }) + + let error: string | null = null + try { + await observer.refetch({ throwOnError: true }) + } catch (err) { + error = err as string + } + + expect(error).toEqual("error") + }) + + test("should not refetch in background if refetchIntervalInBackground is false", async () => { + const key = queryKey() + const queryFn = vi.fn, string>().mockReturnValue("data") + + focusManager.setFocused(false) + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + refetchIntervalInBackground: false, + refetchInterval: 10 + }) + + const unsubscribe = observer.subscribe(() => undefined) + await sleep(30) + + expect(queryFn).toHaveBeenCalledTimes(1) + + // Clean-up + unsubscribe() + focusManager.setFocused(true) + }) + + test("should not use replaceEqualDeep for select value when structuralSharing option is true", async () => { + const key = queryKey() + + const data = { value: "data" } + const selectedData = { value: "data" } + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => data, + select: () => data + }) + + const unsubscribe = observer.subscribe(() => undefined) + + await sleep(10) + expect(observer.getCurrentResult().data).toBe(data) + + observer.setOptions({ + queryKey: key, + queryFn: () => data, + structuralSharing: false, + select: () => selectedData + }) + + await observer.refetch() + expect(observer.getCurrentResult().data).toBe(selectedData) + + unsubscribe() + }) + + test("should not use replaceEqualDeep for select value when structuralSharing option is true and placeholderData is defined", () => { + const key = queryKey() + + const data = { value: "data" } + const selectedData1 = { value: "data" } + const selectedData2 = { value: "data" } + const placeholderData1 = { value: "data" } + const placeholderData2 = { value: "data" } + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => data, + select: () => data + }) + + observer.setOptions({ + queryKey: key, + queryFn: () => data, + select: () => { + return selectedData1 + }, + placeholderData: placeholderData1 + }) + + observer.setOptions({ + queryKey: key, + queryFn: () => data, + select: () => { + return selectedData2 + }, + placeholderData: placeholderData2, + structuralSharing: false + }) + + expect(observer.getCurrentResult().data).toBe(selectedData2) + }) + + test("should not use an undefined value returned by select as placeholderData", () => { + const key = queryKey() + + const data = { value: "data" } + const selectedData = { value: "data" } + const placeholderData1 = { value: "data" } + const placeholderData2 = { value: "data" } + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => data, + select: () => data + }) + + observer.setOptions({ + queryKey: key, + queryFn: () => data, + select: () => { + return selectedData + }, + placeholderData: placeholderData1 + }) + + expect(observer.getCurrentResult().isPlaceholderData).toBe(true) + + observer.setOptions({ + queryKey: key, + queryFn: () => data, + // @ts-expect-error + select: () => undefined, + placeholderData: placeholderData2 + }) + + expect(observer.getCurrentResult().isPlaceholderData).toBe(false) + }) + + test("should pass the correct previous queryKey (from prevQuery) to placeholderData function params with select", async () => { + const results: Array = [] + const keys: Array | null> = [] + + const key1 = queryKey() + const key2 = queryKey() + + const data1 = { value: "data1" } + const data2 = { value: "data2" } + + const observer = new QueryObserver(queryClient, { + queryKey: key1, + queryFn: () => data1, + placeholderData: (prev, prevQuery) => { + keys.push(prevQuery?.queryKey || null) + return prev + }, + select: (data) => data.value + }) + + const unsubscribe = observer.subscribe((result) => { + results.push(result) + }) + + await sleep(1) + + observer.setOptions({ + queryKey: key2, + queryFn: () => data2, + placeholderData: (prev, prevQuery) => { + keys.push(prevQuery?.queryKey || null) + return prev + }, + select: (data) => data.value + }) + + await sleep(1) + unsubscribe() + expect(results.length).toBe(4) + expect(keys.length).toBe(3) + expect(keys[0]).toBe(null) // First Query - status: 'pending', fetchStatus: 'idle' + expect(keys[1]).toBe(null) // First Query - status: 'pending', fetchStatus: 'fetching' + expect(keys[2]).toBe(key1) // Second Query - status: 'pending', fetchStatus: 'fetching' + + expect(results[0]).toMatchObject({ + data: undefined, + status: "pending", + fetchStatus: "fetching" + }) // Initial fetch + expect(results[1]).toMatchObject({ + data: "data1", + status: "success", + fetchStatus: "idle" + }) // Successful fetch + expect(results[2]).toMatchObject({ + data: "data1", + status: "success", + fetchStatus: "fetching" + }) // Fetch for new key, but using previous data as placeholder + expect(results[3]).toMatchObject({ + data: "data2", + status: "success", + fetchStatus: "idle" + }) // Successful fetch for new key + }) + + test("should pass the correct previous data to placeholderData function params when select function is used in conjunction", async () => { + const results: Array = [] + + const key1 = queryKey() + const key2 = queryKey() + + const data1 = { value: "data1" } + const data2 = { value: "data2" } + + const observer = new QueryObserver(queryClient, { + queryKey: key1, + queryFn: () => data1, + placeholderData: (prev) => prev, + select: (data) => data.value + }) + + const unsubscribe = observer.subscribe((result) => { + results.push(result) + }) + + await sleep(1) + + observer.setOptions({ + queryKey: key2, + queryFn: () => data2, + placeholderData: (prev) => prev, + select: (data) => data.value + }) + + await sleep(1) + unsubscribe() + + expect(results.length).toBe(4) + expect(results[0]).toMatchObject({ + data: undefined, + status: "pending", + fetchStatus: "fetching" + }) // Initial fetch + expect(results[1]).toMatchObject({ + data: "data1", + status: "success", + fetchStatus: "idle" + }) // Successful fetch + expect(results[2]).toMatchObject({ + data: "data1", + status: "success", + fetchStatus: "fetching" + }) // Fetch for new key, but using previous data as placeholder + expect(results[3]).toMatchObject({ + data: "data2", + status: "success", + fetchStatus: "idle" + }) // Successful fetch for new key + }) + + test("setOptions should notify cache listeners", async () => { + const key = queryKey() + + const observer = new QueryObserver(queryClient, { + queryKey: key + }) + + const spy = vi.fn() + const unsubscribe = queryClient.getQueryCache().subscribe(spy) + observer.setOptions({ queryKey: key, enabled: false }) + + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ type: "observerOptionsUpdated" }) + ) + + unsubscribe() + }) + + test("should be inferred as a correct result type", async () => { + const key = queryKey() + const data = { value: "data" } + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => await Promise.resolve(data) + }) + + const result = observer.getCurrentResult() + + result.isPending && + expectTypeOf(result.data) && + expectTypeOf(result.error) && + expectTypeOf(result.isLoading) && + expectTypeOf<"pending">(result.status) + + result.isLoading && + expectTypeOf(result.data) && + expectTypeOf(result.error) && + expectTypeOf(result.isPending) && + expectTypeOf<"pending">(result.status) + + result.isLoadingError && + expectTypeOf(result.data) && + expectTypeOf(result.error) && + expectTypeOf<"error">(result.status) + + result.isRefetchError && + expectTypeOf<{ value: string }>(result.data) && + expectTypeOf(result.error) && + expectTypeOf<"error">(result.status) + + result.isSuccess && + expectTypeOf<{ value: string }>(result.data) && + expectTypeOf(result.error) && + expectTypeOf<"success">(result.status) + }) +}) diff --git a/src/lib/queries/client/queries/observer/QueryObserver.ts b/src/lib/queries/client/queries/observer/QueryObserver.ts index 3201286..0ad0dca 100644 --- a/src/lib/queries/client/queries/observer/QueryObserver.ts +++ b/src/lib/queries/client/queries/observer/QueryObserver.ts @@ -179,7 +179,7 @@ export class QueryObserver< } setOptions( - options?: QueryObserverOptions< + options: QueryObserverOptions< TQueryFnData, TError, TData, diff --git a/src/lib/queries/client/queries/observer/types.ts b/src/lib/queries/client/queries/observer/types.ts index a626c25..1ee3633 100644 --- a/src/lib/queries/client/queries/observer/types.ts +++ b/src/lib/queries/client/queries/observer/types.ts @@ -24,12 +24,9 @@ export interface QueryObserverOptions< TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = never -> extends QueryOptions< - TQueryFnData, - TError, - TQueryData, - TQueryKey, - TPageParam +> extends WithRequired< + QueryOptions, + "queryKey" > { /** * Set this to `false` to disable automatic refetching when the query mounts or changes query keys. @@ -88,7 +85,7 @@ export interface QueryObserverOptions< ) => boolean | "always") /** * If set to `true`, the query will refetch on mount if the data is stale. - * If set to `false`, will disable additional instances of a query to trigger background refetches. + * If set to `false`, will disable additional instances of a query to trigger background refetch. * If set to `'always'`, the query will always refetch on mount. * If set to `'always'`, the query will always refetch on mount if idle. * If set to a function, the function will be executed with the latest data and query to compute the value diff --git a/src/lib/queries/client/queries/query/Query.ts b/src/lib/queries/client/queries/query/Query.ts index b62049a..0ef69e9 100644 --- a/src/lib/queries/client/queries/query/Query.ts +++ b/src/lib/queries/client/queries/query/Query.ts @@ -105,11 +105,11 @@ export class Query< ), this.invalidatedSubject.pipe( filter(() => !this.state.isInvalidated), - map(() => ({ - command: "invalidate" as const, + map((): { command: "invalidate"; state: PartialState } => ({ + command: "invalidate", state: { isInvalidated: true - } satisfies PartialState + } })) ), this.cancelSubject.pipe( diff --git a/src/lib/queries/client/queries/query/query.rq.test.ts b/src/lib/queries/client/queries/query/query.rq.test.ts index 4f8874a..1aec3af 100644 --- a/src/lib/queries/client/queries/query/query.rq.test.ts +++ b/src/lib/queries/client/queries/query/query.rq.test.ts @@ -3,10 +3,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest" import { waitFor } from "@testing-library/react" -import { - createQueryClient, - sleep -} from "../../../../../tests/utils" +import { createQueryClient, sleep } from "../../../../../tests/utils" import { type QueryCache } from "../cache/QueryCache" import { type QueryClient } from "../../QueryClient" import { mockVisibilityState, queryKey } from "../../tests/utils" @@ -205,7 +202,6 @@ describe("query", () => { expect(queryFn).toHaveBeenCalledTimes(1) const args = queryFn.mock.calls[0]![0] expect(args).toBeDefined() - // @ts-expect-error page param should be undefined expect(args.pageParam).toBeUndefined() expect(args.queryKey).toEqual(key) expect(args.signal).toBeInstanceOf(AbortSignal) @@ -845,79 +841,79 @@ describe("query", () => { expect(initialDataUpdatedAtSpy).toHaveBeenCalled() }) - // test("queries should be garbage collected even if they never fetched", async () => { - // const key = queryKey() - - // queryClient.setQueryDefaults(key, { gcTime: 10 }) + // test("queries should be garbage collected even if they never fetched", async () => { + // const key = queryKey() - // const fn = vi.fn() + // queryClient.setQueryDefaults(key, { gcTime: 10 }) - // const unsubscribe = queryClient.getQueryCache().subscribe(fn) + // const fn = vi.fn() - // queryClient.setQueryData(key, "data") + // const unsubscribe = queryClient.getQueryCache().subscribe(fn) - // await waitFor(() => - // { expect(fn).toHaveBeenCalledWith( - // expect.objectContaining({ - // type: "removed" - // }) - // ); } - // ) + // queryClient.setQueryData(key, "data") - // expect(queryClient.getQueryCache().findAll()).toHaveLength(0) + // await waitFor(() => + // { expect(fn).toHaveBeenCalledWith( + // expect.objectContaining({ + // type: "removed" + // }) + // ); } + // ) - // unsubscribe() - // }) + // expect(queryClient.getQueryCache().findAll()).toHaveLength(0) - test("should always revert to idle state (#5958)", async () => { - let mockedData = [1] + // unsubscribe() + // }) - const key = queryKey() + test("should always revert to idle state (#5958)", async () => { + let mockedData = [1] - const queryFn = vi - .fn< - [QueryFunctionContext>], - Promise - >() - .mockImplementation(async ({ signal }) => { - return await new Promise((resolve, reject) => { - const abortListener = () => { - clearTimeout(timerId) - reject(signal.reason) - } - signal.addEventListener("abort", abortListener) + const key = queryKey() - const timerId = setTimeout(() => { - signal.removeEventListener("abort", abortListener) - resolve(mockedData.join(" - ")) - }, 50) - }) + const queryFn = vi + .fn< + [QueryFunctionContext>], + Promise + >() + .mockImplementation(async ({ signal }) => { + return await new Promise((resolve, reject) => { + const abortListener = () => { + clearTimeout(timerId) + reject(signal.reason) + } + signal.addEventListener("abort", abortListener) + + const timerId = setTimeout(() => { + signal.removeEventListener("abort", abortListener) + resolve(mockedData.join(" - ")) + }, 50) }) - - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn }) - const unsubscribe = observer.subscribe(() => undefined) - await sleep(60) // let it resolve - mockedData = [1, 2] // update "server" state in the background + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn + }) + const unsubscribe = observer.subscribe(() => undefined) + await sleep(60) // let it resolve - queryClient.invalidateQueries({ queryKey: key }) - await sleep(1) - queryClient.invalidateQueries({ queryKey: key }) - await sleep(1) - unsubscribe() // unsubscribe to simulate unmount + mockedData = [1, 2] // update "server" state in the background - // set up a new observer to simulate a mount of new component - const newObserver = new QueryObserver(queryClient, { - queryKey: key, - queryFn - }) + queryClient.invalidateQueries({ queryKey: key }) + await sleep(1) + queryClient.invalidateQueries({ queryKey: key }) + await sleep(1) + unsubscribe() // unsubscribe to simulate unmount - const spy = vi.fn() - newObserver.subscribe(({ data }) => spy(data)) - await sleep(60) // let it resolve - expect(spy).toHaveBeenCalledWith("1 - 2") + // set up a new observer to simulate a mount of new component + const newObserver = new QueryObserver(queryClient, { + queryKey: key, + queryFn }) + + const spy = vi.fn() + newObserver.subscribe(({ data }) => spy(data)) + await sleep(60) // let it resolve + expect(spy).toHaveBeenCalledWith("1 - 2") + }) }) diff --git a/src/lib/queries/client/queries/types.ts b/src/lib/queries/client/queries/types.ts index 28b436b..39e392c 100644 --- a/src/lib/queries/client/queries/types.ts +++ b/src/lib/queries/client/queries/types.ts @@ -9,10 +9,11 @@ import { type QueryFunctionContext, type QueryBehavior } from "./query/types" +import { type SkipToken } from "./utils" export declare const dataTagSymbol: unique symbol -export type DataTag = Type & { - [dataTagSymbol]: Value +export type DataTag = TType & { + [dataTagSymbol]: TValue } export type Updater = TOutput | ((input: TInput) => TOutput) @@ -103,7 +104,7 @@ export interface QueryOptions< * Setting it to `Infinity` will disable garbage collection. */ gcTime?: number - queryFn?: QueryFunction + queryFn?: QueryFunction | SkipToken persister?: QueryPersister< NoInfer, NoInfer, @@ -120,7 +121,9 @@ export interface QueryOptions< * Set this to a function which accepts the old and new data and returns resolved data of the same type to implement custom structural sharing logic. * Defaults to `true`. */ - structuralSharing?: boolean | ((oldData: T | undefined, newData: T) => T) + structuralSharing?: + | boolean + | ((oldData: unknown | undefined, newData: unknown) => unknown) _defaulted?: boolean /** * Additional payload to be stored on each query. diff --git a/src/lib/queries/client/queries/utils.ts b/src/lib/queries/client/queries/utils.ts index 5739648..e80533b 100644 --- a/src/lib/queries/client/queries/utils.ts +++ b/src/lib/queries/client/queries/utils.ts @@ -7,7 +7,7 @@ import { type QueryOptions, type QueryFilters, type Updater } from "./types" export function hashQueryKeyByOptions( queryKey: TQueryKey, - options?: QueryOptions + options?: Pick, "queryKeyHashFn"> ): string { const hashFn = options?.queryKeyHashFn ?? hashKey return hashFn(queryKey) @@ -82,10 +82,14 @@ export function replaceData< TOptions extends QueryOptions >(prevData: TData | undefined, data: TData, options: TOptions): TData { if (typeof options.structuralSharing === "function") { - return options.structuralSharing(prevData, data) + return options.structuralSharing(prevData, data) as TData } else if (options.structuralSharing !== false) { // Structurally share data between prev and new data if needed return replaceEqualDeep(prevData, data) } return data } + +// eslint-disable-next-line symbol-description +export const skipToken = Symbol() +export type SkipToken = typeof skipToken diff --git a/src/lib/queries/react/mutations/useMutationState.test.tsx b/src/lib/queries/react/mutations/useMutationState.test.tsx index cae25a8..0957fec 100644 --- a/src/lib/queries/react/mutations/useMutationState.test.tsx +++ b/src/lib/queries/react/mutations/useMutationState.test.tsx @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/array-type */ import { describe, expect, expectTypeOf, it } from "vitest" import { fireEvent, render, waitFor } from "@testing-library/react" -import type { MutationStatus } from "@tanstack/query-core" import { useMutationState } from "./useMutationState" import { createQueryClient, @@ -12,7 +11,10 @@ import { } from "../../../../tests/utils" import { useMutation } from "./useMutation" import React, { memo } from "react" -import { type MutationState } from "../../client/mutations/mutation/types" +import { + type MutationStatus, + type MutationState +} from "../../client/mutations/mutation/types" import { useIsMutating } from "./useIsMutating" describe("useIsMutating", () => { diff --git a/src/lib/queries/react/queries/QueryOptions.rq.types.test.ts b/src/lib/queries/react/queries/QueryOptions.rq.types.test.ts deleted file mode 100644 index 006b261..0000000 --- a/src/lib/queries/react/queries/QueryOptions.rq.types.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* eslint-disable @typescript-eslint/no-misused-promises */ -import { describe, it } from 'vitest' -import { queryOptions } from './queryOptions' -import { doNotExecute } from '../../../../tests/utils' -import { type Equal, type Expect } from '../../../utils/types' -import { useQuery } from './useQuery' -import { QueryClient } from '../../client/QueryClient' -import { type dataTagSymbol } from '../../client/queries/types' - -describe('queryOptions', () => { - it('should not allow excess properties', () => { - doNotExecute(() => { - return queryOptions({ - queryKey: ['key'], - queryFn: async () => await Promise.resolve(5), - // @ts-expect-error this is a good error, because stallTime does not exist! - stallTime: 1000, - }) - }) - }) - it('should infer types for callbacks', () => { - doNotExecute(() => { - return queryOptions({ - queryKey: ['key'], - queryFn: async () => await Promise.resolve(5), - staleTime: 1000, - select: (data) => { - const result: Expect> = true - return result - }, - }) - }) - }) - it('should work when passed to useQuery', () => { - doNotExecute(() => { - const options = queryOptions({ - queryKey: ['key'], - queryFn: async () => await Promise.resolve(5), - }) - - const { data } = useQuery(options) - - const result: Expect> = true - return result - }) - }) -// it('should work when passed to useSuspenseQuery', () => { -// doNotExecute(() => { -// const options = queryOptions({ -// queryKey: ['key'], -// queryFn: async () => await Promise.resolve(5), -// }) - -// const { data } = useSuspenseQuery(options) - -// const result: Expect> = true -// return result -// }) -// }) - it('should work when passed to fetchQuery', () => { - doNotExecute(async () => { - const options = queryOptions({ - queryKey: ['key'], - queryFn: async () => await Promise.resolve(5), - }) - - const data = await new QueryClient().fetchQuery(options) - - const result: Expect> = true - return result - }) - }) -// it('should work when passed to useQueries', () => { -// doNotExecute(() => { -// const options = queryOptions({ -// queryKey: ['key'], -// queryFn: async () => await Promise.resolve(5), -// }) - -// const [{ data }] = useQueries({ -// queries: [options], -// }) - -// const result: Expect> = true -// return result -// }) -// }) - it('should tag the queryKey with the result type of the QueryFn', () => { - doNotExecute(() => { - const { queryKey } = queryOptions({ - queryKey: ['key'], - queryFn: async () => await Promise.resolve(5), - }) - - const result: Expect< - Equal<(typeof queryKey)[typeof dataTagSymbol], number> - > = true - return result - }) - }) - it('should tag the queryKey even if no promise is returned', () => { - doNotExecute(() => { - const { queryKey } = queryOptions({ - queryKey: ['key'], - queryFn: () => 5, - }) - - const result: Expect< - Equal<(typeof queryKey)[typeof dataTagSymbol], number> - > = true - return result - }) - }) - it('should tag the queryKey with unknown if there is no queryFn', () => { - doNotExecute(() => { - const { queryKey } = queryOptions({ - queryKey: ['key'], - }) - - const result: Expect< - Equal<(typeof queryKey)[typeof dataTagSymbol], unknown> - > = true - return result - }) - }) - it('should tag the queryKey with the result type of the QueryFn if select is used', () => { - doNotExecute(() => { - const { queryKey } = queryOptions({ - queryKey: ['key'], - queryFn: async () => await Promise.resolve(5), - select: (data) => data.toString(), - }) - - const result: Expect< - Equal<(typeof queryKey)[typeof dataTagSymbol], number> - > = true - return result - }) - }) - it('should return the proper type when passed to getQueryData', () => { - doNotExecute(() => { - const { queryKey } = queryOptions({ - queryKey: ['key'], - queryFn: async () => await Promise.resolve(5), - }) - - const queryClient = new QueryClient() - const data = queryClient.getQueryData(queryKey) - - const result: Expect> = true - return result - }) - }) - it('should properly type updaterFn when passed to setQueryData', () => { - doNotExecute(() => { - const { queryKey } = queryOptions({ - queryKey: ['key'], - queryFn: async () => await Promise.resolve(5), - }) - - const queryClient = new QueryClient() - const data = queryClient.setQueryData(queryKey, (prev) => { - const result: Expect> = true - return result ? prev : 1 - }) - - const result: Expect> = true - return result - }) - }) - it('should properly type value when passed to setQueryData', () => { - doNotExecute(() => { - const { queryKey } = queryOptions({ - queryKey: ['key'], - queryFn: async () => await Promise.resolve(5), - }) - - const queryClient = new QueryClient() - - // @ts-expect-error value should be a number - queryClient.setQueryData(queryKey, '5') - // @ts-expect-error value should be a number - queryClient.setQueryData(queryKey, () => '5') - - const data = queryClient.setQueryData(queryKey, 5) - - const result: Expect> = true - return result - }) - }) -}) \ No newline at end of file diff --git a/src/lib/queries/react/queries/QueryOptions.rq.types.typeTest.ts b/src/lib/queries/react/queries/QueryOptions.rq.types.typeTest.ts new file mode 100644 index 0000000..1d4009f --- /dev/null +++ b/src/lib/queries/react/queries/QueryOptions.rq.types.typeTest.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/prefer-ts-expect-error */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/promise-function-async */ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import { describe, expect, expectTypeOf, it } from "vitest" +import { queryOptions } from "./queryOptions" +import { useQuery } from "./useQuery" +import { QueryClient } from "../../client/QueryClient" +import { dataTagSymbol } from "../../client/queries/types" +import { skipToken } from "../../client/queries/utils" + +describe("queryOptions", () => { + it("should not allow excess properties", () => { + queryOptions({ + queryKey: ["key"], + queryFn: () => Promise.resolve(5), + // @ts-expect-error this is a good error, because stallTime does not exist! + stallTime: 1000 + }) + }) + it("should infer types for callbacks", () => { + queryOptions({ + queryKey: ["key"], + queryFn: () => Promise.resolve(5), + staleTime: 1000, + select: (data) => { + expectTypeOf(data).toEqualTypeOf() + } + }) + }) + it("should work when passed to useQuery", () => { + const options = queryOptions({ + queryKey: ["key"], + queryFn: () => Promise.resolve(5) + }) + + const { data } = useQuery(options) + expectTypeOf(data).toEqualTypeOf() + }) + // it('should work when passed to useSuspenseQuery', () => { + // const options = queryOptions({ + // queryKey: ['key'], + // queryFn: () => Promise.resolve(5), + // }) + + // const { data } = useSuspenseQuery(options) + // expectTypeOf(data).toEqualTypeOf() + // }) + + it("should work when passed to fetchQuery", async () => { + const options = queryOptions({ + queryKey: ["key"], + queryFn: () => Promise.resolve(5) + }) + + const data = await new QueryClient().fetchQuery(options) + expectTypeOf(data).toEqualTypeOf() + }) + // it('should work when passed to useQueries', () => { + // const options = queryOptions({ + // queryKey: ['key'], + // queryFn: () => Promise.resolve(5), + // }) + + // const [{ data }] = useQueries({ + // queries: [options], + // }) + + // expectTypeOf(data).toEqualTypeOf() + // }) + it("should tag the queryKey with the result type of the QueryFn", () => { + expect(() => { + const { queryKey } = queryOptions({ + queryKey: ["key"], + queryFn: () => Promise.resolve(5) + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + }) + }) + it("should tag the queryKey even if no promise is returned", () => { + const { queryKey } = queryOptions({ + queryKey: ["key"], + queryFn: () => 5 + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + }) + it("should tag the queryKey with unknown if there is no queryFn", () => { + const { queryKey } = queryOptions({ + queryKey: ["key"] + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + }) + it("should tag the queryKey with the result type of the QueryFn if select is used", () => { + const { queryKey } = queryOptions({ + queryKey: ["key"], + queryFn: () => Promise.resolve(5), + select: (data) => data.toString() + }) + + expectTypeOf(queryKey[dataTagSymbol]).toEqualTypeOf() + }) + it("should return the proper type when passed to getQueryData", () => { + const { queryKey } = queryOptions({ + queryKey: ["key"], + queryFn: () => Promise.resolve(5) + }) + + const queryClient = new QueryClient() + const data = queryClient.getQueryData(queryKey) + expectTypeOf(data).toEqualTypeOf() + }) + it("should return the proper type when passed to getQueryState", () => { + const { queryKey } = queryOptions({ + queryKey: ["key"], + queryFn: () => Promise.resolve(5) + }) + + const queryClient = new QueryClient() + const state = queryClient.getQueryState(queryKey) + expectTypeOf(state?.data).toEqualTypeOf() + }) + it("should properly type updaterFn when passed to setQueryData", () => { + const { queryKey } = queryOptions({ + queryKey: ["key"], + queryFn: () => Promise.resolve(5) + }) + + const queryClient = new QueryClient() + const data = queryClient.setQueryData(queryKey, (prev) => { + expectTypeOf(prev).toEqualTypeOf() + return prev + }) + expectTypeOf(data).toEqualTypeOf() + }) + it("should properly type value when passed to setQueryData", () => { + const { queryKey } = queryOptions({ + queryKey: ["key"], + queryFn: () => Promise.resolve(5) + }) + + const queryClient = new QueryClient() + + // @ts-expect-error value should be a number + queryClient.setQueryData(queryKey, "5") + // @ts-expect-error value should be a number + queryClient.setQueryData(queryKey, () => "5") + + const data = queryClient.setQueryData(queryKey, 5) + expectTypeOf(data).toEqualTypeOf() + }) + + it("should infer even if there is a conditional skipToken", () => { + const options = queryOptions({ + queryKey: ["key"], + queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5) + }) + + const queryClient = new QueryClient() + const data = queryClient.getQueryData(options.queryKey) + expectTypeOf(data).toEqualTypeOf() + }) + + it("should infer to unknown if we disable a query with just a skipToken", () => { + const options = queryOptions({ + queryKey: ["key"], + queryFn: skipToken + }) + + const queryClient = new QueryClient() + const data = queryClient.getQueryData(options.queryKey) + expectTypeOf(data).toEqualTypeOf() + }) +}) diff --git a/src/lib/queries/react/queries/queryOptions.ts b/src/lib/queries/react/queries/queryOptions.ts index ec1ddfc..2e4a6d4 100644 --- a/src/lib/queries/react/queries/queryOptions.ts +++ b/src/lib/queries/react/queries/queryOptions.ts @@ -7,8 +7,9 @@ export type UndefinedInitialDataOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey -> = UseQueryOptions & { + TQueryKey extends QueryKey = QueryKey, + TPageParam = never +> = UseQueryOptions & { initialData?: undefined } @@ -31,8 +32,8 @@ export function queryOptions< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey >( - options: UndefinedInitialDataOptions -): UndefinedInitialDataOptions & { + options: DefinedInitialDataOptions +): DefinedInitialDataOptions & { queryKey: DataTag } @@ -40,10 +41,23 @@ export function queryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey + TQueryKey extends QueryKey = QueryKey, + TPageParam = never >( - options: DefinedInitialDataOptions -): DefinedInitialDataOptions & { + options: UndefinedInitialDataOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + > +): UndefinedInitialDataOptions< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam +> & { queryKey: DataTag } diff --git a/src/lib/queries/react/queries/types.ts b/src/lib/queries/react/queries/types.ts index 07f32b5..0289c50 100644 --- a/src/lib/queries/react/queries/types.ts +++ b/src/lib/queries/react/queries/types.ts @@ -1,4 +1,4 @@ -import { type WithRequired } from "../../../utils/types" +import { type OmitKeyof } from "../../../utils/types" import { type QueryKey } from "../../client/keys/types" import { type QueryObserverResult, @@ -17,21 +17,31 @@ export interface UseBaseQueryOptions< TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey -> extends WithRequired< - QueryObserverOptions, - "queryKey" + TQueryKey extends QueryKey = QueryKey, + TPageParam = never +> extends QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey, + TPageParam > {} export interface UseQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey -> extends Omit< - WithRequired< - UseBaseQueryOptions, - "queryKey" + TQueryKey extends QueryKey = QueryKey, + TPageParam = never +> extends OmitKeyof< + UseBaseQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey, + TPageParam >, "suspense" > {} diff --git a/src/lib/queries/react/queries/useIsFetching.rq.test.tsx b/src/lib/queries/react/queries/useIsFetching.rq.test.tsx index 05fc7e6..e0455a3 100644 --- a/src/lib/queries/react/queries/useIsFetching.rq.test.tsx +++ b/src/lib/queries/react/queries/useIsFetching.rq.test.tsx @@ -13,9 +13,9 @@ import { queryKey } from "../../client/tests/utils" import { useIsFetching } from "./useIsFetching" import { useQuery } from "./useQuery" -describe('useIsFetching', () => { +describe("useIsFetching", () => { // See https://github.com/tannerlinsley/react-query/issues/105 - it('should update as queries start and stop fetching', async () => { + it("should update as queries start and stop fetching", async () => { const queryCache = new QueryCache() const queryClient = createQueryClient({ queryCache }) const key = queryKey() @@ -32,12 +32,20 @@ describe('useIsFetching', () => { queryKey: key, queryFn: async () => { await sleep(50) - return 'test' + return "test" }, - enabled: ready, + enabled: ready }) - return + return ( + + ) } function Page() { @@ -51,13 +59,13 @@ describe('useIsFetching', () => { const { findByText, getByRole } = renderWithClient(queryClient, ) - await findByText('isFetching: 0') - fireEvent.click(getByRole('button', { name: /setReady/i })) - await findByText('isFetching: 1') - await findByText('isFetching: 0') + await findByText("isFetching: 0") + fireEvent.click(getByRole("button", { name: /setReady/i })) + await findByText("isFetching: 1") + await findByText("isFetching: 0") }) - it('should not update state while rendering', async () => { + it("should not update state while rendering", async () => { const queryCache = new QueryCache() const queryClient = createQueryClient({ queryCache }) @@ -77,8 +85,8 @@ describe('useIsFetching', () => { queryKey: key1, queryFn: async () => { await sleep(100) - return 'data' - }, + return "data" + } }) return null } @@ -88,8 +96,8 @@ describe('useIsFetching', () => { queryKey: key2, queryFn: async () => { await sleep(100) - return 'data' - }, + return "data" + } }) return null } @@ -113,10 +121,12 @@ describe('useIsFetching', () => { } renderWithClient(queryClient, ) - await waitFor(() => { expect(isFetchingArray).toEqual([0, 1, 1, 2, 1, 0]); }) + await waitFor(() => { + expect(isFetchingArray).toEqual([0, 1, 1, 2, 1, 0]) + }) }) - it('should be able to filter', async () => { + it("should be able to filter", async () => { const queryClient = createQueryClient() const key1 = queryKey() const key2 = queryKey() @@ -128,8 +138,8 @@ describe('useIsFetching', () => { queryKey: key1, queryFn: async () => { await sleep(10) - return 'test' - }, + return "test" + } }) return null } @@ -139,8 +149,8 @@ describe('useIsFetching', () => { queryKey: key2, queryFn: async () => { await sleep(20) - return 'test' - }, + return "test" + } }) return null } @@ -153,7 +163,13 @@ describe('useIsFetching', () => { return (
- +
isFetching: {isFetching}
{started ? ( <> @@ -167,15 +183,15 @@ describe('useIsFetching', () => { const { findByText, getByRole } = renderWithClient(queryClient, ) - await findByText('isFetching: 0') - fireEvent.click(getByRole('button', { name: /setStarted/i })) - await findByText('isFetching: 1') - await findByText('isFetching: 0') + await findByText("isFetching: 0") + fireEvent.click(getByRole("button", { name: /setStarted/i })) + await findByText("isFetching: 1") + await findByText("isFetching: 0") // at no point should we have isFetching: 2 expect(isFetchingArray).toEqual(expect.not.arrayContaining([2])) }) - it('should show the correct fetching state when mounted after a query', async () => { + it("should show the correct fetching state when mounted after a query", async () => { const queryClient = createQueryClient() const key = queryKey() @@ -184,8 +200,8 @@ describe('useIsFetching', () => { queryKey: key, queryFn: async () => { await sleep(10) - return 'test' - }, + return "test" + } }) const isFetching = useIsFetching() @@ -199,11 +215,11 @@ describe('useIsFetching', () => { const rendered = renderWithClient(queryClient, ) - await rendered.findByText('isFetching: 1') - await rendered.findByText('isFetching: 0') + await rendered.findByText("isFetching: 1") + await rendered.findByText("isFetching: 0") }) - it('should use provided custom queryClient', async () => { + it("should use provided custom queryClient", async () => { const queryClient = createQueryClient() const key = queryKey() @@ -213,10 +229,10 @@ describe('useIsFetching', () => { queryKey: key, queryFn: async () => { await sleep(10) - return 'test' - }, + return "test" + } }, - queryClient, + queryClient ) const isFetching = useIsFetching({}, queryClient) @@ -230,6 +246,6 @@ describe('useIsFetching', () => { const rendered = render() - await waitFor(() => rendered.getByText('isFetching: 1')) + await waitFor(() => rendered.getByText("isFetching: 1")) }) -}) \ No newline at end of file +}) diff --git a/src/lib/utils/types.ts b/src/lib/utils/types.ts index 28badd9..0389706 100644 --- a/src/lib/utils/types.ts +++ b/src/lib/utils/types.ts @@ -1,9 +1,13 @@ // eslint-disable-next-line @typescript-eslint/ban-types -export type WithRequired = T & { [_ in K]: {} } +export type WithRequired = TTarget & { + // eslint-disable-next-line @typescript-eslint/ban-types + [_ in TKey]: {} +} // eslint-disable-next-line @typescript-eslint/ban-types export type NonFunctionGuard = T extends Function ? never : T +// @todo migrate to 5.4 which is part of the API export type NoInfer = [T][T extends any ? 0 : never] export type Equal = @@ -12,3 +16,11 @@ export type Equal = : false export type Expect = T + +export type OmitKeyof< + TObject, + TKey extends TStrictly extends "safely" + ? keyof TObject | (string & Record) + : keyof TObject, + TStrictly extends "strictly" | "safely" = "strictly" +> = Omit diff --git a/tsconfig.json b/tsconfig.json index a60a6fe..ded9021 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,28 @@ { "compilerOptions": { - "target": "ESNext", "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ESNext", "DOM"], - "moduleResolution": "node", - "strict": true, - "resolveJsonModule": true, - "isolatedModules": true, + "jsx": "react-jsx", + "allowJs": true, + "allowSyntheticDefaultImports": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "checkJs": true, + "declaration": true, "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", "noEmit": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noImplicitReturns": true, + "resolveJsonModule": true, "skipLibCheck": true, - "jsx": "react-jsx", - "noUncheckedIndexedAccess": true + "strict": true, + "target": "ESNext" }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }]