Skip to content

Commit 0436e46

Browse files
Added queueSize option for useAsyncEffect;
Reworked `cancelPrevious` and `combine` logic;
1 parent 4c20798 commit 0436e46

File tree

8 files changed

+677
-246
lines changed

8 files changed

+677
-246
lines changed

CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](http://keepachangelog.com/)
55
and this project adheres to [Semantic Versioning](http://semver.org/).
66

7+
## [0.4.0] - 2021-01-11
8+
9+
### Added
10+
- `queueSize` option for `useAsyncEffect`;
11+
12+
### Updated
13+
- reworked `cancelPrevious` and `combine` logic;
14+
715
## [0.3.0] - 2021-01-07
816

917
### Fixed

README.md

+82-59
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ This is an no-op, but it indicates a memory leak in your application.
1414
To fix, cancel all subscriptions and asynchronous task in "a useEffect cleanup function".
1515
````
1616
It uses [c-promise2](https://www.npmjs.com/package/c-promise2) to make it work.
17-
When it used in conjunction with other libraries that work with the CPromise,
17+
When it used in conjunction with other libraries from CPromise ecosystem,
1818
such as [cp-fetch](https://www.npmjs.com/package/cp-fetch) and [cp-axios](https://www.npmjs.com/package/cp-axios),
1919
you get a powerful tool for building asynchronous logic for your React components.
2020
You just have to use `generators` instead of an async function to make your code cancellable,
@@ -52,70 +52,92 @@ function JSONViewer(props) {
5252
````
5353
Example with a timeout & error handling ([Live demo](https://codesandbox.io/s/async-effect-demo1-vho29?file=/src/TestComponent.js)):
5454
````jsx
55-
import React from "react";
56-
import {useState} from "react";
57-
import {useAsyncEffect, E_REASON_UNMOUNTED} from "use-async-effect2";
58-
import {CanceledError} from "c-promise2";
55+
import React, { useState } from "react";
56+
import { useAsyncEffect, E_REASON_UNMOUNTED } from "use-async-effect2";
57+
import { CanceledError } from "c-promise2";
5958
import cpFetch from "cp-fetch";
6059

6160
export default function TestComponent(props) {
62-
const [text, setText] = useState("");
63-
64-
const [cancel]= useAsyncEffect(function* ({onCancel}) {
65-
console.log("mount");
66-
67-
this.timeout(5000);
68-
69-
onCancel(()=> console.log('scope canceled'));
70-
71-
try {
72-
setText("fetching...");
73-
const response = yield cpFetch(props.url);
74-
const json = yield response.json();
75-
setText(`Success: ${JSON.stringify(json)}`);
76-
} catch (err) {
77-
CanceledError.rethrow(err, E_REASON_UNMOUNTED); //passthrough
78-
setText(`Failed: ${err}`);
79-
}
80-
81-
return () => {
82-
console.log("unmount", this.isCanceled);
83-
};
84-
}, [props.url]);
85-
86-
//setTimeout(()=> cancel("Ooops!"), 1000);
87-
88-
return <div>{text}</div>;
61+
const [text, setText] = useState("");
62+
63+
const cancel = useAsyncEffect(function* ({ onCancel }) {
64+
console.log("mount");
65+
66+
this.timeout(props.timeout);
67+
68+
onCancel(() => console.log("scope canceled"));
69+
70+
try {
71+
setText("fetching...");
72+
const response = yield cpFetch(props.url);
73+
const json = yield response.json();
74+
setText(`Success: ${JSON.stringify(json)}`);
75+
} catch (err) {
76+
CanceledError.rethrow(err, E_REASON_UNMOUNTED); //passthrough
77+
setText(`Failed: ${err}`);
78+
}
79+
80+
return () => {
81+
console.log("unmount");
82+
};
83+
},
84+
[props.url]
85+
);
86+
87+
return (
88+
<div className="component">
89+
<div className="caption">useAsyncEffect demo:</div>
90+
<div>{text}</div>
91+
<button onClick={cancel}>Abort</button>
92+
</div>
93+
);
8994
}
9095
````
9196
useAsyncCallback example ([Live demo](https://codesandbox.io/s/use-async-callback-bzpek?file=/src/TestComponent.js)):
9297
````javascript
9398
import React from "react";
94-
import {useState} from "react";
95-
import {useAsyncCallback} from "use-async-effect2";
96-
import {CPromise} from "c-promise2";
99+
import { useState } from "react";
100+
import { useAsyncCallback, E_REASON_UNMOUNTED } from "../../lib/use-async-effect";
101+
import { CPromise, CanceledError } from "c-promise2";
97102

98-
export default function TestComponent(props) {
103+
export default function TestComponent2() {
99104
const [text, setText] = useState("");
100105

101-
const asyncRoutine= useAsyncCallback(function*(v){
102-
setText(`Stage1`);
103-
yield CPromise.delay(1000);
104-
setText(`Stage2`);
105-
yield CPromise.delay(1000);
106-
setText(`Stage3`);
107-
yield CPromise.delay(1000);
108-
setText(`Done`);
109-
return v;
110-
})
111-
112-
const onClick= ()=>{
113-
asyncRoutine(123).then(value=>{
114-
console.log(`Result: ${value}`)
115-
}, console.warn);
116-
}
117-
118-
return <div><button onClick={onClick}>Run async job</button><div>{text}</div></div>;
106+
const asyncRoutine = useAsyncCallback(
107+
function* (a, b) {
108+
setText(`Stage1`);
109+
yield CPromise.delay(1000);
110+
setText(`Stage2`);
111+
yield CPromise.delay(1000);
112+
setText(`Stage3`);
113+
yield CPromise.delay(1000);
114+
setText(`Done`);
115+
return Math.random();
116+
},
117+
{ cancelPrevious: true }
118+
);
119+
120+
const onClick = () => {
121+
asyncRoutine(123, 456).then(
122+
(value) => {
123+
setText(`Result: ${value}`);
124+
},
125+
(err) => {
126+
console.warn(err);
127+
CanceledError.rethrow(E_REASON_UNMOUNTED);
128+
setText(`Fail: ${err}`);
129+
}
130+
);
131+
};
132+
133+
return (
134+
<div className="component">
135+
<div className="caption">useAsyncCallback demo:</div>
136+
<button onClick={onClick}>Run async job</button>
137+
<div>{text}</div>
138+
<button onClick={() => asyncRoutine.cancel()}>Abort</button>
139+
</div>
140+
);
119141
}
120142
````
121143

@@ -128,7 +150,7 @@ just use the codesandbox [demo](https://codesandbox.io/s/async-effect-demo1-vho2
128150

129151
## API
130152

131-
### useAsyncEffect(generatorFn, deps?): (function cancel():boolean)
153+
### useAsyncEffect(generatorFn, deps?): (cancel():boolean)
132154
A React hook based on [`useEffect`](https://reactjs.org/docs/hooks-effect.html), that resolves passed generator as asynchronous function.
133155
The asynchronous generator sequence and its promise of the result will be canceled if
134156
the effect cleanup process is started before it completes.
@@ -141,11 +163,12 @@ The last argument passed to this function and `this` refer to the CPromise insta
141163
### useAsyncCallback(generatorFn, options?: object): CPromiseAsyncFunction
142164
This hook makes an async callback that can be automatically canceled on unmount or by user request.
143165
#### options:
144-
- `deps: any[]`
145-
- `combine:boolean` - allow only single thread running.
146-
All subsequent callings will return promises that subscribed to the pending promise of the first call.
166+
- `deps: any[]` - effect dependencies
167+
- `combine:boolean` - subscribe to the result of the async function already
168+
running with the same arguments or run a new one.
147169
- `cancelPrevious:boolean` - cancel the previous pending async function before running a new one.
148-
- `concurrency: number=0` - set concurrency limit for simultaneous calls.
170+
- `concurrency: number=0` - set concurrency limit for simultaneous calls. `0` mean unlimited.
171+
- `queueSize: number=0` - set max queue size.
149172

150173
## Related projects
151174
- [c-promise2](https://www.npmjs.com/package/c-promise2) - promise with cancellation, decorators, timeouts, progress capturing, pause and user signals support

lib/use-async-effect.js

+78-33
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
const {useEffect, useCallback, useRef} = require("react");
22
const {CPromise, CanceledError} = require("c-promise2");
3+
const isEqualObjects = require('is-equal-objects/dist/is-equal-objects.cjs');
34

4-
const {E_REASON_UNMOUNTED} = CanceledError.registerErrors({
5-
E_REASON_UNMOUNTED: 'unmounted'
5+
const {E_REASON_UNMOUNTED, E_REASON_QUEUE_OVERFLOW} = CanceledError.registerErrors({
6+
E_REASON_UNMOUNTED: 'unmounted',
7+
E_REASON_QUEUE_OVERFLOW: 'overflow',
68
})
79

810
const isGeneratorFn = (thing) => typeof thing === 'function' &&
@@ -11,6 +13,11 @@ const isGeneratorFn = (thing) => typeof thing === 'function' &&
1113

1214
const isEvent = (thing) => !!(thing && typeof thing === 'object' && thing.type);
1315

16+
const removeElement = (arr, element) => {
17+
const index = arr.indexOf(element);
18+
return (index !== -1) && arr.splice(index, 1);
19+
}
20+
1421
const useAsyncEffect = (generator, deps) => {
1522
let ref = useRef(null);
1623

@@ -52,81 +59,119 @@ const useAsyncEffect = (generator, deps) => {
5259
return cancel;
5360
}
5461

62+
const argsToPromiseMap = new Map();
63+
5564
const useAsyncCallback = (generator, options) => {
56-
let dataRef = useRef({
65+
if (options != null && typeof options !== 'object') {
66+
throw TypeError('options must be an object or array');
67+
}
68+
69+
const {current} = useRef({
5770
promises: [],
5871
queue: [],
59-
pending: 0
72+
pending: 0,
73+
args: null
6074
});
6175

62-
const {
63-
current
64-
} = dataRef;
65-
66-
const {
76+
let {
6777
deps = [],
6878
combine = false,
6979
cancelPrevious = false,
70-
concurrency = 0
80+
concurrency = 0,
81+
queueSize = 0
7182
} = options && Array.isArray(options) ? {deps: options} : options || {};
7283

84+
if (cancelPrevious && (concurrency || queueSize)) {
85+
throw Error('cancelPrevious cannot be used in conjunction with concurrency or queueSize options');
86+
}
87+
88+
if (concurrency && (!Number.isFinite(queueSize) || concurrency < 0)) {
89+
throw Error('concurrency be a positive number');
90+
}
91+
92+
if (queueSize && (!Number.isFinite(queueSize) || queueSize < 0)) {
93+
throw Error('queueSize be a positive number');
94+
}
95+
96+
if(cancelPrevious){
97+
concurrency= 1;
98+
queueSize= 0;
99+
}
100+
73101
const callback = useCallback((...args) => {
74102
const {promises, queue} = current;
103+
let n;
104+
105+
if (combine && (n = promises.length)) {
106+
let promise;
107+
while (n-- > 0) {
108+
promise = promises[n];
109+
if (argsToPromiseMap.has(promise) && isEqualObjects(argsToPromiseMap.get(promise), args)) {
110+
return CPromise.resolve(promise);
111+
}
112+
}
113+
}
75114

76-
if (combine && promises.length) {
77-
return promises[0].finally(() => {
78-
});
115+
if (queueSize && (queueSize - promises.length) <= 0) {
116+
return CPromise.reject(CanceledError.from(E_REASON_QUEUE_OVERFLOW))
79117
}
80118

81119
const resolveGenerator = () => CPromise.resolveGenerator(generator, {args, resolveSignatures: true});
82120

121+
cancelPrevious && cancel();
122+
83123
if (concurrency) {
84124
const promise = new CPromise(resolve => {
85125
if (current.pending === concurrency) {
86-
return queue.push(resolve);
126+
return queue.push(() => {
127+
current.pending++;
128+
resolve()
129+
});
87130
}
88-
89131
current.pending++;
90132
resolve();
91-
})
92-
.then(() => resolveGenerator())
93-
.then((value) => {
133+
}).weight(0)
134+
.then(resolveGenerator)
135+
.finally((value) => {
94136
current.pending--;
137+
removeElement(promises, promise);
138+
combine && argsToPromiseMap.delete(promise);
95139
queue.length && queue.shift()();
96140
return value;
97-
});
141+
}).weight(0);
98142

99143
promises.push(promise);
100144

145+
combine && argsToPromiseMap.set(promise, args);
146+
101147
return promise;
102-
} else if (cancelPrevious) {
103-
cancel();
104148
}
105149

106150
const promise = resolveGenerator().finally(() => {
107-
const index = promises.indexOf(promise);
108-
index !== -1 && promises.splice(index, 1);
109-
});
151+
removeElement(promises, promise);
152+
combine && argsToPromiseMap.delete(promise);
153+
}).weight(0);
110154

111155
promises.push(promise);
112156

113-
return combine ? promise.finally(() => {
114-
}) : promise;
157+
if (combine) {
158+
argsToPromiseMap.set(promise, args);
159+
return CPromise.resolve(promise);
160+
}
161+
162+
return promise;
115163
});
116164

117165
const cancel = (reason) => {
118-
current.promises.forEach(promise => promise.cancel(reason));
119-
current.promises = [];
120-
}
166+
const _reason = isEvent(reason) ? undefined : reason;
167+
current.promises.forEach(promise => promise.cancel(_reason));
168+
current.promises.length = 0;
169+
};
121170

122171
useEffect(() => {
123172
return () => cancel(E_REASON_UNMOUNTED);
124173
}, deps);
125174

126-
if (options != null && typeof options !== 'object') {
127-
throw TypeError('options must be an object or array');
128-
}
129-
130175
callback.cancel = cancel;
131176

132177
return callback;

0 commit comments

Comments
 (0)