Skip to content

Commit d5f7a37

Browse files
committed
✨ Create antonio-utils with custom saga effects for easily canceling API requests
1 parent 0caf14d commit d5f7a37

File tree

16 files changed

+336
-6
lines changed

16 files changed

+336
-6
lines changed

package.json

+4-4
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
"scripts": {
77
"bootstrap": "lerna bootstrap --use-workspaces",
88
"postinstall": "yarn bootstrap",
9-
"build:lib": "lerna exec --scope=@ackee/antonio-core -- babel src --out-dir lib --extensions \".ts\" --config-file=./babel.config.js --source-maps inline",
10-
"build:es": "lerna exec --scope=@ackee/antonio-core -- babel src --out-dir es --extensions \".ts\" --config-file=./babel.config.js --source-maps inline",
9+
"build:lib": "lerna exec --scope={@ackee/antonio-core,@ackee/antonio-utils} -- babel src --out-dir lib --extensions \".ts\" --config-file=./babel.config.js --source-maps inline",
10+
"build:es": "lerna exec --scope={@ackee/antonio-core,@ackee/antonio-utils} -- babel src --out-dir es --extensions \".ts\" --config-file=./babel.config.js --source-maps inline",
1111
"build:test": "babel ./packages/test/src --out-dir lib --extensions \".ts\" --config-file=./babel.config.js",
12-
"build:types": "lerna exec --scope=@ackee/antonio-core -- tsc --project ./tsconfig.types.json --emitDeclarationOnly",
12+
"build:types": "lerna exec --scope={@ackee/antonio-core,@ackee/antonio-utils} -- tsc --project ./tsconfig.types.json --emitDeclarationOnly",
1313
"build:js": "yarn build:es & yarn build:lib",
1414
"build": "yarn clean && yarn build:js && yarn build:types",
1515
"clean": "lerna exec -- rm -rf lib es",
16-
"lint": "lerna exec --scope=@ackee/antonio-core -- eslint 'src/**/*.ts'",
16+
"lint": "lerna exec --scope={@ackee/antonio-core,@ackee/antonio-utils} -- eslint 'src/**/*.ts'",
1717
"type-check": "tsc --noEmit",
1818
"type-check:watch": "yarn type-check -- --watch",
1919
"test": "jest",

packages/@ackee/antonio-core/src/modules/core/utils/mergeRequestConfigs.ts

+5
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,10 @@ export function mergeRequestConfigs(configA: DefaultRequestConfig, configB: Requ
5757
result.searchParams = mergeUrlSearchParams(configA.searchParams, configB.searchParams);
5858
}
5959

60+
if (configB.cancelToken) {
61+
delete result.cancelToken;
62+
result.signal = configB.cancelToken;
63+
}
64+
6065
return result;
6166
}

packages/@ackee/antonio-core/src/types.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ export interface FullRequestConfig extends Omit<RequestInit, 'body' | 'headers'
4242
searchParams: RequestSearchParams;
4343
}
4444

45-
export type RequestConfig = Partial<FullRequestConfig>;
45+
export type RequestConfig = Partial<FullRequestConfig> & {
46+
/**
47+
* @deprecated This prop is going to be removed in next major relase. Use `signal` prop instead.
48+
*/
49+
cancelToken?: AbortSignal;
50+
};
4651

4752
export interface GeneralConfig {
4853
logger: Logger;
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const path = require('path');
2+
3+
module.exports = {
4+
extends: ['../../../.eslintrc'],
5+
settings: {
6+
'import/resolver': {
7+
node: {
8+
paths: [path.resolve(__dirname, './src')],
9+
extensions: ['.ts'],
10+
},
11+
},
12+
},
13+
};
+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# `@ackee/antonio-utils`
2+
3+
Custom Saga effects with built-in cancelation of API requests.
4+
5+
## Table of contents
6+
7+
- [Install](#install)
8+
- [API](#api)
9+
10+
- Effect creators
11+
12+
- [`takeRequest(actionTypes, saga)`](#api-takeRequest)
13+
- [`takeLatestRequest(params, saga)`](#api-takeLatestRequest)
14+
15+
---
16+
17+
## <a name="install"></a>Install
18+
19+
```bash
20+
yarn add @ackee/antonio-utils -S
21+
```
22+
23+
---
24+
25+
## <a name="api"></a>API Reference
26+
27+
### <a name="api-takeRequest"></a>`takeRequest(actionTypes: Object, saga: Function)`
28+
29+
#### Parameters
30+
31+
- `actionTypes: Object`
32+
- `REQUEST: String` - action type that launches the saga
33+
- `CANCEL: String` - action type that aborts the running saga
34+
- `saga(requestAction, cancelToken): Function` - the actual API request is made here
35+
36+
#### Example
37+
38+
```js
39+
import { takeRequest } from '@ackee/antonio-utils';
40+
41+
export default function* () {
42+
// Works same as the Redux saga take effect, but on top of that, it cancels the API request.
43+
yield takeRequest(
44+
{
45+
REQUEST: 'FETCH_TODO_ITEM_REQUEST',
46+
CANCEL: 'FETCH_TODO_ITEM_INVALIDATE',
47+
},
48+
fetchTodoItem,
49+
);
50+
}
51+
```
52+
53+
---
54+
55+
### <a name="api-takeLatestRequest"></a>`takeLatestRequest(params: Object, saga: Function)`
56+
57+
#### Parameters
58+
59+
- `params: Object`
60+
- `REQUEST: String` - action type that launches the saga
61+
- `cancelTask: Function` - Redux action that will cancel the
62+
running saga
63+
- `requestIdSelector: Function` (optional) - A function that receives request action as 1st arg. and returns unique ID of this action, e.g. user ID.
64+
- `saga(requestAction, cancelToken): Function` - the actual API request is made here
65+
66+
#### Example
67+
68+
```js
69+
import { takeLatestRequest } from '@ackee/antonio-utils';
70+
71+
// The 'cancelToken' must be passed to the request config object:
72+
function* fetchTodoItem(requestAction, cancelToken) {
73+
const response = yield api.get(`todos/1`, {
74+
cancelToken,
75+
});
76+
77+
return response.data;
78+
}
79+
80+
const fetchTodoItemInvalidate = () => ({
81+
type: 'FETCH_TODO_ITEM_INVALIDATE',
82+
});
83+
84+
export default function* () {
85+
// Works same as the Redux saga takeLatest effect, but on top of that, it cancels the API request.
86+
yield takeLatestRequest(
87+
{
88+
REQUEST: 'FETCH_TODO_ITEM_REQUEST',
89+
cancelTask: fetchTodoItemInvalidate,
90+
},
91+
fetchTodoItem,
92+
);
93+
}
94+
```
95+
96+
### Example - take latest request for certain user
97+
98+
If `requestIdSelector` function provided, instead of cancelling of all previous requests and taking only the last one for certain action type, take the lastest request for certain user, i.e. **identify the request by action type and by an ID**.
99+
100+
```js
101+
import { takeLatestRequest } from '@ackee/antonio-utils';
102+
103+
// The 'cancelToken' must be passed to the request config object:
104+
function* fetchUser(requestAction, cancelToken) {
105+
const { userId } = requestAction;
106+
const response = yield api.get(`users/${userId}`, {
107+
cancelToken,
108+
});
109+
110+
return response.data;
111+
}
112+
113+
const fetchUserInvalidate = userId => ({
114+
type: 'FETCH_USER_INVALIDATE',
115+
userId,
116+
});
117+
118+
export default function* () {
119+
// Works same as the Redux saga takeLatest effect, but on top of that, it cancels the API request.
120+
yield takeLatestRequest(
121+
{
122+
REQUEST: 'FETCH_USER_REQUEST',
123+
cancelTask: fetchUserInvalidate,
124+
requestIdSelector: action => action.userId,
125+
},
126+
fetchUser,
127+
);
128+
}
129+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module.exports = {
2+
extends: '../../../babel.config.js',
3+
plugins: [
4+
[
5+
'babel-plugin-module-resolver',
6+
{
7+
alias: {
8+
types: './src/types',
9+
},
10+
},
11+
],
12+
],
13+
};
+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "@ackee/antonio-utils",
3+
"version": "4.0.0-beta.0",
4+
"description": "",
5+
"keywords": [
6+
"http-client",
7+
"fetch",
8+
"javascript",
9+
"ackee"
10+
],
11+
"main": "lib/index.js",
12+
"module": "es/index.js",
13+
"jsnext:main": "es/index.js",
14+
"sideEffects": false,
15+
"files": [
16+
"es",
17+
"lib"
18+
],
19+
"publishConfig": {
20+
"access": "public"
21+
},
22+
"repository": {
23+
"type": "git",
24+
"url": "git+https://github.com/AckeeCZ/antonio.git",
25+
"directory": "packages/@ackee/antonio-utils"
26+
},
27+
"homepage": "",
28+
"scripts": {
29+
"size": "package-size es --no-cache"
30+
},
31+
"peerDependencies": {
32+
"redux-saga": "1.x"
33+
},
34+
"devDependencies": {
35+
"redux-saga": "1.1.3",
36+
"redux": "4.1.0"
37+
},
38+
"bugs": {
39+
"url": "https://github.com/AckeeCZ/antonio/issues"
40+
},
41+
"engines": {
42+
"node": ">=12"
43+
},
44+
"author": "Jiří Čermák <[email protected]>",
45+
"license": "MIT"
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './saga-effects';
2+
export type { TakeLatestRequest, TakeRequest, RequestId } from './types';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as takeLatestRequest } from './takeLatestRequest';
2+
export { default as takeRequest } from './takeRequest';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { takeEvery, put, spawn } from 'redux-saga/effects';
2+
import cancellableHandler from './utils/cancellableHandler';
3+
4+
import type { TakeLatestRequest, Fn, RequestId } from 'types';
5+
6+
export default function* takeLatestRequest(
7+
{ REQUEST, cancelTask, requestIdSelector }: TakeLatestRequest,
8+
requestHandler: Fn,
9+
) {
10+
const runningTasks = new Set<RequestId>();
11+
const DEFAULT_REQUEST_ID = Symbol('DEFAULT_REQUEST_ID');
12+
13+
yield takeEvery(REQUEST, function* (action) {
14+
const requestId = requestIdSelector ? requestIdSelector(action) : DEFAULT_REQUEST_ID;
15+
16+
if (runningTasks.has(requestId)) {
17+
yield put(cancelTask(requestId, action));
18+
runningTasks.delete(requestId);
19+
}
20+
21+
yield spawn(cancellableHandler, {
22+
handler: requestHandler,
23+
handlerArg: action,
24+
CANCEL: cancelTask(requestId, action).type,
25+
onComplete() {
26+
runningTasks.delete(requestId);
27+
},
28+
});
29+
30+
runningTasks.add(requestId);
31+
});
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { take } from 'redux-saga/effects';
2+
import type { Fn, TakeRequest } from 'types';
3+
import cancellableHandler from './utils/cancellableHandler';
4+
5+
/**
6+
* Blocking custom saga effect that can cancel the API request
7+
*/
8+
export default function* takeRequest(actionTypes: TakeRequest, handler: Fn) {
9+
while (true) {
10+
const action = yield take(actionTypes.REQUEST);
11+
12+
yield cancellableHandler({
13+
handler,
14+
handlerArg: action,
15+
CANCEL: actionTypes.CANCEL,
16+
});
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { call, take, race } from 'redux-saga/effects';
2+
import { CancellableHandler } from 'types';
3+
4+
const noop = function* () {};
5+
6+
export default function* cancellableHandler({ handlerArg, CANCEL, handler, onComplete = noop }: CancellableHandler) {
7+
// FIXME: add polyfill
8+
// eslint-disable-next-line compat/compat
9+
const controller = new AbortController();
10+
11+
function* tasks() {
12+
yield call(handler, handlerArg, controller.signal);
13+
yield call(onComplete);
14+
}
15+
16+
const result = yield race({
17+
tasks: call(tasks),
18+
cancel: take(CANCEL),
19+
});
20+
21+
if (result.cancel) {
22+
controller.abort();
23+
}
24+
}
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { AnyAction } from 'redux';
2+
import type { ActionPattern } from 'redux-saga/effects';
3+
4+
export type Fn = (...args: any[]) => any;
5+
6+
export interface CancellableHandler {
7+
handlerArg: any;
8+
CANCEL: ActionPattern;
9+
handler: Fn;
10+
onComplete?: Fn;
11+
}
12+
13+
export type RequestId = symbol | string | number;
14+
15+
export interface TakeLatestRequest {
16+
REQUEST: ActionPattern;
17+
cancelTask: (requestId: RequestId, action: AnyAction) => AnyAction;
18+
requestIdSelector?: (action: AnyAction) => RequestId;
19+
}
20+
21+
export interface TakeRequest {
22+
REQUEST: ActionPattern;
23+
CANCEL: ActionPattern;
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../../tsconfig.json",
3+
"compilerOptions": {
4+
"baseUrl": "./src",
5+
"strict": true
6+
},
7+
"include": ["src"]
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../../tsconfig.types.json",
3+
"compilerOptions": {
4+
"outDir": "./lib",
5+
"baseUrl": "./src",
6+
"strict": true
7+
},
8+
"include": ["./src"]
9+
}

yarn.lock

+1-1
Original file line numberDiff line numberDiff line change
@@ -10337,7 +10337,7 @@ redent@^3.0.0:
1033710337
indent-string "^4.0.0"
1033810338
strip-indent "^3.0.0"
1033910339

10340-
10340+
redux-saga@1.1.3, redux-saga@1.x:
1034110341
version "1.1.3"
1034210342
resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.1.3.tgz#9f3e6aebd3c994bbc0f6901a625f9a42b51d1112"
1034310343
integrity sha512-RkSn/z0mwaSa5/xH/hQLo8gNf4tlvT18qXDNvedihLcfzh+jMchDgaariQoehCpgRltEm4zHKJyINEz6aqswTw==

0 commit comments

Comments
 (0)