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": {
"test": "jest",
"build": "rm -rf target && tsc",
"build": "tsc",
"prepublishOnly": "rm -rf target && tsc",
"report-coverage": "cat coverage/lcov.info | coveralls"
},

View File

@ -17,12 +17,17 @@ export type RequestProps<R> = PrepareRequestProps & {
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'>
& { data: Data }
>
& Readonly<{ error?: Error, canceled?: boolean }>
export type ClientResponse<Data extends Record<string, any>> =
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<ClientResponse<Data>> {
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<Data> => {
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();
}
}

View File

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

View File

@ -4,6 +4,8 @@ export type PublicRequestState<R> = Readonly<{
data: R | null;
loading: boolean;
isCalled: boolean;
isCanceled?: boolean;
error?: Error;
}>;
export type RequestState<R> = PublicRequestState<R> & Readonly<{
@ -27,6 +29,9 @@ export type RequestAction<R> =
type: 'failure',
response: ClientResponse<R>
}
| {
type: 'cancel'
}
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': {
return {
...state,
error: undefined,
isCanceled: false,
loading: true,
isCalled: true,
prevHeaders: action.headers,
@ -54,7 +61,15 @@ export function requestReducer<R>(state: RequestState<R>, 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,
};
}
}

8
target/client.d.ts vendored
View File

@ -11,13 +11,19 @@ declare type PrepareRequestProps = {
export declare type RequestProps<R> = PrepareRequestProps & {
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;
}>;
export declare class Client {
private config;
private controller;
constructor(config: ClientConfig);
prepareRequest(props: PrepareRequestProps): Request;
request<Data extends Record<string, any>>({ transformResponseData, ...restProps }: RequestProps<Data>): Promise<ClientResponse<Data>>;
cancelRequest(): void;
}
export {};

View File

@ -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();
}
}

View File

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

24
target/reducer.d.ts vendored
View File

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

View File

@ -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 });
}
}
}

View File

@ -3,4 +3,4 @@ import { LazyRequestConfigFromEndpoint } from './lazy-request-hook';
export declare type RequestConfigFromEndpoint<E extends AnyEndpoint> = Readonly<LazyRequestConfigFromEndpoint<E> & {
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>;