├── .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 | 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 ? : } 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 | 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 | 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 | 40 |
{text}
41 | 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 | 37 | ) : ( 38 | 41 | ) 42 | ) : ( 43 | 46 | )} 47 | {go.pending && ( 48 | 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 | 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 | 34 | 35 | 40 | 41 | 44 | 45 | 49 | 50 | 54 | 55 | 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 | 56 |
57 | 66 | 69 |
70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 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 | 68 | 69 | 70 | 71 | 74 | 77 |
78 | useAsyncCallback Options: 79 |
    80 |
  • 81 | 88 |
  • 89 |
  • 90 | 97 |
  • 98 |
  • 99 | 110 |
  • 111 |
  • 112 | 119 |
  • 120 |
121 |
122 |
123 | Requested calls [{list.length}]: 124 |
    125 | {list.map(({ title, err, task }) => ( 126 |
  • 127 | {title}  128 | {!err && ( 129 | 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 | [![Build Status](https://travis-ci.com/DigitalBrainJS/use-async-effect.svg?branch=master)](https://travis-ci.com/DigitalBrainJS/use-async-effect) 2 | ![npm](https://img.shields.io/npm/dm/use-async-effect2) 3 | ![npm bundle size](https://img.shields.io/bundlephobia/minzip/use-async-effect) 4 | ![David](https://img.shields.io/david/DigitalBrainJS/use-async-effect) 5 | [![Stars](https://badgen.net/github/stars/DigitalBrainJS/use-async-effect)](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 | 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 | 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 | 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 |
{isFetching ? : text}
305 | {!isFetching ? ( 306 | 313 | ) : ( 314 | 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 | 354 | 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 | {} 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 | --------------------------------------------------------------------------------