diff --git a/package.json b/package.json index 532958c..c13cb14 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ ], "scripts": { "test": "jest", - "build": "rm -rf target && tsc", + "build": "tsc", "prepublishOnly": "rm -rf target && tsc", "report-coverage": "cat coverage/lcov.info | coveralls" }, diff --git a/src/client.ts b/src/client.ts index c2f967d..e1f5c16 100644 --- a/src/client.ts +++ b/src/client.ts @@ -17,12 +17,17 @@ export type RequestProps = PrepareRequestProps & { transformResponseData?: (data: unknown) => R, } -export type ClientResponse> = Readonly< +export type ResponseWithError = Pick - & { data: Data } -> + & Readonly<{ error?: Error, canceled?: boolean }> + +export type ClientResponse> = + ResponseWithError + & Readonly<{ data: Data }> export class Client { + private controller = new AbortController(); + constructor(private config: ClientConfig) {} public prepareRequest(props: PrepareRequestProps) { @@ -82,10 +87,29 @@ export class Client { ): Promise> { const req = this.prepareRequest(restProps); - return fetch(req) + return fetch(req, { signal: this.controller.signal }) // TODO: need to check response headers and parse json only if content-type header is application/json - .then(res => Promise.all([res, res.json(), false])) - .then(([res, data]) => { + .then( + (res) => Promise.all([res, res.json()]), + (err) => { + const canceled = err.name === 'AbortError'; + return Promise.all([ + { + ok: false, + redirected: false, + status: canceled ? 499 : 400, + statusText: canceled ? 'Client Closed Request' : err.toString(), + type: 'basic', + headers: {}, + url: req.url, + error: err, + canceled, + } as ResponseWithError, + {} + ]); + } + ) + .then(([res, data]): ClientResponse => { return { ok: res.ok, redirected: res.redirected, @@ -94,6 +118,8 @@ export class Client { type: res.type, headers: res.headers, url: res.url, + error: 'error' in res ? res.error : undefined, + canceled: 'canceled' in res ? res.canceled : false, data: isFunction(transformResponseData) ? transformResponseData(data) : data, }; }) @@ -105,4 +131,9 @@ export class Client { return res; }); } + + public cancelRequest() { + this.controller.abort(); + this.controller = new AbortController(); + } } diff --git a/src/lazy-request-hook.ts b/src/lazy-request-hook.ts index 53e7cb4..96e9a7b 100644 --- a/src/lazy-request-hook.ts +++ b/src/lazy-request-hook.ts @@ -30,16 +30,17 @@ export type LazyRequestHandlerConfig = Readonly< export type RequestHandler = (config?: LazyRequestHandlerConfig) => Promise | null>; -export type RefetchRequestHandler = () => void; - -export type PublicRequestStateWithRefetch = +export type PublicRequestStateWithActions = PublicRequestState> - & { refetch: RefetchRequestHandler }; + & { + refetch: () => void, + cancel: () => void, + }; export function useLazyRequest( endpoint: E, config?: LazyRequestConfigFromEndpoint, -): [RequestHandler, PublicRequestStateWithRefetch] { +): [RequestHandler, PublicRequestStateWithActions] { const [client] = useClient(); const { defaultHeaders } = useRequestContext(); const [state, dispatch] = React.useReducer>>( @@ -63,7 +64,7 @@ export function useLazyRequest( const handler = React.useCallback( (handlerConfig?: LazyRequestHandlerConfig) => { - if (state?.loading) { + if (state?.loading || state?.isCanceled) { return Promise.resolve(null); } @@ -94,7 +95,8 @@ export function useLazyRequest( }; if ( - isSameRequest + state.isCalled + && isSameRequest && state?.prevVariables && isEqual(state.prevVariables, variables) && state?.prevHeaders && isEqual(state.prevHeaders, headers) && !handlerConfig?.force @@ -127,11 +129,13 @@ export function useLazyRequest( return response.data; }, - (response) => { + (response: ClientResponse>) => { dispatch({ type: 'failure', response }); - if (isFunction(onFailure)) { + + if (!response.canceled && isFunction(onFailure)) { onFailure(response); } + return null; } ); @@ -151,13 +155,26 @@ export function useLazyRequest( [handler, prevHandlerConfig] ); + React.useEffect( + () => { + return () => { + dispatch({ type: 'cancel' }); + client.cancelRequest(); + }; + }, + [client] + ); + return [ handler, { data: state.data, loading: state.loading, isCalled: state.isCalled, + isCanceled: state.isCanceled, + error: state.error, refetch, + cancel: client.cancelRequest.bind(client), }, ]; } diff --git a/src/reducer.ts b/src/reducer.ts index 409ef63..03ad523 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -4,6 +4,8 @@ export type PublicRequestState = Readonly<{ data: R | null; loading: boolean; isCalled: boolean; + isCanceled?: boolean; + error?: Error; }>; export type RequestState = PublicRequestState & Readonly<{ @@ -27,6 +29,9 @@ export type RequestAction = type: 'failure', response: ClientResponse } + | { + type: 'cancel' + } export type RequestReducer = React.Reducer, RequestAction> @@ -35,6 +40,8 @@ export function requestReducer(state: RequestState, action: RequestAction< case 'call': { return { ...state, + error: undefined, + isCanceled: false, loading: true, isCalled: true, prevHeaders: action.headers, @@ -54,7 +61,15 @@ export function requestReducer(state: RequestState, action: RequestAction< ...state, loading: false, data: null, - // TODO: need to append errors + error: action.response.error, + isCanceled: action.response.canceled, + }; + } + case 'cancel': { + return { + ...state, + isCanceled: false, + error: undefined, }; } } diff --git a/target/client.d.ts b/target/client.d.ts index 53c1583..22e5c7e 100644 --- a/target/client.d.ts +++ b/target/client.d.ts @@ -11,13 +11,19 @@ declare type PrepareRequestProps = { export declare type RequestProps = PrepareRequestProps & { transformResponseData?: (data: unknown) => R; }; -export declare type ClientResponse> = Readonly & { +export declare type ResponseWithError = Pick & Readonly<{ + error?: Error; + canceled?: boolean; +}>; +export declare type ClientResponse> = ResponseWithError & Readonly<{ data: Data; }>; export declare class Client { private config; + private controller; constructor(config: ClientConfig); prepareRequest(props: PrepareRequestProps): Request; request>({ transformResponseData, ...restProps }: RequestProps): Promise>; + cancelRequest(): void; } export {}; diff --git a/target/client.js b/target/client.js index 73e7d6c..daf2d90 100644 --- a/target/client.js +++ b/target/client.js @@ -15,6 +15,7 @@ import { formDataFromObject, isFunction, urlSearchParamsFromObject } from './mis export class Client { constructor(config) { this.config = config; + this.controller = new AbortController(); } prepareRequest(props) { var _a; @@ -48,9 +49,25 @@ export class Client { request(_a) { var { transformResponseData } = _a, restProps = __rest(_a, ["transformResponseData"]); const req = this.prepareRequest(restProps); - return fetch(req) + return fetch(req, { signal: this.controller.signal }) // TODO: need to check response headers and parse json only if content-type header is application/json - .then(res => Promise.all([res, res.json(), false])) + .then((res) => Promise.all([res, res.json()]), (err) => { + const canceled = err.name === 'AbortError'; + return Promise.all([ + { + ok: false, + redirected: false, + status: canceled ? 499 : 400, + statusText: canceled ? 'Client Closed Request' : err.toString(), + type: 'basic', + headers: {}, + url: req.url, + error: err, + canceled, + }, + {} + ]); + }) .then(([res, data]) => { return { ok: res.ok, @@ -60,6 +77,8 @@ export class Client { type: res.type, headers: res.headers, url: res.url, + error: 'error' in res ? res.error : undefined, + canceled: 'canceled' in res ? res.canceled : false, data: isFunction(transformResponseData) ? transformResponseData(data) : data, }; }) @@ -70,4 +89,8 @@ export class Client { return res; }); } + cancelRequest() { + this.controller.abort(); + this.controller = new AbortController(); + } } diff --git a/target/lazy-request-hook.d.ts b/target/lazy-request-hook.d.ts index 85eb71d..4d1fb1f 100644 --- a/target/lazy-request-hook.d.ts +++ b/target/lazy-request-hook.d.ts @@ -13,8 +13,8 @@ export declare type LazyRequestHandlerConfig = Readonly; export declare type RequestHandler = (config?: LazyRequestHandlerConfig) => Promise | null>; -export declare type RefetchRequestHandler = () => void; -export declare type PublicRequestStateWithRefetch = PublicRequestState> & { - refetch: RefetchRequestHandler; +export declare type PublicRequestStateWithActions = PublicRequestState> & { + refetch: () => void; + cancel: () => void; }; -export declare function useLazyRequest(endpoint: E, config?: LazyRequestConfigFromEndpoint): [RequestHandler, PublicRequestStateWithRefetch]; +export declare function useLazyRequest(endpoint: E, config?: LazyRequestConfigFromEndpoint): [RequestHandler, PublicRequestStateWithActions]; diff --git a/target/lazy-request-hook.js b/target/lazy-request-hook.js index 4de03b5..3d95644 100644 --- a/target/lazy-request-hook.js +++ b/target/lazy-request-hook.js @@ -21,7 +21,7 @@ export function useLazyRequest(endpoint, config) { }, [endpoint]); const handler = React.useCallback((handlerConfig) => { var _a, _b, _c; - if (state === null || state === void 0 ? void 0 : state.loading) { + if ((state === null || state === void 0 ? void 0 : state.loading) || (state === null || state === void 0 ? void 0 : state.isCanceled)) { return Promise.resolve(null); } let params; @@ -38,7 +38,8 @@ export function useLazyRequest(endpoint, config) { } const variables = Object.assign(Object.assign({}, config === null || config === void 0 ? void 0 : config.variables), handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.variables); const headers = Object.assign(Object.assign(Object.assign(Object.assign({}, defaultHeaders), endpoint.headers), config === null || config === void 0 ? void 0 : config.headers), handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.headers); - if (isSameRequest + if (state.isCalled + && isSameRequest && (state === null || state === void 0 ? void 0 : state.prevVariables) && isEqual(state.prevVariables, variables) && (state === null || state === void 0 ? void 0 : state.prevHeaders) && isEqual(state.prevHeaders, headers) && !(handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.force)) { @@ -60,7 +61,7 @@ export function useLazyRequest(endpoint, config) { return response.data; }, (response) => { dispatch({ type: 'failure', response }); - if (isFunction(onFailure)) { + if (!response.canceled && isFunction(onFailure)) { onFailure(response); } return null; @@ -71,13 +72,22 @@ export function useLazyRequest(endpoint, config) { handler(Object.assign(Object.assign({}, prevHandlerConfig), { force: true })); } }, [handler, prevHandlerConfig]); + React.useEffect(() => { + return () => { + dispatch({ type: 'cancel' }); + client.cancelRequest(); + }; + }, [client]); return [ handler, { data: state.data, loading: state.loading, isCalled: state.isCalled, + isCanceled: state.isCanceled, + error: state.error, refetch, + cancel: client.cancelRequest.bind(client), }, ]; } diff --git a/target/reducer.d.ts b/target/reducer.d.ts index bf94a2c..3cec641 100644 --- a/target/reducer.d.ts +++ b/target/reducer.d.ts @@ -4,6 +4,8 @@ export declare type PublicRequestState = Readonly<{ data: R | null; loading: boolean; isCalled: boolean; + isCanceled?: boolean; + error?: Error; }>; export declare type RequestState = PublicRequestState & Readonly<{ prevHeaders?: Record; @@ -21,25 +23,33 @@ export declare type RequestAction = { } | { type: 'failure'; response: ClientResponse; +} | { + type: 'cancel'; }; export declare type RequestReducer = React.Reducer, RequestAction>; export declare function requestReducer(state: RequestState, action: RequestAction): { - loading: boolean; - isCalled: boolean; - prevHeaders: Record; - prevVariables: Record; - prevParams: Record | undefined; - data: R | null; -} | { loading: boolean; data: R; isCalled: boolean; + isCanceled?: boolean | undefined; + error?: Error | undefined; prevHeaders?: Record | undefined; prevVariables?: Record | undefined; prevParams?: Record | undefined; } | { loading: boolean; data: null; + error: Error | undefined; + isCanceled: boolean | undefined; + isCalled: boolean; + prevHeaders?: Record | undefined; + prevVariables?: Record | undefined; + prevParams?: Record | undefined; +} | { + isCanceled: boolean; + error: undefined; + data: R | null; + loading: boolean; isCalled: boolean; prevHeaders?: Record | undefined; prevVariables?: Record | undefined; diff --git a/target/reducer.js b/target/reducer.js index 9d14354..3697cdc 100644 --- a/target/reducer.js +++ b/target/reducer.js @@ -1,13 +1,16 @@ export function requestReducer(state, action) { switch (action.type) { case 'call': { - return Object.assign(Object.assign({}, state), { loading: true, isCalled: true, prevHeaders: action.headers, prevVariables: action.variables, prevParams: action.params }); + return Object.assign(Object.assign({}, state), { error: undefined, isCanceled: false, loading: true, isCalled: true, prevHeaders: action.headers, prevVariables: action.variables, prevParams: action.params }); } case 'success': { return Object.assign(Object.assign({}, state), { loading: false, data: action.response.data }); } case 'failure': { - return Object.assign(Object.assign({}, state), { loading: false, data: null }); + return Object.assign(Object.assign({}, state), { loading: false, data: null, error: action.response.error, isCanceled: action.response.canceled }); + } + case 'cancel': { + return Object.assign(Object.assign({}, state), { isCanceled: false, error: undefined }); } } } diff --git a/target/request-hook.d.ts b/target/request-hook.d.ts index 23106ca..c6a53d2 100644 --- a/target/request-hook.d.ts +++ b/target/request-hook.d.ts @@ -3,4 +3,4 @@ import { LazyRequestConfigFromEndpoint } from './lazy-request-hook'; export declare type RequestConfigFromEndpoint = Readonly & { skip?: boolean; }>; -export declare function useRequest(endpoint: E, config?: RequestConfigFromEndpoint): import("./lazy-request-hook").PublicRequestStateWithRefetch; +export declare function useRequest(endpoint: E, config?: RequestConfigFromEndpoint): import("./lazy-request-hook").PublicRequestStateWithActions;