feat: add request cancelation

Closes #26
This commit is contained in:
Dmitriy Pleshevskiy 2020-12-23 05:14:20 +03:00
parent 68d379d3c5
commit 7683c20a2e
11 changed files with 152 additions and 37 deletions

View file

@ -10,7 +10,7 @@
], ],
"scripts": { "scripts": {
"test": "jest", "test": "jest",
"build": "rm -rf target && tsc", "build": "tsc",
"prepublishOnly": "rm -rf target && tsc", "prepublishOnly": "rm -rf target && tsc",
"report-coverage": "cat coverage/lcov.info | coveralls" "report-coverage": "cat coverage/lcov.info | coveralls"
}, },

View file

@ -17,12 +17,17 @@ export type RequestProps<R> = PrepareRequestProps & {
transformResponseData?: (data: unknown) => R, transformResponseData?: (data: unknown) => R,
} }
export type ClientResponse<Data extends Record<string, any>> = Readonly< export type ResponseWithError =
Pick<Response, 'ok' | 'redirected' | 'status' | 'statusText' | 'type' | 'headers' | 'url'> Pick<Response, 'ok' | 'redirected' | 'status' | 'statusText' | 'type' | 'headers' | 'url'>
& { data: Data } & Readonly<{ error?: Error, canceled?: boolean }>
>
export type ClientResponse<Data extends Record<string, any>> =
ResponseWithError
& Readonly<{ data: Data }>
export class Client { export class Client {
private controller = new AbortController();
constructor(private config: ClientConfig) {} constructor(private config: ClientConfig) {}
public prepareRequest(props: PrepareRequestProps) { public prepareRequest(props: PrepareRequestProps) {
@ -82,10 +87,29 @@ export class Client {
): Promise<ClientResponse<Data>> { ): Promise<ClientResponse<Data>> {
const req = this.prepareRequest(restProps); 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 // 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(
.then(([res, data]) => { (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<Data> => {
return { return {
ok: res.ok, ok: res.ok,
redirected: res.redirected, redirected: res.redirected,
@ -94,6 +118,8 @@ export class Client {
type: res.type, type: res.type,
headers: res.headers, headers: res.headers,
url: res.url, url: res.url,
error: 'error' in res ? res.error : undefined,
canceled: 'canceled' in res ? res.canceled : false,
data: isFunction(transformResponseData) ? transformResponseData(data) : data, data: isFunction(transformResponseData) ? transformResponseData(data) : data,
}; };
}) })
@ -105,4 +131,9 @@ export class Client {
return res; return res;
}); });
} }
public cancelRequest() {
this.controller.abort();
this.controller = new AbortController();
}
} }

View file

@ -30,16 +30,17 @@ export type LazyRequestHandlerConfig<E extends AnyEndpoint> = Readonly<
export type RequestHandler<E extends AnyEndpoint> = export type RequestHandler<E extends AnyEndpoint> =
(config?: LazyRequestHandlerConfig<E>) => Promise<ExtractEndpointResponse<E> | null>; (config?: LazyRequestHandlerConfig<E>) => Promise<ExtractEndpointResponse<E> | null>;
export type RefetchRequestHandler = () => void; export type PublicRequestStateWithActions<E extends AnyEndpoint> =
export type PublicRequestStateWithRefetch<E extends AnyEndpoint> =
PublicRequestState<ExtractEndpointResponse<E>> PublicRequestState<ExtractEndpointResponse<E>>
& { refetch: RefetchRequestHandler }; & {
refetch: () => void,
cancel: () => void,
};
export function useLazyRequest<E extends AnyEndpoint>( export function useLazyRequest<E extends AnyEndpoint>(
endpoint: E, endpoint: E,
config?: LazyRequestConfigFromEndpoint<E>, config?: LazyRequestConfigFromEndpoint<E>,
): [RequestHandler<E>, PublicRequestStateWithRefetch<E>] { ): [RequestHandler<E>, PublicRequestStateWithActions<E>] {
const [client] = useClient(); const [client] = useClient();
const { defaultHeaders } = useRequestContext(); const { defaultHeaders } = useRequestContext();
const [state, dispatch] = React.useReducer<RequestReducer<ExtractEndpointResponse<E>>>( const [state, dispatch] = React.useReducer<RequestReducer<ExtractEndpointResponse<E>>>(
@ -63,7 +64,7 @@ export function useLazyRequest<E extends AnyEndpoint>(
const handler = React.useCallback( const handler = React.useCallback(
(handlerConfig?: LazyRequestHandlerConfig<E>) => { (handlerConfig?: LazyRequestHandlerConfig<E>) => {
if (state?.loading) { if (state?.loading || state?.isCanceled) {
return Promise.resolve(null); return Promise.resolve(null);
} }
@ -94,7 +95,8 @@ export function useLazyRequest<E extends AnyEndpoint>(
}; };
if ( if (
isSameRequest state.isCalled
&& isSameRequest
&& state?.prevVariables && isEqual(state.prevVariables, variables) && state?.prevVariables && isEqual(state.prevVariables, variables)
&& state?.prevHeaders && isEqual(state.prevHeaders, headers) && state?.prevHeaders && isEqual(state.prevHeaders, headers)
&& !handlerConfig?.force && !handlerConfig?.force
@ -127,11 +129,13 @@ export function useLazyRequest<E extends AnyEndpoint>(
return response.data; return response.data;
}, },
(response) => { (response: ClientResponse<ExtractEndpointResponse<E>>) => {
dispatch({ type: 'failure', response }); dispatch({ type: 'failure', response });
if (isFunction(onFailure)) {
if (!response.canceled && isFunction(onFailure)) {
onFailure(response); onFailure(response);
} }
return null; return null;
} }
); );
@ -151,13 +155,26 @@ export function useLazyRequest<E extends AnyEndpoint>(
[handler, prevHandlerConfig] [handler, prevHandlerConfig]
); );
React.useEffect(
() => {
return () => {
dispatch({ type: 'cancel' });
client.cancelRequest();
};
},
[client]
);
return [ return [
handler, handler,
{ {
data: state.data, data: state.data,
loading: state.loading, loading: state.loading,
isCalled: state.isCalled, isCalled: state.isCalled,
isCanceled: state.isCanceled,
error: state.error,
refetch, refetch,
cancel: client.cancelRequest.bind(client),
}, },
]; ];
} }

View file

@ -4,6 +4,8 @@ export type PublicRequestState<R> = Readonly<{
data: R | null; data: R | null;
loading: boolean; loading: boolean;
isCalled: boolean; isCalled: boolean;
isCanceled?: boolean;
error?: Error;
}>; }>;
export type RequestState<R> = PublicRequestState<R> & Readonly<{ export type RequestState<R> = PublicRequestState<R> & Readonly<{
@ -27,6 +29,9 @@ export type RequestAction<R> =
type: 'failure', type: 'failure',
response: ClientResponse<R> response: ClientResponse<R>
} }
| {
type: 'cancel'
}
export type RequestReducer<R> = React.Reducer<RequestState<R>, RequestAction<R>> export type RequestReducer<R> = React.Reducer<RequestState<R>, RequestAction<R>>
@ -35,6 +40,8 @@ export function requestReducer<R>(state: RequestState<R>, action: RequestAction<
case 'call': { case 'call': {
return { return {
...state, ...state,
error: undefined,
isCanceled: false,
loading: true, loading: true,
isCalled: true, isCalled: true,
prevHeaders: action.headers, prevHeaders: action.headers,
@ -54,7 +61,15 @@ export function requestReducer<R>(state: RequestState<R>, action: RequestAction<
...state, ...state,
loading: false, loading: false,
data: null, data: null,
// TODO: need to append errors error: action.response.error,
isCanceled: action.response.canceled,
};
}
case 'cancel': {
return {
...state,
isCanceled: false,
error: undefined,
}; };
} }
} }

8
target/client.d.ts vendored
View file

@ -11,13 +11,19 @@ declare type PrepareRequestProps = {
export declare type RequestProps<R> = PrepareRequestProps & { export declare type RequestProps<R> = PrepareRequestProps & {
transformResponseData?: (data: unknown) => R; transformResponseData?: (data: unknown) => R;
}; };
export declare type ClientResponse<Data extends Record<string, any>> = Readonly<Pick<Response, 'ok' | 'redirected' | 'status' | 'statusText' | 'type' | 'headers' | 'url'> & { export declare type ResponseWithError = Pick<Response, 'ok' | 'redirected' | 'status' | 'statusText' | 'type' | 'headers' | 'url'> & Readonly<{
error?: Error;
canceled?: boolean;
}>;
export declare type ClientResponse<Data extends Record<string, any>> = ResponseWithError & Readonly<{
data: Data; data: Data;
}>; }>;
export declare class Client { export declare class Client {
private config; private config;
private controller;
constructor(config: ClientConfig); constructor(config: ClientConfig);
prepareRequest(props: PrepareRequestProps): Request; prepareRequest(props: PrepareRequestProps): Request;
request<Data extends Record<string, any>>({ transformResponseData, ...restProps }: RequestProps<Data>): Promise<ClientResponse<Data>>; request<Data extends Record<string, any>>({ transformResponseData, ...restProps }: RequestProps<Data>): Promise<ClientResponse<Data>>;
cancelRequest(): void;
} }
export {}; export {};

View file

@ -15,6 +15,7 @@ import { formDataFromObject, isFunction, urlSearchParamsFromObject } from './mis
export class Client { export class Client {
constructor(config) { constructor(config) {
this.config = config; this.config = config;
this.controller = new AbortController();
} }
prepareRequest(props) { prepareRequest(props) {
var _a; var _a;
@ -48,9 +49,25 @@ export class Client {
request(_a) { request(_a) {
var { transformResponseData } = _a, restProps = __rest(_a, ["transformResponseData"]); var { transformResponseData } = _a, restProps = __rest(_a, ["transformResponseData"]);
const req = this.prepareRequest(restProps); 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 // 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]) => { .then(([res, data]) => {
return { return {
ok: res.ok, ok: res.ok,
@ -60,6 +77,8 @@ export class Client {
type: res.type, type: res.type,
headers: res.headers, headers: res.headers,
url: res.url, url: res.url,
error: 'error' in res ? res.error : undefined,
canceled: 'canceled' in res ? res.canceled : false,
data: isFunction(transformResponseData) ? transformResponseData(data) : data, data: isFunction(transformResponseData) ? transformResponseData(data) : data,
}; };
}) })
@ -70,4 +89,8 @@ export class Client {
return res; return res;
}); });
} }
cancelRequest() {
this.controller.abort();
this.controller = new AbortController();
}
} }

View file

@ -13,8 +13,8 @@ export declare type LazyRequestHandlerConfig<E extends AnyEndpoint> = Readonly<L
force?: boolean; force?: boolean;
}>; }>;
export declare type RequestHandler<E extends AnyEndpoint> = (config?: LazyRequestHandlerConfig<E>) => Promise<ExtractEndpointResponse<E> | null>; export declare type RequestHandler<E extends AnyEndpoint> = (config?: LazyRequestHandlerConfig<E>) => Promise<ExtractEndpointResponse<E> | null>;
export declare type RefetchRequestHandler = () => void; export declare type PublicRequestStateWithActions<E extends AnyEndpoint> = PublicRequestState<ExtractEndpointResponse<E>> & {
export declare type PublicRequestStateWithRefetch<E extends AnyEndpoint> = PublicRequestState<ExtractEndpointResponse<E>> & { refetch: () => void;
refetch: RefetchRequestHandler; cancel: () => void;
}; };
export declare function useLazyRequest<E extends AnyEndpoint>(endpoint: E, config?: LazyRequestConfigFromEndpoint<E>): [RequestHandler<E>, PublicRequestStateWithRefetch<E>]; export declare function useLazyRequest<E extends AnyEndpoint>(endpoint: E, config?: LazyRequestConfigFromEndpoint<E>): [RequestHandler<E>, PublicRequestStateWithActions<E>];

View file

@ -21,7 +21,7 @@ export function useLazyRequest(endpoint, config) {
}, [endpoint]); }, [endpoint]);
const handler = React.useCallback((handlerConfig) => { const handler = React.useCallback((handlerConfig) => {
var _a, _b, _c; 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); return Promise.resolve(null);
} }
let params; 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 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); 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.prevVariables) && isEqual(state.prevVariables, variables)
&& (state === null || state === void 0 ? void 0 : state.prevHeaders) && isEqual(state.prevHeaders, headers) && (state === null || state === void 0 ? void 0 : state.prevHeaders) && isEqual(state.prevHeaders, headers)
&& !(handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.force)) { && !(handlerConfig === null || handlerConfig === void 0 ? void 0 : handlerConfig.force)) {
@ -60,7 +61,7 @@ export function useLazyRequest(endpoint, config) {
return response.data; return response.data;
}, (response) => { }, (response) => {
dispatch({ type: 'failure', response }); dispatch({ type: 'failure', response });
if (isFunction(onFailure)) { if (!response.canceled && isFunction(onFailure)) {
onFailure(response); onFailure(response);
} }
return null; return null;
@ -71,13 +72,22 @@ export function useLazyRequest(endpoint, config) {
handler(Object.assign(Object.assign({}, prevHandlerConfig), { force: true })); handler(Object.assign(Object.assign({}, prevHandlerConfig), { force: true }));
} }
}, [handler, prevHandlerConfig]); }, [handler, prevHandlerConfig]);
React.useEffect(() => {
return () => {
dispatch({ type: 'cancel' });
client.cancelRequest();
};
}, [client]);
return [ return [
handler, handler,
{ {
data: state.data, data: state.data,
loading: state.loading, loading: state.loading,
isCalled: state.isCalled, isCalled: state.isCalled,
isCanceled: state.isCanceled,
error: state.error,
refetch, refetch,
cancel: client.cancelRequest.bind(client),
}, },
]; ];
} }

24
target/reducer.d.ts vendored
View file

@ -4,6 +4,8 @@ export declare type PublicRequestState<R> = Readonly<{
data: R | null; data: R | null;
loading: boolean; loading: boolean;
isCalled: boolean; isCalled: boolean;
isCanceled?: boolean;
error?: Error;
}>; }>;
export declare type RequestState<R> = PublicRequestState<R> & Readonly<{ export declare type RequestState<R> = PublicRequestState<R> & Readonly<{
prevHeaders?: Record<string, string>; prevHeaders?: Record<string, string>;
@ -21,25 +23,33 @@ export declare type RequestAction<R> = {
} | { } | {
type: 'failure'; type: 'failure';
response: ClientResponse<R>; response: ClientResponse<R>;
} | {
type: 'cancel';
}; };
export declare type RequestReducer<R> = React.Reducer<RequestState<R>, RequestAction<R>>; export declare type RequestReducer<R> = React.Reducer<RequestState<R>, RequestAction<R>>;
export declare function requestReducer<R>(state: RequestState<R>, action: RequestAction<R>): { export declare function requestReducer<R>(state: RequestState<R>, action: RequestAction<R>): {
loading: boolean;
isCalled: boolean;
prevHeaders: Record<string, string>;
prevVariables: Record<string, any>;
prevParams: Record<string, any> | undefined;
data: R | null;
} | {
loading: boolean; loading: boolean;
data: R; data: R;
isCalled: boolean; isCalled: boolean;
isCanceled?: boolean | undefined;
error?: Error | undefined;
prevHeaders?: Record<string, string> | undefined; prevHeaders?: Record<string, string> | undefined;
prevVariables?: Record<string, any> | undefined; prevVariables?: Record<string, any> | undefined;
prevParams?: Record<string, any> | undefined; prevParams?: Record<string, any> | undefined;
} | { } | {
loading: boolean; loading: boolean;
data: null; data: null;
error: Error | undefined;
isCanceled: boolean | undefined;
isCalled: boolean;
prevHeaders?: Record<string, string> | undefined;
prevVariables?: Record<string, any> | undefined;
prevParams?: Record<string, any> | undefined;
} | {
isCanceled: boolean;
error: undefined;
data: R | null;
loading: boolean;
isCalled: boolean; isCalled: boolean;
prevHeaders?: Record<string, string> | undefined; prevHeaders?: Record<string, string> | undefined;
prevVariables?: Record<string, any> | undefined; prevVariables?: Record<string, any> | undefined;

View file

@ -1,13 +1,16 @@
export function requestReducer(state, action) { export function requestReducer(state, action) {
switch (action.type) { switch (action.type) {
case 'call': { 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': { case 'success': {
return Object.assign(Object.assign({}, state), { loading: false, data: action.response.data }); return Object.assign(Object.assign({}, state), { loading: false, data: action.response.data });
} }
case 'failure': { 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 });
} }
} }
} }

View file

@ -3,4 +3,4 @@ import { LazyRequestConfigFromEndpoint } from './lazy-request-hook';
export declare type RequestConfigFromEndpoint<E extends AnyEndpoint> = Readonly<LazyRequestConfigFromEndpoint<E> & { export declare type RequestConfigFromEndpoint<E extends AnyEndpoint> = Readonly<LazyRequestConfigFromEndpoint<E> & {
skip?: boolean; skip?: boolean;
}>; }>;
export declare function useRequest<E extends AnyEndpoint>(endpoint: E, config?: RequestConfigFromEndpoint<E>): import("./lazy-request-hook").PublicRequestStateWithRefetch<E>; export declare function useRequest<E extends AnyEndpoint>(endpoint: E, config?: RequestConfigFromEndpoint<E>): import("./lazy-request-hook").PublicRequestStateWithActions<E>;