diff --git a/.eslintrc.json b/.eslintrc.json index 7e70f0b..90107b0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -29,6 +29,17 @@ "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn" }, + "overrides": [ + { + "files": ["**/tests/**/*"], + "rules": { + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off" + } + } + ], "settings": { "react": { "pragma": "React", diff --git a/README.md b/README.md index 289ee75..039c213 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,24 @@ const usersRes = await axios(api.getUsers()); const userRes = await axios(api.getUserInfo("ID001")); ``` +custom response type. (if you change the response's return value. like axios.interceptors.response) + +```ts +import { request, _request } from "@axios-use/react"; +const [reqState] = useResource(() => request({ url: `/users` }), []); +// AxiosResponse +reqState.response; +// DataType +reqState.data; + +// custom response type +const [reqState] = useResource(() => _request>({ url: `/users` }), []); +// MyWrapper +reqState.response; +// MyWrapper["data"]. maybe `undefined` type. +reqState.data; +``` + #### createRequestError The `createRequestError` normalizes the error response. This function is used internally as well. The `isCancel` flag is returned, so you don't have to call **axios.isCancel** later on the promise catch block. diff --git a/README.zh-CN.md b/README.zh-CN.md index 2c187ad..eb8d2ea 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -285,6 +285,24 @@ const usersRes = await axios(api.getUsers()); const userRes = await axios(api.getUserInfo("ID001")); ``` +自定义 response 类型. (如果你有手动修改 response 数据的需求。 axios.interceptors.response) + +```ts +import { request, _request } from "@axios-use/react"; +const [reqState] = useResource(() => request({ url: `/users` }), []); +// AxiosResponse +reqState.response; +// DataType +reqState.data; + +// 自定义 response 类型 +const [reqState] = useResource(() => _request>({ url: `/users` }), []); +// MyWrapper +reqState.response; +// MyWrapper["data"]. maybe `undefined` type. +reqState.data; +``` + #### createRequestError `createRequestError` 用于规范错误响应(该函数也默认在内部调用)。 `isCancel` 标志被返回,因此也不必在 promise catch 块上调用 **axios.isCancel**。 diff --git a/src/cache.ts b/src/cache.ts index 033e136..ba3e625 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -14,7 +14,7 @@ export interface Cache { clear(): void; } -const SLASHES_REGEX = /^\/|\/$/g; +const SLASHES_REGEX = /(?:^\/)|(?:\/$)/g; export const defaultCacheKeyGenerator = ( config: Resource, diff --git a/src/request.ts b/src/request.ts index ae21952..e66b151 100644 --- a/src/request.ts +++ b/src/request.ts @@ -12,29 +12,56 @@ export type AxiosRestResponse = Omit< "data" >; -export interface Resource extends AxiosRequestConfig { - payload?: TPayload; +export interface Resource< + T = AxiosResponse, + D = any, + K1 extends keyof T = never, + K2 extends keyof T[K1] = never, + K3 extends keyof T[K1][K2] = never, +> extends AxiosRequestConfig { + _payload?: T; + _payload_item?: [K3] extends [never] + ? [K2] extends [never] + ? [K1] extends [never] + ? T extends AxiosResponse | { data?: infer DD } + ? DD + : undefined + : T[K1] + : T[K1][K2] + : T[K1][K2][K3]; } -export type Request = (...args: any[]) => Resource; +export type Request< + T = any, + D = any, + K1 extends keyof T = any, + K2 extends keyof T[K1] = any, + K3 extends keyof T[K1][K2] = any, +> = (...args: any[]) => Resource; -export type Payload = ReturnType["payload"]; -export type BodyData = ReturnType["data"]; +type _AnyKeyValue = K extends keyof T ? T[K] : any; + +export type Payload = Check extends true + ? _AnyKeyValue, "_payload_item"> + : T extends Request + ? Exclude<_AnyKeyValue, "_payload">, undefined> + : _AnyKeyValue, "_payload">; +export type BodyData = _AnyKeyValue, "data">; /** @deprecated No longer use. Use `BodyData` instead */ export type CData = BodyData; -export interface RequestFactory { - (...args: Parameters): { - cancel: Canceler; - ready: () => Promise<[Payload, AxiosResponse, BodyData>]>; - }; -} +export type RequestFactory = (...args: Parameters) => { + cancel: Canceler; + ready: () => Promise, Payload]>; +}; -export interface RequestDispatcher { - (...args: Parameters): Canceler; -} +export type RequestDispatcher = ( + ...args: Parameters +) => Canceler; -// Normalize the error response returned from our hooks +/** + * Normalize the error response returned from `@axios-use/vue` + */ export interface RequestError< T = any, D = any, @@ -48,19 +75,37 @@ export interface RequestError< } export type RequestCallbackFn = { - onCompleted?: ( - data: Payload, - response: AxiosResponse, BodyData>, - ) => void; - onError?: (err?: RequestError, BodyData>) => void; + /** + * A callback function that's called when your request successfully completes with zero errors. + * This function is passed the request's result `data` and `response`. + */ + onCompleted?: (data: Payload, response: Payload) => void; + /** + * A callback function that's called when the request encounters one or more errors. + * This function is passed an `RequestError` object that contains either a networkError object or a `AxiosError`, depending on the error(s) that occurred. + */ + onError?: (err: RequestError, BodyData>) => void; }; -export function request( - config: AxiosRequestConfig, -): Resource { +/** + * For TypeScript type deduction + */ +export function _request< + T, + D = any, + K1 extends keyof T = never, + K2 extends keyof T[K1] = never, + K3 extends keyof T[K1][K2] = never, +>(config: AxiosRequestConfig): Resource { return config; } +/** + * For TypeScript type deduction + */ +export const request = (config: AxiosRequestConfig) => + _request, D>(config); + export function createRequestError< T = any, D = any, diff --git a/src/requestContext.tsx b/src/requestContext.tsx index fa55393..e0d4099 100644 --- a/src/requestContext.tsx +++ b/src/requestContext.tsx @@ -1,6 +1,5 @@ -import React from "react"; +import React, { createContext, useMemo } from "react"; import type { PropsWithChildren } from "react"; -import { createContext } from "react"; import type { AxiosInstance } from "axios"; import type { RequestError } from "./request"; @@ -41,16 +40,13 @@ export const RequestProvider = ( ...rest } = props; + const providerValue = useMemo( + () => ({ instance, cache, cacheKey, cacheFilter, customCreateReqError }), + [cache, cacheFilter, cacheKey, customCreateReqError, instance], + ); + return ( - + {children} ); diff --git a/src/useRequest.ts b/src/useRequest.ts index f4ca245..f932e08 100644 --- a/src/useRequest.ts +++ b/src/useRequest.ts @@ -4,7 +4,6 @@ import type { CancelTokenSource, Canceler, CancelToken, - AxiosResponse, AxiosInstance, } from "axios"; import axios from "axios"; @@ -14,6 +13,7 @@ import type { Request, Payload, BodyData, + RequestError, } from "./request"; import { createRequestError } from "./request"; import { RequestContext } from "./requestContext"; @@ -72,11 +72,14 @@ export function useRequest( setSources((prevSources) => [...prevSources, source]); } return axiosInstance({ ...config, cancelToken: source.token }) - .then((response: AxiosResponse, BodyData>) => { + .then((response) => { removeCancelToken(source.token); - onCompletedRef.current?.(response.data, response); - return [response.data, response]; + onCompletedRef.current?.( + response.data as Payload, + response as Payload, + ); + return [response.data, response as Payload] as const; }) .catch((err: AxiosError, BodyData>) => { removeCancelToken(source.token); @@ -85,10 +88,12 @@ export function useRequest( ? customCreateReqError(err) : createRequestError(err); - onErrorRef.current?.(error); + onErrorRef.current?.( + error as RequestError, BodyData>, + ); throw error; - }) as Promise<[Payload, AxiosResponse>]>; + }); }; return { diff --git a/src/useResource.ts b/src/useResource.ts index 45e6498..a6d9acc 100644 --- a/src/useResource.ts +++ b/src/useResource.ts @@ -1,5 +1,5 @@ import { useEffect, useCallback, useContext, useReducer, useMemo } from "react"; -import type { Canceler, AxiosResponse } from "axios"; +import type { Canceler } from "axios"; import { useRequest } from "./useRequest"; import type { Payload, @@ -22,19 +22,19 @@ import { useDeepMemo, useMountedState, useRefFn, getStrByFn } from "./utils"; const REQUEST_CLEAR_MESSAGE = "A new request has been made before completing the last one"; -type RequestState = { - data?: Payload; - response?: AxiosResponse>; - error?: RequestError, BodyData>; +type RequestState = { + data?: Payload; + response?: Payload; + error?: RequestError, BodyData>; isLoading?: boolean; /** @deprecated Use `response` instead */ - other?: AxiosResponse>; + other?: Payload; }; -export type UseResourceResult = [ - RequestState & { cancel: Canceler }, - RequestDispatcher, +export type UseResourceResult = [ + RequestState & { cancel: Canceler }, + RequestDispatcher, () => Canceler | undefined, ]; @@ -62,14 +62,14 @@ function getDefaultStateLoading( return undefined; } -type Action = - | { type: "success"; data: T; response: AxiosResponse } - | { type: "error"; error: RequestError } +type Action = + | { type: "success"; data: Payload; response: Payload } + | { type: "error"; error: RequestError, BodyData> } | { type: "reset" | "start" }; function getNextState( state: RequestState, - action: Action, BodyData>, + action: Action, ): RequestState { const response = action.type === "success" ? action.response : state.response; @@ -134,9 +134,10 @@ export function useResource( ); }, [RequestConfig.cacheKey, fnOptions, options?.cacheKey, requestCache]); const cacheData = useMemo(() => { - return requestCache && cacheKey && typeof requestCache.get === "function" - ? requestCache.get(cacheKey) ?? undefined - : undefined; + if (requestCache && cacheKey && typeof requestCache.get === "function") { + return (requestCache.get(cacheKey) as Payload) ?? undefined; + } + return undefined; }, [cacheKey, requestCache]); const [createRequest, { clear }] = useRequest(fn, { diff --git a/tests/request.test.ts b/tests/request.test.ts index aeadca4..135a284 100644 --- a/tests/request.test.ts +++ b/tests/request.test.ts @@ -1,5 +1,7 @@ -import type { AxiosRequestConfig } from "axios"; -import { request, createRequestError } from "../src"; +import type { AxiosRequestConfig, AxiosResponse } from "axios"; +import type { Payload, Resource } from "../src"; +import { _request, request, createRequestError } from "../src"; +import { expectTypeShell } from "./utils"; const config1 = { url: "/config1", method: "GET" } as AxiosRequestConfig; const config2 = { @@ -10,13 +12,47 @@ const config2 = { describe("request", () => { it("should be defined", () => { + expect(_request).toBeDefined(); expect(request).toBeDefined(); }); it("value", () => { + expect(_request(config1)).toStrictEqual(config1); + expect(_request(config2)).toStrictEqual(config2); expect(request(config1)).toStrictEqual(config1); expect(request(config2)).toStrictEqual(config2); }); + + it("type checking", () => { + type DataType = { a: string; b?: number }; + type ItemType = { z: string[] }; + type DataType2 = DataType & { data?: ItemType }; + const rq0 = () => _request({}); + const rq1 = () => request({}); + const rq2 = () => _request({}); + + expect( + expectTypeShell(rq0()).type>(), + ).toBeDefined(); + expect( + expectTypeShell(rq1()).type, any>>(), + ).toBeDefined(); + + const c0 = null as unknown as Payload; + expect(expectTypeShell(c0).type()).toBeNull(); + const c1 = null as unknown as Payload; + expect(expectTypeShell(c1).type()).toBeNull(); + + const c2 = null as unknown as Payload; + expect(expectTypeShell(c2).type>()).toBeNull(); + const c3 = null as unknown as Payload; + expect(expectTypeShell(c3).type()).toBeNull(); + + const c4 = null as unknown as Payload; + expect(expectTypeShell(c4).type()).toBeNull(); + const c5 = null as unknown as Payload; + expect(expectTypeShell(c5).type()).toBeNull(); + }); }); describe("createRequestError", () => { diff --git a/tests/useRequest.test.ts b/tests/useRequest.test.ts index 497cead..82f7755 100644 --- a/tests/useRequest.test.ts +++ b/tests/useRequest.test.ts @@ -1,4 +1,4 @@ -import type { AxiosError } from "axios"; +import type { AxiosError, AxiosResponse } from "axios"; import { renderHook, originalRenderHook, @@ -6,15 +6,17 @@ import { act, cache, axios, + expectTypeShell, } from "./utils"; import type { RequestError } from "../src"; -import { useRequest } from "../src"; +import { useRequest, request, _request } from "../src"; const okResponse = { code: 0, data: [1, 2], message: null }; const okResponse2 = { code: 0, data: "res2", message: null }; const errResponse = { code: 2001, data: [3, 4], message: "some error" }; const errResponse2 = { code: 2001, data: [3, 4], msg: "some error" }; +type ResOK1Type = typeof okResponse; describe("useRequest", () => { beforeAll(() => { @@ -213,6 +215,47 @@ describe("useRequest", () => { } }); }); + + it("width type", async () => { + const { result } = renderHook(() => + useRequest(() => request({ url: "/users", method: "GET" })), + ); + await act(async () => { + const [data, res] = await result.current[0]().ready(); + expect( + expectTypeShell(data).type(), + ).toStrictEqual(okResponse); + expect( + expectTypeShell(res).type>(), + ).toBeDefined(); + expect(expectTypeShell(res.data).type()).toStrictEqual( + okResponse, + ); + expect(res?.status).toBe(200); + }); + }); + + it("custom response type", async () => { + const { result } = renderHook(() => + useRequest(() => + _request, any, "data">({ + url: "/users", + method: "GET", + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + transformResponse: (r) => r.data, + }), + ), + ); + await act(async () => { + const [data, res] = await result.current[0]().ready(); + expect(expectTypeShell(data).type()).toStrictEqual( + okResponse.data, + ); + expect( + expectTypeShell(res).type>().data, + ).toStrictEqual(okResponse.data); + }); + }); }); describe("useRequest - custom instance", () => { diff --git a/tests/utils.tsx b/tests/utils.tsx index 5606d00..6a87bde 100644 --- a/tests/utils.tsx +++ b/tests/utils.tsx @@ -45,6 +45,24 @@ function customRenderHook( export * from "@testing-library/react-hooks"; +type Equal = [Left] extends [Right] + ? [Right] extends [Left] + ? true + : false + : false; +type MismatchArgs = Equal< + B, + C +> extends true + ? [] + : [never]; + +export const expectTypeShell = ( + r: R, +): { type(...args: MismatchArgs, true>): R } => ({ + type: () => r, +}); + export { customRenderHook as renderHook, mockAdapter,