├── .github └── workflows │ └── build.yml ├── .gitignore ├── .np-config.js ├── .yaspeller.json ├── LICENSE ├── README.md ├── images ├── QUEUE.png ├── RACE.png ├── TAKE_EVERY.png ├── TAKE_FIRST.png ├── TAKE_LAST.png └── src.graffle ├── jest.config.json ├── package.json ├── prettier.config.js ├── src ├── createReEffect.spec.ts ├── createReEffect.ts ├── error.spec.ts ├── error.ts ├── fork.spec.ts ├── index.spec.ts ├── index.ts ├── instance.ts ├── promise.ts ├── runner.ts ├── strategy.ts ├── tools.ts └── types.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: ['push', 'pull_request'] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-18.04 8 | 9 | strategy: 10 | matrix: 11 | node: ['12', '14', '16'] 12 | 13 | name: Node ${{ matrix.node }} 14 | 15 | steps: 16 | - uses: actions/checkout@master 17 | 18 | - name: Setup Node ${{ matrix.node }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node }} 22 | 23 | - name: Install dependencies 24 | run: yarn 25 | 26 | - name: Linting and check spelling 27 | run: yarn lint 28 | 29 | - name: Test and collect coverage 30 | run: yarn test 31 | 32 | - name: Build package 33 | run: yarn build 34 | 35 | - name: Push coverage to Coveralls 36 | uses: coverallsapp/github-action@master 37 | with: 38 | github-token: ${{ secrets.github_token }} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /coverage/ 3 | /lib/ 4 | /pkg/ 5 | /test/ 6 | yarn-error.log 7 | .DS_Store 8 | .project 9 | .vscode 10 | .idea 11 | *.log 12 | -------------------------------------------------------------------------------- /.np-config.js: -------------------------------------------------------------------------------- 1 | // https://github.com/sindresorhus/np/issues/398 2 | module.exports = { 3 | // Avoids `np` trying to set 2FA again and again 4 | // after publishing to NPM 5 | exists: true, 6 | } 7 | -------------------------------------------------------------------------------- /.yaspeller.json: -------------------------------------------------------------------------------- 1 | { 2 | "excludeFiles": [".git", ".vscode", "node_modules", "lib"], 3 | "lang": "en", 4 | "fileExtensions": [".md"], 5 | "dictionary": [ 6 | "[Rr]e[Ee]ffects?", 7 | "[Cc]o[Ee]ffects?", 8 | "[Aa]xios", 9 | "[Kk]y", 10 | "Setplex", 11 | "payload", 12 | "hardcoded", 13 | "AbortController", 14 | "XMLHttpRequest" 15 | ], 16 | "ignoreText": [], 17 | "ignoreUrls": true, 18 | "ignoreUppercase": true, 19 | "findRepeatWords": false, 20 | "maxRequests": 5 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Victor Didenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # THIS PROJECT IS UNMAINTAINED AND DEPRECATED 2 | 3 | Please use something else. See: https://github.com/yumauri/effector-reeffect/issues/26 4 | 5 | # effector-reeffect 6 | 7 | [![Build Status](https://github.com/yumauri/effector-reeffect/workflows/build/badge.svg)](https://github.com/yumauri/effector-reeffect/actions?workflow=build) 8 | [![Coverage Status](https://coveralls.io/repos/github/yumauri/effector-reeffect/badge.svg)](https://coveralls.io/github/yumauri/effector-reeffect) 9 | [![License](https://img.shields.io/github/license/yumauri/effector-reeffect.svg?color=yellow)](./LICENSE) 10 | [![NPM](https://img.shields.io/npm/v/effector-reeffect.svg)](https://www.npmjs.com/package/effector-reeffect) 11 | ![Made with Love](https://img.shields.io/badge/made%20with-❤-red.svg) 12 | 13 | ReEffects for [Effector](https://github.com/zerobias/effector) ☄️
14 | Like regular Effects, but better :) 15 | 16 | - Supports different launch strategies: TAKE_FIRST, TAKE_LAST, TAKE_EVERY, QUEUE, RACE 17 | - Handles promises cancellation 18 | - Can handle _logic_ cancellation 19 | 20 | ## Table of Contents 21 | 22 | 23 | 24 | - [Install](#install) 25 | - [Usage](#usage) 26 | - [Strategies](#strategies) 27 | - [TAKE_EVERY](#take_every) 28 | - [TAKE_FIRST](#take_first) 29 | - [TAKE_LAST](#take_last) 30 | - [QUEUE](#queue) 31 | - [RACE](#race) 32 | - [Properties](#properties) 33 | - [Options](#options) 34 | - [Cancellation](#cancellation) 35 | - [axios](#axios) 36 | - [fetch](#fetch) 37 | - [ky](#ky) 38 | - [request](#request) 39 | - [XMLHttpRequest](#xmlhttprequest) 40 | - [FAQ](#faq) 41 | - [Sponsored](#sponsored) 42 | 43 | ## Install 44 | 45 | ```bash 46 | $ yarn add effector-reeffect 47 | ``` 48 | 49 | Or using `npm` 50 | 51 | ```bash 52 | $ npm install --save effector-reeffect 53 | ``` 54 | 55 | ## Usage 56 | 57 | In basic version you can use it like regular [Effect](https://effector.now.sh/en/api/effector/effect): 58 | 59 | ```javascript 60 | import { createReEffect } from 'effector-reeffect' 61 | 62 | // create ReEffect 63 | const fetchUser = createReEffect({ 64 | handler: ({ id }) => 65 | fetch(`https://example.com/users/${id}`).then(res => res.json()), 66 | }) 67 | ``` 68 | 69 | Nothing special yet, created ReEffect has same properties, as usual Effect: Events `done`, `fail`, `finally`; Store `pending`; and methods `use(handler)`, `watch(watcher)`, `prepend(fn)`. Check out [documentation](https://effector.now.sh/en/api/effector/effect) to learn more. 70 | 71 | Magic begins when you call ReEffect more that once, while previous asynchronous operation is not finished yet 🧙‍♂️ 72 | 73 | ```javascript 74 | import { createReEffect, TAKE_LAST } from 'effector-reeffect' 75 | 76 | // create ReEffect 77 | const fetchUser = createReEffect({ 78 | handler: ({ id }) => 79 | fetch(`https://example.com/users/${id}`).then(res => res.json()), 80 | }) 81 | 82 | // call it once 83 | fetchUser({ id: 1 }, TAKE_LAST) 84 | 85 | // and somewhere in a galaxy far, far away 86 | fetchUser({ id: 1 }, TAKE_LAST) 87 | ``` 88 | 89 | You see the new `TAKE_LAST` argument - this is called _strategy_, and _TAKE_LAST_ one ensures, that only latest call will trigger `done` (or `fail`) event. Each call still remain separate Promise, so you can _await_ it, but first one in the example above will be rejected with `CancelledError` instance (you can import this class from package and check `error instanceof CancelledError`). 90 | 91 | ## Strategies 92 | 93 | ### TAKE_EVERY 94 | 95 | This is _default strategy_, if you will not specify any other. 96 | 97 | TAKE_EVERY 98 | 99 | Second effect call will launch second asynchronous operation. In contrast with usual Effect, ReEffect will trigger `.done` (or `.fail`) event only for latest operation, and `.pending` store will contain `true` for a whole time of all operations (in other words, if there is at least single pending operation - `.pending` will hold `true`). 100 | 101 | ### TAKE_FIRST 102 | 103 | TAKE_FIRST 104 | 105 | Second effect call will be immediately rejected with `CancelledError` (handler will not be executed at all). 106 | 107 | ### TAKE_LAST 108 | 109 | TAKE_LAST 110 | 111 | Second effect call will reject all currently pending operations (if any) with `CancelledError`. 112 | 113 | ### QUEUE 114 | 115 | QUEUE 116 | 117 | Second effect will not be launched until all other pending effects are finished. 118 | 119 | ### RACE 120 | 121 | RACE 122 | 123 | First finished effect will win the race and cancel all other pending effects with `CancelledError`. 124 | 125 | This strategy is a bit different, then first four. You can call them "**→IN** strategies", while RACE is "**OUT→** strategy". 126 | 127 | ReEffect checks **→IN** strategy in the moment effect was launched. Effect, launched with _TAKE_LAST_ strategy, will cancel all currently pending effects, regardless of their strategies. Effect, launched with _QUEUE_ strategy, will be placed in queue to wait all currently pending effects, regardless of their strategies. And so on. 128 | 129 | **OUT→** strategy is checked, when effect is fulfilled (but not cancelled). Effect with _RACE_ strategy, upon finished, will cancel all other pending effects, regardless of their strategies. 130 | 131 | It should be noted, that due to asynchronous cancellation, `cancelled` events for loser effects will happen _after_ main `done`/`fail` event, and _after_ `pending` is set to `false`. 132 | 133 | ## Properties 134 | 135 | ReEffect has few new properties: 136 | 137 | - `.cancelled`: Event triggered when any handler is rejected with `CancelledError` or `LimitExceededError` (this will be described later). 138 | - `.cancel`: Event you can trigger to manually cancel all currently pending operations - each cancelled operation will trigger `.cancelled` event. 139 | 140 | ## Options 141 | 142 | `createReEffect` function accepts same arguments as usual Effect, with few possible additions in config: 143 | 144 | - `strategy`: this strategy will be considered as default, instead of `TAKE_EVERY`. Possible values: `TAKE_EVERY`, `TAKE_FIRST`, `TAKE_LAST`, `QUEUE` or `RACE`. 145 | - `feedback`: if `true` — puts `strategy` field into `done`, `fail` or `cancelled` event's payload. With `false` by default ReEffect behaves just like usual Effect, with exactly the same results. 146 | - `limit`: maximum count of simultaneously running operation, by default `Infinity`. If new effect call will exceed this value, call will be immediately rejected with `LimitExceededError` error. 147 | - `timeout`: timeout for effect execution, in milliseconds. If timeout is exceeded — effect will be rejected with `TimeoutError`. 148 | 149 | ```javascript 150 | const fetchUser = createReEffect('fetchUser', { 151 | handler: ({ id }) => 152 | fetch(`https://example.com/users/${id}`).then(res => res.json()), 153 | strategy: TAKE_LAST, 154 | feedback: true, 155 | limit: 3, 156 | timeout: 5000, 157 | }) 158 | ``` 159 | 160 | ReEffect, created with `createReEffect` function, behave like usual Effect, with one difference: in addition to effect's `payload` you can specify _strategy_ as a second argument. This strategy will override default strategy for this effect (but will not replace default strategy). 161 | 162 | You can also specify config object, with `strategy` or/and `timeout`. 163 | 164 | ```javascript 165 | // this are equivalent calls 166 | fetchUser({ id: 2 }, TAKE_EVERY) 167 | fetchUser({ id: 2 }, { strategy: TAKE_EVERY }) 168 | fetchUser({ params: { id: 2 }, strategy: TAKE_EVERY }) 169 | 170 | // or if your effect doesn't have payload 171 | fetchAllUsers(undefined, RACE) 172 | fetchAllUsers(undefined, { strategy: RACE }) 173 | fetchAllUsers({ strategy: RACE }) 174 | 175 | // with timeout 176 | fetchUser({ id: 42 }, { timeout: 5000 }) 177 | fetchUser({ params: { id: 42 }, strategy: TAKE_EVERY, timeout: 5000 }) 178 | ``` 179 | 180 | ## Cancellation 181 | 182 | ReEffect will handle Promises cancellation for you (so handler promise result will be ignored), _but it cannot cancel logic_ by itself! There are quite an amount of possible asynchronous operations, and each one could be cancelled differently (and some could not be cancelled at all). 183 | 184 | But bright side of it is that you can tell ReEffect, _how to cancel your logic_ ☀️ 185 | 186 | To do this, `handler` accepts `onCancel` callback as a second argument, and you can specify, what actually to do on cancel. 187 | 188 | Let me show an example: 189 | 190 | ```javascript 191 | import { createReEffect, TAKE_LAST } from 'effector-reeffect' 192 | 193 | const reeffect = createReEffect({ strategy: TAKE_LAST }) 194 | 195 | reeffect.watch(_ => console.log('reeffect called:', _)) 196 | reeffect.done.watch(_ => console.log('reeffect done:', _)) 197 | reeffect.fail.watch(_ => console.log('reeffect fail:', _)) 198 | reeffect.cancelled.watch(_ => console.log('reeffect cancelled:', _)) 199 | 200 | reeffect.use( 201 | params => 202 | new Promise(resolve => { 203 | setTimeout(() => { 204 | console.log(`-> AHA! TIMEOUT FROM EFFECT WITH PARAMS: ${params}`) 205 | resolve('done') 206 | }, 1000) 207 | }) 208 | ) 209 | 210 | reeffect(1) 211 | reeffect(2) 212 | ``` 213 | 214 | If you will run code above, you will get 215 | 216 | ``` 217 | reeffect called: 1 218 | reeffect called: 2 219 | reeffect cancelled: { params: 1, 220 | error: Error: Cancelled due to "TAKE_LAST", new effect was added } 221 | -> AHA! TIMEOUT FROM EFFECT WITH PARAMS: 1 222 | -> AHA! TIMEOUT FROM EFFECT WITH PARAMS: 2 223 | reeffect done: { params: 2, result: 'done' } 224 | ``` 225 | 226 | As you can see, first effect call was rejected and cancelled, but timeout itself was not cancelled, and printed message. 227 | 228 | Let's change code above: 229 | 230 | ```javascript 231 | import { createReEffect, TAKE_LAST } from 'effector-reeffect' 232 | 233 | const reeffect = createReEffect({ strategy: TAKE_LAST }) 234 | 235 | reeffect.watch(_ => console.log('reeffect called:', _)) 236 | reeffect.done.watch(_ => console.log('reeffect done:', _)) 237 | reeffect.fail.watch(_ => console.log('reeffect fail:', _)) 238 | reeffect.cancelled.watch(_ => console.log('reeffect cancelled:', _)) 239 | 240 | reeffect.use((params, onCancel) => { 241 | let timeout 242 | onCancel(() => clearTimeout(timeout)) 243 | return new Promise(resolve => { 244 | timeout = setTimeout(() => { 245 | console.log(`-> AHA! TIMEOUT FROM EFFECT WITH PARAMS: ${params}`) 246 | resolve('done') 247 | }, 1000) 248 | }) 249 | }) 250 | 251 | reeffect(1) 252 | reeffect(2) 253 | ``` 254 | 255 | Now ReEffect know, how to cancel your Promise's logic, and will do it while cancelling operation: 256 | 257 | ``` 258 | reeffect called: 1 259 | reeffect called: 2 260 | reeffect cancelled: { params: 1, 261 | error: Error: Cancelled due to "TAKE_LAST", new effect was added } 262 | -> AHA! TIMEOUT FROM EFFECT WITH PARAMS: 2 263 | reeffect done: { params: 2, result: 'done' } 264 | ``` 265 | 266 | This could be done with any asynchronous operation, which supports cancellation or abortion. 267 | 268 | ### [axios](https://github.com/axios/axios) 269 | 270 | Axios supports cancellation via [_cancel token_](https://github.com/axios/axios#cancellation): 271 | 272 | ```javascript 273 | reeffect.use(({ id }, onCancel) => { 274 | const source = CancelToken.source() 275 | onCancel(() => source.cancel()) 276 | return axios.get(`https://example.com/users/${id}`, { 277 | cancelToken: source.token, 278 | }) 279 | }) 280 | ``` 281 | 282 | ### [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) 283 | 284 | Fetch API supports cancellation via [_AbortController_](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) ([read more](https://developers.google.com/web/updates/2017/09/abortable-fetch)): 285 | 286 | ```javascript 287 | reeffect.use(({ id }, onCancel) => { 288 | const controller = new AbortController() 289 | onCancel(() => controller.abort()) 290 | return fetch(`https://example.com/users/${id}`, { 291 | signal: controller.signal, 292 | }) 293 | }) 294 | ``` 295 | 296 | ### [ky](https://github.com/sindresorhus/ky) 297 | 298 | Ky is built on top of Fetch API, and supports cancellation via [_AbortController_](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) as well: 299 | 300 | ```javascript 301 | reeffect.use(({ id }, onCancel) => { 302 | const controller = new AbortController() 303 | onCancel(() => controller.abort()) 304 | return ky(`https://example.com/users/${id}`, { 305 | signal: controller.signal, 306 | }).json() 307 | }) 308 | ``` 309 | 310 | ### [request](https://github.com/request/request) 311 | 312 | _**Note**: request has been [deprecated](https://github.com/request/request/issues/3142), you probably should not use it._ 313 | 314 | Request HTTP client supports [`.abort()` method](https://github.com/request/request/issues/772): 315 | 316 | ```javascript 317 | reeffect.use(({ id }, onCancel) => { 318 | let r 319 | onCancel(() => r.abort()) 320 | return new Promise((resolve, reject) => { 321 | r = request(`https://example.com/users/${id}`, (err, resp, body) => 322 | err ? reject(err) : resolve(body) 323 | ) 324 | }) 325 | }) 326 | ``` 327 | 328 | ### [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) 329 | 330 | If you happen to use good old `XMLHttpRequest`, I will not blame you (but others definitely will). Good to know it supports cancellation too, via [`.abort()` method](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/abort): 331 | 332 | ```javascript 333 | reeffect.use(({ id }, onCancel) => { 334 | let xhr 335 | onCancel(() => xhr.abort()) 336 | return new Promise(function (resolve, reject) { 337 | xhr = new XMLHttpRequest() 338 | xhr.open('GET', `https://example.com/users/${id}`) 339 | xhr.onload = function () { 340 | if (this.status >= 200 && this.status < 300) { 341 | resolve(xhr.response) 342 | } else { 343 | reject({ 344 | status: this.status, 345 | statusText: xhr.statusText, 346 | }) 347 | } 348 | } 349 | xhr.onerror = function () { 350 | reject({ 351 | status: this.status, 352 | statusText: xhr.statusText, 353 | }) 354 | } 355 | xhr.send() 356 | }) 357 | }) 358 | ``` 359 | 360 | ## FAQ 361 | 362 | ### Can I use ReEffect with Domain? 363 | 364 | Yes! Alongside with `createReEffect` ReEffect package exports factory `createReEffectFactory`, you can use it to wrap `createEffect` from domain: 365 | 366 | ```javascript 367 | import { createDomain } from 'effector' 368 | import { createReEffectFactory } from 'effector-reeffect' 369 | 370 | const domain = createDomain() 371 | const createReEffect = createReEffectFactory(domain.createEffect) 372 | const fetchUser = createReEffect(/* ... */) 373 | // -> fetchUser will belong to domain 374 | ``` 375 | 376 | ### Can I use ReEffect with `fork`? 377 | 378 | Yes, with Effector version 22. 379 | 380 | ### Can I use ReEffect with `attach`? 381 | 382 | I didn't try it, but most probably no :(
383 | First of all, after `attach` you will get regular Effect, not ReEffect, and secondarily, looks like `attach` implementation [replaces `req` parameter](https://github.com/zerobias/effector/blob/d2f711c9fc702436e44dcf9637e4e7ee5a884570/src/effector/attach.js#L34), which highly likely will break ReEffect functionality.
384 | There is [issue #8](https://github.com/yumauri/effector-reeffect/issues/8) to track this case. 385 | 386 | If you want just attach store to your ReEffect, you can try technique, called "CoEffect": 387 | 388 | ```javascript 389 | /** 390 | * Creates CoEffect - ReEffect, attached to the store value 391 | */ 392 | function createCoEffect({ store, handler, ...config }) { 393 | const fx = createReEffect(config) 394 | 395 | // save original `use` 396 | const use = fx.use 397 | 398 | // replace original `use`, to be able to change handler on CoEffect 399 | // you can omit this, if you don't intend to replace CoEffect handler 400 | fx.use = fn => (handler = fn) 401 | 402 | // on each store change replace handler for ReEffect, 403 | // so it will be called with actual store value every time 404 | store.watch(value => { 405 | use((payload, onCancel) => handler(payload, value, onCancel)) 406 | }) 407 | 408 | return fx 409 | } 410 | ``` 411 | 412 | ## Sponsored 413 | 414 | [Setplex OTT Platform](https://setplex.com/en/) 415 | 416 | [Setplex OTT Platform](https://setplex.com/en/) 417 | -------------------------------------------------------------------------------- /images/QUEUE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yumauri/effector-reeffect/d6052934bc66abb6b1c9a640c28125272b32744c/images/QUEUE.png -------------------------------------------------------------------------------- /images/RACE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yumauri/effector-reeffect/d6052934bc66abb6b1c9a640c28125272b32744c/images/RACE.png -------------------------------------------------------------------------------- /images/TAKE_EVERY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yumauri/effector-reeffect/d6052934bc66abb6b1c9a640c28125272b32744c/images/TAKE_EVERY.png -------------------------------------------------------------------------------- /images/TAKE_FIRST.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yumauri/effector-reeffect/d6052934bc66abb6b1c9a640c28125272b32744c/images/TAKE_FIRST.png -------------------------------------------------------------------------------- /images/TAKE_LAST.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yumauri/effector-reeffect/d6052934bc66abb6b1c9a640c28125272b32744c/images/TAKE_LAST.png -------------------------------------------------------------------------------- /images/src.graffle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yumauri/effector-reeffect/d6052934bc66abb6b1c9a640c28125272b32744c/images/src.graffle -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.ts$": "ts-jest" 4 | }, 5 | "collectCoverage": true, 6 | "coverageReporters": ["text", "lcov"], 7 | "collectCoverageFrom": ["src/**/*.ts"], 8 | "testRegex": ".+\\.spec\\.ts$", 9 | "maxConcurrency": 3, 10 | "testEnvironment": "node" 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "effector-reeffect", 3 | "version": "3.0.0", 4 | "description": "Concurrent effects for Effector", 5 | "author": "Victor Didenko (https://yumaa.name)", 6 | "contributors": [ 7 | "Alexander Khoroshikh ", 8 | "Sergey Sova (https://sova.dev)" 9 | ], 10 | "license": "MIT", 11 | "keywords": [ 12 | "effector", 13 | "effects", 14 | "side effects" 15 | ], 16 | "scripts": { 17 | "dev": "ts-node src/index.ts", 18 | "test": "jest", 19 | "build": "rm -rf pkg/ && pika build", 20 | "format": "prettier --write \"src/**/*.ts\"", 21 | "lint": "tslint -p tsconfig.json && yarn spell", 22 | "spell": "yaspeller .", 23 | "release": "pika publish", 24 | "version": "yarn build", 25 | "size": "size-limit" 26 | }, 27 | "size-limit": [ 28 | { 29 | "path": "pkg/dist-web/index.js", 30 | "limit": "1935 B" 31 | }, 32 | { 33 | "path": "pkg/dist-node/index.js", 34 | "limit": "1907 B" 35 | } 36 | ], 37 | "@pika/pack": { 38 | "pipeline": [ 39 | [ 40 | "@pika/plugin-ts-standard-pkg" 41 | ], 42 | [ 43 | "@pika/plugin-build-node" 44 | ], 45 | [ 46 | "@pika/plugin-build-web" 47 | ], 48 | [ 49 | "pika-plugin-package.json", 50 | { 51 | "+author": "^", 52 | "*files": [ 53 | "-bin/" 54 | ], 55 | "-dependencies": {}, 56 | "-devDependencies": {} 57 | } 58 | ] 59 | ] 60 | }, 61 | "repository": { 62 | "type": "git", 63 | "url": "git+https://github.com/yumauri/effector-reeffect" 64 | }, 65 | "bugs": { 66 | "url": "https://github.com/yumauri/effector-reeffect/issues" 67 | }, 68 | "homepage": "https://github.com/yumauri/effector-reeffect#readme", 69 | "dependencies": {}, 70 | "devDependencies": { 71 | "@pika/pack": "^0.5.0", 72 | "@pika/plugin-build-node": "^0.9.2", 73 | "@pika/plugin-build-web": "^0.9.2", 74 | "@pika/plugin-ts-standard-pkg": "^0.9.2", 75 | "@size-limit/preset-small-lib": "^4.9.1", 76 | "@types/jest": "^26.0.19", 77 | "effector": "^22.1.1", 78 | "jest": "^26.6.3", 79 | "pika-plugin-package.json": "^1.0.2", 80 | "prettier": "^2.2.1", 81 | "size-limit": "^4.9.1", 82 | "ts-jest": "^26.4.4", 83 | "ts-node": "^9.1.1", 84 | "tslint": "^6.1.3", 85 | "tslint-config-prettier": "^1.18.0", 86 | "tslint-config-security": "^1.16.0", 87 | "tslint-config-standard-plus": "^2.3.0", 88 | "typescript": "^4.1.3", 89 | "yaspeller": "^7.0.0" 90 | }, 91 | "peerDependencies": { 92 | "effector": "^22.0.0" 93 | }, 94 | "engines": { 95 | "node": ">=12.13.0" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: false, 6 | singleQuote: true, 7 | quoteProps: 'as-needed', 8 | jsxSingleQuote: true, 9 | trailingComma: 'es5', 10 | bracketSpacing: true, 11 | jsxBracketSameLine: false, 12 | arrowParens: 'avoid', 13 | } 14 | -------------------------------------------------------------------------------- /src/createReEffect.spec.ts: -------------------------------------------------------------------------------- 1 | import { createDomain, createEvent, forward, is } from 'effector' 2 | import { createReEffectFactory } from './createReEffect' 3 | import { CancelledError, LimitExceededError, TimeoutError } from './error' 4 | import { QUEUE, RACE, TAKE_EVERY, TAKE_FIRST, TAKE_LAST } from './strategy' 5 | 6 | console.error = jest.fn() 7 | 8 | test('createReEffectFactory should be factory', () => { 9 | expect(typeof createReEffectFactory()).toBe('function') 10 | const createEffect = createDomain().createEffect 11 | expect(typeof createReEffectFactory(createEffect)).toBe('function') 12 | }) 13 | 14 | test('createReEffect should create Effect-like object', () => { 15 | const createReEffect = createReEffectFactory() 16 | const reeffect = createReEffect('test') 17 | 18 | expect(is.effect(reeffect)).toBe(true) 19 | expect(reeffect.shortName).toBe('test') 20 | expect(typeof reeffect.use).toBe('function') 21 | expect(typeof reeffect.watch).toBe('function') 22 | expect(typeof reeffect.prepend).toBe('function') 23 | 24 | expect(is.event(reeffect.done)).toBe(true) 25 | expect(is.event(reeffect.fail)).toBe(true) 26 | expect(is.event(reeffect.finally)).toBe(true) 27 | expect(is.store(reeffect.pending)).toBe(true) 28 | 29 | // additional properties 30 | expect(is.event(reeffect.cancelled)).toBe(true) 31 | expect(is.event(reeffect.cancel)).toBe(true) 32 | 33 | // reeffect should return promise 34 | expect(reeffect() instanceof Promise).toBe(true) 35 | }) 36 | 37 | test('createReEffect single successful operation', async () => { 38 | expect.assertions(4) 39 | const fn = jest.fn() 40 | 41 | const createReEffect = createReEffectFactory() 42 | const reeffect = createReEffect({ 43 | async handler() { 44 | return new Promise(resolve => 45 | setTimeout(() => resolve('test'), 10) 46 | ) 47 | }, 48 | }) 49 | 50 | reeffect.done.watch(fn) 51 | reeffect.fail.watch(fn) 52 | reeffect.cancelled.watch(fn) 53 | reeffect.finally.watch(fn) 54 | 55 | const result = await reeffect({ strategy: TAKE_EVERY }) 56 | expect(result).toBe('test') 57 | 58 | expect(fn).toHaveBeenCalledTimes(2) 59 | 60 | // finally event 61 | expect(fn.mock.calls[0][0]).toEqual({ 62 | params: undefined, 63 | result: 'test', 64 | status: 'done', 65 | }) 66 | 67 | // done event 68 | expect(fn.mock.calls[1][0]).toEqual({ 69 | params: undefined, 70 | result: 'test', 71 | }) 72 | }) 73 | 74 | test('createReEffect single sync successful operation', async () => { 75 | expect.assertions(4) 76 | const fn = jest.fn() 77 | 78 | const createReEffect = createReEffectFactory() 79 | const reeffect = createReEffect({ 80 | handler() { 81 | return 'test' 82 | }, 83 | }) 84 | 85 | reeffect.done.watch(fn) 86 | reeffect.fail.watch(fn) 87 | reeffect.cancelled.watch(fn) 88 | reeffect.finally.watch(fn) 89 | 90 | const result = await reeffect({ strategy: TAKE_EVERY }) 91 | expect(result).toBe('test') 92 | 93 | expect(fn).toHaveBeenCalledTimes(2) 94 | 95 | // finally event 96 | expect(fn.mock.calls[0][0]).toEqual({ 97 | params: undefined, 98 | result: 'test', 99 | status: 'done', 100 | }) 101 | 102 | // done event 103 | expect(fn.mock.calls[1][0]).toEqual({ 104 | params: undefined, 105 | result: 'test', 106 | }) 107 | }) 108 | 109 | test('createReEffect single failed operation', async () => { 110 | expect.assertions(4) 111 | const fn = jest.fn() 112 | 113 | const createReEffect = createReEffectFactory() 114 | const reeffect = createReEffect({ 115 | async handler() { 116 | return new Promise((_, reject) => 117 | setImmediate(() => reject('error')) 118 | ) 119 | }, 120 | }) 121 | 122 | reeffect.done.watch(fn) 123 | reeffect.fail.watch(fn) 124 | reeffect.cancelled.watch(fn) 125 | reeffect.finally.watch(fn) 126 | 127 | try { 128 | await reeffect(42) 129 | } catch (error) { 130 | expect(error).toBe('error') 131 | } 132 | 133 | expect(fn).toHaveBeenCalledTimes(2) 134 | 135 | // finally event 136 | expect(fn.mock.calls[0][0]).toEqual({ 137 | params: 42, 138 | error: 'error', 139 | status: 'fail', 140 | }) 141 | 142 | // fail event 143 | expect(fn.mock.calls[1][0]).toEqual({ 144 | params: 42, 145 | error: 'error', 146 | }) 147 | }) 148 | 149 | test('createReEffect single sync failed operation', async () => { 150 | expect.assertions(4) 151 | const fn = jest.fn() 152 | 153 | const createReEffect = createReEffectFactory() 154 | const reeffect = createReEffect({ 155 | handler() { 156 | // tslint:disable-next-line no-string-throw 157 | throw 'error' 158 | }, 159 | }) 160 | 161 | reeffect.done.watch(fn) 162 | reeffect.fail.watch(fn) 163 | reeffect.cancelled.watch(fn) 164 | reeffect.finally.watch(fn) 165 | 166 | try { 167 | await reeffect(42) 168 | } catch (error) { 169 | expect(error).toBe('error') 170 | } 171 | 172 | expect(fn).toHaveBeenCalledTimes(2) 173 | 174 | // finally event 175 | expect(fn.mock.calls[0][0]).toEqual({ 176 | params: 42, 177 | error: 'error', 178 | status: 'fail', 179 | }) 180 | 181 | // fail event 182 | expect(fn.mock.calls[1][0]).toEqual({ 183 | params: 42, 184 | error: 'error', 185 | }) 186 | }) 187 | 188 | test('createReEffect with TAKE_EVERY strategy', async () => { 189 | expect.assertions(7) 190 | const fn = jest.fn() 191 | 192 | const createReEffect = createReEffectFactory() 193 | const reeffect = createReEffect({ 194 | async handler(params) { 195 | return new Promise(resolve => 196 | setTimeout(() => resolve(`test${params}`), params) 197 | ) 198 | }, 199 | }) 200 | 201 | reeffect.done.watch(fn) 202 | reeffect.fail.watch(fn) 203 | reeffect.cancelled.watch(fn) 204 | reeffect.finally.watch(fn) 205 | reeffect.pending.watch(fn) 206 | 207 | const result = await Promise.all([reeffect(11), reeffect(22)]) 208 | expect(result).toEqual(['test11', 'test22']) 209 | 210 | expect(fn).toHaveBeenCalledTimes(5) 211 | 212 | // pending 213 | expect(fn.mock.calls[0][0]).toEqual(false) 214 | expect(fn.mock.calls[1][0]).toEqual(true) 215 | 216 | // finally event 217 | expect(fn.mock.calls[2][0]).toEqual({ 218 | params: 22, 219 | result: 'test22', 220 | status: 'done', 221 | }) 222 | 223 | // done event 224 | expect(fn.mock.calls[3][0]).toEqual({ 225 | params: 22, 226 | result: 'test22', 227 | }) 228 | 229 | // pending 230 | expect(fn.mock.calls[4][0]).toEqual(false) 231 | }) 232 | 233 | test('createReEffect with TAKE_EVERY strategy with first one failed', async () => { 234 | expect.assertions(7) 235 | const fn = jest.fn() 236 | 237 | const createReEffect = createReEffectFactory() 238 | const reeffect = createReEffect({ 239 | async handler(params) { 240 | return new Promise((resolve, reject) => 241 | setTimeout( 242 | () => 243 | params === 11 244 | ? reject(`reject${params}`) 245 | : resolve(`resolve${params}`), 246 | params 247 | ) 248 | ) 249 | }, 250 | }) 251 | 252 | reeffect.done.watch(fn) 253 | reeffect.fail.watch(fn) 254 | reeffect.cancelled.watch(fn) 255 | reeffect.finally.watch(fn) 256 | reeffect.pending.watch(fn) 257 | 258 | const result = await Promise.all([ 259 | reeffect(11).catch(error => error), 260 | reeffect(22), 261 | ]) 262 | expect(result).toEqual(['reject11', 'resolve22']) 263 | 264 | expect(fn).toHaveBeenCalledTimes(5) 265 | 266 | // pending 267 | expect(fn.mock.calls[0][0]).toEqual(false) 268 | expect(fn.mock.calls[1][0]).toEqual(true) 269 | 270 | // finally event 271 | expect(fn.mock.calls[2][0]).toEqual({ 272 | params: 22, 273 | result: 'resolve22', 274 | status: 'done', 275 | }) 276 | 277 | // done event 278 | expect(fn.mock.calls[3][0]).toEqual({ 279 | params: 22, 280 | result: 'resolve22', 281 | }) 282 | 283 | // pending 284 | expect(fn.mock.calls[4][0]).toEqual(false) 285 | }) 286 | 287 | test('createReEffect with TAKE_FIRST strategy', async () => { 288 | expect.assertions(8) 289 | const fn = jest.fn() 290 | 291 | const createReEffect = createReEffectFactory() 292 | const reeffect = createReEffect({ 293 | strategy: TAKE_FIRST, 294 | async handler(params) { 295 | return new Promise(resolve => 296 | setImmediate(() => resolve(`test${params}`)) 297 | ) 298 | }, 299 | }) 300 | 301 | reeffect.done.watch(fn) 302 | reeffect.fail.watch(fn) 303 | reeffect.cancelled.watch(fn) 304 | reeffect.finally.watch(fn) 305 | reeffect.pending.watch(fn) 306 | 307 | const result = await Promise.all([ 308 | reeffect(11), 309 | reeffect(22).catch(error => error), 310 | ]) 311 | expect(result).toEqual(['test11', expect.any(CancelledError)]) 312 | 313 | expect(fn).toHaveBeenCalledTimes(6) 314 | 315 | // pending 316 | expect(fn.mock.calls[0][0]).toEqual(false) 317 | expect(fn.mock.calls[1][0]).toEqual(true) 318 | 319 | // cancelled event 320 | expect(fn.mock.calls[2][0]).toEqual({ 321 | params: 22, 322 | error: expect.any(CancelledError), 323 | }) 324 | 325 | // finally event 326 | expect(fn.mock.calls[3][0]).toEqual({ 327 | params: 11, 328 | result: 'test11', 329 | status: 'done', 330 | }) 331 | 332 | // done event 333 | expect(fn.mock.calls[4][0]).toEqual({ 334 | params: 11, 335 | result: 'test11', 336 | }) 337 | 338 | // pending 339 | expect(fn.mock.calls[5][0]).toEqual(false) 340 | }) 341 | 342 | test('createReEffect with TAKE_LAST strategy', async () => { 343 | expect.assertions(8) 344 | const fn = jest.fn() 345 | 346 | const createReEffect = createReEffectFactory() 347 | const reeffect = createReEffect('name', { 348 | strategy: TAKE_LAST, 349 | async handler(params) { 350 | return new Promise(resolve => 351 | setImmediate(() => resolve(`test${params}`)) 352 | ) 353 | }, 354 | }) 355 | 356 | reeffect.done.watch(fn) 357 | reeffect.fail.watch(fn) 358 | reeffect.cancelled.watch(fn) 359 | reeffect.finally.watch(fn) 360 | reeffect.pending.watch(fn) 361 | 362 | const result = await Promise.all([ 363 | reeffect(11).catch(error => error), 364 | reeffect(22), 365 | ]) 366 | expect(result).toEqual([expect.any(CancelledError), 'test22']) 367 | 368 | expect(fn).toHaveBeenCalledTimes(6) 369 | 370 | // pending 371 | expect(fn.mock.calls[0][0]).toEqual(false) 372 | expect(fn.mock.calls[1][0]).toEqual(true) 373 | 374 | // cancelled event 375 | expect(fn.mock.calls[2][0]).toEqual({ 376 | params: 11, 377 | error: expect.any(CancelledError), 378 | }) 379 | 380 | // finally event 381 | expect(fn.mock.calls[3][0]).toEqual({ 382 | params: 22, 383 | result: 'test22', 384 | status: 'done', 385 | }) 386 | 387 | // done event 388 | expect(fn.mock.calls[4][0]).toEqual({ 389 | params: 22, 390 | result: 'test22', 391 | }) 392 | 393 | // pending 394 | expect(fn.mock.calls[5][0]).toEqual(false) 395 | }) 396 | 397 | test('createReEffect with QUEUE strategy', async () => { 398 | expect.assertions(7) 399 | const fn = jest.fn() 400 | 401 | const createReEffect = createReEffectFactory() 402 | const reeffect = createReEffect('test_queue_strategy', { 403 | strategy: QUEUE, 404 | async handler(params) { 405 | return new Promise(resolve => 406 | setTimeout(() => resolve(`test${params}`), 100) 407 | ) 408 | }, 409 | }) 410 | 411 | reeffect.done.watch(fn) 412 | reeffect.fail.watch(fn) 413 | reeffect.cancelled.watch(fn) 414 | reeffect.finally.watch(fn) 415 | reeffect.pending.watch(fn) 416 | 417 | const result = await Promise.all([reeffect(11), reeffect(22)]) 418 | expect(result).toEqual(['test11', 'test22']) 419 | 420 | expect(fn).toHaveBeenCalledTimes(5) 421 | 422 | // pending 423 | expect(fn.mock.calls[0][0]).toEqual(false) 424 | expect(fn.mock.calls[1][0]).toEqual(true) 425 | 426 | // finally event 427 | expect(fn.mock.calls[2][0]).toEqual({ 428 | params: 22, 429 | result: 'test22', 430 | status: 'done', 431 | }) 432 | 433 | // done event 434 | expect(fn.mock.calls[3][0]).toEqual({ 435 | params: 22, 436 | result: 'test22', 437 | }) 438 | 439 | // pending 440 | expect(fn.mock.calls[4][0]).toEqual(false) 441 | }) 442 | 443 | test('createReEffect with QUEUE strategy and cancel', async () => { 444 | expect.assertions(7) 445 | const fn = jest.fn() 446 | 447 | const createReEffect = createReEffectFactory() 448 | const reeffect = createReEffect('test_queue_strategy', { 449 | strategy: QUEUE, 450 | async handler(params) { 451 | return new Promise(resolve => 452 | setTimeout(() => resolve(`test${params}`), 1000) 453 | ) 454 | }, 455 | }) 456 | 457 | reeffect.done.watch(fn) 458 | reeffect.fail.watch(fn) 459 | reeffect.cancelled.watch(fn) 460 | reeffect.finally.watch(fn) 461 | reeffect.pending.watch(fn) 462 | 463 | const running = Promise.all([ 464 | reeffect(11).catch(error => error), 465 | reeffect(22).catch(error => error), 466 | ]) 467 | reeffect.cancel() 468 | const result = await running 469 | 470 | expect(result).toEqual([ 471 | expect.any(CancelledError), 472 | expect.any(CancelledError), 473 | ]) 474 | 475 | expect(fn).toHaveBeenCalledTimes(5) 476 | 477 | // pending 478 | expect(fn.mock.calls[0][0]).toEqual(false) 479 | expect(fn.mock.calls[1][0]).toEqual(true) 480 | 481 | // cancelled event 482 | expect(fn.mock.calls[2][0]).toEqual({ 483 | params: 11, 484 | error: expect.any(CancelledError), 485 | }) 486 | 487 | // cancelled event 488 | expect(fn.mock.calls[3][0]).toEqual({ 489 | params: 22, 490 | error: expect.any(CancelledError), 491 | }) 492 | 493 | // pending 494 | expect(fn.mock.calls[4][0]).toEqual(false) 495 | }) 496 | 497 | test('createReEffect with QUEUE strategy and change handler', async () => { 498 | expect.assertions(10) 499 | const fn = jest.fn() 500 | 501 | const createReEffect = createReEffectFactory() 502 | const reeffect = createReEffect('test_queue_strategy', { 503 | strategy: QUEUE, 504 | async handler(params) { 505 | return new Promise(resolve => 506 | setTimeout(() => resolve(`first${params}`), 100) 507 | ) 508 | }, 509 | }) 510 | 511 | reeffect.done.watch(fn) 512 | reeffect.fail.watch(fn) 513 | reeffect.cancelled.watch(fn) 514 | reeffect.finally.watch(fn) 515 | reeffect.pending.watch(fn) 516 | 517 | reeffect(1).catch(error => error) 518 | const fx = reeffect(2).catch(error => error) 519 | 520 | reeffect.use( 521 | params => 522 | new Promise(resolve => 523 | setTimeout(() => resolve(`second${params}`), 100) 524 | ) 525 | ) 526 | 527 | await fx 528 | await reeffect(3).catch(error => error) 529 | 530 | expect(fn).toHaveBeenCalledTimes(9) 531 | 532 | // pending 533 | expect(fn.mock.calls[0][0]).toEqual(false) 534 | expect(fn.mock.calls[1][0]).toEqual(true) 535 | 536 | // finally event 537 | expect(fn.mock.calls[2][0]).toEqual({ 538 | params: 2, 539 | result: 'first2', 540 | status: 'done', 541 | }) 542 | 543 | // done event 544 | expect(fn.mock.calls[3][0]).toEqual({ 545 | params: 2, 546 | result: 'first2', 547 | }) 548 | 549 | // pending 550 | expect(fn.mock.calls[4][0]).toEqual(false) 551 | expect(fn.mock.calls[5][0]).toEqual(true) 552 | 553 | // finally event 554 | expect(fn.mock.calls[6][0]).toEqual({ 555 | params: 3, 556 | result: 'second3', 557 | status: 'done', 558 | }) 559 | 560 | // done event 561 | expect(fn.mock.calls[7][0]).toEqual({ 562 | params: 3, 563 | result: 'second3', 564 | }) 565 | 566 | // pending 567 | expect(fn.mock.calls[8][0]).toEqual(false) 568 | }) 569 | 570 | test('createReEffect with RACE strategy', async () => { 571 | expect.assertions(8) 572 | const fn = jest.fn() 573 | 574 | const createReEffect = createReEffectFactory() 575 | const reeffect = createReEffect({ 576 | feedback: true, 577 | async handler(params) { 578 | return new Promise(resolve => 579 | setTimeout(() => resolve(`resolve${params}`), params) 580 | ) 581 | }, 582 | }) 583 | 584 | reeffect.done.watch(fn) 585 | reeffect.fail.watch(fn) 586 | reeffect.cancelled.watch(fn) 587 | reeffect.finally.watch(fn) 588 | reeffect.pending.watch(fn) 589 | 590 | const result = await Promise.all([ 591 | reeffect(1000).catch(error => error), 592 | reeffect(100, { strategy: RACE }), 593 | ]) 594 | expect(result).toEqual([expect.any(CancelledError), 'resolve100']) 595 | 596 | expect(fn).toHaveBeenCalledTimes(6) 597 | 598 | // pending 599 | expect(fn.mock.calls[0][0]).toEqual(false) 600 | expect(fn.mock.calls[1][0]).toEqual(true) 601 | 602 | // finally event 603 | expect(fn.mock.calls[2][0]).toEqual({ 604 | params: 100, 605 | strategy: RACE, 606 | result: 'resolve100', 607 | status: 'done', 608 | }) 609 | 610 | // done event 611 | expect(fn.mock.calls[3][0]).toEqual({ 612 | params: 100, 613 | strategy: RACE, 614 | result: 'resolve100', 615 | }) 616 | 617 | // cancelled event 618 | expect(fn.mock.calls[4][0]).toEqual({ 619 | params: 1000, 620 | strategy: TAKE_EVERY, 621 | error: expect.any(CancelledError), 622 | }) 623 | 624 | // pending 625 | expect(fn.mock.calls[5][0]).toEqual(false) 626 | }) 627 | 628 | test('createReEffect with manual cancellation', async () => { 629 | expect.assertions(7) 630 | const fn = jest.fn() 631 | 632 | const createReEffect = createReEffectFactory() 633 | const reeffect = createReEffect({ 634 | async handler(params) { 635 | return new Promise(resolve => 636 | setImmediate(() => resolve(`test${params}`)) 637 | ) 638 | }, 639 | }) 640 | 641 | reeffect.done.watch(fn) 642 | reeffect.fail.watch(fn) 643 | reeffect.cancelled.watch(fn) 644 | reeffect.finally.watch(fn) 645 | reeffect.pending.watch(fn) 646 | 647 | const running = Promise.all([ 648 | reeffect(11).catch(error => error), 649 | reeffect(22).catch(error => error), 650 | ]) 651 | reeffect.cancel() 652 | const result = await running 653 | expect(result).toEqual([ 654 | expect.any(CancelledError), 655 | expect.any(CancelledError), 656 | ]) 657 | 658 | expect(fn).toHaveBeenCalledTimes(5) 659 | 660 | // pending 661 | expect(fn.mock.calls[0][0]).toEqual(false) 662 | expect(fn.mock.calls[1][0]).toEqual(true) 663 | 664 | // cancelled event 665 | expect(fn.mock.calls[2][0]).toEqual({ 666 | params: 11, 667 | error: expect.any(CancelledError), 668 | }) 669 | 670 | // cancelled event 671 | expect(fn.mock.calls[3][0]).toEqual({ 672 | params: 22, 673 | error: expect.any(CancelledError), 674 | }) 675 | 676 | // pending 677 | expect(fn.mock.calls[4][0]).toEqual(false) 678 | }) 679 | 680 | test('createReEffect with logic cancel callback', async () => { 681 | expect.assertions(3) 682 | const fn = jest.fn() 683 | 684 | const createReEffect = createReEffectFactory() 685 | const reeffect = createReEffect({ 686 | async handler(params, onCancel) { 687 | let timeout 688 | onCancel(() => clearTimeout(timeout)) 689 | return new Promise(resolve => { 690 | timeout = setTimeout(() => { 691 | fn(`logic${params}`) 692 | resolve(`test${params}`) 693 | }, 1) 694 | }) 695 | }, 696 | }) 697 | 698 | const result = await Promise.all([ 699 | reeffect(11).catch(error => error), 700 | reeffect(22, { strategy: TAKE_LAST }), 701 | ]) 702 | expect(result).toEqual([expect.any(CancelledError), 'test22']) 703 | expect(fn).toHaveBeenCalledTimes(1) 704 | expect(fn.mock.calls[0][0]).toEqual('logic22') 705 | }) 706 | 707 | test('createReEffect with limit', async () => { 708 | expect.assertions(1) 709 | 710 | const createReEffect = createReEffectFactory() 711 | const reeffect = createReEffect({ 712 | limit: 1, 713 | handler: () => 714 | new Promise(resolve => setImmediate(() => resolve('test'))), 715 | }) 716 | 717 | const result = await Promise.all([ 718 | reeffect(11), 719 | reeffect(22).catch(error => error), 720 | ]) 721 | expect(result).toEqual(['test', expect.any(LimitExceededError)]) 722 | }) 723 | 724 | test('createReEffect use should change handler', async () => { 725 | expect.assertions(4) 726 | 727 | const handler1 = () => 728 | new Promise(resolve => setImmediate(() => resolve('handler1'))) 729 | const handler2 = () => 730 | new Promise(resolve => setImmediate(() => resolve('handler2'))) 731 | 732 | const createReEffect = createReEffectFactory() 733 | const reeffect = createReEffect({ handler: handler1 }) 734 | 735 | expect(reeffect.use.getCurrent()).toBe(handler1) 736 | expect(reeffect.use(handler2)).toBe(reeffect) 737 | expect(reeffect.use.getCurrent()).toBe(handler2) 738 | 739 | const result = await Promise.all([ 740 | reeffect(11), 741 | reeffect(22, TAKE_FIRST).catch(error => error), 742 | ]) 743 | expect(result).toEqual(['handler2', expect.any(CancelledError)]) 744 | }) 745 | 746 | test('synchronious exception in handler should reject operation', () => { 747 | expect.assertions(1) 748 | 749 | const createReEffect = createReEffectFactory() 750 | const reeffect = createReEffect() 751 | reeffect.use(() => { 752 | throw 'error' // tslint:disable-line no-string-throw 753 | }) 754 | 755 | return reeffect().catch(error => expect(error).toBe('error')) 756 | }) 757 | 758 | test('createReEffect with Effector API, success', async () => { 759 | expect.assertions(3) 760 | const fn = jest.fn() 761 | 762 | const createReEffect = createReEffectFactory() 763 | const reeffect = createReEffect({ 764 | async handler() { 765 | return new Promise(resolve => setImmediate(() => resolve('test'))) 766 | }, 767 | }) 768 | 769 | reeffect.done.watch(fn) 770 | reeffect.fail.watch(fn) 771 | reeffect.cancelled.watch(fn) 772 | reeffect.finally.watch(fn) 773 | 774 | const event = createEvent() 775 | forward({ 776 | from: event, 777 | to: reeffect, 778 | }) 779 | event() 780 | 781 | await new Promise(resolve => setTimeout(resolve, 10)) 782 | 783 | expect(fn).toHaveBeenCalledTimes(2) 784 | 785 | // finally event 786 | expect(fn.mock.calls[0][0]).toEqual({ 787 | params: undefined, 788 | result: 'test', 789 | status: 'done', 790 | }) 791 | 792 | // done event 793 | expect(fn.mock.calls[1][0]).toEqual({ 794 | params: undefined, 795 | result: 'test', 796 | }) 797 | }) 798 | 799 | test('createReEffect with Effector API, failure', async () => { 800 | expect.assertions(3) 801 | const fn = jest.fn() 802 | 803 | const createReEffect = createReEffectFactory() 804 | const reeffect = createReEffect({ 805 | async handler() { 806 | return new Promise((_, reject) => 807 | setImmediate(() => reject('test')) 808 | ) 809 | }, 810 | }) 811 | 812 | reeffect.done.watch(fn) 813 | reeffect.fail.watch(fn) 814 | reeffect.cancelled.watch(fn) 815 | reeffect.finally.watch(fn) 816 | 817 | const event = createEvent() 818 | forward({ 819 | from: event, 820 | to: reeffect, 821 | }) 822 | event() 823 | 824 | await new Promise(resolve => setTimeout(resolve, 10)) 825 | 826 | expect(fn).toHaveBeenCalledTimes(2) 827 | 828 | // finally event 829 | expect(fn.mock.calls[0][0]).toEqual({ 830 | params: undefined, 831 | error: 'test', 832 | status: 'fail', 833 | }) 834 | 835 | // done event 836 | expect(fn.mock.calls[1][0]).toEqual({ 837 | params: undefined, 838 | error: 'test', 839 | }) 840 | }) 841 | 842 | test('createReEffect with feedback', async () => { 843 | expect.assertions(4) 844 | const fn = jest.fn() 845 | 846 | const createReEffect = createReEffectFactory() 847 | const reeffect = createReEffect({ 848 | feedback: true, 849 | async handler() { 850 | return new Promise(resolve => 851 | setTimeout(() => resolve('test'), 10) 852 | ) 853 | }, 854 | }) 855 | 856 | reeffect.done.watch(fn) 857 | reeffect.fail.watch(fn) 858 | reeffect.cancelled.watch(fn) 859 | reeffect.finally.watch(fn) 860 | 861 | const result = await reeffect({ strategy: TAKE_EVERY }) 862 | expect(result).toBe('test') 863 | 864 | expect(fn).toHaveBeenCalledTimes(2) 865 | 866 | // finally event 867 | expect(fn.mock.calls[0][0]).toEqual({ 868 | params: undefined, 869 | strategy: TAKE_EVERY, 870 | result: 'test', 871 | status: 'done', 872 | }) 873 | 874 | // done event 875 | expect(fn.mock.calls[1][0]).toEqual({ 876 | params: undefined, 877 | strategy: TAKE_EVERY, 878 | result: 'test', 879 | }) 880 | }) 881 | 882 | test('createReEffect with default timeout', async () => { 883 | expect.assertions(1) 884 | 885 | const createReEffect = createReEffectFactory() 886 | const reeffect = createReEffect({ 887 | timeout: 100, 888 | handler: () => 889 | new Promise(resolve => setTimeout(() => resolve('test'), 1000)), 890 | }) 891 | 892 | const result = await reeffect(22).catch(error => error) 893 | expect(result).toEqual(expect.any(TimeoutError)) 894 | }) 895 | 896 | test('createReEffect with effect timeout', async () => { 897 | expect.assertions(1) 898 | 899 | const createReEffect = createReEffectFactory() 900 | const reeffect = createReEffect({ 901 | handler: () => 902 | new Promise(resolve => setTimeout(() => resolve('test'), 100)), 903 | }) 904 | 905 | const result = await Promise.all([ 906 | reeffect({ params: 11, timeout: 10 }).catch(error => error), 907 | reeffect(22), 908 | reeffect(33, { timeout: 50 }).catch(error => error), 909 | ]) 910 | expect(result).toEqual([ 911 | expect.any(TimeoutError), 912 | 'test', 913 | expect.any(TimeoutError), 914 | ]) 915 | }) 916 | -------------------------------------------------------------------------------- /src/createReEffect.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEffect as effectorCreateEffect, 3 | createEvent, 4 | createStore, 5 | } from 'effector' 6 | import { CreateReEffect, CreateReEffectConfig, ReEffect } from './types' 7 | import { patchInstance } from './instance' 8 | import { patchRunner } from './runner' 9 | import { TAKE_EVERY } from './strategy' 10 | 11 | /** 12 | * High-order function over createEffect 13 | * Creates `createReEffect` function, based on given `createEffect` 14 | */ 15 | export const createReEffectFactory = ( 16 | createEffect = effectorCreateEffect 17 | ): CreateReEffect => ( 18 | nameOrConfig?: string | CreateReEffectConfig, 19 | maybeConfig?: CreateReEffectConfig 20 | ): ReEffect => { 21 | const instance = (createEffect as any)(nameOrConfig, maybeConfig) 22 | const cancelled = (createEvent as any)({ named: 'cancelled' }) 23 | const cancel = (createEvent as any)({ named: 'cancel' }) 24 | // Separate instance of inFlight is created, 25 | // because inFlight will be force-synchronized with internal runningCount 26 | // Doing this with actual inFlight instance leads to crazy bugs like negative inFlight count 27 | // Not doing this at all leads to changes in observable behaviour of ReEffect 28 | const inFlightInternal = (createStore as any)(0, { 29 | named: 'reeffectInFlight', 30 | }).on(instance, s => s + 1) 31 | const pendingInternal = inFlightInternal.map({ 32 | fn: amount => amount > 0, 33 | named: 'reeffectPending', 34 | }) 35 | 36 | // prettier-ignore 37 | const config = 38 | maybeConfig 39 | ? maybeConfig 40 | : nameOrConfig && typeof nameOrConfig === 'object' 41 | ? nameOrConfig 42 | : {} 43 | 44 | const scope = { 45 | strategy: config.strategy || TAKE_EVERY, 46 | feedback: config.feedback || false, 47 | limit: config.limit || Infinity, 48 | timeout: config.timeout, 49 | cancelled, 50 | cancel, 51 | inFlight: inFlightInternal, 52 | pending: pendingInternal, 53 | anyway: instance.finally, 54 | } 55 | 56 | patchRunner(instance.graphite.scope.runner, scope as any) 57 | patchInstance(instance, scope) 58 | 59 | return instance 60 | } 61 | -------------------------------------------------------------------------------- /src/error.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CancelledError, 3 | LimitExceededError, 4 | ReEffectError, 5 | TimeoutError, 6 | } from './error' 7 | import { QUEUE, RACE, TAKE_EVERY, TAKE_FIRST, TAKE_LAST } from './strategy' 8 | 9 | test('ReEffectError class', () => { 10 | const err = new ReEffectError('test') 11 | expect(err instanceof Error).toBe(true) 12 | expect(err.stack).toBeUndefined() 13 | expect(err.message).toBe('test') 14 | }) 15 | 16 | test('CancelledError class', () => { 17 | expect(new CancelledError() instanceof ReEffectError).toBe(true) 18 | expect(new CancelledError(QUEUE) instanceof ReEffectError).toBe(true) 19 | expect(new CancelledError(RACE) instanceof ReEffectError).toBe(true) 20 | expect(new CancelledError(TAKE_EVERY) instanceof ReEffectError).toBe(true) 21 | expect(new CancelledError(TAKE_FIRST) instanceof ReEffectError).toBe(true) 22 | expect(new CancelledError(TAKE_LAST) instanceof ReEffectError).toBe(true) 23 | }) 24 | 25 | test('LimitExceededError class', () => { 26 | expect(new LimitExceededError(1, 1) instanceof ReEffectError).toBe(true) 27 | }) 28 | 29 | test('TimeoutError class', () => { 30 | expect(new TimeoutError(1) instanceof ReEffectError).toBe(true) 31 | }) 32 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | import { RACE, Strategy, TAKE_FIRST, TAKE_LAST } from './strategy' 2 | 3 | export class ReEffectError extends Error { 4 | constructor(message: string) { 5 | super(message) 6 | 7 | // stacktrace is useless here (because of async nature), so remove it 8 | delete this.stack 9 | } 10 | } 11 | 12 | export class CancelledError extends ReEffectError { 13 | constructor(strategy: Strategy | 'cancel' = 'cancel') { 14 | // prettier-ignore 15 | super( 16 | 'Cancelled due to "' + strategy + '"' + ({ 17 | [TAKE_FIRST]: `, there are already running effects`, 18 | [TAKE_LAST]: `, new effect was added`, 19 | [RACE]: `, other effect won race`, 20 | cancel: `, cancel all already running effects`, 21 | }[strategy] || `, but should not happen...`) 22 | ) 23 | } 24 | } 25 | 26 | export class LimitExceededError extends ReEffectError { 27 | constructor(limit: number, running: number) { 28 | // prettier-ignore 29 | super( 30 | 'Cancelled due to limit of "' + limit + '" exceeded,' + 31 | 'there are already ' + running + ' running effects' 32 | ) 33 | } 34 | } 35 | 36 | export class TimeoutError extends ReEffectError { 37 | constructor(timeout: number) { 38 | super('Cancelled due to timeout of "' + timeout + '"ms') 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/fork.spec.ts: -------------------------------------------------------------------------------- 1 | import { createDomain, forward, scopeBind, guard } from 'effector' 2 | import { fork, serialize, allSettled } from 'effector' 3 | import { createReEffectFactory } from './createReEffect' 4 | import { TAKE_FIRST, TAKE_LAST, QUEUE, RACE } from './strategy' 5 | 6 | test('createReEffect resolves in fork by default', async () => { 7 | const createReEffect = createReEffectFactory() 8 | 9 | const app = createDomain() 10 | const start = app.createEvent() 11 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 12 | const reeffect = createReEffect({ 13 | async handler() { 14 | return 5 15 | }, 16 | }) 17 | 18 | $store.on(reeffect.done, (state, { result }) => state + result) 19 | 20 | forward({ 21 | from: start, 22 | to: reeffect, 23 | }) 24 | 25 | const scope = fork(app) 26 | 27 | await allSettled(start, { 28 | scope, 29 | params: undefined, 30 | }) 31 | 32 | expect(serialize(scope)).toMatchInlineSnapshot(` 33 | Object { 34 | "$store": 5, 35 | } 36 | `) 37 | }) 38 | 39 | test('createReEffect do not affect other forks', async () => { 40 | const createReEffect = createReEffectFactory() 41 | 42 | const app = createDomain() 43 | const start = app.createEvent() 44 | const $store = app.createStore(0, { sid: '$store' }) 45 | const reeffect = createReEffect({ 46 | async handler(param: number, onCancel) { 47 | await new Promise((rs, rj) => { 48 | let id = setTimeout(() => { 49 | rs() 50 | }, 5 * param) 51 | 52 | onCancel(() => { 53 | clearTimeout(id) 54 | rj() 55 | }) 56 | }) 57 | 58 | return 5 * param 59 | }, 60 | }) 61 | 62 | $store.on(reeffect.done, (state, { result }) => state + result) 63 | 64 | forward({ 65 | from: start, 66 | to: reeffect, 67 | }) 68 | 69 | guard({ 70 | source: start, 71 | filter: p => p === 1000, 72 | }).watch(p => { 73 | const scopedCancel = scopeBind(reeffect.cancel) 74 | 75 | setTimeout(() => scopedCancel(), p) 76 | }) 77 | 78 | const scopeAlice = fork(app) 79 | const scopeBob = fork(app) 80 | 81 | await allSettled(start, { 82 | scope: scopeAlice, 83 | params: 10, 84 | }) 85 | 86 | await allSettled(start, { 87 | scope: scopeBob, 88 | params: 1000, 89 | }) 90 | 91 | expect(serialize(scopeAlice)).toMatchInlineSnapshot(` 92 | Object { 93 | "$store": 50, 94 | } 95 | `) 96 | 97 | expect(serialize(scopeBob)).toMatchInlineSnapshot(`Object {}`) 98 | }) 99 | 100 | test('createReEffect cancelled reeffect do not affect other forks', async () => { 101 | const createReEffect = createReEffectFactory() 102 | 103 | const app = createDomain() 104 | const start = app.createEvent() 105 | const $store = app.createStore(0, { sid: '$store' }) 106 | const reeffect = createReEffect({ 107 | async handler(param: number) { 108 | return 5 * param 109 | }, 110 | }) 111 | 112 | $store.on(reeffect.done, (state, { result }) => state + result) 113 | 114 | forward({ 115 | from: start, 116 | to: reeffect, 117 | }) 118 | 119 | const scopeAlice = fork(app) 120 | const scopeBob = fork(app) 121 | 122 | await allSettled(start, { 123 | scope: scopeAlice, 124 | params: 10, 125 | }) 126 | 127 | await allSettled(start, { 128 | scope: scopeBob, 129 | params: 1000, 130 | }) 131 | 132 | expect(serialize(scopeAlice)).toMatchInlineSnapshot(` 133 | Object { 134 | "$store": 50, 135 | } 136 | `) 137 | 138 | expect(serialize(scopeBob)).toMatchInlineSnapshot(` 139 | Object { 140 | "$store": 5000, 141 | } 142 | `) 143 | }) 144 | 145 | test('createReEffect in fork do not affect domain', async () => { 146 | const createReEffect = createReEffectFactory() 147 | 148 | const app = createDomain() 149 | const start = app.createEvent() 150 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 151 | const reeffect = createReEffect({ 152 | async handler() { 153 | return 5 154 | }, 155 | }) 156 | 157 | $store.on(reeffect.done, (state, { result }) => state + result) 158 | 159 | forward({ 160 | from: start, 161 | to: reeffect, 162 | }) 163 | 164 | const scope = fork(app) 165 | 166 | await allSettled(start, { 167 | scope, 168 | params: undefined, 169 | }) 170 | 171 | expect($store.getState()).toMatchInlineSnapshot(`0`) 172 | }) 173 | 174 | test('createReEffect resolves in scope with scopeBind', async () => { 175 | const app = createDomain() 176 | const createReEffect = createReEffectFactory(app.createEffect) 177 | const start = app.createEvent() 178 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 179 | const reeffect = createReEffect({ 180 | handler(p) { 181 | return p 182 | }, 183 | }) 184 | 185 | start.watch(() => { 186 | const bindReeffect = scopeBind(reeffect) 187 | 188 | bindReeffect(5) 189 | }) 190 | 191 | $store.on(reeffect.done, (state, { result }) => state + result) 192 | 193 | const scope = fork(app) 194 | 195 | await allSettled(start, { 196 | scope, 197 | params: undefined, 198 | }) 199 | 200 | expect(serialize(scope)).toMatchInlineSnapshot(` 201 | Object { 202 | "$store": 5, 203 | } 204 | `) 205 | }) 206 | 207 | test('async createReEffect resolves in scope with scopeBind', async () => { 208 | const app = createDomain() 209 | const createReEffect = createReEffectFactory(app.createEffect) 210 | const start = app.createEvent() 211 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 212 | const reeffect = createReEffect({ 213 | async handler(p) { 214 | return new Promise(resolve => setTimeout(() => resolve(p), 30)) 215 | }, 216 | }) 217 | 218 | start.watch(() => { 219 | const bindReeffect = scopeBind(reeffect) 220 | 221 | bindReeffect(5) 222 | }) 223 | 224 | $store.on(reeffect.done, (state, { result }) => state + result) 225 | 226 | const scope = fork(app) 227 | 228 | await allSettled(start, { 229 | scope, 230 | params: undefined, 231 | }) 232 | 233 | expect(serialize(scope)).toMatchInlineSnapshot(` 234 | Object { 235 | "$store": 5, 236 | } 237 | `) 238 | }) 239 | 240 | test('createReEffect resolves in scope when called as `inner effect`', async () => { 241 | const app = createDomain() 242 | const createReEffect = createReEffectFactory(app.createEffect) 243 | const start = app.createEvent() 244 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 245 | const reeffect = createReEffect({ 246 | async handler(p) { 247 | return new Promise(resolve => setTimeout(() => resolve(p), 30)) 248 | }, 249 | }) 250 | const effect = app.createEffect(async () => { 251 | await reeffect(5) 252 | }) 253 | 254 | $store.on(reeffect.done, (state, { result }) => state + result) 255 | 256 | forward({ 257 | from: start, 258 | to: effect, 259 | }) 260 | 261 | const scope = fork(app) 262 | 263 | await allSettled(start, { 264 | scope, 265 | params: undefined, 266 | }) 267 | 268 | expect(serialize(scope)).toMatchInlineSnapshot(` 269 | Object { 270 | "$store": 5, 271 | } 272 | `) 273 | }) 274 | 275 | test('createReEffect in scope: cancelled reeffect does not hanging up `allSettled`', async () => { 276 | const app = createDomain() 277 | const createReEffect = createReEffectFactory(app.createEffect) 278 | const start = app.createEvent() 279 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 280 | const reeffect = createReEffect({ 281 | async handler(p) { 282 | await new Promise(r => setTimeout(r, 50)) 283 | 284 | return Promise.resolve(p) 285 | }, 286 | }) 287 | const delayFx = app.createEffect( 288 | async t => new Promise(r => setTimeout(() => r(t), t)) 289 | ) 290 | 291 | forward({ 292 | from: start, 293 | to: delayFx.prepend(() => 25), 294 | }) 295 | 296 | forward({ 297 | from: start, 298 | to: reeffect.prepend(() => 5), 299 | }) 300 | 301 | forward({ 302 | from: delayFx.done, 303 | to: reeffect.cancel, 304 | }) 305 | 306 | $store.on(reeffect.done, (state, { result }) => state + result) 307 | 308 | const scope = fork(app) 309 | 310 | await allSettled(start, { 311 | scope, 312 | params: undefined, 313 | }) 314 | 315 | expect(scope.getState($store)).toEqual(0) 316 | expect(serialize(scope)).toMatchInlineSnapshot(`Object {}`) // store is not changed, so it must be not serialized 317 | }) 318 | 319 | test('createReEffect in scope: failed reeffect does not hanging up `allSettled` and resolves in scope correctly', async () => { 320 | const fail = jest.fn() 321 | 322 | const app = createDomain() 323 | const createReEffect = createReEffectFactory(app.createEffect) 324 | const start = app.createEvent() 325 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 326 | const reeffect = createReEffect({ 327 | async handler() { 328 | await new Promise(r => setTimeout(r, 30)) 329 | 330 | throw new Error('failed!') 331 | }, 332 | }) 333 | 334 | reeffect.fail.watch(fail) 335 | 336 | forward({ 337 | from: start, 338 | to: reeffect.prepend(() => 5), 339 | }) 340 | 341 | $store.on(reeffect.done, (state, { result }) => state + result) 342 | $store.on(reeffect.fail, () => -1) 343 | 344 | const scope = fork(app) 345 | 346 | await allSettled(start, { 347 | scope, 348 | params: undefined, 349 | }) 350 | 351 | expect(fail).toBeCalledTimes(1) 352 | 353 | expect(serialize(scope)).toMatchInlineSnapshot(` 354 | Object { 355 | "$store": -1, 356 | } 357 | `) 358 | }) 359 | 360 | test('createReEffect in scope: multiple calls aren`t hanging up `allSettled`', async () => { 361 | const cancelled = jest.fn() 362 | 363 | const app = createDomain() 364 | const createReEffect = createReEffectFactory(app.createEffect) 365 | const start = app.createEvent() 366 | const secondTrigger = app.createEvent() 367 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 368 | const reeffect = createReEffect({ 369 | async handler() { 370 | return new Promise(resolve => setTimeout(() => resolve(5), 30)) 371 | }, 372 | }) 373 | 374 | reeffect.cancelled.watch(cancelled) 375 | 376 | forward({ 377 | from: start, 378 | to: reeffect, 379 | }) 380 | 381 | forward({ 382 | from: start, 383 | to: secondTrigger, 384 | }) 385 | 386 | forward({ 387 | from: secondTrigger, 388 | to: reeffect, 389 | }) 390 | 391 | $store.on(reeffect.done, (state, { result }) => state + result) 392 | 393 | const scope = fork(app) 394 | 395 | await allSettled(start, { 396 | scope, 397 | params: undefined, 398 | }) 399 | 400 | expect(serialize(scope)).toMatchInlineSnapshot(` 401 | Object { 402 | "$store": 5, 403 | } 404 | `) 405 | }) 406 | 407 | test('createReEffect in scope: handler for scope works', async () => { 408 | const createReEffect = createReEffectFactory() 409 | 410 | const app = createDomain() 411 | const start = app.createEvent() 412 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 413 | const reeffect = createReEffect({ 414 | async handler() { 415 | return 5 416 | }, 417 | sid: 'reeffect', 418 | }) 419 | 420 | $store.on(reeffect.done, (state, { result }) => state + result) 421 | 422 | forward({ 423 | from: start, 424 | to: reeffect, 425 | }) 426 | 427 | const scope = fork({ 428 | handlers: [[reeffect, async () => 7]], 429 | }) 430 | 431 | await allSettled(start, { 432 | scope, 433 | params: undefined, 434 | }) 435 | 436 | expect(serialize(scope)).toMatchInlineSnapshot(` 437 | Object { 438 | "$store": 7, 439 | } 440 | `) 441 | }) 442 | 443 | test('createReEffect in scope: TAKE_EVERY', async () => { 444 | const cancelled = jest.fn() 445 | 446 | const app = createDomain() 447 | const createReEffect = createReEffectFactory(app.createEffect) 448 | const start = app.createEvent() 449 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 450 | const reeffect = createReEffect({ 451 | async handler(p) { 452 | return new Promise(resolve => setTimeout(() => resolve(p), 30)) 453 | }, 454 | }) 455 | 456 | reeffect.cancelled.watch(cancelled) 457 | 458 | start.watch(() => { 459 | const bindReeffect = scopeBind(reeffect) 460 | 461 | bindReeffect(1) 462 | bindReeffect(2) 463 | bindReeffect(5) 464 | }) 465 | 466 | $store.on(reeffect.done, (state, { result }) => state + result) 467 | 468 | const scope = fork(app) 469 | 470 | await allSettled(start, { 471 | scope, 472 | params: undefined, 473 | }) 474 | 475 | expect(serialize(scope)).toMatchInlineSnapshot(` 476 | Object { 477 | "$store": 5, 478 | } 479 | `) 480 | }) 481 | 482 | test('createReEffect in scope: TAKE_FIRST', async () => { 483 | const cancelled = jest.fn() 484 | 485 | const app = createDomain() 486 | const createReEffect = createReEffectFactory(app.createEffect) 487 | const start = app.createEvent() 488 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 489 | const reeffect = createReEffect({ 490 | async handler(p) { 491 | return new Promise(resolve => setTimeout(() => resolve(p), 30)) 492 | }, 493 | strategy: TAKE_FIRST, 494 | }) 495 | 496 | reeffect.cancelled.watch(cancelled) 497 | 498 | start.watch(() => { 499 | const bindReeffect = scopeBind(reeffect) 500 | 501 | bindReeffect(5) 502 | bindReeffect(2) 503 | bindReeffect(3) 504 | }) 505 | 506 | $store.on(reeffect.done, (state, { result }) => state + result) 507 | 508 | const scope = fork(app) 509 | 510 | await allSettled(start, { 511 | scope, 512 | params: undefined, 513 | }) 514 | 515 | expect(cancelled).toBeCalledTimes(2) 516 | 517 | expect(serialize(scope)).toMatchInlineSnapshot(` 518 | Object { 519 | "$store": 5, 520 | } 521 | `) 522 | }) 523 | 524 | test('createReEffect in scope: TAKE_LAST', async () => { 525 | const cancelled = jest.fn() 526 | 527 | const app = createDomain() 528 | const createReEffect = createReEffectFactory(app.createEffect) 529 | const start = app.createEvent() 530 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 531 | const reeffect = createReEffect({ 532 | async handler(p) { 533 | return new Promise(resolve => setTimeout(() => resolve(p), 30)) 534 | }, 535 | strategy: TAKE_LAST, 536 | }) 537 | 538 | reeffect.cancelled.watch(cancelled) 539 | 540 | start.watch(() => { 541 | const bindReeffect = scopeBind(reeffect) 542 | 543 | bindReeffect(5) 544 | bindReeffect(2) 545 | bindReeffect(5) 546 | }) 547 | 548 | $store.on(reeffect.done, (state, { result }) => state + result) 549 | 550 | const scope = fork(app) 551 | 552 | await allSettled(start, { 553 | scope, 554 | params: undefined, 555 | }) 556 | 557 | expect(cancelled).toBeCalledTimes(2) 558 | 559 | expect(serialize(scope)).toMatchInlineSnapshot(` 560 | Object { 561 | "$store": 5, 562 | } 563 | `) 564 | }) 565 | 566 | test('createReEffect in scope: QUEUE', async () => { 567 | const cancelled = jest.fn() 568 | 569 | const app = createDomain() 570 | const createReEffect = createReEffectFactory(app.createEffect) 571 | const start = app.createEvent() 572 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 573 | const reeffect = createReEffect({ 574 | async handler(p) { 575 | return new Promise(resolve => 576 | setTimeout(() => { 577 | resolve(p) 578 | }, 30) 579 | ) 580 | }, 581 | strategy: QUEUE, 582 | }) 583 | 584 | reeffect.cancelled.watch(cancelled) 585 | 586 | start.watch(() => { 587 | const bindReeffect = scopeBind(reeffect) 588 | 589 | bindReeffect(1) 590 | bindReeffect(2) 591 | bindReeffect(3) 592 | bindReeffect(4) 593 | bindReeffect(5) 594 | }) 595 | 596 | $store.on(reeffect.done, (state, { result }) => { 597 | return state + result 598 | }) 599 | 600 | const scope = fork(app) 601 | 602 | await allSettled(start, { 603 | scope, 604 | params: undefined, 605 | }) 606 | 607 | expect(serialize(scope)).toMatchInlineSnapshot(` 608 | Object { 609 | "$store": 5, 610 | } 611 | `) 612 | }) 613 | 614 | test('createReEffect in scope: RACE', async () => { 615 | const cancelled = jest.fn() 616 | 617 | const app = createDomain() 618 | const createReEffect = createReEffectFactory(app.createEffect) 619 | const start = app.createEvent() 620 | const $store = app.createStore(0, { name: '$store', sid: '$store' }) 621 | const reeffect = createReEffect({ 622 | async handler(p) { 623 | const timeout = p === 5 ? 10 : 20 624 | 625 | return new Promise(resolve => 626 | setTimeout(() => resolve(p), timeout) 627 | ) 628 | }, 629 | strategy: RACE, 630 | }) 631 | 632 | reeffect.cancelled.watch(cancelled) 633 | 634 | start.watch(() => { 635 | const bindReeffect = scopeBind(reeffect) 636 | 637 | bindReeffect(2) 638 | bindReeffect(2) 639 | bindReeffect(5) 640 | }) 641 | 642 | $store.on(reeffect.done, (state, { result }) => state + result) 643 | 644 | const scope = fork(app) 645 | 646 | await allSettled(start, { 647 | scope, 648 | params: undefined, 649 | }) 650 | 651 | expect(cancelled).toBeCalledTimes(2) 652 | 653 | expect(serialize(scope)).toMatchInlineSnapshot(` 654 | Object { 655 | "$store": 5, 656 | } 657 | `) 658 | }) 659 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { is } from 'effector' 2 | import { createReEffect } from './index' 3 | 4 | test('default createReEffect', () => { 5 | const reeffect = createReEffect() 6 | expect(is.effect(reeffect)).toBe(true) 7 | }) 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createReEffectFactory } from './createReEffect' 2 | 3 | export { QUEUE, RACE, TAKE_EVERY, TAKE_FIRST, TAKE_LAST } from './strategy' 4 | export { 5 | CancelledError, 6 | LimitExceededError, 7 | ReEffectError, 8 | TimeoutError, 9 | } from './error' 10 | export { ReEffect, CreateReEffectConfig, ReEffectConfig } from './types' 11 | 12 | export { createReEffectFactory } 13 | export const createReEffect = createReEffectFactory() 14 | -------------------------------------------------------------------------------- /src/instance.ts: -------------------------------------------------------------------------------- 1 | import { Event, launch, Node as Step, step, Store } from 'effector' 2 | import { CancelledPayload, MutableReEffect, ReEffectConfig } from './types' 3 | import { defer } from './promise' 4 | import { assign, own, setMeta } from './tools' 5 | 6 | interface InstanceNewEvents { 7 | readonly cancelled: Event> & { graphite: Step } 8 | readonly cancel: Event & { graphite: Step } 9 | readonly feedback: boolean 10 | readonly inFlight: Store & { graphite: Step } 11 | readonly pending: Store & { graphite: Step } 12 | } 13 | 14 | /** 15 | * Patch effect, add new events and change direct call 16 | */ 17 | export const patchInstance = ( 18 | instance: MutableReEffect, 19 | { cancelled, cancel, feedback, inFlight, pending }: InstanceNewEvents 20 | ) => { 21 | assign(instance, { cancelled, cancel, inFlight, pending }) 22 | own(instance, [cancelled, cancel]) 23 | setMeta(cancelled, 'needFxCounter', 'dec') 24 | 25 | // adjust create function, to be able to set strategy, alongside with params 26 | instance.create = (paramsOrConfig, [strategyOrConfig]) => { 27 | // prettier-ignore 28 | const config = ( 29 | paramsOrConfig && ((paramsOrConfig as any).strategy || (paramsOrConfig as any).timeout) 30 | ? paramsOrConfig 31 | : strategyOrConfig && ((strategyOrConfig as any).strategy || (strategyOrConfig as any).timeout) 32 | ? strategyOrConfig 33 | : { strategy: strategyOrConfig } 34 | ) as ReEffectConfig 35 | 36 | const req = defer() 37 | launch(instance, { 38 | params: config === paramsOrConfig ? config.params : paramsOrConfig, 39 | args: { 40 | strategy: config.strategy, 41 | timeout: config.timeout, 42 | }, 43 | req, 44 | }) 45 | return req.req 46 | } 47 | 48 | // adjust `done`/`fail` events in case of feedback = true 49 | if (feedback) { 50 | const feedbackStep = step.compute({ 51 | fn(data, _, stack) { 52 | // https://github.com/zerobias/effector/pull/312 53 | // https://share.effector.dev/wfu6mWpc 54 | data.strategy = stack.parent.parent.value.strategy 55 | return data 56 | }, 57 | } as any) 58 | ;(instance.done as any).graphite.seq.push(feedbackStep) 59 | ;(instance.fail as any).graphite.seq.push(feedbackStep) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/promise.ts: -------------------------------------------------------------------------------- 1 | import { createEvent, createStore, Scope } from 'effector' 2 | import { CancelledError, TimeoutError } from './error' 3 | import { Strategy } from './strategy' 4 | import { assign, read } from './tools' 5 | 6 | export type CancellablePromise = Promise & { 7 | cancel: (strategy?: Strategy | void) => void 8 | } 9 | 10 | /** 11 | * Wrap promise to CancellablePromise, with `cancel` method 12 | */ 13 | export const cancellable = ( 14 | promise: PromiseLike, 15 | abort?: () => void, 16 | timeout?: number 17 | ): CancellablePromise => { 18 | let cancel: any 19 | const cancelable = new Promise((_, reject) => { 20 | let timeoutId 21 | const rejectWith: { 22 | (Cls: typeof CancelledError): (strategy?: Strategy) => void 23 | (Cls: typeof TimeoutError): (timeout: number) => void 24 | } = Cls => arg => { 25 | clearTimeout(timeoutId) 26 | reject(new Cls(arg)) 27 | abort && abort() 28 | } 29 | 30 | // set `.cancel()` callback 31 | cancel = rejectWith(CancelledError) 32 | 33 | // set timeout boundary 34 | if (typeof timeout === 'number') { 35 | timeoutId = setTimeout(rejectWith(TimeoutError), timeout, timeout) 36 | } 37 | }) 38 | 39 | // return race of two Promises, with exposed `.cancel()` method 40 | // to cancel our `cancelable` promise, created above, to finish race 41 | return assign(Promise.race([promise, cancelable]), { cancel }) 42 | } 43 | 44 | /** 45 | * Creates deferred promise 46 | */ 47 | export const defer = (): { 48 | rs: (value?: Done | PromiseLike | undefined) => void 49 | rj: (reason?: any) => void 50 | req: Promise 51 | } => { 52 | const deferred: any = {} 53 | deferred.req = new Promise((resolve, reject) => { 54 | deferred.rs = resolve 55 | deferred.rj = reject 56 | }) 57 | deferred.req.catch(() => {}) 58 | return deferred 59 | } 60 | 61 | /** 62 | * Creates running promises storage 63 | * Store is automatically forked within the scope, which allows to use reeffect easily with fork api 64 | */ 65 | export const createRunning = () => { 66 | // needed to catch the scope when cancelling manually 67 | const cancelAllEv = createEvent({ 68 | sid: 'internal/cancelAll', 69 | }) 70 | const $running = createStore[]>([], { 71 | sid: 'internal/$running', 72 | serialize: 'ignore', 73 | }) 74 | 75 | $running.watch(cancelAllEv, (runs, strategy) => 76 | runs.forEach(p => p.cancel(strategy)) 77 | ) 78 | 79 | const push = (promise: CancellablePromise, scope?: Scope) => { 80 | read(scope)($running).push(promise) 81 | } 82 | const unpush = (promise: CancellablePromise, scope?: Scope) => { 83 | if (!promise) return 84 | read(scope)($running).splice(read(scope)($running).indexOf(promise), 1) 85 | } 86 | const cancelAll = (strategy: Strategy | undefined, scope?: Scope) => { 87 | read(scope)($running).forEach(p => p.cancel(strategy)) 88 | } 89 | 90 | return { $running, push, unpush, cancelAll, cancelAllEv } 91 | } 92 | -------------------------------------------------------------------------------- /src/runner.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createEvent, 3 | createNode, 4 | Event, 5 | launch, 6 | Node as Step, 7 | Scope, 8 | step, 9 | Store, 10 | } from 'effector' 11 | import { CancelledError, LimitExceededError, ReEffectError } from './error' 12 | import { 13 | QUEUE, 14 | RACE, 15 | Strategy, 16 | TAKE_EVERY, 17 | TAKE_FIRST, 18 | TAKE_LAST, 19 | } from './strategy' 20 | import { 21 | cancellable, 22 | CancellablePromise, 23 | createRunning, 24 | defer, 25 | } from './promise' 26 | import { assign, getForkPage, read, setMeta } from './tools' 27 | import { CancelledPayload, FinallyPayload, Handler } from './types' 28 | 29 | interface RunnerParams { 30 | readonly params: Payload 31 | readonly args?: { 32 | strategy?: Strategy 33 | timeout?: number 34 | } 35 | readonly req: ReturnType 36 | readonly handler: Handler 37 | } 38 | 39 | interface RunnerScope { 40 | readonly finally: Event> 41 | readonly strategy: Strategy 42 | readonly feedback: boolean 43 | readonly limit: number 44 | readonly timeout?: number 45 | readonly cancelled: Event> 46 | readonly cancel: Event 47 | readonly running: CancellablePromise[] 48 | readonly inFlight: Store 49 | readonly anyway: Event 50 | readonly push: ReturnType['push'] 51 | readonly unpush: ReturnType['unpush'] 52 | readonly cancelAll: ReturnType['cancelAll'] 53 | readonly $running: ReturnType['$running'] 54 | readonly scope: Scope 55 | } 56 | 57 | const enum Result { 58 | DONE, 59 | FAIL, 60 | CANCEL, 61 | } 62 | 63 | /** 64 | * Patch runner, add new events and replace step sequence 65 | */ 66 | export const patchRunner = ( 67 | runner: Step, 68 | runnerScope: RunnerScope 69 | ) => { 70 | const runs = createRunning() 71 | assign(runner.scope, runnerScope) 72 | runner.seq = seq(runnerScope.anyway, runs as any) 73 | 74 | // make `cancel` event work 75 | runnerScope.cancel.watch(() => runs.cancelAllEv()) 76 | } 77 | 78 | /** 79 | * Create new sequence for runner 80 | */ 81 | const seq = ( 82 | anyway: Event, 83 | runs: ReturnType 84 | ) => [ 85 | // extract current handler of this reeffect 86 | step.compute({ 87 | safe: true, 88 | filter: false, 89 | priority: 'effect', 90 | fn(upd, scopeArg, stack) { 91 | const scope: { handlerId: string; handler: Function } = scopeArg as any 92 | let handler = scope.handler 93 | if (getForkPage(stack)) { 94 | // FIXME 95 | // @ts-expect-error 96 | const handlerArg = getForkPage(stack).handlers[scope.handlerId] 97 | if (handlerArg) handler = handlerArg 98 | } 99 | upd.handler = handler 100 | return upd 101 | }, 102 | }), 103 | step.run({ 104 | fn( 105 | { params, args, req, handler }: RunnerParams, 106 | runScope: RunnerScope, 107 | { scope }: { scope: { [id: string]: any } | null } 108 | ) { 109 | const { feedback, limit, cancelled, inFlight } = runScope 110 | let { strategy, timeout } = runScope 111 | 112 | strategy = (args && args.strategy) || strategy 113 | timeout = (args && args.timeout) || timeout 114 | 115 | const runnerScope = { 116 | params, 117 | strategy, 118 | timeout, 119 | feedback, 120 | push: runs.push, 121 | unpush: runs.unpush, 122 | cancelAll: runs.cancelAll, 123 | inFlight, 124 | scope, 125 | $running: runs.$running, 126 | } 127 | const go = run( 128 | runnerScope, 129 | handler, 130 | fin(runnerScope, anyway, Result.DONE, req.rs), 131 | fin(runnerScope, anyway, Result.FAIL, req.rj), 132 | fin(runnerScope, cancelled, Result.CANCEL, req.rj) 133 | ) 134 | 135 | const running = read(scope as Scope)(runs.$running) 136 | 137 | if (running.length >= limit) { 138 | return go(new LimitExceededError(limit, running.length)) 139 | } 140 | 141 | // check ->IN strategies 142 | 143 | if (strategy === TAKE_FIRST && running.length > 0) { 144 | return go(new CancelledError(strategy)) 145 | } 146 | 147 | if (strategy === TAKE_LAST && running.length > 0) { 148 | runs.cancelAll(strategy, scope as Scope) 149 | } 150 | 151 | if (strategy === QUEUE && running.length > 0) { 152 | const promise = cancellable( 153 | // this is analogue for Promise.allSettled() 154 | Promise.all(running.map(p => p.catch(() => {}))) 155 | ) 156 | runs.push(promise, scope as Scope) 157 | return promise.then( 158 | () => (runs.unpush(promise, scope as Scope), go()), 159 | error => (runs.unpush(promise, scope as Scope), go(error)) 160 | ) 161 | } 162 | 163 | go() 164 | }, 165 | } as any), 166 | ] 167 | 168 | interface ReEffectScope { 169 | readonly params: Payload 170 | readonly strategy: Strategy 171 | readonly timeout?: number 172 | readonly feedback: boolean 173 | readonly push: ReturnType['push'] 174 | readonly unpush: ReturnType['unpush'] 175 | readonly cancelAll: ReturnType['cancelAll'] 176 | readonly inFlight: Store 177 | readonly scope: { [id: string]: any } | null 178 | readonly $running: ReturnType['$running'] 179 | } 180 | 181 | /** 182 | * Run effect, synchronously or asynchronously 183 | * Or immediately cancel effect, if error is passed 184 | */ 185 | const run = ( 186 | { params, push, timeout, scope }: ReEffectScope, 187 | handler: Handler, 188 | onResolve: ReturnType, 189 | onReject: ReturnType, 190 | onCancel: ReturnType 191 | ) => (immediatelyCancelError?: ReEffectError) => { 192 | if (immediatelyCancelError) { 193 | return onCancel()(immediatelyCancelError) 194 | } 195 | 196 | let cancel: (() => void) | undefined 197 | let result: Done | PromiseLike 198 | try { 199 | result = handler(params, abort => (cancel = abort)) 200 | } catch (error) { 201 | return onReject()(error) 202 | } 203 | 204 | if (Object(result) === result && typeof (result as any).then === 'function') { 205 | const promise = cancellable(result as any, cancel, timeout) 206 | push(promise, scope as Scope) 207 | return promise.then(onResolve(promise), error => 208 | error instanceof CancelledError 209 | ? onCancel(promise)(error) 210 | : onReject(promise)(error) 211 | ) 212 | } 213 | 214 | return onResolve()(result) 215 | } 216 | 217 | /** 218 | * In current ReEffect implementation `finally` event fires only at the very end of the current strategy round 219 | * This means, that internal fxCounter in scope will not be decrementent properly (increments on each effect start) 220 | * and `allSettled` method will never settled - which breaks usage of ReEffect and Fork API 221 | * 222 | * To fix this, internalFinally event is created to manually decrement this counter after each reeffect is done 223 | */ 224 | const internalFinally = (createEvent as any)({ named: 'internalFinally' }) 225 | setMeta(internalFinally, 'needFxCounter', 'dec') 226 | 227 | /** 228 | * onResolve / onReject / onCancel universal handler 229 | */ 230 | const fin = ( 231 | { 232 | params, 233 | unpush, 234 | strategy, 235 | feedback, 236 | cancelAll, 237 | inFlight, 238 | scope, 239 | $running, 240 | }: ReEffectScope, 241 | target: Event, 242 | type: Result, 243 | fn: (data: any) => void 244 | ) => (promise?: CancellablePromise) => (data: any) => { 245 | unpush(promise as any, scope as Scope) 246 | 247 | const runningCount = read(scope as Scope)($running).length 248 | const targets: (Event | Store | Step)[] = [sidechain] 249 | const payloads: any[] = [[fn, data]] 250 | 251 | // Run `finally` or `cancelled` 252 | // - if this is `cancelled` event 253 | // - if this was last event in `running` 254 | // - or strategy is RACE 255 | if (type === Result.CANCEL || !runningCount || strategy === RACE) { 256 | const body: any = 257 | type === Result.DONE 258 | ? { params, result: data, status: 'done' } 259 | : type === Result.FAIL 260 | ? { params, error: data, status: 'fail' } 261 | : { params, error: data } 262 | if (feedback) body.strategy = strategy 263 | 264 | targets.unshift(target) 265 | payloads.unshift(body) 266 | 267 | // check OUT-> strategies 268 | // only when event is `done` or `fail` (with `anyway`) 269 | if (strategy === RACE && type !== Result.CANCEL) { 270 | cancelAll(strategy, scope as Scope) 271 | } 272 | } else if (runningCount && (strategy === TAKE_EVERY || strategy === QUEUE)) { 273 | // add internalFinally event to reconcile the internal fxCounter 274 | targets.push(internalFinally) 275 | } 276 | 277 | // internal inFlight also needs to be reconciled with actual running count 278 | // if running count is > 0 and this is not RACE winner or Cancellation 279 | // then inFlight must be synced in synchronous launch 280 | if (runningCount && (type !== Result.CANCEL || strategy !== RACE)) { 281 | launch({ 282 | scope: scope as Scope, 283 | target: [inFlight], 284 | params: [runningCount], 285 | }) 286 | // otherwise, deferred launch is preferred 287 | // this way observable behaviour of reeffect is not changed 288 | } else { 289 | targets.unshift(inFlight) 290 | payloads.unshift(runningCount) 291 | } 292 | 293 | launch({ 294 | target: targets, 295 | params: payloads, 296 | defer: true, 297 | scope, 298 | } as any) 299 | } 300 | 301 | /** 302 | * Helper node to resolve or reject deferred promise 303 | */ 304 | const sidechain = createNode({ 305 | node: [ 306 | step.run({ 307 | fn([fn, value]) { 308 | fn(value) 309 | }, 310 | }), 311 | ], 312 | meta: { op: 'fx', fx: 'sidechain' }, 313 | } as any) 314 | -------------------------------------------------------------------------------- /src/strategy.ts: -------------------------------------------------------------------------------- 1 | export const QUEUE = 'QUEUE' 2 | export const RACE = 'RACE' 3 | export const TAKE_EVERY = 'TAKE_EVERY' 4 | export const TAKE_FIRST = 'TAKE_FIRST' 5 | export const TAKE_LAST = 'TAKE_LAST' 6 | 7 | export type Strategy = 8 | | typeof QUEUE 9 | | typeof RACE 10 | | typeof TAKE_EVERY 11 | | typeof TAKE_FIRST 12 | | typeof TAKE_LAST 13 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | import { Node as Step, Scope, Store } from 'effector' 2 | 3 | /** 4 | * Shortcut for smaller bundle size 5 | */ 6 | export const assign = Object.assign 7 | 8 | /** 9 | * Helper from Effector's code, to add family links 10 | * https://github.com/zerobias/effector/blob/master/src/effector/stdlib/family.js 11 | */ 12 | export const own = ( 13 | { graphite: owner }: { graphite: Step }, 14 | links: { graphite: Step }[] 15 | ) => { 16 | for (const { graphite } of links) { 17 | graphite.family.type = 'crosslink' 18 | graphite.family.owners.push(owner) 19 | owner.family.links.push(graphite) 20 | } 21 | } 22 | 23 | /** 24 | * Helper from Effector's code, to set meta of the fields 25 | * https://github.com/effector/effector/blob/master/src/effector/getter.ts 26 | */ 27 | export const setMeta = ( 28 | unit: { graphite: Step }, 29 | field: string, 30 | value: any 31 | ) => { 32 | unit.graphite.meta[field] = value 33 | } 34 | 35 | /** 36 | * Helper from Effector's code, to get fork page 37 | * https://github.com/effector/effector/blob/master/src/effector/getter.ts 38 | */ 39 | export const getForkPage = (val: any): Scope | void => val.scope 40 | 41 | export const read = (scope?: Scope) => ($store: Store) => 42 | scope ? scope.getState($store) : $store.getState() 43 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Effect, Event, Node as Step } from 'effector' 2 | import { ReEffectError } from './error' 3 | import { Strategy } from './strategy' 4 | 5 | export interface CreateReEffectConfig { 6 | handler?: Handler 7 | name?: string 8 | sid?: string 9 | strategy?: Strategy 10 | feedback?: boolean 11 | limit?: number 12 | timeout?: number 13 | } 14 | 15 | export interface ReEffectConfig { 16 | params?: Payload 17 | strategy?: Strategy 18 | timeout?: number 19 | } 20 | 21 | interface CallableReEffect { 22 | (params: Payload, strategy?: Strategy): Promise 23 | (params: Payload, config?: ReEffectConfig): Promise 24 | (config?: ReEffectConfig): Promise 25 | } 26 | 27 | // prettier-ignore 28 | export interface ReEffect 29 | extends 30 | CallableReEffect, 31 | Omit, 'done' | 'fail' | 'finally' | 'use'> 32 | { 33 | readonly done: Event> 34 | readonly fail: Event> 35 | readonly finally: Event> 36 | readonly cancelled: Event> 37 | readonly cancel: Event 38 | readonly use: { 39 | (handler: Handler): ReEffect 40 | // FIXME: original effect does not have onCancel in handler, types are incompatible again :( 41 | getCurrent(): (params: Payload) => Promise 42 | } 43 | // FIXME: effects do not have thru field, while events do - ReEffect type now fails in scopeBind because of that 44 | readonly thru: any 45 | } 46 | 47 | // prettier-ignore 48 | export interface MutableReEffect 49 | extends 50 | CallableReEffect, 51 | Mutable> 52 | { 53 | graphite: Step, 54 | create: ( 55 | paramsOrConfig: Payload | ReEffectConfig | undefined, 56 | [maybeStrategyOrConfig]: [ReEffectConfig | Strategy | undefined] 57 | ) => Promise 58 | } 59 | 60 | export type Handler = ( 61 | payload: Payload, 62 | onCancel: (callback: () => any) => void 63 | ) => Promise | Done 64 | 65 | export interface CreateReEffect { 66 | (): ReEffect 67 | (name: string): ReEffect 68 | ( 69 | config: CreateReEffectConfig 70 | ): ReEffect 71 | ( 72 | name: string, 73 | config: CreateReEffectConfig 74 | ): ReEffect 75 | } 76 | 77 | export type DonePayload = { 78 | params: Payload 79 | strategy?: Strategy 80 | result: Done 81 | } 82 | 83 | export type FailPayload = { 84 | params: Payload 85 | strategy?: Strategy 86 | error: Fail 87 | } 88 | 89 | export type FinallyPayload = 90 | | (DonePayload & { status: 'done' }) 91 | | (FailPayload & { status: 'fail' }) 92 | 93 | export type CancelledPayload = { 94 | params: Payload 95 | strategy?: Strategy 96 | error: ReEffectError 97 | } 98 | 99 | type Mutable = { 100 | -readonly [P in keyof T]: T[P] 101 | } 102 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "removeComments": true, 12 | "declaration": true, 13 | "baseUrl": "./src" 14 | }, 15 | "include": ["src/**/*"], 16 | "exclude": ["node_modules", "**/*.spec.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard-plus", 4 | "tslint-config-security", 5 | "tslint-config-prettier" 6 | ], 7 | "rules": { 8 | "no-any": false, 9 | "no-empty": false 10 | } 11 | } 12 | --------------------------------------------------------------------------------