├── .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 | [](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 |
--------------------------------------------------------------------------------