├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package.json ├── packages ├── mst-async-task │ ├── .eslintrc.js │ ├── .gitignore │ ├── __tests__ │ │ ├── AsyncTask.test.ts │ │ └── runTask.test.ts │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── AsyncTask.ts │ │ ├── index.ts │ │ ├── lib.ts │ │ └── runTask.ts │ └── tsconfig.json └── react-example │ ├── .gitignore │ ├── package.json │ ├── public │ └── index.html │ ├── src │ ├── App.tsx │ ├── Store.ts │ ├── index.css │ ├── index.tsx │ └── react-app-env.d.ts │ ├── tsconfig.json │ └── yarn.lock └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | npm-debug.log 4 | yarn-debug.log* 5 | yarn-error.log* 6 | .eslintcache 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | src -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2021 Jesse Cooper 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mst-async-task 2 | [![npm version](https://badge.fury.io/js/mst-async-task.svg)](https://badge.fury.io/js/mst-async-task) 3 | 4 | Manage the lifecycles of asynchronous flows in Mobx-State-Tree. 5 | 6 | ## Introduction 7 | Mobx-State-Tree's `flow` provides a solid foundation for dealing with asynchronous actions, but we're left to do all of the bookkeeping ourselves. 8 | 9 | `mst-async-task` encapsulates the various states of asychronous actions and gives you powerful abilities like chaining and aborting tasks with minimal boilerplate. 10 | 11 | ## Example 12 | ```ts 13 | import { types } from 'mobx-state-tree' 14 | import { AsyncTask, runTask } from 'mst-async-task' 15 | import { User, Item } from './models' 16 | 17 | export const Store = types 18 | .model({ 19 | user: types.maybe(User), 20 | loadUserTask: types.optional(AsyncTask, {}) 21 | }) 22 | .actions(self => { 23 | const loadUser = () => runTask(self.loadUserTask, function*({ exec }) { 24 | // No need to wrap in a try/catch block. Errors are handled by the task runner. 25 | const data = yield fetch(`https://example-api.com/user`) 26 | // Wrap state updates in an exec() callback, which will prevent 27 | // execution if the task is aborted. 28 | exec(() => { 29 | self.user = User.create(data) 30 | }) 31 | }) 32 | 33 | return { loadUser } 34 | }) 35 | ``` 36 | 37 | #### Component 38 | ```tsx 39 | export const Main = observer(() => { 40 | const { user, loadUserTask, loadUser } = useStore() 41 | 42 | useEffect(() => { 43 | loadUser() 44 | }, []) 45 | 46 | if (loadUserTask.pending) { 47 | return ( 48 |
49 |

Loading...

50 | 53 |
54 | ) 55 | } 56 | 57 | if (loadUserTask.error) { 58 | return

Error: {loadUserTask.error.message}

59 | } 60 | 61 | if (user) { 62 | return

Hello, {user.name}

63 | } 64 | 65 | return null 66 | }) 67 | ``` 68 | 69 | ## AbortSignal 70 | If you're making a long-running request that you want to be cancelable, you can pass the `signal` parameter to 71 | `fetch()`, and the request will be stopped when you call `task.abort()`. 72 | ```ts 73 | const uploadFile = (data: FormData) => runTask(self.uploadFileTask, function*({ signal }) { 74 | yield fetch(`https://example-api.com/uploads`, { 75 | body: data, 76 | method: 'POST', 77 | // Pass the AbortSignal to fetch() to stop uploading when 78 | // when the task is aborted. 79 | signal 80 | }) 81 | }) 82 | ``` 83 | 84 | ## Chaining tasks 85 | Tasks can execute other tasks in a controlled fashion. If an error occurs in the child task, it will bubble up to the parent. If the parent task is aborted, the child task will be aborted as well. 86 | ```ts 87 | const loadUser = () => runTask(self.loadUserTask, function*({ exec }) { 88 | const data = yield fetch(`https://example-api.com/user`) 89 | yield exec(() => { 90 | self.user = User.create(data) 91 | return loadItem(self.user.itemId) 92 | }) 93 | }) 94 | 95 | const loadItem = (id: string) => runTask(self.loadItemTask, function*({ exec }) { 96 | const data = yield fetch(`https://example-api.com/items/${id}`) 97 | exec(() => { 98 | // This will not be executed if `loadUserTask.abort()` is called or if loadUser() is 99 | // called again while still pending. 100 | self.item = Item.create(data) 101 | }) 102 | }) 103 | ``` 104 | 105 | ## Running tasks in parallel 106 | ```ts 107 | const loadAllThings = () => runTask(self.loadAllTask, function*({ exec }) { 108 | yield Promise.all([ 109 | exec(loadThing1), 110 | exec(loadThing2) 111 | ]) 112 | }) 113 | ``` 114 | 115 | # Documentation 116 | 117 | ## AsyncTask 118 | Model that encapsulates the state of an async action. 119 | 120 | ### Properties 121 | | Name | Type | Description | 122 | | ---------------- | ------------------ | ---------------------------------------------------------- | 123 | | **status** | AsyncTaskStatus | 'init' \| 'pending' \| 'complete' \| 'failed' \| 'aborted' | 124 | | **error** | Error \| undefined | Any error that is thrown during task execution. | 125 | 126 | ### Views 127 | | Name | Type | Description | 128 | | ---------------- | ------------------ | ---------------------------------------------------------- | 129 | | **clean** | boolean | Indicates the task has not been run yet. | 130 | | **pending** | boolean | Indicates the task is currently running. | 131 | | **complete** | boolean | Indicates the task completed successfully. | 132 | | **failed** | boolean | Indicates an error was thrown during task execution. | 133 | | **aborted** | boolean | Indicates the task was aborted. | 134 | | **unresolved** | boolean | Indicates the task has not been run or is pending. | 135 | | **resolved** | boolean | Indicates the task is complete, failed, or aborted. | 136 | 137 | ### Actions 138 | | Name | Description | 139 | | ---------------- | ------------------------------------------------------------------------------- | 140 | | **abort()** | Aborts the task if it is currently running. | 141 | | **reset()** | Aborts the task if it is currently running, and resets to the initial state. | 142 | 143 | ## runTask(task, generator) 144 | Executes a `flow` generator while maintaining lifecycle updates in an AsyncTask. In the generator function, you perform async operations like loading data and 145 | updating the store state, similarly to how you would with `flow`. The generator function receives an object with two parameters: `signal` and `exec`: 146 | 147 | `signal` is an AbortSignal that is useful for canceling requests. For example, you can pass it into `fetch()`, and if the task is aborted, the http request will be 148 | aborted as well. 149 | 150 | `exec` serves two purposes: 151 | 1. If you want your task to be cancelable via `task.abort()` but don't want to deal with signals, you can prevent your task from updating state after it is aborted by wrapping your updates in an `exec()` callback. 152 | 2. Calling a task action from another task. 153 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "cd packages/mst-async-task && yarn build", 5 | "dev": "cd packages/mst-async-task && tsc -w", 6 | "test": "cd packages/mst-async-task && yarn test", 7 | "lint": "cd packages/mst-async-task && yarn lint" 8 | }, 9 | "dependencies": {}, 10 | "devDependencies": {}, 11 | "workspaces": [ 12 | "packages/mst-async-task" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /packages/mst-async-task/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | }, 5 | 6 | parser: '@typescript-eslint/parser', 7 | 8 | parserOptions: { 9 | sourceType: 'module', 10 | }, 11 | 12 | plugins: [ 13 | '@typescript-eslint' 14 | ], 15 | 16 | extends: [ 17 | 'plugin:@typescript-eslint/eslint-recommended', 18 | 'plugin:@typescript-eslint/recommended' 19 | ], 20 | 21 | settings: { 22 | react: { 23 | version: 'detect', 24 | }, 25 | }, 26 | 27 | overrides: [ 28 | { 29 | files: ['**/__tests__/**/*.{js,ts,tsx}'], 30 | env: { 31 | jest: true 32 | }, 33 | }, 34 | ], 35 | 36 | ignorePatterns: ['build/**/**', 'dist/**/**', 'rollup.config.js'], 37 | 38 | rules: { 39 | 'comma-dangle': 0, 40 | 'curly': 0, 41 | 'eqeqeq': 0, 42 | 'indent': [1, 2, { 'SwitchCase': 1 }], 43 | 'key-spacing': 1, 44 | 'no-bitwise': 0, 45 | 'no-shadow': 0, 46 | 'no-unexpected-multiline': 0, 47 | 'no-unused-vars': 0, 48 | 'semi': [1, 'never'], 49 | 'object-curly-spacing': [1, 'always'], 50 | 'no-trailing-spaces': 0, 51 | 'quotes': [0, 'single', 'avoid-escape'], 52 | 53 | '@typescript-eslint/explicit-module-boundary-types': 0, 54 | '@typescript-eslint/no-explicit-any': 0, 55 | '@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/mst-async-task/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /dist 3 | /README.md 4 | /LICENSE 5 | -------------------------------------------------------------------------------- /packages/mst-async-task/__tests__/AsyncTask.test.ts: -------------------------------------------------------------------------------- 1 | import { AsyncTask, AsyncTaskStatus } from '../src' 2 | 3 | describe('AsyncTask', () => { 4 | describe('views', () => { 5 | test('init', () => { 6 | const task = AsyncTask.create() 7 | expect(task.clean).toBe(true) 8 | expect(task.pending).toBe(false) 9 | expect(task.complete).toBe(false) 10 | expect(task.failed).toBe(false) 11 | expect(task.aborted).toBe(false) 12 | expect(task.unresolved).toBe(true) 13 | expect(task.resolved).toBe(false) 14 | }) 15 | 16 | test('pending', () => { 17 | const task = AsyncTask.create({ status: AsyncTaskStatus.PENDING }) 18 | expect(task.clean).toBe(false) 19 | expect(task.pending).toBe(true) 20 | expect(task.complete).toBe(false) 21 | expect(task.failed).toBe(false) 22 | expect(task.aborted).toBe(false) 23 | expect(task.unresolved).toBe(true) 24 | expect(task.resolved).toBe(false) 25 | }) 26 | 27 | test('complete', () => { 28 | const task = AsyncTask.create({ status: AsyncTaskStatus.COMPLETE }) 29 | expect(task.clean).toBe(false) 30 | expect(task.pending).toBe(false) 31 | expect(task.complete).toBe(true) 32 | expect(task.failed).toBe(false) 33 | expect(task.aborted).toBe(false) 34 | expect(task.unresolved).toBe(false) 35 | expect(task.resolved).toBe(true) 36 | }) 37 | 38 | test('failed', () => { 39 | const task = AsyncTask.create({ status: AsyncTaskStatus.FAILED }) 40 | expect(task.clean).toBe(false) 41 | expect(task.pending).toBe(false) 42 | expect(task.complete).toBe(false) 43 | expect(task.failed).toBe(true) 44 | expect(task.aborted).toBe(false) 45 | expect(task.unresolved).toBe(false) 46 | expect(task.resolved).toBe(true) 47 | }) 48 | 49 | test('aborted', () => { 50 | const task = AsyncTask.create({ status: AsyncTaskStatus.ABORTED }) 51 | expect(task.clean).toBe(false) 52 | expect(task.pending).toBe(false) 53 | expect(task.complete).toBe(false) 54 | expect(task.failed).toBe(false) 55 | expect(task.aborted).toBe(true) 56 | expect(task.unresolved).toBe(false) 57 | expect(task.resolved).toBe(true) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /packages/mst-async-task/__tests__/runTask.test.ts: -------------------------------------------------------------------------------- 1 | import { types, Instance, destroy, applySnapshot } from 'mobx-state-tree' 2 | import { 3 | AsyncTask, 4 | AsyncTaskStatus, 5 | AsyncTaskResult, 6 | AsyncTaskAbortError, 7 | runTask, 8 | } from '../src' 9 | 10 | const sleep = (signal?: AbortSignal) => new Promise((resolve, reject) => { 11 | if (signal) { 12 | signal.addEventListener('abort', () => { 13 | reject(new Error('Aborted')) 14 | }) 15 | } 16 | setTimeout(resolve, 10) 17 | }) 18 | 19 | const runTimers = async (count = 1) => { 20 | for (let i = 0; i < count; i++) { 21 | jest.runAllTimers() 22 | await Promise.resolve() 23 | } 24 | } 25 | 26 | describe('runTask()', () => { 27 | beforeEach(() => { 28 | jest.useFakeTimers() 29 | }) 30 | 31 | afterEach(() => { 32 | jest.clearAllTimers() 33 | }) 34 | 35 | afterAll(() => { 36 | jest.useRealTimers() 37 | }) 38 | 39 | describe('single task', () => { 40 | const TestStore = types 41 | .model({ 42 | value: types.maybe(types.string), 43 | task: types.optional(AsyncTask, {}) 44 | }) 45 | .actions(self => { 46 | return { 47 | run: (fail = false) => runTask(self.task, function*({ signal, exec }) { 48 | yield sleep(signal) 49 | if (fail) { 50 | throw new Error('failed') 51 | } 52 | exec(() => self.value = 'ok') 53 | return 'result' 54 | }), 55 | runNoSignal: () => runTask(self.task, function*({ exec }) { 56 | yield sleep() 57 | exec(() => self.value = 'ok') 58 | }) 59 | } 60 | }) 61 | 62 | let store: Instance 63 | 64 | beforeEach(() => { 65 | store = TestStore.create() 66 | }) 67 | 68 | it('sets status to pending immediately', () => { 69 | store.run() 70 | expect(store.task.status).toBe(AsyncTaskStatus.PENDING) 71 | }) 72 | 73 | it('sets status to complete and returns result', async () => { 74 | const promise = store.run() 75 | await runTimers() 76 | const result = await promise 77 | expect(result).toBeInstanceOf(AsyncTaskResult) 78 | expect(result.status).toBe(AsyncTaskStatus.COMPLETE) 79 | expect(result.error).toBeUndefined() 80 | expect(result.value).toBe('result') 81 | expect(store.task.status).toBe(AsyncTaskStatus.COMPLETE) 82 | expect(store.task.result).toBe('result') 83 | expect(store.value).toBe('ok') 84 | }) 85 | 86 | it('sets status to failed when an error is thrown', async () => { 87 | const promise = store.run(true) 88 | await runTimers() 89 | const result = await promise 90 | expect(result.status).toBe(AsyncTaskStatus.FAILED) 91 | expect(result.error?.message).toBe('failed') 92 | expect(store.task.status).toBe(AsyncTaskStatus.FAILED) 93 | expect(store.task.error?.message).toBe('failed') 94 | }) 95 | 96 | it('resets state when reset() is called', async () => { 97 | const promise = store.run() 98 | await runTimers() 99 | await promise 100 | store.task.reset() 101 | expect(store.task.status).toBe(AsyncTaskStatus.INIT) 102 | expect(store.task.result).toBeUndefined() 103 | }) 104 | 105 | it('aborts the task when abort() is called while pending', async () => { 106 | const promise = store.run() 107 | store.task.abort() 108 | await runTimers() 109 | const result = await promise 110 | expect(result.status).toBe(AsyncTaskStatus.ABORTED) 111 | expect(result.error).toBeInstanceOf(AsyncTaskAbortError) 112 | expect(store.task.error).toBeInstanceOf(AsyncTaskAbortError) 113 | expect(store.task.status).toBe(AsyncTaskStatus.ABORTED) 114 | }) 115 | 116 | it('aborts and resets state when reset() is called while pending', async () => { 117 | const promise = store.run() 118 | store.task.reset() 119 | await runTimers() 120 | await promise 121 | expect(store.task.status).toBe(AsyncTaskStatus.INIT) 122 | expect(store.task.error).toBeUndefined() 123 | expect(store.value).toBeUndefined() 124 | }) 125 | 126 | it('aborts previous task when run again while pending', async () => { 127 | const promise1 = store.run() 128 | const promise2 = store.run() 129 | await runTimers() 130 | const result1 = await promise1 131 | expect(result1.error).toBeInstanceOf(AsyncTaskAbortError) 132 | const result2 = await promise2 133 | expect(result2.error).toBeUndefined() 134 | expect(store.task.status).toBe(AsyncTaskStatus.COMPLETE) 135 | expect(store.value).toBe('ok') 136 | }) 137 | 138 | it('aborts previous task when run again while pending with no signal', async () => { 139 | const promise1 = store.runNoSignal() 140 | const promise2 = store.runNoSignal() 141 | await runTimers() 142 | const result1 = await promise1 143 | expect(result1.error).toBeInstanceOf(AsyncTaskAbortError) 144 | const result2 = await promise2 145 | expect(result2.error).toBeUndefined() 146 | expect(store.task.status).toBe(AsyncTaskStatus.COMPLETE) 147 | expect(store.value).toBe('ok') 148 | }) 149 | 150 | it('only updates state after flow is complete if the task is alive in the state tree', async () => { 151 | const promise = store.run() 152 | destroy(store) 153 | await runTimers() 154 | const result = await promise 155 | expect(result.status).toBe(AsyncTaskStatus.ABORTED) 156 | expect(result.error).toBeInstanceOf(AsyncTaskAbortError) 157 | }) 158 | 159 | it('does not update state after flow is complete if state was reset', async () => { 160 | // Can't figure out how to test this with fake timers... 161 | jest.useRealTimers() 162 | const promise1 = store.run() 163 | applySnapshot(store, {}) 164 | await new Promise(r => setTimeout(r, 5)) 165 | const promise2 = store.run() 166 | await new Promise(r => setTimeout(r, 5)) 167 | expect(store.task.status).toBe(AsyncTaskStatus.PENDING) 168 | await new Promise(r => setTimeout(r, 10)) 169 | const result1 = await promise1 170 | expect(result1.status).toBe(AsyncTaskStatus.ABORTED) 171 | const result2 = await promise2 172 | expect(result2.status).toBe(AsyncTaskStatus.COMPLETE) 173 | expect(store.task.status).toBe(AsyncTaskStatus.COMPLETE) 174 | }) 175 | }) 176 | 177 | describe('chained tasks', () => { 178 | const TestStore = types 179 | .model({ 180 | value1: types.maybe(types.string), 181 | value2: types.maybe(types.string), 182 | value3: types.maybe(types.string), 183 | task1: types.optional(AsyncTask, {}), 184 | task2: types.optional(AsyncTask, {}), 185 | task3: types.optional(AsyncTask, {}) 186 | }) 187 | .actions(self => { 188 | const run = (fail = false) => runTask(self.task1, function*({ signal, exec }) { 189 | yield sleep(signal) 190 | yield exec(() => { 191 | self.value1 = 'ok' 192 | return run2(fail) 193 | }) 194 | }) 195 | 196 | const run2 = (fail = false) => runTask(self.task2, function*({ signal, exec }) { 197 | yield sleep(signal) 198 | if (fail) { 199 | throw new Error('failed') 200 | } 201 | yield exec(() => { 202 | self.value2 = 'ok' 203 | return run3() 204 | }) 205 | }) 206 | 207 | const run3 = () => runTask(self.task3, function*({ signal, exec }) { 208 | yield sleep(signal) 209 | exec(() => self.value3 = 'ok') 210 | }) 211 | 212 | return { run, run2, run3 } 213 | }) 214 | 215 | let store: Instance 216 | 217 | beforeEach(() => { 218 | store = TestStore.create() 219 | }) 220 | 221 | it('runs all tasks', async () => { 222 | const promise = store.run() 223 | await runTimers(3) 224 | const result = await promise 225 | expect(result.error).toBeUndefined() 226 | expect(store.task1.status).toBe(AsyncTaskStatus.COMPLETE) 227 | expect(store.task2.status).toBe(AsyncTaskStatus.COMPLETE) 228 | expect(store.task3.status).toBe(AsyncTaskStatus.COMPLETE) 229 | expect(store.value1).toBe('ok') 230 | expect(store.value2).toBe('ok') 231 | expect(store.value3).toBe('ok') 232 | }) 233 | 234 | it('bubbles up nested failures to the initial task', async () => { 235 | const promise = store.run(true) 236 | await runTimers(3) 237 | const result = await promise 238 | expect(result.error?.message).toBe('failed') 239 | expect(store.task1.status).toBe(AsyncTaskStatus.FAILED) 240 | expect(store.task1.error?.message).toBe('failed') 241 | expect(store.task2.status).toBe(AsyncTaskStatus.FAILED) 242 | expect(store.task2.error?.message).toBe('failed') 243 | expect(store.task3.status).toBe(AsyncTaskStatus.INIT) 244 | expect(store.value1).toBe('ok') 245 | expect(store.value2).toBeUndefined() 246 | expect(store.value3).toBeUndefined() 247 | }) 248 | 249 | it('bubbles up nested abort errors to the initial task', async () => { 250 | const promise = store.run() 251 | await runTimers(2) 252 | store.task3.abort() 253 | await runTimers() 254 | const result = await promise 255 | expect(result.error).toBeInstanceOf(AsyncTaskAbortError) 256 | expect(store.task1.status).toBe(AsyncTaskStatus.ABORTED) 257 | expect(store.task1.error).toBeInstanceOf(AsyncTaskAbortError) 258 | expect(store.task2.status).toBe(AsyncTaskStatus.ABORTED) 259 | expect(store.task2.error).toBeInstanceOf(AsyncTaskAbortError) 260 | expect(store.task3.status).toBe(AsyncTaskStatus.ABORTED) 261 | expect(store.task3.error).toBeInstanceOf(AsyncTaskAbortError) 262 | expect(store.value1).toBe('ok') 263 | expect(store.value2).toBe('ok') 264 | expect(store.value3).toBeUndefined() 265 | }) 266 | }) 267 | 268 | describe('sequential/parallel tasks', () => { 269 | const TestStore = types 270 | .model({ 271 | value1: types.maybe(types.string), 272 | value2: types.maybe(types.string), 273 | runAllSeqTask: types.optional(AsyncTask, {}), 274 | runAllParallelTask: types.optional(AsyncTask, {}), 275 | task1: types.optional(AsyncTask, {}), 276 | task2: types.optional(AsyncTask, {}), 277 | }) 278 | .actions(self => { 279 | const runAllSeq = (fail = false) => runTask(self.runAllSeqTask, function*({ exec }) { 280 | yield exec(run1) 281 | yield exec(run2, fail) 282 | }) 283 | 284 | const runAllParallel = (fail = false) => runTask(self.runAllParallelTask, function*({ exec }) { 285 | yield Promise.all([ 286 | exec(run1), 287 | exec(run2, fail) 288 | ]) 289 | }) 290 | 291 | const run1 = () => runTask(self.task1, function*({ signal, exec }) { 292 | yield sleep(signal) 293 | exec(() => self.value1 = 'ok') 294 | }) 295 | 296 | const run2 = (fail = false) => runTask(self.task2, function*({ signal, exec }) { 297 | yield sleep(signal) 298 | if (fail) { 299 | throw new Error('failed') 300 | } 301 | exec(() => self.value2 = 'ok') 302 | }) 303 | 304 | return { runAllSeq, runAllParallel } 305 | }) 306 | 307 | let store: Instance 308 | 309 | beforeEach(() => { 310 | store = TestStore.create() 311 | }) 312 | 313 | describe('sequential', () => { 314 | it('runs all tasks', async () => { 315 | const promise = store.runAllSeq() 316 | await runTimers(6) 317 | const result = await promise 318 | expect(result.status).toBe(AsyncTaskStatus.COMPLETE) 319 | expect(store.runAllSeqTask.status).toBe(AsyncTaskStatus.COMPLETE) 320 | expect(store.task1.status).toBe(AsyncTaskStatus.COMPLETE) 321 | expect(store.task2.status).toBe(AsyncTaskStatus.COMPLETE) 322 | expect(store.value1).toBe('ok') 323 | expect(store.value2).toBe('ok') 324 | }) 325 | 326 | it('does not modify resolved child tasks when parent task is aborted', async () => { 327 | const promise = store.runAllSeq() 328 | await runTimers(5) 329 | store.runAllSeqTask.abort() 330 | await runTimers() 331 | const result = await promise 332 | expect(result.status).toBe(AsyncTaskStatus.ABORTED) 333 | expect(store.runAllSeqTask.status).toBe(AsyncTaskStatus.ABORTED) 334 | expect(store.task1.status).toBe(AsyncTaskStatus.COMPLETE) 335 | expect(store.task2.status).toBe(AsyncTaskStatus.ABORTED) 336 | }) 337 | }) 338 | 339 | describe('parallel', () => { 340 | it('runs all tasks', async () => { 341 | const promise = store.runAllParallel() 342 | await runTimers() 343 | const result = await promise 344 | expect(result.error).toBeUndefined() 345 | expect(store.runAllParallelTask.status).toBe(AsyncTaskStatus.COMPLETE) 346 | expect(store.task1.status).toBe(AsyncTaskStatus.COMPLETE) 347 | expect(store.task2.status).toBe(AsyncTaskStatus.COMPLETE) 348 | expect(store.value1).toBe('ok') 349 | expect(store.value2).toBe('ok') 350 | }) 351 | 352 | it('bubbles up failures to the parent task', async () => { 353 | const promise = store.runAllParallel(true) 354 | await runTimers() 355 | const result = await promise 356 | expect(result.error?.message).toBe('failed') 357 | expect(store.runAllParallelTask.status).toBe(AsyncTaskStatus.FAILED) 358 | expect(store.runAllParallelTask.error?.message).toBe('failed') 359 | expect(store.task1.status).toBe(AsyncTaskStatus.COMPLETE) 360 | expect(store.task2.status).toBe(AsyncTaskStatus.FAILED) 361 | expect(store.value1).toBe('ok') 362 | expect(store.value2).toBeUndefined() 363 | }) 364 | 365 | it('aborts child tasks when parent task is aborted', async () => { 366 | const promise = store.runAllParallel() 367 | store.runAllParallelTask.abort() 368 | await runTimers() 369 | const result = await promise 370 | expect(result.status).toBe(AsyncTaskStatus.ABORTED) 371 | expect(store.runAllParallelTask.status).toBe(AsyncTaskStatus.ABORTED) 372 | expect(store.task1.status).toBe(AsyncTaskStatus.ABORTED) 373 | expect(store.task2.status).toBe(AsyncTaskStatus.ABORTED) 374 | expect(store.value1).toBeUndefined() 375 | expect(store.value2).toBeUndefined() 376 | }) 377 | }) 378 | }) 379 | }) 380 | -------------------------------------------------------------------------------- /packages/mst-async-task/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mst-async-task", 3 | "version": "1.0.4", 4 | "description": "Manage the lifecycles of asynchronous flows in Mobx-State-Tree", 5 | "main": "dist/mst-async-task.js", 6 | "umd:main": "dist/mst-async-task.umd.js", 7 | "module": "dist/mst-async-task.module.js", 8 | "browser": { 9 | "./dist/mst-async-task.js": "./dist/mst-async-task.js", 10 | "./dist/mst-async-task.module.js": "./dist/mst-async-task.module.js" 11 | }, 12 | "jsnext:main": "dist/mst-async-task.module.js", 13 | "react-native": "dist/mst-async-task.module.js", 14 | "typings": "dist/index.d.ts", 15 | "files": [ 16 | "dist/" 17 | ], 18 | "scripts": { 19 | "clean": "shx rm -rf dist && shx rm -rf build && shx rm -f README.md && shx rm -f LICENSE", 20 | "build": "yarn clean && shx cp ../../README.md . && shx cp ../../LICENSE . && tsc && cpr build dist --overwrite --filter=\\.js$ && rollup -c", 21 | "dev": "tsc -w", 22 | "test": "jest", 23 | "lint": "eslint .", 24 | "prepublishOnly": "yarn lint && yarn test && yarn build" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/jetako/mst-async-task.git" 29 | }, 30 | "keywords": [ 31 | "mobx-state-tree", 32 | "action", 33 | "async", 34 | "promise", 35 | "task" 36 | ], 37 | "author": "Jesse Cooper ", 38 | "license": "ISC", 39 | "bugs": { 40 | "url": "https://github.com/jetako/mst-async-task/issues" 41 | }, 42 | "homepage": "https://github.com/jetako/mst-async-task#readme", 43 | "peerDependencies": { 44 | "mobx-state-tree": ">=3.16.0" 45 | }, 46 | "devDependencies": { 47 | "@typescript-eslint/eslint-plugin": "^4.14.1", 48 | "@typescript-eslint/parser": "^4.14.1", 49 | "cpr": "^3.0.1", 50 | "eslint": "^7.18.0", 51 | "jest": "26.6.0", 52 | "mobx": "^6.1.0", 53 | "mobx-state-tree": "^5.0.1", 54 | "rollup": "^2.38.4", 55 | "rollup-plugin-commonjs": "^10.1.0", 56 | "rollup-plugin-node-resolve": "^5.2.0", 57 | "shx": "^0.3.3", 58 | "ts-jest": "^26.5.0", 59 | "typescript": "^4.1.3" 60 | }, 61 | "jest": { 62 | "preset": "ts-jest", 63 | "testRegex": "(/__tests__/.*.test)\\.ts?$" 64 | }, 65 | "dependencies": {} 66 | } 67 | -------------------------------------------------------------------------------- /packages/mst-async-task/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | 3 | const pkg = require('./package.json') 4 | 5 | const globals = { 6 | 'mobx-state-tree': 'mobxStateTree', 7 | 'tslib': 'tslib' 8 | } 9 | 10 | export default { 11 | input: 'build/index.js', 12 | external: ['mobx-state-tree', 'tslib'], 13 | output: [ 14 | { file: pkg.main, format: 'cjs', globals }, 15 | { file: pkg['umd:main'], name: 'mstAsyncTask', format: 'umd', globals }, 16 | { file: pkg.module, format: 'es', globals }, 17 | ], 18 | plugins: [ 19 | resolve() 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /packages/mst-async-task/src/AsyncTask.ts: -------------------------------------------------------------------------------- 1 | import { Instance, types } from 'mobx-state-tree' 2 | import { AsyncTaskStatus } from './lib' 3 | 4 | /** 5 | * AsyncTask encapsulates the lifecycle of an asychronous action. 6 | * See `runTask()` for usage. 7 | */ 8 | export const AsyncTask = types 9 | .model({ 10 | status: types.optional( 11 | types.enumeration([ 12 | AsyncTaskStatus.INIT, 13 | AsyncTaskStatus.PENDING, 14 | AsyncTaskStatus.COMPLETE, 15 | AsyncTaskStatus.FAILED, 16 | AsyncTaskStatus.ABORTED 17 | ]), 18 | AsyncTaskStatus.INIT 19 | ), 20 | error: types.maybe(types.frozen()), 21 | result: types.maybe(types.frozen()) 22 | }) 23 | .views(self => { 24 | return { 25 | get clean() { 26 | return self.status === AsyncTaskStatus.INIT 27 | }, 28 | 29 | get pending() { 30 | return self.status === AsyncTaskStatus.PENDING 31 | }, 32 | 33 | get complete() { 34 | return self.status === AsyncTaskStatus.COMPLETE 35 | }, 36 | 37 | get failed() { 38 | return self.status === AsyncTaskStatus.FAILED 39 | }, 40 | 41 | get aborted() { 42 | return self.status === AsyncTaskStatus.ABORTED 43 | }, 44 | 45 | get unresolved() { 46 | return self.status === AsyncTaskStatus.INIT || self.status === AsyncTaskStatus.PENDING 47 | }, 48 | 49 | get resolved() { 50 | return self.status !== AsyncTaskStatus.INIT && self.status !== AsyncTaskStatus.PENDING 51 | } 52 | } 53 | }) 54 | .actions(self => { 55 | const abort = () => { 56 | const task = self as IAsyncTask 57 | if (task._abortController) { 58 | task._abortController.abort() 59 | } 60 | } 61 | 62 | const reset = () => { 63 | abort() 64 | self.status = AsyncTaskStatus.INIT 65 | self.error = undefined 66 | self.result = undefined 67 | } 68 | 69 | /** 70 | * Used internally by `runTask()`. This should not be called directly. 71 | */ 72 | const _resolve = (status: AsyncTaskStatus, error?: Error, result?: any) => { 73 | self.status = status 74 | self.error = error 75 | self.result = result 76 | delete (self as IAsyncTask)._abortController 77 | } 78 | 79 | return { abort, reset, _resolve } 80 | }) 81 | 82 | export interface IAsyncTask extends Instance { 83 | _abortController?: AbortController 84 | } 85 | -------------------------------------------------------------------------------- /packages/mst-async-task/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AsyncTask' 2 | export * from './lib' 3 | export * from './runTask' 4 | -------------------------------------------------------------------------------- /packages/mst-async-task/src/lib.ts: -------------------------------------------------------------------------------- 1 | export enum AsyncTaskStatus { 2 | INIT = 'init', 3 | PENDING = 'pending', 4 | COMPLETE = 'complete', 5 | FAILED = 'failed', 6 | ABORTED = 'aborted' 7 | } 8 | 9 | export class AsyncTaskResult { 10 | status: AsyncTaskStatus 11 | error?: Error 12 | value?: any 13 | 14 | constructor(status: AsyncTaskStatus, error?: Error, value?: any) { 15 | this.status = status 16 | this.error = error 17 | this.value = value 18 | } 19 | } 20 | 21 | export class AsyncTaskAbortError extends Error { 22 | constructor() { 23 | super("Task was aborted.") 24 | this.name = 'AsyncTaskAbortError' 25 | Object.setPrototypeOf(this, AsyncTaskAbortError.prototype) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/mst-async-task/src/runTask.ts: -------------------------------------------------------------------------------- 1 | import { flow, isAlive } from 'mobx-state-tree' 2 | import { IAsyncTask } from './AsyncTask' 3 | import { AsyncTaskStatus, AsyncTaskResult, AsyncTaskAbortError } from './lib' 4 | 5 | type Exec = any>(callback: T, ...args: Parameters) => any 6 | 7 | let parentAbortSignal: AbortSignal | null = null 8 | 9 | /** 10 | * Executes a `flow` generator while maintaining lifecycle updates in an AsyncTask. 11 | * In the generator function, you perform async operations like loading data and 12 | * updating the store state, similarly to how you would with `flow`. The difference 13 | * is, you don't need to manage lifecycle state or capture errors explicitly. 14 | * 15 | * The generator function receives an object with two parameters: `signal` and `exec`: 16 | * 17 | * `signal` is an AbortSignal that is useful for canceling requests. For example, you 18 | * can pass it into `fetch()`, and if the task is aborted, the http request will be 19 | * aborted as well. 20 | * 21 | * `exec` serves two purposes: 22 | * 1. If you want your task to be cancelable via `task.abort()` but don't want to deal 23 | * with signals, you can prevent your task from updating state after it is aborted 24 | * by wrapping your updates in an `exec()` callback. 25 | * 2. Calling a task action from another task. See example below. 26 | * 27 | * @example 28 | * // Basic example: 29 | * import { types, Instance } from 'mobx-state-tree' 30 | * import { AsyncTask, runTask } from 'mst-async-task' 31 | * 32 | * export const Store = types 33 | * .model({ 34 | * item: types.maybe(Item), 35 | * loadItemTask: types.optional(AsyncTask, {}) 36 | * }) 37 | * .actions(self => { 38 | * const loadItem = (itemId: string) => runTask(self.loadItemTask, function*() { 39 | * const data = yield fetch(`https://example-api.com/items/${itemId}`) 40 | * self.item = Item.create(data) 41 | * }) 42 | * return { loadItem } 43 | * }) 44 | * 45 | * // Using the provided AbortSignal to cancel the http request when the task is aborted: 46 | * const loadItem = (itemId: string) => runTask(self.loadItemTask, function*({ signal }) { 47 | * const data = yield fetch(`https://example-api.com/items/${itemId}`, { signal }) 48 | * self.item = Item.create(data) 49 | * }) 50 | * 51 | * // Wrapping state updates in an `exec()` callback: 52 | * const loadItem = (itemId: string) => runTask(self.loadItemTask, function*({ exec }) { 53 | * const data = yield fetch(`https://example-api.com/items/${itemId}`) 54 | * exec(() => { 55 | * // This will not be executed if the task is aborted. 56 | * self.item = Item.create(data) 57 | * }) 58 | * }) 59 | * 60 | * // Running a task from within another task: 61 | * const loadUser = () => runTask(self.loadUserTask, function*({ exec }) { 62 | * const user = yield fetch(`https://example-api.com/user`) 63 | * // If the loadItem task encounters an error, it will propagate up to 64 | * // the loadUser task. 65 | * yield exec(self.loadItem, user.itemId) 66 | * }) 67 | * 68 | * @param task AsyncTask instance that encapsulates the task status. 69 | * @param generator Generator function that performs the task operations. It receives 70 | * an object with 2 parameters: `signal` and `exec`. See `runTask()` description 71 | * for a detailed explanation. 72 | * @returns Promise(AsyncTaskResult) 73 | */ 74 | export async function runTask( 75 | task: IAsyncTask, 76 | generator: (params: { signal: AbortSignal, exec: Exec }) => Generator, any, any> 77 | ): Promise { 78 | const abortController = new AbortController() 79 | const { signal } = abortController 80 | 81 | if (task._abortController) { 82 | task._abortController.abort() 83 | delete task._abortController 84 | } 85 | 86 | if (parentAbortSignal) { 87 | parentAbortSignal.addEventListener('abort', () => { 88 | abortController.abort() 89 | }) 90 | } 91 | 92 | const exec: Exec = (callback, ...args) => { 93 | if (!isAlive(task) || !task.pending || signal.aborted) { 94 | throw new AsyncTaskAbortError() 95 | } 96 | 97 | const execAsync = async () => { 98 | try { 99 | parentAbortSignal = signal 100 | const ret = callback(...args) 101 | parentAbortSignal = null 102 | const result = await ret 103 | 104 | if (result instanceof AsyncTaskResult) { 105 | if (result.error) { 106 | throw result.error 107 | } 108 | return undefined 109 | } 110 | 111 | return result 112 | } catch (err) { 113 | parentAbortSignal = null 114 | throw err 115 | } 116 | } 117 | 118 | return execAsync() 119 | } 120 | 121 | task.status = AsyncTaskStatus.PENDING 122 | task.error = undefined 123 | task._abortController = abortController 124 | 125 | const [status, error, result]: [AsyncTaskStatus, Error?, any?] = await new Promise(async resolve => { 126 | const done = (args: [AsyncTaskStatus, Error?, any?]) => { 127 | signal.removeEventListener('abort', abortHandler) 128 | resolve(args) 129 | } 130 | 131 | const abortHandler = () => { 132 | const status = AsyncTaskStatus.ABORTED 133 | const error = new AsyncTaskAbortError() 134 | // To ensure that the effect of aborting is synchronous, 135 | // set task properties immediately instead of on next tick. 136 | if (isAlive(task) && task.pending) { 137 | task._resolve(status, error) 138 | } 139 | done([status, error]) 140 | } 141 | 142 | signal.addEventListener('abort', abortHandler) 143 | 144 | try { 145 | const result = await flow(generator)({ signal, exec }) 146 | done([AsyncTaskStatus.COMPLETE, undefined, result]) 147 | } catch (err) { 148 | if (err instanceof AsyncTaskAbortError) { 149 | done([AsyncTaskStatus.ABORTED, err]) 150 | } else { 151 | done([AsyncTaskStatus.FAILED, err]) 152 | } 153 | } 154 | }) 155 | 156 | if (isAlive(task) && task.pending && !signal.aborted) { 157 | task._resolve(status, error, result) 158 | } 159 | 160 | return new AsyncTaskResult(status, error, result) 161 | } 162 | -------------------------------------------------------------------------------- /packages/mst-async-task/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "sourceMap": false, 5 | "declaration": true, 6 | "module": "es2015", 7 | "lib": ["dom", "es6"], 8 | "allowJs": true, 9 | "esModuleInterop": true, 10 | "removeComments": false, 11 | "moduleResolution": "node", 12 | "strict": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "noImplicitAny": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitReturns": true, 18 | "noImplicitThis": true, 19 | "importHelpers": true, 20 | "stripInternal": true, 21 | "downlevelIteration": true, 22 | "useDefineForClassFields": true, 23 | "outDir": "build" 24 | }, 25 | "files": ["src/index.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/react-example/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /packages/react-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "^26.0.15", 7 | "@types/node": "^12.0.0", 8 | "@types/react": "^16.9.53", 9 | "@types/react-dom": "^16.9.8", 10 | "mobx": "^6.1.0", 11 | "mobx-react-lite": "3.2.0", 12 | "mobx-state-tree": "^5.0.1", 13 | "mst-async-task": "file:../mst-async-task", 14 | "react": "^17.0.1", 15 | "react-dom": "^17.0.1", 16 | "react-scripts": "4.0.1", 17 | "typescript": "^4.0.3" 18 | }, 19 | "scripts": { 20 | "start": "yarn add file:../mst-async-task && react-scripts start", 21 | "build": "yarn add file:../mst-async-task && react-scripts build", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": [ 26 | "react-app", 27 | "react-app/jest" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/react-example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | mst-async-task react example 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/react-example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { observer } from 'mobx-react-lite' 3 | import { Store, IStore } from './Store' 4 | import { AsyncTaskStatus, IAsyncTask } from 'mst-async-task' 5 | 6 | const store = Store.create() 7 | 8 | export default function App() { 9 | return ( 10 |
11 |
12 |
13 | ) 14 | } 15 | 16 | const Main = observer((({ store }: { store: IStore }) => { 17 | const { 18 | message1, 19 | message2, 20 | task1, 21 | task2, 22 | runAllSeqTask, 23 | runAllParallelTask, 24 | shouldFailMap, 25 | runTask1, 26 | runTask2, 27 | runAllSeq, 28 | runAllParallel, 29 | toggleShouldFail, 30 | reset, 31 | } = store 32 | 33 | return ( 34 |
35 |
36 |
37 |
mst-async-task: React Example
38 |
39 | 40 |
41 | toggleShouldFail('task1')} 47 | onRun={runTask1} 48 | /> 49 | 50 | toggleShouldFail('task2')} 56 | onRun={runTask2} 57 | /> 58 | 59 |
Run All Tasks
60 |
61 | 62 | 67 | 68 | 73 |
74 | 75 |
76 | 79 |
80 |
81 |
82 | ) 83 | })) 84 | 85 | const STATUS_COLORS = { 86 | [AsyncTaskStatus.INIT]: 'secondary', 87 | [AsyncTaskStatus.PENDING]: 'primary', 88 | [AsyncTaskStatus.COMPLETE]: 'success', 89 | [AsyncTaskStatus.ABORTED]: 'warning', 90 | [AsyncTaskStatus.FAILED]: 'danger' 91 | } 92 | 93 | interface TaskRowProps { 94 | name: string 95 | task: IAsyncTask 96 | message?: string 97 | fail?: boolean 98 | onToggleFail?: () => void 99 | onRun: () => void 100 | } 101 | 102 | const TaskRow = observer((({ name, task, message, fail, onToggleFail, onRun }: TaskRowProps) => { 103 | const { status } = task 104 | 105 | return ( 106 |
107 |
108 |
109 |
110 | {name} 111 | {message && `(${message})`} 112 |
113 | 114 |
115 |
118 |
119 | {status} 120 |
121 |
122 |
123 | 124 |
125 | {onToggleFail && ( 126 |
127 | onToggleFail()} 133 | id={`fail_${name}`} 134 | /> 135 | 138 |
139 | )} 140 | 141 | {!task.pending ? ( 142 | 148 | ) : ( 149 | 155 | )} 156 |
157 |
158 | ) 159 | })) 160 | -------------------------------------------------------------------------------- /packages/react-example/src/Store.ts: -------------------------------------------------------------------------------- 1 | import { types, Instance, applySnapshot } from 'mobx-state-tree' 2 | import { AsyncTask, runTask } from 'mst-async-task' 3 | 4 | const sleep = (delay: number) => new Promise(resolve => { 5 | setTimeout(resolve, delay) 6 | }) 7 | 8 | const api = { 9 | async getMessage1() { 10 | await sleep(1000) 11 | return 'Hello' 12 | }, 13 | 14 | async getMessage2(signal: AbortSignal) { 15 | return new Promise(async (resolve, reject) => { 16 | signal.addEventListener('abort', () => { 17 | reject(new Error('Aborted')) 18 | }) 19 | await sleep(2000) 20 | resolve('Greetings') 21 | }) 22 | } 23 | } 24 | 25 | export const Store = types 26 | .model({ 27 | message1: types.maybe(types.string), 28 | message2: types.maybe(types.string), 29 | task1: types.optional(AsyncTask, {}), 30 | task2: types.optional(AsyncTask, {}), 31 | runAllSeqTask: types.optional(AsyncTask, {}), 32 | runAllParallelTask: types.optional(AsyncTask, {}), 33 | shouldFailMap: types.optional(types.map(types.boolean), {}) 34 | }) 35 | .actions(self => { 36 | const runTask1 = () => runTask(self.task1, function*({ exec }) { 37 | const message = yield api.getMessage1() 38 | 39 | if (self.shouldFailMap.get('task1')) { 40 | throw new Error('Failed') 41 | } 42 | 43 | exec(() => { 44 | self.message1 = message 45 | }) 46 | }) 47 | 48 | const runTask2 = () => runTask(self.task2, function*({ signal, exec }) { 49 | const message = yield api.getMessage2(signal) 50 | 51 | if (self.shouldFailMap.get('task2')) { 52 | throw new Error('Failed') 53 | } 54 | 55 | exec(() => { 56 | self.message2 = message 57 | }) 58 | }) 59 | 60 | const runAllSeq = () => runTask(self.runAllSeqTask, function*({ exec }) { 61 | yield exec(runTask1) 62 | yield exec(runTask2) 63 | }) 64 | 65 | const runAllParallel = () => runTask(self.runAllParallelTask, function*({ exec }) { 66 | yield Promise.all([exec(runTask1), exec(runTask2)]) 67 | }) 68 | 69 | const toggleShouldFail = (taskName: string) => { 70 | self.shouldFailMap.set(taskName, !self.shouldFailMap.get(taskName)) 71 | } 72 | 73 | const reset = () => { 74 | applySnapshot(self, {}) 75 | } 76 | 77 | return { 78 | runTask1, 79 | runTask2, 80 | runAllSeq, 81 | runAllParallel, 82 | toggleShouldFail, 83 | reset 84 | } 85 | }) 86 | 87 | export interface IStore extends Instance {} 88 | -------------------------------------------------------------------------------- /packages/react-example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | background-color: #6A6A6A; 9 | } 10 | -------------------------------------------------------------------------------- /packages/react-example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ) 12 | -------------------------------------------------------------------------------- /packages/react-example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/react-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------