├── .npmignore
├── .gitignore
├── .travis.yml
├── playground
├── src
│ ├── index.js
│ ├── TestComponent3.js
│ ├── TestComponent4.js
│ ├── TestComponent8.js
│ ├── TestComponent1.js
│ ├── TestComponent2.js
│ ├── TestComponent9.js
│ ├── TestComponent5.js
│ ├── TestComponent7.js
│ ├── App.js
│ └── TestComponent6.js
├── index.html
└── rollup.config.js
├── .eslintrc.js
├── test
├── test.html
├── rollup.config.js
└── src
│ └── index.js
├── LICENSE.txt
├── ECOSYSTEM.md
├── use-async-effect.d.ts
├── package.json
├── CHANGELOG.md
├── README.md
└── lib
└── use-async-effect.js
/.npmignore:
--------------------------------------------------------------------------------
1 | *
2 | !lib/**
3 | !use-async-effect.d.ts
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /coverage/
2 | /.nyc_output/
3 | /dist/
4 | /test/build/
5 | /playground/build/
6 | /node_modules/
7 | /trash/
8 | /.idea/
9 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | node_js:
4 | - "14"
5 | env:
6 | - NODE_ENV=TEST
7 | script:
8 | - npm run test
9 |
--------------------------------------------------------------------------------
/playground/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import App from "./App";
4 |
5 | ReactDOM.render(
6 | ,
7 | document.getElementById('root')
8 | );
9 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "env": {
3 | "browser": true,
4 | "commonjs": true,
5 | "es2021": true,
6 | "node": true
7 | },
8 | "extends": "eslint:recommended",
9 | "parserOptions": {
10 | "ecmaVersion": 12
11 | },
12 | "rules": {
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/test/test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Mocha Test Runner
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
20 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/playground/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Playground
6 |
7 |
8 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/playground/src/TestComponent3.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useState } from "react";
3 | import { useAsyncCallback, useAsyncEffect, E_REASON_UNMOUNTED } from "../../lib/use-async-effect";
4 | import { CPromise, CanceledError } from "c-promise2";
5 | import cpAxios from "cp-axios";
6 |
7 | export default function TestComponent3(props) {
8 | const [cancel, done, result]= useAsyncEffect(function*(){
9 | return (yield cpAxios(props.url)).data;
10 | },{states: true})
11 |
12 | return (
13 |
14 |
useAsyncEffect demo:
15 |
{done? JSON.stringify(result) : "loading..."}
16 |
Cancel async effect
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/playground/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from '@rollup/plugin-node-resolve';
2 | import commonjs from '@rollup/plugin-commonjs';
3 | import babel from '@rollup/plugin-babel';
4 | import replace from '@rollup/plugin-replace';
5 |
6 | export default
7 | {
8 | plugins: [
9 | babel({
10 | exclude: 'node_modules/**',
11 | babelHelpers: 'bundled',
12 | plugins: [],
13 | presets: [
14 | "@babel/preset-react"
15 | ]
16 | }),
17 | replace({
18 | 'process.env.NODE_ENV': JSON.stringify( 'production' )
19 | }),
20 | resolve({browser: true}),
21 | commonjs({
22 | extensions: ['.js', '.jsx']
23 | })
24 | ]
25 | };
26 |
--------------------------------------------------------------------------------
/playground/src/TestComponent4.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useState } from "react";
3 | import { useAsyncCallback, useAsyncEffect, E_REASON_UNMOUNTED } from "../../lib/use-async-effect";
4 | import { CPromise, CanceledError } from "c-promise2";
5 | import cpAxios from "cp-axios";
6 |
7 | export default function TestComponent3(props) {
8 | const [fetch, cancel, pending, done, result, error]= useAsyncCallback(function*(){
9 | return (yield cpAxios(props.url)).data;
10 | }, {threads: 1, states: true})
11 |
12 | return (
13 |
14 |
useAsyncCallback demo:
15 |
{done? error? error.toString() : JSON.stringify(result) : pending? "loading..." : "Press fetch"}
16 | {pending ?
Cancel :
Fetch }
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/test/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from '@rollup/plugin-node-resolve';
2 | import commonjs from '@rollup/plugin-commonjs';
3 | import babel from '@rollup/plugin-babel';
4 | import replace from '@rollup/plugin-replace';
5 | import json from '@rollup/plugin-json';
6 |
7 | export default
8 | {
9 | plugins: [
10 | babel({
11 | exclude: 'node_modules/**',
12 | babelHelpers: 'bundled',
13 | plugins: [],
14 | presets: [
15 | "@babel/preset-react"
16 | ]
17 | }),
18 | replace({
19 | 'process.env.NODE_ENV': JSON.stringify( 'production' )
20 | }),
21 | resolve({
22 | browser: true,
23 | preferBuiltins: false
24 | }),
25 | json(),
26 | commonjs({
27 | extensions: ['.js', '.jsx']
28 | })
29 | ]
30 | };
31 |
--------------------------------------------------------------------------------
/playground/src/TestComponent8.js:
--------------------------------------------------------------------------------
1 | import React, {useEffect, useCallback, useState} from "react";
2 | import {CPromise} from "c-promise2";
3 | import {
4 | useAsyncWatcher,
5 | } from "../../lib/use-async-effect";
6 |
7 | export default function TestComponent7(props) {
8 | const [counter, setCounter] = useState(0);
9 | const [text, setText] = useState("");
10 |
11 | const textWatcher = useAsyncWatcher(text);
12 |
13 | useEffect(() => {
14 | setText(`Counter: ${counter}`);
15 | }, [counter]);
16 |
17 | const inc = useCallback(() => {
18 | (async () => {
19 | await CPromise.delay(1000);
20 | setCounter((counter) => counter + 1);
21 | const updatedText = await textWatcher();
22 | console.log(updatedText);
23 | })();
24 | }, []);
25 |
26 | return (
27 |
28 |
useAsyncWatcher demo
29 |
{counter}
30 |
Inc counter
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 |
2 | The MIT License (MIT)
3 |
4 | Copyright (c) 2021 Mozgovoy Dmitriy and Contributors
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the 'Software'), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is furnished
11 | to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
20 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/ECOSYSTEM.md:
--------------------------------------------------------------------------------
1 | # Ecosystem
2 |
3 | This is a list of `CPromise` related libraries and resources.
4 |
5 | ## Libraries
6 |
7 | ### General
8 |
9 | * [c-promise2](https://www.npmjs.com/package/c-promise2) - Advanced cancellable promise that should be used with [`use-async-effect2`](https://www.npmjs.com/package/use-async-effect2) to get the most out of it
10 |
11 | ### React
12 | * [use-async-effect](https://www.npmjs.com/package/use-async-effect2) (🔴 this library)- Feature-rich React async hooks that built on top of the cancellable promises ([`CPromise`](https://www.npmjs.com/package/c-promise2))
13 |
14 | ### Data fetching
15 | * [cp-axios](https://www.npmjs.com/package/cp-axios) - Axios cancellable wrapper that supports CPromise context. Can be directly used in [`use-async-effect2`](https://www.npmjs.com/package/use-async-effect2) or general CPromise context
16 | * [cp-fetch](https://www.npmjs.com/package/cp-fetch) - Cross-platform fetch wrapper that can be used in cooperation with [`use-async-effect2`](https://www.npmjs.com/package/use-async-effect2) or general [`CPromise`](https://www.npmjs.com/package/c-promise2) chains
17 |
18 | ### Server application framework
19 | * [cp-koa](https://www.npmjs.com/package/cp-koa) - Wrapper for [`koa`](https://www.npmjs.com/package/koa), that implements cancellable middlewares/routes to the framework
20 |
--------------------------------------------------------------------------------
/playground/src/TestComponent1.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {useState} from "react";
3 | import {useAsyncEffect, E_REASON_UNMOUNTED} from "../../lib/use-async-effect";
4 | import {CanceledError} from "c-promise2";
5 | import cpFetch from "cp-fetch";
6 |
7 | export default function TestComponent1(props) {
8 | const [text, setText] = useState("");
9 |
10 | const cancel = useAsyncEffect(function* ({onCancel}) {
11 | console.log("mount");
12 |
13 | this.timeout(10000);
14 |
15 | onCancel(() => console.log('scope canceled'));
16 |
17 | try {
18 | setText("fetching...");
19 | const response = yield cpFetch(props.url);
20 | const json = yield response.json();
21 | setText(`Success: ${JSON.stringify(json)}`);
22 | } catch (err) {
23 | CanceledError.rethrow(err, E_REASON_UNMOUNTED); //passthrough
24 | setText(`Failed: ${err}`);
25 | }
26 |
27 | return () => {
28 | console.log("unmount", this.isCanceled);
29 | };
30 | }, [props.url]);
31 |
32 | //setTimeout(()=> cancel("Ooops!"), 1000);
33 |
34 | return
35 |
useAsyncEffect demo:
36 |
{text}
37 |
Abort
38 |
;
39 | }
40 |
41 |
--------------------------------------------------------------------------------
/playground/src/TestComponent2.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useState } from "react";
3 | import { useAsyncCallback, E_REASON_UNMOUNTED } from "../../lib/use-async-effect";
4 | import { CPromise, CanceledError } from "c-promise2";
5 |
6 | export default function TestComponent2() {
7 | const [text, setText] = useState("");
8 |
9 | const asyncRoutine = useAsyncCallback(
10 | function* (a, b) {
11 | setText(`Stage1`);
12 | yield CPromise.delay(1000);
13 | setText(`Stage2`);
14 | yield CPromise.delay(1000);
15 | setText(`Stage3`);
16 | yield CPromise.delay(1000);
17 | setText(`Done`);
18 | return Math.random();
19 | },
20 | { cancelPrevious: true }
21 | );
22 |
23 | const onClick = () => {
24 | asyncRoutine(123, 456, Math.random()).then(
25 | (value) => {
26 | setText(`Result: ${value}`);
27 | },
28 | (err) => {
29 | console.warn(err);
30 | CanceledError.rethrow(E_REASON_UNMOUNTED);
31 | setText(`Fail: ${err}`);
32 | }
33 | );
34 | };
35 |
36 | return (
37 |
38 |
useAsyncCallback demo:
39 |
Run async job
40 |
{text}
41 |
asyncRoutine.cancel()}>Abort
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/playground/src/TestComponent9.js:
--------------------------------------------------------------------------------
1 | import React, {useState} from "react";
2 | import {
3 | useAsyncCallback
4 | } from "../../lib/use-async-effect";
5 | import {CPromise} from "c-promise2";
6 |
7 | export default function TestComponent7(props) {
8 | const [text, setText] = useState("one two three four five");
9 | const [word, setWord] = useState("");
10 |
11 | const go = useAsyncCallback(
12 | function* (text, delay) {
13 | const words = text.split(/\s+/);
14 | for (const word of words) {
15 | setWord(word);
16 | yield CPromise.delay(delay);
17 | }
18 | },
19 | { states: true, cancelPrevious: true }
20 | );
21 |
22 | return (
23 |
24 |
useAsyncEffect demo
25 |
{
28 | setText(target.value);
29 | }}
30 | />
31 |
{go.error ? go.error.toString() : word}
32 | {go.pending ? (
33 | go.paused ? (
34 |
35 | Resume
36 |
37 | ) : (
38 |
39 | Pause
40 |
41 | )
42 | ) : (
43 |
go(text, 1000)}>
44 | Run
45 |
46 | )}
47 | {go.pending && (
48 |
49 | Cancel request
50 |
51 | )}
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/playground/src/TestComponent5.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { useAsyncEffect, useAsyncCallback } from "../../lib/use-async-effect";
3 | import { CPromise } from "c-promise2";
4 |
5 | function* doJob2() {
6 | console.log("job2:begin");
7 | yield CPromise.delay(4000);
8 | console.log("job2:end");
9 | }
10 |
11 | const doJob3 = CPromise.promisify(function* () {
12 | console.log("job3:begin");
13 | yield CPromise.delay(4000);
14 | console.log("job3:end");
15 | });
16 |
17 | function TestComponent(props) {
18 | const [stage, setStage] = useState("");
19 | const [progress, setProgress] = useState(0);
20 | const [cancel, done, result, err] = useAsyncEffect(
21 | function* () {
22 | this.progress(setProgress);
23 | this.innerWeight(4);
24 | setStage("Stage 1");
25 | const promise = doJob1();
26 | yield promise;
27 | yield CPromise.delay(4000);
28 | setStage("Stage 2");
29 | yield* doJob2();
30 | setStage("Stage 3");
31 | yield doJob3();
32 | yield CPromise.delay(5000);
33 | return "Done";
34 | },
35 | { states: true, deps: [props.url] }
36 | );
37 |
38 | const doJob1 = useAsyncCallback(function* () {
39 | console.log("job1:begin");
40 | yield CPromise.delay(5000);
41 | console.log("job1:end");
42 | });
43 |
44 | return (
45 |
46 |
useAsyncEffect demo:
47 |
48 | {done ? (
49 | err ? (
50 | err.toString()
51 | ) : (
52 | result
53 | )
54 | ) : (
55 | progress
56 | )}
57 |
58 |
59 | Cancel async effect
60 |
61 |
62 | );
63 | }
64 |
65 | export default TestComponent;
66 |
67 |
--------------------------------------------------------------------------------
/playground/src/TestComponent7.js:
--------------------------------------------------------------------------------
1 | import React, {useRef} from "react";
2 | import {
3 | useAsyncDeepState
4 | } from "../../lib/use-async-effect";
5 |
6 | let renderCounter= 0;
7 |
8 | export default function TestComponent7(props) {
9 | const symbol= Symbol('test');
10 |
11 | const [state, setState] = useAsyncDeepState({
12 | foo: 123,
13 | bar: 456,
14 | counter: 0,
15 | items: [],
16 | [symbol]: "test"
17 | });
18 |
19 | console.log('Rendered state: ', JSON.stringify(state));
20 |
21 | return (
22 |
23 |
useAsyncState demo:
24 |
renderCounter: {++renderCounter}
25 |
{JSON.stringify(state, null, 2)}
26 |
27 |
{
28 | const newState= await setState((state)=> {
29 | return {counter: state.counter + 1}
30 | });
31 |
32 | console.log('Updated state: ', newState);
33 | }}>Inc foo
34 |
35 |
{
36 | setState((_state)=>{
37 | return {items: [..._state.items, {timestamp: Date.now()}]};
38 | })
39 | }}>Add item
40 |
41 |
{
42 | state.items= [{message: "test"}];
43 | }}>Replace item using setter
44 |
45 |
{
46 | state.items.push({message: "pushed, render as needed"});
47 | setState(null);
48 | }}>Push item and render as needed
49 |
50 |
{
51 | state.items.push({message: "pushed, forced render"});
52 | setState(true);
53 | }}>Push item and re-render anyway
54 |
55 |
setState((_state)=>{
56 | console.log('splice', state.items.slice(0));
57 | return ({
58 | items: state.items.splice(0)
59 | })
60 | })}>Set cloned object as items value
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/playground/src/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import TestComponent1 from "./TestComponent1";
3 | import TestComponent2 from "./TestComponent2";
4 | import TestComponent3 from "./TestComponent3";
5 | import TestComponent4 from "./TestComponent4";
6 | import TestComponent5 from "./TestComponent5";
7 | import TestComponent6 from "./TestComponent6";
8 | import TestComponent7 from "./TestComponent7";
9 | import TestComponent8 from "./TestComponent8";
10 | import TestComponent9 from "./TestComponent9";
11 | import LiveTest from "./states";
12 |
13 | export default class App extends React.Component {
14 | constructor(props) {
15 | super(props);
16 | this.state = { url: props.url, _url: props.url, timeout: 4500 };
17 | this.onClick = this.onClick.bind(this);
18 | this.onFetchClick = this.onFetchClick.bind(this);
19 | this.handleChange = this.handleChange.bind(this);
20 | this.handleTimeoutChange = this.handleTimeoutChange.bind(this);
21 | }
22 |
23 | onFetchClick() {
24 | this.setState(({ _url }) => ({ url: _url }));
25 | }
26 |
27 | onClick() {
28 | this.setState({ timestamp: Date.now() });
29 | }
30 |
31 | handleChange(event) {
32 | console.log(event.target.value);
33 | this.setState({ _url: event.target.value });
34 | }
35 |
36 | handleTimeoutChange(event) {
37 | this.setState({ timeout: event.target.value * 1 });
38 | }
39 |
40 | render() {
41 | return (
42 |
43 |
49 |
54 | Change URL to Fetch
55 |
56 |
57 |
66 |
67 | Timeout [{this.state.timeout}]ms
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | Remount component
82 |
83 |
84 | );
85 | }
86 | }
87 |
88 |
89 |
--------------------------------------------------------------------------------
/use-async-effect.d.ts:
--------------------------------------------------------------------------------
1 | export interface UseAsyncFnOptions {
2 | /**
3 | * @default []
4 | */
5 | deps?: any[],
6 | /**
7 | * @default false
8 | */
9 | combine?: boolean,
10 | /**
11 | * @default false
12 | */
13 | cancelPrevious?: boolean,
14 | /**
15 | * @default 0
16 | */
17 | threads?: number,
18 | /**
19 | * @default 0
20 | */
21 | queueSize?: number,
22 | /**
23 | * @default false
24 | */
25 | scopeArg?: boolean,
26 | /**
27 | * @default false
28 | */
29 | states?: boolean,
30 | /**
31 | * @default false
32 | */
33 | catch?: boolean
34 | }
35 |
36 | export interface UseAsyncEffectOptions {
37 | /**
38 | * @default false
39 | */
40 | skipFirst: boolean,
41 | /**
42 | * @default []
43 | */
44 | deps?: any[],
45 | /**
46 | * @default false
47 | */
48 | states?: boolean,
49 | /**
50 | * @default false
51 | */
52 | once?: boolean
53 | }
54 |
55 | export type CancelReason = string | Error;
56 |
57 | export type pendingState = boolean;
58 | export type doneState = boolean;
59 | export type resultState = boolean;
60 | export type errorState = boolean;
61 | export type canceledState = boolean;
62 | export type pausedState = boolean;
63 |
64 | export interface AsyncEffectCancelFn {
65 | (reason?: CancelReason): boolean,
66 | pause: (data: any)=> boolean,
67 | resume: (data: any)=> boolean,
68 | 0: doneState,
69 | 1: resultState,
70 | 2: errorState,
71 | 3: canceledState,
72 | 4: pausedState
73 | }
74 |
75 | export interface DecoratedCallback {
76 | (...args: any[]): any
77 |
78 | cancel: (reason?: CancelReason)=> void,
79 | pause: (data: any)=> boolean,
80 | resume: (data: any)=> boolean,
81 | 0: DecoratedCallback,
82 | 1: (reason?: CancelReason)=> void,
83 | 2: pendingState,
84 | 3: doneState,
85 | 4: resultState,
86 | 5: errorState,
87 | 6: canceledState,
88 | 7: pausedState
89 | }
90 |
91 | export type CPromiseGeneratorYield = null | PromiseLike | CPromiseGeneratorYield[];
92 |
93 | export interface CPromiseGenerator {
94 | (...args: any[]): Generator
95 | }
96 |
97 | export interface UseAsyncDeepStateOptions {
98 | /**
99 | * @default true
100 | */
101 | watch?: boolean;
102 | /**
103 | * @default true
104 | */
105 | defineSetters?: boolean;
106 | }
107 |
108 | export function useAsyncEffect(generator: CPromiseGenerator, deps?: any[]): AsyncEffectCancelFn
109 | export function useAsyncEffect(generator: CPromiseGenerator, options?: UseAsyncEffectOptions): AsyncEffectCancelFn
110 | export function useAsyncCallback(generator: CPromiseGenerator, deps?: any[]): DecoratedCallback
111 | export function useAsyncCallback(generator: CPromiseGenerator, options?: UseAsyncFnOptions): DecoratedCallback
112 |
113 | export function useAsyncDeepState(initialValue?: object, options?: UseAsyncDeepStateOptions): [object, (newState?: object)=> Promise|void]
114 | export function useAsyncWatcher(...values: any): (grabPrevValue?: boolean)=> Promise
115 |
116 |
117 |
118 | export const E_REASON_UNMOUNTED: 'E_REASON_UNMOUNTED'
119 | export const E_REASON_QUEUE_OVERFLOW: 'E_REASON_QUEUE_OVERFLOW'
120 | export const E_REASON_RESTART: 'E_REASON_RESTART'
121 |
122 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-async-effect2",
3 | "version": "0.12.2",
4 | "description": "Asynchronous versions of the `useEffect` and` useCallback` hooks that able to cancel internal code by user requests or component unmounting",
5 | "main": "lib/use-async-effect.js",
6 | "scripts": {
7 | "test": "npm run test:build && mocha-headless-chrome -f test/test.html",
8 | "test:build": "rollup ./test/src/index.js --file ./test/build/index.js --format iife --config ./test/rollup.config.js",
9 | "changelog": "auto-changelog -p",
10 | "version": "npm run changelog && git add CHANGELOG.md",
11 | "playground": "npm run playground:build && concurrently --kill-others \"npm run playground:server\" \"npm run playground:watch\"",
12 | "playground:run": "node playground/build/index.js || true",
13 | "playground:build": "rollup ./playground/src/index.js --file ./playground/build/index.js --format iife --config ./playground/rollup.config.js",
14 | "playground:watch": "nodemon --delay 1000ms --watch ./playground/src/ --watch lib/ --exec \\\"npm run playground:build\\\"",
15 | "playground:server": "browser-sync start --server --index ./playground/index.html --files \"./playground/build/*.*\"",
16 | "prepublishOnly": "npm run test",
17 | "postversion": "git push && git push --tags"
18 | },
19 | "author": {
20 | "name": "Dmitriy Mozgovoy",
21 | "email": "robotshara@gmail.com",
22 | "url": "http://github.com/DigitalBrainJS/"
23 | },
24 | "license": "MIT",
25 | "keywords": [
26 | "react",
27 | "reactjs",
28 | "hook",
29 | "hooks",
30 | "useEffect",
31 | "useCallback",
32 | "useAsyncEffect",
33 | "use-async-effect",
34 | "async",
35 | "deepstate",
36 | "setState",
37 | "promise",
38 | "c-promise",
39 | "cpromise",
40 | "cancelable",
41 | "cancellable",
42 | "p-cancelable",
43 | "timeout",
44 | "progress",
45 | "cancel",
46 | "abortable",
47 | "abort",
48 | "AbortController",
49 | "AbortSignal",
50 | "signal",
51 | "await",
52 | "wait",
53 | "promises",
54 | "generator",
55 | "co",
56 | "yield",
57 | "reject",
58 | "race",
59 | "decorator",
60 | "delay",
61 | "break",
62 | "suspending",
63 | "bluebird",
64 | "deferred",
65 | "react",
66 | "cancellation",
67 | "aborting",
68 | "close",
69 | "closable",
70 | "pause",
71 | "task"
72 | ],
73 | "repository": "https://github.com/DigitalBrainJS/use-async-effect.git",
74 | "bugs": {
75 | "url": "https://github.com/DigitalBrainJS/use-async-effect/issues"
76 | },
77 | "typings": "./use-async-effect.d.ts",
78 | "typescript": {
79 | "definition": "./use-async-effect.d.ts"
80 | },
81 | "auto-changelog": {
82 | "output": "CHANGELOG.md",
83 | "unreleased": false,
84 | "commitLimit": false
85 | },
86 | "dependencies": {
87 | "c-promise2": "^0.13.7",
88 | "is-equal-objects": "^0.3.0"
89 | },
90 | "peerDependencies": {
91 | "react": ">=16.8.0"
92 | },
93 | "devDependencies": {
94 | "@babel/core": "^7.13.15",
95 | "@babel/preset-react": "^7.13.13",
96 | "@rollup/plugin-babel": "^5.3.0",
97 | "@rollup/plugin-commonjs": "^17.1.0",
98 | "@rollup/plugin-json": "^4.1.0",
99 | "@rollup/plugin-node-resolve": "^11.2.1",
100 | "@rollup/plugin-replace": "^2.4.2",
101 | "assert": "^2.0.0",
102 | "auto-changelog": "^2.2.1",
103 | "browser-sync": "^2.26.14",
104 | "chai": "^4.3.4",
105 | "concurrently": "^5.3.0",
106 | "cp-axios": "^0.3.0",
107 | "cp-fetch": "^0.3.0",
108 | "eslint": "^7.24.0",
109 | "mocha": "^8.3.2",
110 | "mocha-headless-chrome": "^3.1.0",
111 | "nodemon": "^2.0.7",
112 | "prop-types": "^15.7.2",
113 | "react": "^17.0.2",
114 | "react-dom": "^17.0.2",
115 | "react-is": "^17.0.2",
116 | "rollup": "^2.45.2"
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/playground/src/TestComponent6.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { useAsyncCallback } from "../../lib/use-async-effect";
3 | import { CPromise } from "c-promise2";
4 |
5 | function TestComponent(props) {
6 | const [combine, setCombine] = useState(false);
7 | const [cancelPrevious, setCancelPrevious] = useState(false);
8 | const [randomDelay, setRandomDelay] = useState(false);
9 | const [queueSize, setQueueSize] = useState(-1);
10 | const [list, setList] = useState([]);
11 |
12 | const [callback, cancel, pending, done, result, err] = useAsyncCallback(
13 | function* (...args) {
14 | this.timeout(props.timeout);
15 | console.log(`start [${args}]`);
16 | yield CPromise.delay(randomDelay ? 2000 + Math.random() * 5000 : 4000);
17 | if (args[1]) {
18 | throw args[1];
19 | }
20 | console.log(`end [${args}]`);
21 | return new Date().toLocaleTimeString();
22 | },
23 | {
24 | states: true,
25 | deps: [props.url, combine, cancelPrevious, randomDelay, queueSize],
26 | combine,
27 | cancelPrevious,
28 | queueSize
29 | }
30 | );
31 |
32 | const pushTask = (...args) => {
33 | const promise = callback(...args)
34 | .catch((err) => {
35 | setList((list) =>
36 | list.map((item) =>
37 | item.task === promise
38 | ? {
39 | ...item,
40 | title: `Task with [${args}] argument failed ${err.toString()}`,
41 | err
42 | }
43 | : item
44 | )
45 | );
46 | return CPromise.delay(3000);
47 | })
48 | .then(() => {
49 | setList((list) => list.filter((entry) => promise !== entry.task));
50 | });
51 | setList((list) => [
52 | ...list,
53 | {
54 | title: `Task with [${args}] argument queued at ${new Date().toLocaleTimeString()}`,
55 | arg: args,
56 | task: promise
57 | }
58 | ]);
59 | };
60 |
61 | return (
62 |
63 |
useAsyncCallback combine demo:
64 |
65 | {done ? (err ? err.toString() : result) : pending ? "pending..." : ""}
66 |
67 |
pushTask("🍊")}>Make a call with 🍊
68 |
pushTask("🍓")}>Make a call with 🍓
69 |
pushTask("🍏")}>Make a call with 🍏
70 |
pushTask("🍑")}>Make a call with 🍑
71 |
pushTask("🍇", new Error("my error"))}>
72 | Make a call with 🍇 that will fail after 4000ms
73 |
74 |
75 | Cancel all running calls
76 |
77 |
78 | useAsyncCallback Options:
79 |
121 |
122 |
123 | Requested calls [{list.length}]:
124 |
125 | {list.map(({ title, err, task }) => (
126 |
127 | {title}
128 | {!err && (
129 | {
131 | task.cancel();
132 | }}
133 | >
134 | ❌
135 |
136 | )}
137 |
138 | ))}
139 |
140 |
141 |
142 | );
143 | }
144 |
145 | export default TestComponent;
146 |
147 |
148 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ### Changelog
2 |
3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC.
4 |
5 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6 |
7 | #### [v0.12.2](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.12.1...v0.12.2)
8 |
9 | - Expose cancel method for useAsyncEffect hook; [`662dfdc`](https://github.com/DigitalBrainJS/use-async-effect/commit/662dfdc2cc495e782ea1869b25edba895dec1b8d)
10 |
11 | #### [v0.12.1](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.12.0...v0.12.1)
12 |
13 | > 24 September 2021
14 |
15 | - Fixed typings; [`18cd920`](https://github.com/DigitalBrainJS/use-async-effect/commit/18cd920ea4604b2f6c0b193e30afbefe24bfaae6)
16 | - Fixed examples; [`81f20f3`](https://github.com/DigitalBrainJS/use-async-effect/commit/81f20f3afb86ff0fb6809b8f0516c41c46707ece)
17 |
18 | #### [v0.12.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.11.5...v0.12.0)
19 |
20 | > 19 September 2021
21 |
22 | - Refactored `useDeepState` hook; [`eb83466`](https://github.com/DigitalBrainJS/use-async-effect/commit/eb834667e8a7c893f7ef073e61f6233784c1e4e5)
23 |
24 | #### [v0.11.5](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.11.4...v0.11.5)
25 |
26 | > 14 July 2021
27 |
28 | - Added symbol props support for state created by `useAsyncDeepState` hook; [`c71507d`](https://github.com/DigitalBrainJS/use-async-effect/commit/c71507dac9624dda633c391a834cd6cb9267641b)
29 |
30 | #### [v0.11.4](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.11.3...v0.11.4)
31 |
32 | > 1 July 2021
33 |
34 | - Refactored & optimized `useAsyncCallback` hook; [`20c2802`](https://github.com/DigitalBrainJS/use-async-effect/commit/20c28023c6f26e7aa2125e074973219589c93878)
35 |
36 | #### [v0.11.3](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.11.2...v0.11.3)
37 |
38 | > 27 May 2021
39 |
40 | - Fixed .npmignore due to missing module typings; [`9d48443`](https://github.com/DigitalBrainJS/use-async-effect/commit/9d48443d70275973f4df050b41149bcc7be1758a)
41 |
42 | #### [v0.11.2](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.11.1...v0.11.2)
43 |
44 | > 23 May 2021
45 |
46 | - Added the ability for `useAsyncWatcher` and `useAsyncDeepState` to unsubscribe from state updates; [`0915b17`](https://github.com/DigitalBrainJS/use-async-effect/commit/0915b177375a29c6948f4c8e9d1a236425beb5d8)
47 |
48 | #### [v0.11.1](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.11.0...v0.11.1)
49 |
50 | > 22 May 2021
51 |
52 | - Fixed bug with affected initial state object of the `useAsyncDeepState` hook; [`2fde232`](https://github.com/DigitalBrainJS/use-async-effect/commit/2fde2327da6cd7935b2b50fd6b0073fa13fc5bdf)
53 |
54 | #### [v0.11.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.10.0...v0.11.0)
55 |
56 | > 22 May 2021
57 |
58 | - Refactored `useAsyncState` to `useAsyncDeepState`; [`11eb17f`](https://github.com/DigitalBrainJS/use-async-effect/commit/11eb17f8d5b005e4d700c925f9afffbd09eab0a8)
59 |
60 | #### [v0.10.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.9.4...v0.10.0)
61 |
62 | > 17 May 2021
63 |
64 | - Added `useAsyncState` and `useAsyncWatcher` hooks; [`861b2a4`](https://github.com/DigitalBrainJS/use-async-effect/commit/861b2a4d11f147049c9055d2b6dcd3001f30a0c5)
65 | - Updated typings; [`45cb882`](https://github.com/DigitalBrainJS/use-async-effect/commit/45cb882a0e53fa568384d31d873a8dd469e4e624)
66 |
67 | #### [v0.9.4](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.9.3...v0.9.4)
68 |
69 | > 15 May 2021
70 |
71 | - Refactored useAsyncEffect hook; [`b62ea48`](https://github.com/DigitalBrainJS/use-async-effect/commit/b62ea48653db2199f742a7ea206940aa82beed0c)
72 |
73 | #### [v0.9.3](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.9.2...v0.9.3)
74 |
75 | > 14 May 2021
76 |
77 | - Refactored the internal finalize logic of the useAsyncCallback hook; [`fe20f5f`](https://github.com/DigitalBrainJS/use-async-effect/commit/fe20f5f59718366535ed43a9fbba3b486c513b90)
78 |
79 | #### [v0.9.2](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.9.1...v0.9.2)
80 |
81 | > 13 May 2021
82 |
83 | - Added `catchErrors` option for the `useAsyncCallbackHook`; [`377bc1a`](https://github.com/DigitalBrainJS/use-async-effect/commit/377bc1acc0671bb0aa7b4f64c387ef84b9826f78)
84 | - Refactored isGeneratorFn to avoid using function name; [`390fdd6`](https://github.com/DigitalBrainJS/use-async-effect/commit/390fdd609c5c1ccfa0d52ff4f2b64b4f6ed18cb3)
85 |
86 | #### [v0.9.1](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.9.0...v0.9.1)
87 |
88 | > 12 May 2021
89 |
90 | - Fixed queueSize bug; [`071b3a2`](https://github.com/DigitalBrainJS/use-async-effect/commit/071b3a295244465292a08caa1df7bec140fa960e)
91 |
92 | #### [v0.9.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.8.0...v0.9.0)
93 |
94 | > 9 May 2021
95 |
96 | - Improved `useAsyncCallback` queue logic; [`aa6e76b`](https://github.com/DigitalBrainJS/use-async-effect/commit/aa6e76bd8e5a83b05ee65c7d1b2f7c0dcb03390f)
97 |
98 | #### [v0.8.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.7.1...v0.8.0)
99 |
100 | > 7 May 2021
101 |
102 | - Updated dependencies; [`a69597a`](https://github.com/DigitalBrainJS/use-async-effect/commit/a69597ad047d69bb09803bc7f48d78b4d170efd8)
103 |
104 | #### [v0.7.1](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.7.0...v0.7.1)
105 |
106 | > 5 May 2021
107 |
108 | - Updated dependencies; [`a7ec761`](https://github.com/DigitalBrainJS/use-async-effect/commit/a7ec7612833003c60c6bc4786de81cf5c827d4fa)
109 |
110 | #### [v0.7.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.6.0...v0.7.0)
111 |
112 | > 3 May 2021
113 |
114 | - Added internal states support; [`1b7a703`](https://github.com/DigitalBrainJS/use-async-effect/commit/1b7a703f2516dc82b13d872b3169cf2e795efbcb)
115 |
116 | #### [v0.6.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.5.0...v0.6.0)
117 |
118 | > 27 April 2021
119 |
120 | - Updated c-promise2 to v0.12.1; [`9a1863e`](https://github.com/DigitalBrainJS/use-async-effect/commit/9a1863e97cf6f5ad86c5f4c6d713d01f92d0a187)
121 |
122 | #### [v0.5.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.4.0...v0.5.0)
123 |
124 | > 13 April 2021
125 |
126 | - Added `scopeArg` option for `useAsyncCallback` hook; [`af1ff83`](https://github.com/DigitalBrainJS/use-async-effect/commit/af1ff836c10daa19237493b6b3d16b44bda0c0aa)
127 | - Updated c-promise2 to v0.11.2; [`a5624bd`](https://github.com/DigitalBrainJS/use-async-effect/commit/a5624bdaa45e335d1dfd73dc99cb0f8b377b2cc1)
128 | - Fixed build status badge; [`190daa2`](https://github.com/DigitalBrainJS/use-async-effect/commit/190daa2af2e8547c7fd1be0d4311c647c4c9bd6f)
129 | - Fixed build status badge - use travis-ci.com instead .org; [`ed67075`](https://github.com/DigitalBrainJS/use-async-effect/commit/ed670752698106578316ebe2c80fd6ddde1c5140)
130 |
131 | #### [v0.4.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.3.0...v0.4.0)
132 |
133 | > 11 January 2021
134 |
135 | - Added `queueSize` option for `useAsyncEffect`; [`0436e46`](https://github.com/DigitalBrainJS/use-async-effect/commit/0436e46fc55309ccf5965221ba6389356e1b2259)
136 |
137 | #### [v0.3.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.2.1...v0.3.0)
138 |
139 | > 7 January 2021
140 |
141 | - Fixed bug with useAsyncEffect user cancellation; [`3ccd038`](https://github.com/DigitalBrainJS/use-async-effect/commit/3ccd03813d0b7e01132118de660f2b030967389e)
142 |
143 | #### [v0.2.1](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.2.0...v0.2.1)
144 |
145 | > 6 January 2021
146 |
147 | - Fixed demo links in the README.md; [`1cb5f11`](https://github.com/DigitalBrainJS/use-async-effect/commit/1cb5f11a5dc035d2755b8638b9844d726521f481)
148 | - Added typings config to the package.json; [`481afc8`](https://github.com/DigitalBrainJS/use-async-effect/commit/481afc81612fe7c01b516cb11b9b8f084d63d3b1)
149 |
150 | #### [v0.2.0](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.1.2...v0.2.0)
151 |
152 | > 6 January 2021
153 |
154 | - Added useAsyncCallback hook; [`baca6a7`](https://github.com/DigitalBrainJS/use-async-effect/commit/baca6a73792cf47262f3a21eade60600ba8cf877)
155 | - spellcheck; [`34c70ea`](https://github.com/DigitalBrainJS/use-async-effect/commit/34c70ea037f9e592a3d1f039948bf588e68dac6c)
156 |
157 | #### [v0.1.2](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.1.1...v0.1.2)
158 |
159 | > 5 January 2021
160 |
161 | - Added console.error for catch handler; [`df9b78f`](https://github.com/DigitalBrainJS/use-async-effect/commit/df9b78fa77a0a823436d96f7a14c80bcbe972fdc)
162 |
163 | #### [v0.1.1](https://github.com/DigitalBrainJS/use-async-effect/compare/v0.1.0...v0.1.1)
164 |
165 | > 5 January 2021
166 |
167 | - Refactored package.json; [`3a7253a`](https://github.com/DigitalBrainJS/use-async-effect/commit/3a7253a9726f4f65acc0164838f226c81ff2ca8f)
168 | - Added prepublishOnly & postversion scripts; [`40ebb4d`](https://github.com/DigitalBrainJS/use-async-effect/commit/40ebb4d0c20834121645b16bcedeb4f719092df3)
169 | - Renamed package; [`a0d6894`](https://github.com/DigitalBrainJS/use-async-effect/commit/a0d68945bc10c165a93aea0271a10a01b651c15c)
170 |
171 | #### v0.1.0
172 |
173 | > 5 January 2021
174 |
175 | - Initial commit [`5529feb`](https://github.com/DigitalBrainJS/use-async-effect/commit/5529febb3c24fb3d6f1dccc1bd210e9f6e88bf26)
176 | - Improved README.md; [`a0a7144`](https://github.com/DigitalBrainJS/use-async-effect/commit/a0a7144ef707085e879f828d49620761227dba0b)
177 | - Refactored; [`590756c`](https://github.com/DigitalBrainJS/use-async-effect/commit/590756c6dbd7053200c8c46a49bd9bb1d76716b1)
178 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.com/DigitalBrainJS/use-async-effect)
2 | 
3 | 
4 | 
5 | [](https://github.com/DigitalBrainJS/use-async-effect/stargazers)
6 |
7 | ## useAsyncEffect2 :snowflake:
8 |
9 | This library provides an async belt for the React components as:
10 | - `useAsyncEffect` - deeply cancellable asynchronous effects that can be cleared (canceled) on component unmounting, timeout, or by user request.
11 | - `useAsyncCallback` - cancellable async callbacks
12 | - `useAsyncDeepState` - to define a deep state, the actual values of which can be accessed from an async routine
13 | - `useAsyncWatcher` - to watch for state updates in a promise flow
14 |
15 | The library is designed to make it as easy as possible to use complex and composite asynchronous routines
16 | in React components. It works on top of a [custom cancellable promise](https://www.npmjs.com/package/c-promise2),
17 | simplifying the solution to many common challenges with asynchronous tasks. Can be composed with cancellable version of `Axios`
18 | ([cp-axios](https://www.npmjs.com/package/cp-axios)) and `fetch API` ([cp-fetch](https://www.npmjs.com/package/cp-fetch))
19 | to get auto cancellable React async effects/callbacks with network requests.
20 |
21 | ### Quick start
22 |
23 | 1. You have to use the generator syntax instead of ECMA async functions, basically by replacing `await` with `yield`
24 | and `async()=>{}` or `async function()` with `function*`:
25 |
26 | ````javascript
27 | // plain React effect using `useEffect` hook
28 | useEffect(()=>{
29 | const doSomething = async()=>{
30 | await somePromiseHandle;
31 | setStateVar('foo');
32 | };
33 | doSomething();
34 | }, [])
35 | // auto-cleanable React async effect using `useAsyncEffect` hook
36 | useAsyncEffect(function*(){
37 | yield somePromiseHandle;
38 | setStateVar('foo');
39 | }, [])
40 | ````
41 |
42 | 1. It's recommended to use [`CPromise`](https://www.npmjs.com/package/c-promise2) instead of the native Promise to make
43 | the promise chain deeply cancellable, at least if you're going to change the component state inside it.
44 |
45 | ````javascript
46 | import { CPromise } from "c-promise2";
47 |
48 | const MyComponent= ()=>{
49 | const [text, setText]= useState('');
50 |
51 | useAsyncEffect(function*(){
52 | yield CPromise.delay(1000);
53 | setText('Hello!');
54 | });
55 | }
56 | ````
57 |
58 | 1. Don't catch (or just rethrow caught) `CanceledError` errors with `E_REASON_UNMOUNTED`
59 | reason inside your code before making any stage change:
60 |
61 | ````javascript
62 | import {
63 | useAsyncEffect,
64 | E_REASON_UNMOUNTED,
65 | CanceledError
66 | } from "use-async-effect2";
67 | import cpAxios from "cp-axios";
68 |
69 | const MyComponent= ()=>{
70 | const [text, setText]= useState('');
71 |
72 | useAsyncEffect(function*(){
73 | try{
74 | const json= (yield cpAxios('http://localhost/')).data;
75 | setText(`Data: ${JSON.stringify(json)}`);
76 | }catch(err){
77 | // just rethrow the CanceledError error if it has E_REASON_UNMOUNTED reason
78 | CanceledError.rethrow(err, E_REASON_UNMOUNTED);
79 | // otherwise work with it somehow
80 | setText(`Failed: ${err.toString}`);
81 | }
82 | });
83 | }
84 | ````
85 |
86 | ## Installation :hammer:
87 | - Install for node.js using npm/yarn:
88 |
89 | ```bash
90 | $ npm install use-async-effect2
91 | ```
92 |
93 | ```bash
94 | $ yarn add use-async-effect2
95 | ```
96 |
97 | ## Why
98 | Every asynchronous procedure in your component that changes its state must properly handle the unmount event
99 | and stop execution in some way before attempting to change the state of the unmounted component, otherwise
100 | you will get the well-known React leakage warning:
101 | ````
102 | Warning: Can't perform a React state update on an unmounted component.
103 | This is an no-op, but it indicates a memory leak in your application.
104 | To fix, cancel all subscriptions and asynchronous task in "a useEffect cleanup function".
105 | ````
106 |
107 | It uses [c-promise2](https://www.npmjs.com/package/c-promise2) to make it work.
108 | When used in conjunction with other libraries from CPromise ecosystem,
109 | such as [cp-fetch](https://www.npmjs.com/package/cp-fetch) and [cp-axios](https://www.npmjs.com/package/cp-axios),
110 | you get a powerful tool for building asynchronous logic of React components.
111 |
112 | ## Examples
113 |
114 | ### useAsyncEffect
115 |
116 | A tiny `useAsyncEffect` demo with JSON fetching using internal states:
117 |
118 | [Live demo to play](https://codesandbox.io/s/use-async-effect-axios-minimal-pdngg?file=/src/TestComponent.js)
119 |
120 | ````javascript
121 | function JSONViewer({ url, timeout }) {
122 | const [cancel, done, result, err] = useAsyncEffect(function* () {
123 | return (yield cpAxios(url).timeout(timeout)).data;
124 | }, { states: true });
125 |
126 | return (
127 |
128 | {done ? (err ? err.toString() : JSON.stringify(result)) : "loading..."}
129 |
130 | Cancel async effect (abort request)
131 |
132 |
133 | );
134 | }
135 | ````
136 |
137 | [Another demo](https://codesandbox.io/s/use-async-effect-fetch-tiny-ui-xbmk2?file=/src/TestComponent.js)
138 |
139 | ````jsx
140 | import React from "react";
141 | import {useState} from "react";
142 | import {useAsyncEffect} from "use-async-effect2";
143 | import cpFetch from "cp-fetch";
144 |
145 | function JSONViewer(props) {
146 | const [text, setText] = useState("");
147 |
148 | useAsyncEffect(function* () {
149 | setText("fetching...");
150 | const response = yield cpFetch(props.url); // will throw a CanceledError if component get unmounted
151 | const json = yield response.json();
152 | setText(`Success: ${JSON.stringify(json)}`);
153 | }, [props.url]);
154 |
155 | return {text}
;
156 | }
157 | ````
158 | Notice: the related network request will be aborted, when unmounting.
159 |
160 | An example with a timeout & error handling ([Live demo](https://codesandbox.io/s/async-effect-demo1-vho29?file=/src/TestComponent.js)):
161 | ````jsx
162 | import React, { useState } from "react";
163 | import { useAsyncEffect, E_REASON_UNMOUNTED, CanceledError} from "use-async-effect2";
164 | import cpFetch from "cp-fetch";
165 |
166 | export default function TestComponent(props) {
167 | const [text, setText] = useState("");
168 | const [isPending, setIsPending] = useState(true);
169 |
170 | const cancel = useAsyncEffect(
171 | function* ({ onCancel }) {
172 | console.log("mount");
173 |
174 | this.timeout(props.timeout);
175 |
176 | onCancel(() => console.log("scope canceled"));
177 |
178 | try {
179 | setText("fetching...");
180 | const response = yield cpFetch(props.url);
181 | const json = yield response.json();
182 | setIsPending(false);
183 | setText(`Success: ${JSON.stringify(json)}`);
184 | } catch (err) {
185 | CanceledError.rethrow(err, E_REASON_UNMOUNTED); //passthrough for UNMOUNTED rejection
186 | setIsPending(false);
187 | setText(`Failed: ${err}`);
188 | }
189 |
190 | return () => {
191 | console.log("unmount");
192 | };
193 | },
194 | [props.url]
195 | );
196 |
197 | return (
198 |
199 |
useAsyncEffect demo:
200 |
{text}
201 |
202 | Cancel request
203 |
204 |
205 | );
206 | }
207 | ````
208 |
209 | ### useAsyncCallback
210 |
211 | Here's a [Demo App](https://codesandbox.io/s/use-async-callback-demo-app-yyic4?file=/src/TestComponent.js) to play with
212 | `asyncCallback` and learn about its options.
213 |
214 | Live search for character from the `rickandmorty` universe using `rickandmortyapi.com`:
215 |
216 | [Live demo](https://codesandbox.io/s/use-async-effect-axios-rickmorty-search-ui-sd2mv?file=/src/TestComponent.js)
217 | ````jsx
218 | import React, { useState } from "react";
219 | import {
220 | useAsyncCallback,
221 | E_REASON_UNMOUNTED,
222 | CanceledError
223 | } from "use-async-effect2";
224 | import { CPromise } from "c-promise2";
225 | import cpAxios from "cp-axios";
226 |
227 | export default function TestComponent(props) {
228 | const [text, setText] = useState("");
229 |
230 | const handleSearch = useAsyncCallback(
231 | function* (event) {
232 | const { value } = event.target;
233 | if (value.length < 3) return;
234 | yield CPromise.delay(1000);
235 | setText("searching...");
236 | try {
237 | const response = yield cpAxios(
238 | `https://rickandmortyapi.com/api/character/?name=${value}`
239 | ).timeout(props.timeout);
240 | setText(response.data?.results?.map(({ name }) => name).join(","));
241 | } catch (err) {
242 | CanceledError.rethrow(err, E_REASON_UNMOUNTED);
243 | setText(err.response?.status === 404 ? "Not found" : err.toString());
244 | }
245 | },
246 | { cancelPrevious: true }
247 | );
248 |
249 | return (
250 |
251 |
252 | useAsyncCallback demo: Rickandmorty universe character search
253 |
254 | Character name:
255 |
{text}
256 |
257 | Cancel request
258 |
259 |
260 | );
261 | }
262 | ````
263 | This code handles the cancellation of the previous search sequence (including aborting the request) and
264 | canceling the sequence when the component is unmounted to avoid the React leak warning.
265 |
266 | `useAsyncCallback` example: fetch with progress capturing & cancellation
267 | ([Live demo](https://codesandbox.io/s/use-async-callback-axios-catch-ui-l30h5?file=/src/TestComponent.js)):
268 | ````javascript
269 | import React, { useState } from "react";
270 | import { useAsyncCallback, E_REASON_UNMOUNTED } from "use-async-effect2";
271 | import { CPromise, CanceledError } from "c-promise2";
272 | import cpAxios from "cp-axios";
273 | import { ProgressBar } from "react-bootstrap";
274 |
275 | export default function TestComponent(props) {
276 | const [text, setText] = useState("");
277 | const [progress, setProgress] = useState(0);
278 | const [isFetching, setIsFetching] = useState(false);
279 |
280 | const fetchUrl = useAsyncCallback(
281 | function* (options) {
282 | try {
283 | setIsFetching(true);
284 | this.innerWeight(3); // for progress calculation
285 | this.progress(setProgress);
286 | setText("fetching...");
287 | const response = yield cpAxios(options).timeout(props.timeout);
288 | yield CPromise.delay(500); // just for fun
289 | yield CPromise.delay(500); // just for fun
290 | setText(JSON.stringify(response.data));
291 | setIsFetching(false);
292 | } catch (err) {
293 | CanceledError.rethrow(err, E_REASON_UNMOUNTED);
294 | setText(err.toString());
295 | setIsFetching(false);
296 | }
297 | },
298 | [props.url]
299 | );
300 |
301 | return (
302 |
303 |
useAsyncEffect demo:
304 |
305 | {!isFetching ? (
306 |
fetchUrl(props.url)}
309 | disabled={isFetching}
310 | >
311 | Fetch data
312 |
313 | ) : (
314 |
fetchUrl.cancel()}
317 | disabled={!isFetching}
318 | >
319 | Cancel request
320 |
321 | )}
322 |
323 | );
324 | }
325 | ````
326 |
327 | ### useAsyncDeepState
328 |
329 | An enhancement of the useState hook for use inside async routines.
330 | It defines a deep state abd works very similar to the React `setState` class method.
331 | The hook returns a promise that will be fulfilled with an array of newState and oldState values
332 | after the state has changed.
333 |
334 | ````javascript
335 | export default function TestComponent(props) {
336 |
337 | const [state, setState] = useAsyncDeepState({
338 | foo: 123,
339 | bar: 456,
340 | counter: 0
341 | });
342 |
343 | return (
344 |
345 |
useAsyncDeepState demo:
346 |
{state.counter}
347 |
{
348 | const newState= await setState((state)=> {
349 | return {counter: state.counter + 1}
350 | });
351 |
352 | console.log(`Updated: ${newState.counter}`);
353 | }}>Inc
354 |
setState({
355 | counter: state.counter
356 | })}>Set the same state value
357 |
358 | );
359 | }
360 | ````
361 |
362 | ### useAsyncWatcher
363 |
364 | This hook is a promisified abstraction on top of the `useEffect` hook. The hook returns the watcher function that resolves
365 | its promise when one of the watched dependencies have changed.
366 |
367 | ````javascript
368 | export default function TestComponent7(props) {
369 | const [value, setValue] = useState(0);
370 |
371 | const [fn, cancel, pending, done, result, err] = useAsyncCallback(function* () {
372 | console.log('inside callback the value is:', value);
373 | return (yield cpAxios(`https://rickandmortyapi.com/api/character/${value}`)).data;
374 | }, {states: true, deps: [value]})
375 |
376 | const callbackWatcher = useAsyncWatcher(fn);
377 |
378 | return (
379 |
380 |
useAsyncWatcher demo:
381 |
{pending ? "loading..." : (done ? err ? err.toString() : JSON.stringify(result, null, 2) : "")}
382 |
{
383 | setValue(target.value * 1);
384 | const [fn]= await callbackWatcher();
385 | await fn();
386 | }}/>
387 | {
Cancel async effect }
388 |
389 | );
390 | }
391 | ````
392 |
393 | To learn more about available features, see the c-promise2 [documentation](https://www.npmjs.com/package/c-promise2).
394 |
395 | ### Wiki
396 |
397 | See the [Project Wiki](https://github.com/DigitalBrainJS/use-async-effect/wiki) to get the most exhaustive guide.
398 |
399 | ## Playground
400 |
401 | To get it, clone the repository and run `npm run playground` in the project directory or
402 | just use the codesandbox [demo](https://codesandbox.io/s/async-effect-demo1-vho29) to play with the library online.
403 |
404 | ## API
405 |
406 | ### useAsyncEffect(generatorFn, deps?: []): (cancel():boolean)
407 | ### useAsyncEffect(generatorFn, options?: object): (cancel():boolean)
408 | A React hook based on [`useEffect`](https://reactjs.org/docs/hooks-effect.html), that resolves passed generator as asynchronous function.
409 | The asynchronous generator sequence and its promise of the result will be canceled if
410 | the effect cleanup process started before it completes.
411 | The generator can return a cleanup function similar to the `useEffect` hook.
412 | - `generatorFn(scope: CPromise)` : `GeneratorFunction` - generator to resolve as an async function.
413 | Generator context (`this`) refers to the CPromise instance.
414 | - `deps?: any[] | UseAsyncEffectOptions` - effect dependencies
415 |
416 | #### UseAsyncEffectOptions:
417 | - `options.deps?: any[]` - effect dependencies
418 | - `options.skipFirst?: boolean` - skip first render
419 | - `options.states: boolean= false` - use states
420 | - `options.once: boolean= false` - run the effect only once (the effect's async routine should be fully completed)
421 |
422 | #### Available states vars:
423 | - `done: boolean` - the function execution is completed (with success or failure)
424 | - `result: any` - refers to the resolved function result
425 | - `error: object` - refers to the error object. This var is always set when an error occurs.
426 | - `canceled:boolean` - is set to true if the function has been failed with a `CanceledError`.
427 |
428 | All these vars defined on the returned `cancelFn` function and can be alternative reached through
429 | the iterator interface in the following order:
430 | ````javascript
431 | const [cancelFn, done, result, error, canceled]= useAsyncEffect(/*code*/);
432 | ````
433 |
434 | ### useAsyncCallback(generatorFn, deps?: []): CPromiseAsyncFunction
435 | ### useAsyncCallback(generatorFn, options?: object): CPromiseAsyncFunction
436 | This hook makes an async callback that can be automatically canceled on unmount or by user request.
437 | - `generatorFn([scope: CPromise], ...userArguments)` : `GeneratorFunction` - generator to resolve as an async function.
438 | Generator context (`this`) and the first argument (if `options.scopeArg` is set) refer to the CPromise instance.
439 | - `deps?: any[] | UseAsyncCallbackOptions` - effect dependencies
440 | #### UseAsyncCallbackOptions:
441 | - `deps: any[]` - effect dependencies
442 | - `combine:boolean` - subscribe to the result of the async function already running with the same arguments instead
443 | of running a new one.
444 | - `cancelPrevious:boolean` - cancel the previous pending async function before running a new one.
445 | - `threads: number=0` - set concurrency limit for simultaneous calls. `0` means unlimited.
446 | - `queueSize: number=0` - set max queue size.
447 | - `scopeArg: boolean=false` - pass `CPromise` scope to the generator function as the first argument.
448 | - `states: boolean=false` - enable state changing. The function must be single threaded to use the states.
449 |
450 | #### Available state vars:
451 | - `pending: boolean` - the function is in the pending state
452 | - `done: boolean` - the function execution completed (with success or failure)
453 | - `result: any` - refers to the resolved function result
454 | - `error: object` - refers to the error object. This var always set when an error occurs.
455 | - `canceled:boolean` - is set to true if the function has been failed with a `CanceledError`.
456 |
457 | All these vars defined on the decorated function and can be alternative reached through
458 | the iterator interface in the following order:
459 | ````javascript
460 | const [decoratedFn, cancel, pending, done, result, error, canceled]= useAsyncCallback(/*code*/);
461 | ````
462 | ### useAsyncDeepState([initialValue?: object]): ([value: any, accessor: function])
463 | #### arguments
464 | - `initialValue`
465 | #### returns
466 | Iterable of:
467 | - `value: object` - current state value
468 | - `accessor:(newValue)=>Promise` - promisified setter function that can be used
469 | as a getter if called without arguments
470 |
471 | ### useAsyncWatcher([...valuesToWatch]): watcherFn
472 | #### arguments
473 | - `...valuesToWatch: any` - any values to watch that will be passed to the internal effect hook
474 | #### returns
475 | - `watcherFn: ([grabPrevValue= false]): Promise<[newValue, [prevValue]]>` - if the hook is watching one value
476 | - `watcherFn: ([grabPrevValue= false]): Promise<[...[newValue, [prevValue]]]>` - if the hook is watching multiple values
477 |
478 | ## Related projects
479 | - [c-promise2](https://www.npmjs.com/package/c-promise2) - promise with cancellation, decorators, timeouts, progress capturing, pause and user signals support
480 | - [cp-axios](https://www.npmjs.com/package/cp-axios) - a simple axios wrapper that provides an advanced cancellation api
481 | - [cp-fetch](https://www.npmjs.com/package/cp-fetch) - fetch with timeouts and request cancellation
482 | - [cp-koa](https://www.npmjs.com/package/cp-koa) - koa with middlewares cancellation
483 |
484 | ## License
485 |
486 | The MIT License Copyright (c) 2021 Dmitriy Mozgovoy robotshara@gmail.com
487 |
488 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
489 |
490 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
491 |
492 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
493 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
494 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
495 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
496 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
497 |
--------------------------------------------------------------------------------
/lib/use-async-effect.js:
--------------------------------------------------------------------------------
1 | const {useEffect, useMemo, useRef, useState} = require("react");
2 | const {CPromise, CanceledError, E_REASON_UNMOUNTED} = require("c-promise2");
3 | const {isEqualObjects, cloneObject} = require('is-equal-objects');
4 |
5 | const {E_REASON_QUEUE_OVERFLOW, E_REASON_RESTART} = CanceledError.registerErrors({
6 | E_REASON_QUEUE_OVERFLOW: 'overflow',
7 | E_REASON_RESTART: 'restarted'
8 | })
9 |
10 | function isGeneratorFn(thing) {
11 | return typeof thing === 'function' && thing[Symbol.toStringTag] === 'GeneratorFunction';
12 | }
13 |
14 | const isEvent = (thing) => !!(thing && typeof thing === 'object' && thing.type);
15 |
16 | const removeElement = (arr, element) => {
17 | const index = arr.indexOf(element);
18 | return (index !== -1) && arr.splice(index, 1);
19 | }
20 |
21 | /**
22 | * @typedef {function} CancelFn
23 | * @param {string} [reason]
24 | * returns {boolean}
25 | */
26 |
27 | /**
28 | * @typedef {function} UseAsyncEffectCancelFn
29 | * @param {string} [reason]
30 | * @property {Boolean} done
31 | * @property {*} result
32 | * @property {*} error
33 | * @property {boolean} canceled
34 | * @property {UseAsyncEffectCancelFn} cancel
35 | * @property {PauseFn} pause
36 | * @property {ResumeFn} resume
37 | * returns {boolean}
38 | */
39 |
40 | /**
41 | * @typedef {function} PauseFn
42 | * @param {*} [data]
43 | * returns {boolean}
44 | */
45 |
46 | /**
47 | * @typedef {function} ResumeFn
48 | * @param {*} [data]
49 | * returns {boolean}
50 | */
51 |
52 | /**
53 | * AsyncEffect hook to define cancellable effects
54 | * @param {GeneratorFunction} generatorFn
55 | * @param {object} [options]
56 | * @param {deps} [options.deps= []]
57 | * @param {boolean} [options.skipFirst= false]
58 | * @param {boolean} [options.states= false]
59 | * @param {boolean} [options.once= false]
60 | * @returns {UseAsyncEffectCancelFn}
61 | */
62 | const useAsyncEffect = (generatorFn, options) => {
63 | let {current} = useRef({});
64 |
65 | let {
66 | deps = [],
67 | skipFirst= false,
68 | states = false,
69 | once = false
70 | } = options && Array.isArray(options) ? {deps: options} : options || {};
71 |
72 | const initialState = {
73 | done: false,
74 | result: undefined,
75 | error: undefined,
76 | canceled: false,
77 | paused: false
78 | };
79 |
80 | const [state, setState] = states ? useAsyncDeepState(initialState, {watch: false}) : [];
81 |
82 | if (!isGeneratorFn(generatorFn)) {
83 | throw TypeError('useAsyncEffect requires a generator as the first argument');
84 | }
85 |
86 | const cancel = useMemo(()=> {
87 | const cancel= (reason) => {
88 | const promise = current && current.promise;
89 | if (promise) {
90 | current.promise = null;
91 | return promise.cancel(isEvent(reason) ? undefined : reason)
92 | }
93 | return false;
94 | };
95 |
96 | cancel.pause= (data)=> current.promise.pause(data);
97 | cancel.resume= (data)=> current.promise.resume(data);
98 |
99 | if(states) {
100 | cancel[Symbol.iterator] = function*() {
101 | yield* [cancel, state.done, state.result, state.error, state.canceled, state.paused];
102 | }
103 | }else{
104 | cancel[Symbol.iterator] = function () {
105 | throw Error(`Can not unwrap states. The 'states' option is disabled`);
106 | }
107 | }
108 |
109 | return cancel;
110 | });
111 |
112 | useEffect(() => {
113 | if (!current.inited) {
114 | current.inited = true;
115 | if (skipFirst) {
116 | return;
117 | }
118 | }
119 |
120 | if (once && current.done) {
121 | return;
122 | }
123 |
124 | let cb;
125 |
126 | states && setState(initialState);
127 |
128 | let promise = current.promise = CPromise.run(generatorFn, {resolveSignatures: true, scopeArg: true})
129 | .then(result => {
130 |
131 | current.done = true;
132 |
133 | if (typeof result === 'function') {
134 | cb = result;
135 | states && setState({
136 | done: true,
137 | canceled: false
138 | })
139 | return;
140 | }
141 |
142 | states && setState({
143 | done: true,
144 | result: result,
145 | canceled: false
146 | })
147 | }, err => {
148 | if (!CanceledError.isCanceledError(err)) {
149 | if(states){
150 | setState({
151 | done: true,
152 | error: err || new Error(err),
153 | canceled: false
154 | })
155 | }else{
156 | console.error(err);
157 | }
158 | return;
159 | }
160 |
161 | state && err.code !== E_REASON_UNMOUNTED && setState({
162 | done: true,
163 | error: err,
164 | canceled: true
165 | })
166 | });
167 |
168 | if (states) {
169 | promise.onPause(() => {
170 | setState({
171 | paused: true
172 | })
173 | });
174 |
175 | promise.onResume(() => {
176 | setState({
177 | paused: false
178 | })
179 | });
180 | }
181 |
182 | return () => {
183 | cancel(E_REASON_UNMOUNTED);
184 | cb && cb();
185 | }
186 | }, deps);
187 |
188 | if(states) {
189 | cancel.done = state.done;
190 | cancel.result = state.result;
191 | cancel.error = state.error;
192 | cancel.canceled = state.canceled;
193 | cancel.paused = state.paused;
194 | cancel.cancel= cancel;
195 | }
196 |
197 | return cancel;
198 | }
199 |
200 | const argsToPromiseMap = new Map();
201 |
202 | const asyncEffectFactory= (options) => {
203 | if (options != null) {
204 | if (typeof options !== 'object') {
205 | throw TypeError('options must be an object');
206 | }
207 |
208 | if (options.threads === undefined) {
209 | options.threads = options.cancelPrevious || options.states ? 1 : 0;
210 | } else {
211 | if (!Number.isFinite(options.threads) || options.threads < 0) {
212 | throw Error('threads must be a positive number');
213 | }
214 |
215 | if (options.states && options.threads !== 1) {
216 | throw Error(`Can not use states in not single threaded async function`);
217 | }
218 | }
219 |
220 | if (options.queueSize !== undefined && (!Number.isFinite(options.queueSize) || options.queueSize < -1)) {
221 | throw Error('queueSize must be a finite number >=-1');
222 | }
223 | }
224 |
225 | const promises= [];
226 |
227 | return {
228 | promises,
229 | queue: [],
230 | pending: 0,
231 | args: null,
232 | cancel : (reason) => {
233 | const _reason = isEvent(reason) ? undefined : reason;
234 | promises.forEach(promise => promise.cancel(_reason));
235 | promises.length = 0;
236 | },
237 | pause: (data)=> promises.forEach(promise=> promise.pause(data)),
238 | resume: (data)=> promises.forEach(promise=> promise.resume(data)),
239 | initialState: {
240 | done: false,
241 | result: undefined,
242 | error: undefined,
243 | canceled: false,
244 | pending: false
245 | },
246 | options: {
247 | deps: [],
248 | combine: false,
249 | cancelPrevious: false,
250 | threads: 0,
251 | queueSize: -1,
252 | scopeArg: false,
253 | states: false,
254 | catchErrors: false,
255 | ...(Array.isArray(options) ? {deps: options} : options)
256 | }
257 | };
258 | }
259 |
260 | /**
261 | * @typedef {Function} UseAsyncCallbackDecoratedFn
262 | * @param {CPromise} [scope]
263 | * @param {*} [...userArgs]
264 | * @property {CancelFn} cancel
265 | * @property {PauseFn} pause
266 | * @property {ResumeFn} pause
267 | * @returns {*}
268 | *//**
269 | * @typedef {Function} UseAsyncCallbackDecoratedFn
270 | * @param {*} [...userArgs]
271 | * @property {CancelFn} cancel
272 | * @property {PauseFn} pause
273 | * @property {ResumeFn} pause
274 | * @returns {*}
275 | */
276 |
277 | /**
278 | * useAsyncCallback hook for defining cancellable async routines
279 | * @param {GeneratorFunction} generatorFn
280 | * @param {object|array} [options]
281 | * @param {array} [options.deps= []]
282 | * @param {boolean} [options.combine= false]
283 | * @param {number} [options.threads=0]
284 | * @param {number} [options.queueSize=0]
285 | * @param {boolean} [options.cancelPrevious=false]
286 | * @param {boolean} [options.scopeArg= false]
287 | * @param {boolean} [options.states= true]
288 | * @param {boolean} [options.catchErrors= true]
289 | * @returns {UseAsyncCallbackDecoratedFn}
290 | */
291 | const useAsyncCallback = (generatorFn, options) => {
292 | const current = useFactory(asyncEffectFactory, [options]);
293 |
294 | let {
295 | initialState,
296 | options: {
297 | deps,
298 | combine,
299 | cancelPrevious,
300 | threads,
301 | queueSize,
302 | scopeArg,
303 | states,
304 | catchErrors
305 | }
306 | } = current;
307 |
308 | const [state, setState] = states ? useAsyncDeepState(initialState, {watch: false}) : [];
309 |
310 | const callback = useMemo(() => {
311 | const {
312 | promises,
313 | queue,
314 | cancel,
315 | pause,
316 | resume
317 | } = current;
318 |
319 | const fn = (...args) => {
320 | let n;
321 |
322 | if (combine && (n = promises.length)) {
323 | let promise;
324 | while (n-- > 0) {
325 | promise = promises[n];
326 | if (argsToPromiseMap.has(promise) && isEqualObjects(argsToPromiseMap.get(promise), args)) {
327 | if (cancelPrevious) {
328 | promise.cancel(E_REASON_RESTART);
329 | break;
330 | }
331 | return CPromise.resolve(promise);
332 | }
333 | }
334 | }
335 |
336 | const resolveGenerator = () => CPromise.run(generatorFn, {args, resolveSignatures: true, scopeArg});
337 |
338 | cancelPrevious && !combine && cancel(E_REASON_RESTART);
339 |
340 | if (threads || queueSize !== -1) {
341 | let started;
342 |
343 | let promise = new CPromise(resolve => {
344 | const start = () => {
345 | current.pending++;
346 |
347 | started = true;
348 |
349 | if (states) {
350 | setState({
351 | ...initialState,
352 | pending: true,
353 | })
354 | }
355 | resolve();
356 | }
357 |
358 | if (current.pending === threads) {
359 | if (queueSize !== -1 && queue.length === queueSize) {
360 | throw CanceledError.from(E_REASON_QUEUE_OVERFLOW);
361 | }
362 |
363 | return queue.push(start);
364 | }
365 |
366 | start();
367 | }).weight(0)
368 | .then(resolveGenerator)
369 | [catchErrors ? 'done' : 'finally']((value, isRejected) => {
370 | started && current.pending--;
371 | removeElement(promises, promise);
372 | combine && argsToPromiseMap.delete(promise);
373 | queue.length && queue.shift()();
374 | const canceled = !!(isRejected && CanceledError.isCanceledError(value));
375 |
376 | if (canceled && (value.code === E_REASON_UNMOUNTED || value.code === E_REASON_RESTART)) {
377 | return;
378 | }
379 |
380 | states && setState({
381 | pending: false,
382 | done: true,
383 | error: isRejected ? value : undefined,
384 | result: isRejected ? undefined : value,
385 | canceled
386 | });
387 |
388 | return isRejected ? undefined : value;
389 | }).weight(0).aggregate();
390 |
391 | if(states){
392 | promise.onPause(()=> setState({
393 | paused: true
394 | }))
395 |
396 | promise.onResume(()=> setState({
397 | paused: false
398 | }))
399 | }
400 |
401 | promises.push(promise);
402 |
403 | combine && argsToPromiseMap.set(promise, args);
404 |
405 | return promise;
406 | }
407 |
408 | cancelPrevious && cancel(E_REASON_RESTART);
409 |
410 | const promise = resolveGenerator()[catchErrors ? 'done' : 'finally'](() => {
411 | removeElement(promises, promise);
412 | combine && argsToPromiseMap.delete(promise);
413 | }).weight(0).aggregate();
414 |
415 | promises.push(promise);
416 |
417 | if (combine) {
418 | argsToPromiseMap.set(promise, args);
419 | }
420 |
421 | return promise;
422 | }
423 |
424 | if(states) {
425 | const makeDescriptor = (name) => ({
426 | get() {
427 | return state[name];
428 | }
429 | })
430 |
431 | Object.defineProperties(fn, {
432 | done: makeDescriptor('done'),
433 | pending: makeDescriptor('pending'),
434 | result: makeDescriptor('result'),
435 | error: makeDescriptor('error'),
436 | canceled: makeDescriptor('canceled'),
437 | paused: makeDescriptor('paused'),
438 | [Symbol.iterator]: {
439 | value: function*() {
440 | yield* [
441 | fn,
442 | cancel,
443 | state.pending,
444 | state.done,
445 | state.result,
446 | state.error,
447 | state.canceled,
448 | state.paused
449 | ];
450 | }
451 | }
452 | })
453 | } else{
454 | fn[Symbol.iterator] = function*() {
455 | yield* [fn, cancel];
456 | }
457 | }
458 |
459 | fn.cancel = cancel;
460 |
461 | fn.pause= pause;
462 |
463 | fn.resume= resume;
464 |
465 | return fn;
466 | }, deps);
467 |
468 | useEffect(() => {
469 | return () => callback.cancel(E_REASON_UNMOUNTED);
470 | }, []);
471 |
472 | return callback;
473 | }
474 |
475 | const initialized= new WeakSet();
476 |
477 | const useFactory = (factory, args) => {
478 | const {current} = useRef({});
479 |
480 | if (initialized.has(current)) return current;
481 |
482 | initialized.add(current);
483 |
484 | return Object.assign(current, factory.apply(null, args));
485 | }
486 |
487 | const assignEnumerableProps = (source, target) => {
488 | Object.assign(source, target);
489 | const symbols = Object.getOwnPropertySymbols(target);
490 | let i = symbols.length;
491 | while (i-- > 0) {
492 | let symbol = symbols[i];
493 | source[symbol] = target[symbol];
494 | }
495 | return source;
496 | }
497 |
498 | const protoState = Object.create(null, {
499 | toJSON: {
500 | value: function toJSON(){
501 | const obj = {};
502 | let target = this;
503 | do {
504 | assignEnumerableProps(obj, target);
505 | } while ((target = Object.getPrototypeOf(target)) && target !== Object.prototype);
506 | return obj;
507 | }
508 | },
509 |
510 | [isEqualObjects.plainObject]: {
511 | value: true
512 | }
513 | })
514 |
515 | const getAllKeys = (obj) => Object.keys(obj).concat(Object.getOwnPropertyNames(obj));
516 |
517 | /**
518 | * useAsyncDeepState hook whose setter returns a promise
519 | * @param {*} [initialState]
520 | * @param {Boolean} [watch= true]
521 | * @param {Boolean} [defineSetters= true]
522 | * @returns {[any, function(*=, boolean=): (Promise<*>|undefined)]}
523 | */
524 | const useAsyncDeepState = (initialState, {watch = true, defineSetters = true}= {}) => {
525 |
526 | const current = useFactory(()=>{
527 | if (initialState !== undefined && typeof initialState !== "object") {
528 | throw TypeError('initial state must be a plain object');
529 | }
530 |
531 | const setter = (patch, scope, cb)=>{
532 | setState((state)=>{
533 | if (typeof patch === 'function') {
534 | patch = patch(state);
535 | }
536 |
537 | if (patch!==true && patch != null && typeof patch !== 'object') {
538 | throw new TypeError('patch must be a plain object or boolean');
539 | }
540 |
541 | if (
542 | patch !== true &&
543 | (patch === null || assignEnumerableProps(current.state, patch)) &&
544 | (!current.stateChanged && isEqualObjects(current.state, current.snapshot))
545 | ) {
546 | scope && cb(state);
547 | return state;
548 | }
549 |
550 | current.stateChanged = true;
551 |
552 | if (scope) {
553 | current.callbacks.set(scope, cb);
554 | scope.onDone(() => current.callbacks.delete(scope))
555 | }
556 |
557 | return Object.freeze(Object.create(current.proxy));
558 | });
559 | }
560 |
561 | const state = assignEnumerableProps(Object.create(protoState), initialState)
562 |
563 | const proxy = Object.create(state, defineSetters && getAllKeys(initialState)
564 | .reduce((props, prop) => {
565 | props[prop] = {
566 | get() {
567 | return state[prop];
568 | },
569 | set(value) {
570 | state[prop] = value;
571 | setter(null);
572 | }
573 | }
574 | return props;
575 | }, {}));
576 |
577 | return {
578 | state,
579 | snapshot: null,
580 | proxy,
581 | initialState: Object.freeze(Object.create(proxy)),
582 | stateChanged: false,
583 | callbacks: new Map(),
584 | setter
585 | }
586 | });
587 |
588 | const [state, setState] = useState(current.initialState);
589 |
590 | useEffect(()=>{
591 | current.stateChanged = false;
592 | current.callbacks.forEach(cb=> cb(state));
593 | current.callbacks.clear();
594 | current.snapshot= cloneObject(current.state);
595 | }, [state]);
596 |
597 | return [
598 | state,
599 | /**
600 | * state async accessor
601 | * @param {Object} [handlerOrPatch]
602 | * @param {Boolean} [watchChanges= true]
603 | * @returns {Promise<*>|undefined}
604 | */
605 | function (handlerOrPatch, watchChanges= watch) {
606 | return watchChanges ? new CPromise((resolve, reject, scope) => {
607 | current.setter(handlerOrPatch, scope, resolve);
608 | }) : current.setter(handlerOrPatch);
609 | }
610 | ]
611 | }
612 |
613 | const useAsyncWatcher = (...value) => {
614 | const ref = useRef({
615 | oldValue: value
616 | });
617 |
618 | if (!ref.current.callbacks) {
619 | ref.current.callbacks = new Map()
620 | }
621 |
622 | const multiple= value.length > 1;
623 |
624 | useEffect(() => {
625 | const {current} = ref;
626 | const data = multiple ? value.map((value, index) => [value, current.oldValue[index]]) :
627 | [value[0], current.oldValue[0]];
628 | current.callbacks.forEach(cb => cb(data));
629 | current.callbacks.clear();
630 | current.oldValue = value;
631 | }, value);
632 |
633 | /**
634 | * @param {Boolean} [grabPrevValue]
635 | * @returns {Promise}
636 | */
637 | return (grabPrevValue) => {
638 | return new CPromise((resolve, reject, scope) => {
639 | ref.current.callbacks.set(scope, entry => {
640 | if (multiple) {
641 | resolve(grabPrevValue ? entry : entry.map(values => values[0]))
642 | }
643 |
644 | resolve(grabPrevValue ? entry : entry[0]);
645 | });
646 |
647 | scope.onDone(()=>{
648 | ref.current.callbacks.delete(scope);
649 | })
650 | });
651 | }
652 | }
653 |
654 | module.exports = {
655 | useAsyncEffect,
656 | useAsyncCallback,
657 | useAsyncDeepState,
658 | useAsyncWatcher,
659 | CanceledError,
660 | E_REASON_UNMOUNTED,
661 | E_REASON_RESTART,
662 | E_REASON_QUEUE_OVERFLOW
663 | }
664 |
--------------------------------------------------------------------------------
/test/src/index.js:
--------------------------------------------------------------------------------
1 | import React, {useState, useEffect} from "react";
2 | import ReactDOM from "react-dom";
3 | import {useAsyncEffect, useAsyncCallback, useAsyncWatcher, useAsyncDeepState} from "../../lib/use-async-effect";
4 | import {CPromise, CanceledError} from "c-promise2";
5 |
6 | const measureTime = () => {
7 | let timestamp = Date.now();
8 | return () => Date.now() - timestamp;
9 | }
10 |
11 | const {E_REASON_QUEUE_OVERFLOW, E_REASON_UNMOUNTED}= CanceledError;
12 |
13 | const delay = (ms, value) => new Promise(resolve => setTimeout(resolve, ms, value));
14 |
15 | const noop= ()=>{}
16 |
17 | const stateSnapshots= (reject, ...states)=>{
18 | let index= 0;
19 | const {length}= states;
20 | return (state)=>{
21 | try {
22 | assert.deepStrictEqual(state, states[index], `States at index [${index}] do not match`);
23 | index++;
24 | }catch(err){
25 | reject(err);
26 | return;
27 | }
28 |
29 | return index >= length;
30 | }
31 | }
32 |
33 | describe("useAsyncEffect", function () {
34 |
35 | it("should support generator resolving", function (done) {
36 | let counter = 0;
37 | let time = measureTime();
38 |
39 | function TestComponent() {
40 | const [value, setValue] = useState(0);
41 |
42 | useAsyncEffect(function* () {
43 | yield CPromise.delay(100);
44 | setValue(123);
45 | }, []);
46 |
47 | try {
48 | switch (counter++) {
49 | case 0:
50 | assert.strictEqual(value, 0);
51 | break;
52 | case 1:
53 | if (time() < 100) {
54 | assert.fail('Early remount detected');
55 | }
56 | assert.strictEqual(value, 123);
57 | setTimeout(done, 500);
58 | break;
59 | default:
60 | assert.fail('Unexpected state change');
61 | }
62 | } catch (err) {
63 | done(err);
64 | }
65 |
66 | return Test
67 | }
68 |
69 | ReactDOM.render(
70 | ,
71 | document.getElementById('root')
72 | );
73 | })
74 |
75 | it("should handle cancellation", function (done) {
76 |
77 | let counter = 0;
78 |
79 | function TestComponent() {
80 | const [value, setValue] = useState(0);
81 |
82 | const cancel = useAsyncEffect(function* () {
83 | yield CPromise.delay(200);
84 | setValue(123);
85 | yield CPromise.delay(200);
86 | setValue(456);
87 | yield CPromise.delay(200);
88 | setValue(789);
89 | }, []);
90 |
91 | (async () => {
92 | switch (counter++) {
93 | case 0:
94 | assert.strictEqual(value, 0);
95 | break;
96 | case 1:
97 | assert.strictEqual(value, 123);
98 | break;
99 | case 2:
100 | assert.strictEqual(value, 456);
101 | await delay(250);
102 | done();
103 | break;
104 | default:
105 | assert.fail('Unexpected state change');
106 | }
107 | })().catch(done);
108 |
109 | setTimeout(cancel, 500);
110 |
111 | return Test
112 | }
113 |
114 | ReactDOM.render(
115 | ,
116 | document.getElementById('root')
117 | );
118 |
119 | });
120 |
121 | it("should able to catch cancellation error with E_REASON_UNMOUNTED code", function (done) {
122 | function TestComponent() {
123 | useAsyncEffect(function*(){
124 | try{
125 | yield CPromise.delay(500);
126 | }catch(err){
127 | CanceledError.rethrow(err, E_REASON_UNMOUNTED);
128 | assert.fail('E_REASON_UNMOUNTED was not caught');
129 | }
130 | done();
131 | });
132 |
133 | return Test
;
134 | }
135 |
136 | ReactDOM.render(
137 | ,
138 | document.getElementById('root')
139 | );
140 | });
141 |
142 | it("should support pause & resume features", (done)=>{
143 | function TestComponent() {
144 | let stage= 0;
145 |
146 | const cancelFn = useAsyncEffect(function* () {
147 | yield delay(100);
148 | stage= 1;
149 | yield delay(100);
150 | stage= 2;
151 | yield delay(100);
152 | stage= 3;
153 | yield delay(100);
154 | stage= 4;
155 | yield delay(100);
156 | stage= 5;
157 | });
158 |
159 | (async()=>{
160 | await delay(120);
161 | cancelFn.pause();
162 | assert.strictEqual(stage, 1);
163 | await delay(220);
164 | assert.strictEqual(stage, 1);
165 | cancelFn.resume();
166 | await delay(50);
167 | assert.strictEqual(stage, 2);
168 | await delay(200);
169 | assert.strictEqual(stage, 4);
170 | })().then(()=> done(), done);
171 |
172 | return Test
173 | }
174 |
175 | ReactDOM.render(
176 | ,
177 | document.getElementById('root')
178 | );
179 | })
180 |
181 | describe('states', function (){
182 | it("should handle success case", function () {
183 | return new Promise((resolve, reject)=>{
184 | const matchState= stateSnapshots(reject, {
185 | done: false,
186 | result: undefined,
187 | error: undefined,
188 | canceled: false
189 | },{
190 | done: true,
191 | result: 123,
192 | error: undefined,
193 | canceled: false
194 | })
195 |
196 | function TestComponent() {
197 | const [cancelFn, done, result, error, canceled]= useAsyncEffect(function*(){
198 | return yield CPromise.delay(100, 123);
199 | }, {states: true});
200 |
201 | matchState({
202 | done, result, error, canceled
203 | }) && resolve();
204 |
205 | return Test
;
206 | }
207 |
208 | ReactDOM.render(
209 | ,
210 | document.getElementById('root')
211 | );
212 | });
213 | });
214 |
215 | it("should handle failure case", function () {
216 | return new Promise((resolve, reject)=>{
217 | const targetError= new Error('test');
218 |
219 | const matchState= stateSnapshots(reject, {
220 | done: false,
221 | result: undefined,
222 | error: undefined,
223 | canceled: false
224 | },{
225 | done: true,
226 | result: undefined,
227 | error: targetError,
228 | canceled: false
229 | })
230 |
231 | function TestComponent() {
232 | const [cancelFn, done, result, error, canceled]= useAsyncEffect(function*(){
233 | yield CPromise.delay(100);
234 | throw targetError;
235 | }, {states: true});
236 |
237 | matchState({
238 | done, result, error, canceled
239 | }) && resolve();
240 |
241 | return Test
;
242 | }
243 |
244 | ReactDOM.render(
245 | ,
246 | document.getElementById('root')
247 | );
248 | });
249 | });
250 |
251 | it("should handle cancellation case", function () {
252 | return new Promise((resolve, reject)=>{
253 | const targetError= new CanceledError('test');
254 |
255 | const matchState= stateSnapshots(reject, {
256 | done: false,
257 | result: undefined,
258 | error: undefined,
259 | canceled: false
260 | },{
261 | done: true,
262 | result: undefined,
263 | error: targetError,
264 | canceled: true
265 | })
266 |
267 | function TestComponent() {
268 | const [cancelFn, done, result, error, canceled]= useAsyncEffect(function*(){
269 | yield CPromise.delay(100);
270 | throw targetError;
271 | }, {states: true});
272 |
273 | useState(()=>{
274 | setTimeout(()=> cancelFn(targetError), 50);
275 | })
276 |
277 | matchState({
278 | done, result, error, canceled
279 | }) && resolve();
280 |
281 | return Test
;
282 | }
283 |
284 |
285 |
286 | ReactDOM.render(
287 | ,
288 | document.getElementById('root')
289 | );
290 | });
291 | });
292 | });
293 | });
294 |
295 | describe("useAsyncCallback", function () {
296 | it("should decorate user generator to CPromise", function (done) {
297 | let called = false;
298 | let time = measureTime();
299 |
300 | function TestComponent() {
301 | const fn = useAsyncCallback(function* (a, b) {
302 | called = true;
303 | yield CPromise.delay(100);
304 | assert.deepStrictEqual([a, b], [1, 2]);
305 | });
306 |
307 | fn(1, 2).then(() => {
308 | assert.ok(called);
309 | if (time() < 100) {
310 | assert.fail('early completion');
311 | }
312 |
313 | done();
314 | }, done);
315 |
316 | return Test
317 | }
318 |
319 | ReactDOM.render(
320 | ,
321 | document.getElementById('root')
322 | );
323 | });
324 |
325 | it("should support concurrency limitation", function (done) {
326 | let pending = 0;
327 | let counter = 0;
328 | const threads = 2;
329 |
330 | function TestComponent() {
331 | const fn = useAsyncCallback(function* () {
332 | if (++pending > threads) {
333 | assert.fail(`threads excess ${pending}>${threads}`);
334 | }
335 | yield CPromise.delay(100);
336 | pending--;
337 | counter++;
338 | }, {threads});
339 |
340 | const promises = [];
341 |
342 | for (let i = 0; i < 10; i++) {
343 | promises.push(fn());
344 | }
345 |
346 | Promise.all(promises).then(() => {
347 | assert.strictEqual(counter, 10);
348 | }).then(() => done(), done);
349 |
350 | return Test
351 | }
352 |
353 | ReactDOM.render(
354 | ,
355 | document.getElementById('root')
356 | );
357 | });
358 |
359 | it("should support combine option", function (done) {
360 | let pending = 0;
361 | let counter = 0;
362 | const concurrency = 1;
363 | let value = 0;
364 |
365 | function TestComponent() {
366 | const fn = useAsyncCallback(function* () {
367 | if (++pending > concurrency) {
368 | assert.fail(`threads excess ${pending}>${concurrency}`);
369 | }
370 | yield CPromise.delay(100);
371 | pending--;
372 | counter++;
373 | return ++value;
374 | }, {combine: true});
375 |
376 | const promises = [];
377 |
378 | for (let i = 0; i < 10; i++) {
379 | promises.push(fn());
380 | }
381 |
382 | Promise.all(promises).then((results) => {
383 | assert.strictEqual(counter, 1, "counter fail");
384 | results.forEach(result => assert.strictEqual(result, value, "result fail"))
385 | }).then(() => done(), done);
386 |
387 | return Test
388 | }
389 |
390 | ReactDOM.render(
391 | ,
392 | document.getElementById('root')
393 | );
394 | });
395 |
396 | it("should support cancelPrevious option", function (done) {
397 | let pending = 0;
398 | const concurrency = 1;
399 |
400 | function TestComponent() {
401 |
402 | const fn = useAsyncCallback(function* (v) {
403 | yield CPromise.delay(100);
404 | if (++pending > concurrency) {
405 | assert.fail(`threads excess ${pending}>${concurrency}`);
406 | }
407 | yield CPromise.delay(200);
408 | return v;
409 | }, {cancelPrevious: true});
410 |
411 | Promise.all([
412 | fn(123).finally(() => {
413 | pending--;
414 | }).then(() => {
415 | assert.fail('was not cancelled');
416 | }, (err) => {
417 | assert.ok(err instanceof CanceledError, `not canceled error ${err}`);
418 | return true;
419 | }),
420 | delay(100).then(() => {
421 | return fn(456)
422 | })
423 | ])
424 | .then(values => {
425 | assert.deepStrictEqual(values, [true, 456])
426 | done();
427 | }).catch(done);
428 |
429 | return Test
430 | }
431 |
432 | ReactDOM.render(
433 | ,
434 | document.getElementById('root')
435 | );
436 | });
437 |
438 | describe('states', function (){
439 | it("should handle success case", function () {
440 | return new Promise((resolve, reject)=>{
441 | const matchState= stateSnapshots(reject, {
442 | done: false,
443 | result: undefined,
444 | error: undefined,
445 | pending: false,
446 | canceled: false
447 | },{
448 | done: false,
449 | result: undefined,
450 | error: undefined,
451 | pending: true,
452 | canceled: false
453 | },{
454 | done: true,
455 | pending: false,
456 | result: 123,
457 | error: undefined,
458 | canceled: false
459 | });
460 |
461 | function TestComponent() {
462 | const [fn, cancelFn, pending, done, result, error, canceled]= useAsyncCallback(function*(){
463 | return yield CPromise.delay(100, 123);
464 | }, {states: true, threads: 1});
465 |
466 | matchState({
467 | pending, done, result, error, canceled
468 | }) && resolve();
469 |
470 | useEffect(()=>{
471 | fn();
472 | }, []);
473 |
474 | return Test
;
475 | }
476 |
477 | ReactDOM.render(
478 | ,
479 | document.getElementById('root')
480 | );
481 | });
482 | });
483 |
484 | it("should handle failure case", function () {
485 | return new Promise((resolve, reject)=>{
486 | const targetError= new Error('test');
487 |
488 | const matchState = stateSnapshots(reject, {
489 | done: false,
490 | pending: false,
491 | result: undefined,
492 | error: undefined,
493 | canceled: false
494 | }, {
495 | done: false,
496 | result: undefined,
497 | error: undefined,
498 | pending: true,
499 | canceled: false
500 | }, {
501 | done: true,
502 | pending: false,
503 | result: undefined,
504 | error: targetError,
505 | canceled: false
506 | });
507 |
508 | function TestComponent() {
509 | const [fn, cancelFn, pending, done, result, error, canceled]= useAsyncCallback(function*(){
510 | yield CPromise.delay(100);
511 | throw targetError;
512 | }, {states: true, threads: 1});
513 |
514 | matchState({
515 | pending, done, result, error, canceled
516 | }) && resolve();
517 |
518 | useEffect(()=>{
519 | fn().catch(noop);
520 | }, []);
521 |
522 | return Test
;
523 | }
524 |
525 | ReactDOM.render(
526 | ,
527 | document.getElementById('root')
528 | );
529 | });
530 | });
531 |
532 | it("should handle cancellation case", function () {
533 | return new Promise((resolve, reject)=>{
534 | const targetError= new CanceledError('test');
535 |
536 | const matchState = stateSnapshots(reject, {
537 | done: false,
538 | pending: false,
539 | result: undefined,
540 | error: undefined,
541 | canceled: false
542 | }, {
543 | done: false,
544 | result: undefined,
545 | error: undefined,
546 | pending: true,
547 | canceled: false
548 | }, {
549 | done: true,
550 | pending: false,
551 | result: undefined,
552 | error: targetError,
553 | canceled: true
554 | });
555 |
556 | function TestComponent() {
557 | const [fn, cancelFn, pending, done, result, error, canceled]= useAsyncCallback(function*(){
558 | yield CPromise.delay(100);
559 | throw targetError;
560 | }, {states: true, threads: 1});
561 |
562 | matchState({
563 | pending, done, result, error, canceled
564 | }) && resolve();
565 |
566 | useEffect(()=>{
567 | fn().catch(noop);
568 | setTimeout(()=>{
569 | cancelFn(targetError);
570 | }, 50);
571 | }, []);
572 |
573 | return Test
;
574 | }
575 |
576 | ReactDOM.render(
577 | ,
578 | document.getElementById('root')
579 | );
580 | });
581 | });
582 | });
583 |
584 | it("should throw E_REASON_QUEUE_OVERFLOW if queue size exceeded", function (done) {
585 | let counter = 0;
586 |
587 | function TestComponent() {
588 | const fn = useAsyncCallback(function* () {
589 | counter++;
590 | }, {queueSize: 1, threads: 1});
591 |
592 | const promises = [];
593 |
594 | for (let i = 0; i < 10; i++) {
595 | promises.push(fn());
596 | }
597 |
598 | Promise.all(promises).then((results) => {
599 | assert.fail("doesn't throw an error");
600 | }, (err)=>{
601 | assert.ok(CanceledError.isCanceledError(err, E_REASON_QUEUE_OVERFLOW));
602 | assert.strictEqual(counter, 2);
603 | }).then(() => done(), done);
604 |
605 | return Test
606 | }
607 |
608 | ReactDOM.render(
609 | ,
610 | document.getElementById('root')
611 | );
612 | });
613 |
614 | it("should not throw if catchErrors option is set and internal async task was rejected", function (done) {
615 | let counter = 0;
616 |
617 | function TestComponent() {
618 | const fn1 = useAsyncCallback(function* () {
619 | throw Error('test');
620 | }, {catchErrors: true});
621 |
622 | const fn2 = useAsyncCallback(function* () {
623 | throw Error('test');
624 | }, {catchErrors: false});
625 |
626 | Promise.all([
627 | fn1().catch(()=>{
628 | assert.fail('should not throw');
629 | }),
630 | fn2().then(()=>{
631 | assert.fail('should throw')
632 | }, (err)=>{
633 | assert.ok(err instanceof Error);
634 | assert.strictEqual(err.message, 'test');
635 | })
636 | ]).then(()=> done(), done);
637 |
638 | return Test
639 | }
640 |
641 | ReactDOM.render(
642 | ,
643 | document.getElementById('root')
644 | );
645 | });
646 |
647 | it("should support pause & resume features", (done)=>{
648 | function TestComponent() {
649 | let stage= 0;
650 |
651 | const fn = useAsyncCallback(function* () {
652 | yield delay(100);
653 | stage= 1;
654 | yield delay(100);
655 | stage= 2;
656 | yield delay(100);
657 | stage= 3;
658 | yield delay(100);
659 | stage= 4;
660 | yield delay(100);
661 | stage= 5;
662 | return 123;
663 | });
664 |
665 | (async()=>{
666 | const promise= fn();
667 | await delay(120);
668 | fn.pause();
669 | assert.strictEqual(stage, 1);
670 | await delay(220);
671 | assert.strictEqual(stage, 1);
672 | fn.resume();
673 | await delay(50);
674 | assert.strictEqual(stage, 2);
675 | const result= await promise;
676 | assert.strictEqual(result, 123);
677 | })().then(()=> done(), done);
678 |
679 | return Test
680 | }
681 |
682 | ReactDOM.render(
683 | ,
684 | document.getElementById('root')
685 | );
686 | })
687 | });
688 |
689 | describe("useAsyncDeepState", function () {
690 | describe("state setter", function () {
691 | it("should return Promise that resolves with the latest state value", (done) => {
692 | function TestComponent() {
693 | const [state, setState]= useAsyncDeepState({
694 | x: 123,
695 | y: 456
696 | });
697 |
698 | useEffect(() => {
699 | (async () => {
700 | assert.strictEqual(state.x, 123);
701 | await delay(100);
702 | const newState = await setState(({x}) => ({x: x + 1}));
703 | assert.strictEqual(newState.x, 124);
704 | assert.strictEqual(state.y, 456);
705 | }
706 | )().then(() => done(), done)
707 | }, [])
708 |
709 | return Test
710 | }
711 |
712 | ReactDOM.render(
713 | ,
714 | document.getElementById('root')
715 | );
716 | })
717 | });
718 | });
719 |
720 | describe("useAsyncWatcher", function () {
721 |
722 | it("should return an async watcher function", (done)=>{
723 | function TestComponent() {
724 | const [state1] = useState(123);
725 | const watcher= useAsyncWatcher(state1);
726 | (async()=>{
727 | assert.ok(typeof watcher==='function', 'not a function');
728 | assert.ok(watcher() instanceof Promise, 'not a promise');
729 | })().then(() => done(), done)
730 | return Test
731 | }
732 |
733 | ReactDOM.render(
734 | ,
735 | document.getElementById('root')
736 | );
737 | })
738 |
739 | describe("watcher function", function () {
740 | it("should be resolved with the latest state values", (done) => {
741 | function TestComponent() {
742 | const [state1, setState1] = useState(123);
743 | const [state2, setState2] = useState(456);
744 |
745 | const watcher = useAsyncWatcher(state1, state2);
746 |
747 | useEffect(() => {
748 | (async () => {
749 | setState1(v => v + 1);
750 | setState2(v => v + 1);
751 | const [st1, st2] = await watcher();
752 | assert.strictEqual(st1, 124);
753 | assert.strictEqual(st2, 457);
754 | }
755 | )().then(() => done(), done)
756 | }, [])
757 |
758 | return Test
759 | }
760 |
761 | ReactDOM.render(
762 | ,
763 | document.getElementById('root')
764 | );
765 | });
766 |
767 | it("should be resolved with pairs of states that contain the newest and the previous value", (done) => {
768 | function TestComponent() {
769 | const [state1, setState1] = useState(123);
770 | const [state2, setState2] = useState(456);
771 |
772 | const watcher = useAsyncWatcher(state1, state2);
773 |
774 | useEffect(() => {
775 | (async () => {
776 | setState1(v => v + 1);
777 | setState2(v => v + 1);
778 | const [[st1, st1prev], [st2, st2prev]] = await watcher(true);
779 | assert.strictEqual(st1, 124);
780 | assert.strictEqual(st2, 457);
781 | assert.strictEqual(st1prev, 123);
782 | assert.strictEqual(st2prev, 456);
783 | }
784 | )().then(() => done(), done)
785 | }, [])
786 |
787 | return Test
788 | }
789 |
790 | ReactDOM.render(
791 | ,
792 | document.getElementById('root')
793 | );
794 | })
795 | });
796 | });
797 |
--------------------------------------------------------------------------------