diff --git a/packages/toolkit/src/query/react/buildHooks.ts b/packages/toolkit/src/query/react/buildHooks.ts index eb408b99ed..2e945099fd 100644 --- a/packages/toolkit/src/query/react/buildHooks.ts +++ b/packages/toolkit/src/query/react/buildHooks.ts @@ -79,9 +79,7 @@ export interface MutationHooks< useMutation: UseMutation } -type IdleState = Arg extends SkipToken - ? { isSkipped: true } - : { isSkipped: boolean } +type SkippedState = { isSkipped: Skipped } /** * A React hook that automatically triggers fetches of data from an endpoint, 'subscribes' the component to the cached data, and reads the request status and cached data from the Redux store. The component will re-render as the loading status changes and the data becomes available. @@ -98,16 +96,43 @@ type IdleState = Arg extends SkipToken * - Returns the latest request status and cached data from the Redux store * - Re-renders as the request status changes and data becomes available */ -export type UseQuery> = < - R extends Record = UseQueryStateDefaultResult, - Arg extends QueryArgFrom | SkipToken = QueryArgFrom | SkipToken ->( - arg: QueryArgFrom | SkipToken, - options?: UseQuerySubscriptionOptions & UseQueryStateOptions -) => UseQueryStateResult & - ReturnType> & - Suspendable & - IdleState +export interface UseQuery> { + // arg provided + = UseQueryStateDefaultResult>( + arg: QueryArgFrom, + options?: UseQuerySubscriptionOptions & UseQueryStateOptions + ): UseQueryStateResult & + ReturnType> & + Suspendable & + SkippedState + // skipped query + = UseQueryStateDefaultResult>( + arg: SkipToken, + options?: UseQuerySubscriptionOptions & UseQueryStateOptions + ): UseQueryStateResult & + ReturnType> & + Suspendable & + SkippedState + = UseQueryStateDefaultResult>( + arg: QueryArgFrom | SkipToken, + options?: UseQuerySubscriptionOptions & UseQueryStateOptions + ): UseQueryStateResult & + ReturnType> & + Suspendable & + SkippedState +} + +/** + * @internal + */ +type UseQueryParams> = Parameters< + UseQuery +> + +/** + * @internal + */ +type AnyQueryDefinition = QueryDefinition interface UseQuerySubscriptionOptions extends SubscriptionOptions { /** @@ -551,7 +576,7 @@ const createSuspendablePromise = < Definitions, Key >): Suspendable['getSuspendablePromise'] => { - const retry = () => { + const fetchOnce = () => { prefetch(args, { force: true, }) @@ -565,27 +590,19 @@ const createSuspendablePromise = < let pendingPromise = api.util.getRunningOperationPromise(name, args) if (!pendingPromise) { - prefetch(args, { - force: true, - }) + fetchOnce() pendingPromise = api.util.getRunningOperationPromise( name as any, args ) - - if (!pendingPromise) { - throw new Error( - `[rtk-query][react]: invalid state error, expected getRunningOperationPromise(${name}, ${queryStateResults.requestId}) to be defined` - ) - } } return pendingPromise } else if (queryStateResults.isError && !queryStateResults.isFetching) { throw new SuspenseQueryError( queryStateResults.error, queryStateResults.endpointName + '', - retry + fetchOnce ) } } @@ -938,7 +955,10 @@ export function buildHooks({ [trigger, queryStateResults, info] ) }, - useQuery(arg, options) { + useQuery( + arg: UseQueryParams['0'], + options: UseQueryParams['1'] + ) { const isSkipped: boolean = arg === skipToken || !!options?.skip const querySubscriptionResults = useQuerySubscription(arg, options) const queryStateResults = useQueryState(arg, { diff --git a/packages/toolkit/src/query/react/exceptions.ts b/packages/toolkit/src/query/react/exceptions.ts index 6bb1ceab18..f3320f449d 100644 --- a/packages/toolkit/src/query/react/exceptions.ts +++ b/packages/toolkit/src/query/react/exceptions.ts @@ -4,11 +4,13 @@ const computeErrorMessage = (reason: any, queryKey: string) => { if (reason instanceof Error) { message += reason } else if (typeof reason === 'object' && reason !== null) { - ;[reason?.status, reason?.code, reason?.error].forEach((value) => { - if (value) { - message += ` ${value}` + const relevantProperties = [reason?.status, reason?.code, reason?.error] + + for (const property of relevantProperties) { + if (property) { + message += ` ${property}` } - }) + } } else { message += reason } @@ -25,5 +27,8 @@ export class SuspenseQueryError extends Error { super(computeErrorMessage(reason, endpointName)) this.reason = reason this.name = 'SuspenseQueryError' + + // https://www.typescriptlang.org/docs/handbook/2/classes.html#inheriting-built-in-types + Object.setPrototypeOf(this, SuspenseQueryError.prototype) } } diff --git a/packages/toolkit/src/query/react/suspense-utils.ts b/packages/toolkit/src/query/react/suspense-utils.ts index 935792e0a9..f43296857a 100644 --- a/packages/toolkit/src/query/react/suspense-utils.ts +++ b/packages/toolkit/src/query/react/suspense-utils.ts @@ -1,3 +1,5 @@ +import { isPromiseLike } from '../utils/isPromiseLike' + export interface Resource { data?: Data | undefined isLoading?: boolean @@ -34,26 +36,13 @@ export type UseSuspendAllOutput = { : never } -function isPromiseLike(val: unknown): val is PromiseLike { - return ( - !!val && typeof val === 'object' && typeof (val as any).then === 'function' - ) -} - -function getSuspendable(suspendable: Suspendable) { +const getSuspendable = (suspendable: Suspendable) => { return suspendable.getSuspendablePromise() } export function useSuspendAll< - G extends SuspendableResource, - T extends SuspendableResource[] ->( - ...suspendables: readonly [G, ...T] -): UseSuspendAllOutput { - if (!suspendables.length) { - throw new TypeError('useSuspendAll: requires one or more arguments') - } - + T extends ReadonlyArray> +>(...suspendables: T): UseSuspendAllOutput { let promises = suspendables .map(getSuspendable) .filter(isPromiseLike) as Promise[] @@ -62,5 +51,5 @@ export function useSuspendAll< throw Promise.all(promises) } - return suspendables as UseSuspendAllOutput + return suspendables as UseSuspendAllOutput } diff --git a/packages/toolkit/src/query/tests/buildHooks.test.tsx b/packages/toolkit/src/query/tests/buildHooks.test.tsx index a3a3b49c0a..bc1c9059ce 100644 --- a/packages/toolkit/src/query/tests/buildHooks.test.tsx +++ b/packages/toolkit/src/query/tests/buildHooks.test.tsx @@ -1542,7 +1542,7 @@ describe('hooks tests', () => { baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com' }), tagTypes: ['User'], endpoints: (build) => ({ - checkSession: build.query({ + checkSession: build.query({ query: () => '/me', providesTags: ['User'], }), @@ -1837,7 +1837,7 @@ describe('hooks with createApi defaults set', () => { baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/' }), tagTypes: ['Posts'], endpoints: (build) => ({ - getPosts: build.query({ + getPosts: build.query({ query: () => ({ url: 'posts' }), providesTags: (result) => result ? result.map(({ id }) => ({ type: 'Posts', id })) : [], @@ -2134,9 +2134,9 @@ describe('hooks with createApi defaults set', () => { test('useQuery with selectFromResult option has a type error if the result is not an object', async () => { function SelectedPost() { + // @ts-expect-error const _res1 = api.endpoints.getPosts.useQuery(undefined, { // selectFromResult must always return an object - // @ts-expect-error selectFromResult: ({ data }) => data?.length ?? 0, }) @@ -2434,18 +2434,16 @@ describe('suspense', () => { describe('useSuspendAll', () => { const consoleErrorSpy = jest.spyOn(console, 'error') - function ThrowsBecauseNoArgs() { + function ExceptionCausedByAnInvalidArg() { const tuple = [ { - getSuspendablePromise() { + invalid() { return undefined }, }, ] as const - ;(tuple as unknown as any[]).splice(0, tuple.length) - - useSuspendAll(...tuple) + useSuspendAll(...(tuple as any)) return
} @@ -2457,7 +2455,7 @@ describe('suspense', () => {
{String(error)}
)} > - + ) diff --git a/packages/toolkit/src/query/tests/unionTypes.test.ts b/packages/toolkit/src/query/tests/unionTypes.test.ts index ab24b155d6..3d87b3260a 100644 --- a/packages/toolkit/src/query/tests/unionTypes.test.ts +++ b/packages/toolkit/src/query/tests/unionTypes.test.ts @@ -358,7 +358,10 @@ describe.skip('TS only tests', () => { getSuspendablePromise, ...useQueryResultWithoutMethods } = useQueryResult - expectExactType(useQueryStateResult)(useQueryResultWithoutMethods) + expectExactType(useQueryStateResult)( + // @ts-expect-error + useQueryResultWithoutMethods + ) expectExactType(useQueryStateWithSelectFromResult)( // @ts-expect-error useQueryResultWithoutMethods @@ -411,10 +414,12 @@ describe.skip('TS only tests', () => { isFetching, isError, isSuccess, + isSkipped: false, isUninitialized, } }, }) + expectExactType({ getSuspendablePromise: expect.any(Function), data: '' as string | number, @@ -423,6 +428,7 @@ describe.skip('TS only tests', () => { isFetching: true, isSuccess: false, isError: false, + isSkipped: false, refetch: () => {}, })(result) }) diff --git a/packages/toolkit/src/query/utils/isPromiseLike.ts b/packages/toolkit/src/query/utils/isPromiseLike.ts new file mode 100644 index 0000000000..f09f0877c9 --- /dev/null +++ b/packages/toolkit/src/query/utils/isPromiseLike.ts @@ -0,0 +1,9 @@ +/** + * Thenable type guard. + * @internal + */ +export const isPromiseLike = (val: unknown): val is PromiseLike => { + return ( + !!val && typeof val === 'object' && typeof (val as any).then === 'function' + ) +}