Skip to content

Commit baca6a7

Browse files
committedJan 6, 2021
Added useAsyncCallback hook;
1 parent c890b42 commit baca6a7

File tree

9 files changed

+317
-22
lines changed

9 files changed

+317
-22
lines changed
 

Diff for: ‎CHANGELOG.md

+9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ 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.2.0] - 2021-01-06
8+
9+
### Added
10+
- `useAsyncCallback` hook
11+
- typings
12+
13+
### Updated
14+
- `useAsyncEffect` now returns a cancel function directly instead of an array with it.
15+
716
## [0.1.0] - 2021-01-04
817

918
### Added

Diff for: ‎README.md

+55-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
![David](https://img.shields.io/david/DigitalBrainJS/use-async-effect)
55
[![Stars](https://badgen.net/github/stars/DigitalBrainJS/use-async-effect)](https://github.com/DigitalBrainJS/use-async-effect/stargazers)
66

7-
## useAsyncEffect2
7+
## useAsyncEffect2 :snowflake:
88
The library provides a React hook with ability to automatically cancel asynchronous code inside it.
99
It just makes it easier to write cancelable asynchronous code that doesn't cause
1010
the following React issue when unmounting, if your asynchronous tasks that change state are pending:
@@ -16,7 +16,7 @@ To fix, cancel all subscriptions and asynchronous task in "a useEffect cleanup f
1616
It uses [c-promise2](https://www.npmjs.com/package/c-promise2) to make it work.
1717
When it used in conjunction with other libraries that work with the CPromise,
1818
such as [cp-fetch](https://www.npmjs.com/package/cp-fetch) and [cp-axios](https://www.npmjs.com/package/cp-axios),
19-
you get a powerful tool for building asynchronous logic for your components.
19+
you get a powerful tool for building asynchronous logic of your React components.
2020
You just have to use `generators` instead of an async function to make your code cancellable,
2121
but basically, that just means you will have to use `yield` instead of `await` keyword.
2222
## Installation :hammer:
@@ -50,7 +50,7 @@ function JSONViewer(props) {
5050
return <div>{text}</div>;
5151
}
5252
````
53-
Example with timeout & error handling ([Live demo](https://codesandbox.io/s/async-effect-demo1-vho29)):
53+
Example with a timeout & error handling ([Live demo](https://codesandbox.io/s/async-effect-demo1-vho29)):
5454
````jsx
5555
import React from "react";
5656
import {useState} from "react";
@@ -88,13 +88,65 @@ export default function TestComponent(props) {
8888
return <div>{text}</div>;
8989
}
9090
````
91+
useAsyncCallback example ([Live demo](https://codesandbox.io/s/use-async-callback-bzpek?file=/src/TestComponent.js)):
92+
````javascript
93+
import React from "react";
94+
import {useState} from "react";
95+
import {useAsyncCallback} from "use-async-effect2";
96+
import {CPromise} from "c-promise2";
97+
98+
export default function TestComponent(props) {
99+
const [text, setText] = useState("");
100+
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>;
119+
}
120+
````
121+
91122
To learn more about available features, see the c-promise2 [documentation](https://www.npmjs.com/package/c-promise2).
92123

93124
## Playground
94125

95126
To get it, clone the repository and run `npm run playground` in the project directory or
96127
just use the codesandbox [demo](https://codesandbox.io/s/async-effect-demo1-vho29) to play with the library online.
97128

129+
## API
130+
131+
### useAsyncEffect(generatorFn, deps?): (function cancel():boolean)
132+
A React hook based on [`useEffect`](https://reactjs.org/docs/hooks-effect.html), that resolves passed generator as asynchronous function.
133+
The asynchronous generator sequence and its promise of the result will be canceled if
134+
the effect cleanup process is started before it completes.
135+
The generator can return a cleanup function similar to the `useEffect` hook.
136+
- `generatorFn(...userArgs, scope: CPromise)` : `GeneratorFunction` - generator to resolve as an async function.
137+
The last argument passed to this function and `this` refer to the CPromise instance.
138+
- `deps?: any[]` - effect dependencies
139+
140+
### useAsyncCallback(generatorFn, deps?): CPromiseAsyncFunction
141+
### useAsyncCallback(generatorFn, options?: object): CPromiseAsyncFunction
142+
This hook makes an async callback that can be automatically canceled on unmount or by user request.
143+
#### 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.
147+
- `cancelPrevious:boolean` - cancel the previous pending async function before running a new one.
148+
- `concurrency: number=0` - set concurrency limit for simultaneous calls.
149+
98150
## Related projects
99151
- [c-promise2](https://www.npmjs.com/package/c-promise2) - promise with cancellation, decorators, timeouts, progress capturing, pause and user signals support
100152
- [cp-axios](https://www.npmjs.com/package/cp-axios) - a simple axios wrapper that provides an advanced cancellation api

Diff for: ‎lib/use-async-effect.js

+116-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const {useEffect} = require("react");
1+
const {useEffect, useCallback, useRef} = require("react");
22
const {CPromise, CanceledError} = require("c-promise2");
33

44
const {E_REASON_UNMOUNTED} = CanceledError.registerErrors({
@@ -26,8 +26,8 @@ const useAsyncEffect = (generator, deps) => {
2626
throw TypeError('useAsyncEffect handler should return a function');
2727
}
2828
cb = _cb;
29-
}, err=>{
30-
if(!CanceledError.isCanceledError(err)){
29+
}, err => {
30+
if (!CanceledError.isCanceledError(err)) {
3131
console.error(err);
3232
}
3333
});
@@ -39,12 +39,122 @@ const useAsyncEffect = (generator, deps) => {
3939
}
4040
}, deps);
4141

42-
return [
43-
(reason) => !!promise && promise.cancel(reason)
44-
]
42+
return (reason) => !!promise && promise.cancel(reason);
43+
}
44+
45+
function asyncBottleneck(fn, concurrency = 1) {
46+
const queue = [];
47+
let pending = 0;
48+
return (...args) => new CPromise(resolve => pending === concurrency ? queue.push(resolve) : resolve())
49+
.then(() => {
50+
pending++;
51+
return fn(...args).finally((value) => {
52+
pending--;
53+
queue.length && queue.shift()();
54+
return value;
55+
})
56+
});
57+
}
58+
59+
function asyncBottleneck2(fn, concurrency = 1) {
60+
const queue = [];
61+
let pending = 0;
62+
return async (...args) => {
63+
if (pending === concurrency) {
64+
await new Promise((resolve) => queue.push(resolve));
65+
}
66+
67+
pending++;
68+
69+
return fn(...args).then((value) => {
70+
pending--;
71+
queue.length && queue.shift()();
72+
return value;
73+
});
74+
};
75+
}
76+
77+
const useAsyncCallback = (generator, options) => {
78+
let dataRef = useRef({
79+
promises: [],
80+
queue: [],
81+
pending: 0
82+
});
83+
84+
const {
85+
current
86+
} = dataRef;
87+
88+
const {
89+
deps = [],
90+
combine = false,
91+
cancelPrevious = false,
92+
concurrency = 0
93+
} = options && Array.isArray(options) ? {deps: options} : options || {};
94+
95+
const callback = useCallback((...args) => {
96+
const {promises, queue} = current;
97+
98+
if (combine && promises.length) {
99+
return promises[0].finally(() => {
100+
});
101+
}
102+
103+
const resolveGenerator = () => CPromise.resolveGenerator(generator, {args, resolveSignatures: true});
104+
105+
if (concurrency) {
106+
const promise= new CPromise(resolve => {
107+
if (current.pending === concurrency) {
108+
return queue.push(resolve);
109+
}
110+
111+
current.pending++;
112+
resolve();
113+
})
114+
.then(() => resolveGenerator())
115+
.then((value) => {
116+
current.pending--;
117+
queue.length && queue.shift()();
118+
return value;
119+
});
120+
121+
promises.push(promise);
122+
123+
return promise;
124+
} else if (cancelPrevious) {
125+
cancel();
126+
}
127+
128+
const promise= resolveGenerator().finally(()=>{
129+
const index = promises.indexOf(promise);
130+
index !== -1 && promises.splice(index, 1);
131+
});
132+
133+
promises.push(promise);
134+
135+
return combine? promise.finally(()=>{}) : promise;
136+
});
137+
138+
const cancel = (reason) => {
139+
current.promises.forEach(promise => promise.cancel(reason));
140+
current.promises = [];
141+
}
142+
143+
useEffect(() => {
144+
return () => cancel(E_REASON_UNMOUNTED);
145+
}, deps);
146+
147+
if (options != null && typeof options !== 'object') {
148+
throw TypeError('options must be an object or array');
149+
}
150+
151+
callback.cancel = cancel;
152+
153+
return callback;
45154
}
46155

47156
module.exports = {
48157
useAsyncEffect,
158+
useAsyncCallback,
49159
E_REASON_UNMOUNTED
50160
}

Diff for: ‎playground/index.html

+16-2
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,22 @@
1414
margin-left: 5px;
1515
}
1616

17-
#app>div{
18-
margin: 10px 0px ;
17+
.component{
18+
margin: 20px 0px;
19+
border: 1px solid black;
20+
width: 300px;
21+
padding: 5px;
22+
}
23+
24+
.component>*{
25+
margin: 5px;
26+
}
27+
28+
.component>.caption{
29+
background-color: #535353;
30+
color: white;
31+
margin: 0px;
32+
padding: 5px 10px;
1933
}
2034

2135

Diff for: ‎playground/src/App.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from "react";
2-
import TestComponent from "./TestComponent";
2+
import TestComponent1 from "./TestComponent1";
3+
import TestComponent2 from "./TestComponent2";
34

45
export default class App extends React.Component {
56
constructor(props) {
@@ -28,8 +29,9 @@ export default class App extends React.Component {
2829
<div key={this.state.timestamp} id="app">
2930
<input className="field-url" type="text" value={this.state._url} onChange={this.handleChange} />
3031
<button className="btn-change" onClick={this.onFetchClick} disabled={this.state.url==this.state._url}>Change URL to Fetch</button>
31-
<TestComponent url={this.state.url}></TestComponent>
32-
<button onClick={this.onClick}>Remount</button>
32+
<TestComponent1 url={this.state.url}></TestComponent1>
33+
<TestComponent2 url={this.state.url}></TestComponent2>
34+
<div><button onClick={this.onClick}>Remount all components</button></div>
3335
</div>
3436
);
3537
}

Diff for: ‎playground/src/TestComponent.js renamed to ‎playground/src/TestComponent1.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import {useAsyncEffect, E_REASON_UNMOUNTED} from "../../lib/use-async-effect";
44
import {CanceledError} from "c-promise2";
55
import cpFetch from "cp-fetch";
66

7-
export default function TestComponent(props) {
7+
export default function TestComponent1(props) {
88
const [text, setText] = useState("");
99

10-
const [cancel]= useAsyncEffect(function* ({onCancel}) {
10+
const cancel= useAsyncEffect(function* ({onCancel}) {
1111
console.log("mount");
1212

1313
this.timeout(5000);
@@ -31,5 +31,6 @@ export default function TestComponent(props) {
3131

3232
//setTimeout(()=> cancel("Ooops!"), 1000);
3333

34-
return <div>{text}</div>;
34+
return <div className="component"><div className="caption">useAsyncEffect demo:</div><div>{text}</div></div>;
3535
}
36+

Diff for: ‎playground/src/TestComponent2.js

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from "react";
2+
import {useState} from "react";
3+
import {useAsyncCallback} from "../../lib/use-async-effect";
4+
import {CPromise} from "c-promise2";
5+
6+
export default function TestComponent2() {
7+
const [text, setText] = useState("");
8+
9+
const asyncRoutine= useAsyncCallback(function*(v){
10+
setText(`Stage1`);
11+
yield CPromise.delay(1000);
12+
setText(`Stage2`);
13+
yield CPromise.delay(1000);
14+
setText(`Stage3`);
15+
yield CPromise.delay(1000);
16+
setText(`Done`);
17+
return v;
18+
})
19+
20+
const onClick= ()=>{
21+
asyncRoutine(123).then(value=>{
22+
console.log(`Result: ${value}`)
23+
}, console.warn);
24+
}
25+
26+
return <div className="component"><div className="caption">useAsyncCallback demo:</div><button onClick={onClick}>Run async job</button><div>{text}</div></div>;
27+
}

Diff for: ‎test/src/index.js

+71-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, {useState} from "react";
22
import ReactDOM from "react-dom";
3-
import {useAsyncEffect} from "../../lib/use-async-effect";
3+
import {useAsyncEffect, useAsyncCallback} from "../../lib/use-async-effect";
44
import CPromise from "c-promise2";
55

66
const measureTime = () => {
@@ -48,7 +48,7 @@ describe("useAsyncEffect", function () {
4848
}
4949

5050
ReactDOM.render(
51-
<TestComponent></TestComponent>,
51+
<TestComponent/>,
5252
document.getElementById('root')
5353
);
5454

@@ -57,12 +57,11 @@ describe("useAsyncEffect", function () {
5757
it("should handle cancellation", function (done) {
5858

5959
let counter = 0;
60-
let time = measureTime();
6160

6261
function TestComponent() {
6362
const [value, setValue] = useState(0);
6463

65-
const [cancel] = useAsyncEffect(function* () {
64+
const cancel = useAsyncEffect(function* () {
6665
yield CPromise.delay(200);
6766
setValue(123);
6867
yield CPromise.delay(200);
@@ -95,9 +94,76 @@ describe("useAsyncEffect", function () {
9594
}
9695

9796
ReactDOM.render(
98-
<TestComponent></TestComponent>,
97+
<TestComponent/>,
9998
document.getElementById('root')
10099
);
101100

102101
})
103102
});
103+
104+
describe("useAsyncFn", function () {
105+
it("should decorate user generator to CPromise", function (done) {
106+
let called = false;
107+
let time = measureTime();
108+
109+
function TestComponent() {
110+
const fn = useAsyncCallback(function* (a, b) {
111+
called = true;
112+
yield CPromise.delay(100);
113+
assert.deepStrictEqual([a, b], [1, 2]);
114+
});
115+
116+
fn(1, 2).then(value => {
117+
assert.ok(called);
118+
if (time() < 100) {
119+
assert.fail('early completion');
120+
}
121+
122+
done();
123+
}, done);
124+
125+
return <div>Test</div>
126+
}
127+
128+
ReactDOM.render(
129+
<TestComponent/>,
130+
document.getElementById('root')
131+
);
132+
});
133+
134+
it("should support concurrency limitation", function (done) {
135+
let called = false;
136+
let time = measureTime();
137+
let pending = 0;
138+
let counter = 0;
139+
const concurrency = 2;
140+
141+
function TestComponent() {
142+
const fn = useAsyncCallback(function* () {
143+
if (++pending > concurrency) {
144+
assert.fail('threads excess');
145+
}
146+
yield CPromise.delay(100);
147+
pending--;
148+
counter++;
149+
}, {concurrency});
150+
151+
const promises = [];
152+
153+
for (let i = 0; i < 10; i++) {
154+
promises.push(fn());
155+
}
156+
157+
Promise.all(promises).then(() => {
158+
assert.strictEqual(counter, 10);
159+
}).then(() => done(), done);
160+
161+
return <div>Test</div>
162+
}
163+
164+
ReactDOM.render(
165+
<TestComponent/>,
166+
document.getElementById('root')
167+
);
168+
});
169+
});

Diff for: ‎use-async-effect.d.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
interface UseAsyncFnOptions {
2+
deps?: [],
3+
combine?: false,
4+
cancelPrevious?: false,
5+
concurrency?: 0
6+
}
7+
8+
type CancelReason= string|Error;
9+
10+
export function useAsyncHook(generator: GeneratorFunction, deps?: any[]): ((reason?: CancelReason)=> boolean)
11+
export function useAsyncCallback(generator: GeneratorFunction, deps?: any[]): ((reason?: CancelReason)=> boolean)
12+
export function useAsyncCallback(generator: GeneratorFunction, options?: UseAsyncFnOptions): ((reason?: CancelReason)=> boolean)
13+
export const E_REASON_UNMOUNTED: 'E_REASON_UNMOUNTED'
14+

0 commit comments

Comments
 (0)
Please sign in to comment.