Skip to content

Commit 565be8a

Browse files
committed
introduced canceellable flows
1 parent a65d5f5 commit 565be8a

4 files changed

Lines changed: 176 additions & 125 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The changes mentioned here are discussed in detail in the [release highlights](h
1818
* Introduced `mobx.configure({ disableErrorBoundaries })`, for easier debugging of exceptoins. By [NaridaL](https://github.com/NaridaL) through [#1262](https://github.com/mobxjs/mobx/pull/1262)
1919
* `toJS` now accepts the options: `{ detectCycles?: boolean, exportMapsAsObjects?: boolean }`, both `true` by default
2020
* Introduced `flow` to create a chain of async actions. This is the same function as [`asyncActions`](https://github.com/mobxjs/mobx-utils#asyncaction) of the mobx-utils package
21+
* These `flow`'s are now cancellable, by calling `.cancel()` on the returned promise, which will throw a cancellation exception into the generator function.
2122
* The flow typings have been updated. Since this is a manual effort, there can be mistakes, so feel free to PR!
2223

2324
* `computed(fn, options?)` / `@computed(options) get fn()` now accept the following options:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"typings": "lib/mobx.d.ts",
1111
"scripts": {
1212
"test": "yarn quick-build && yarn jest",
13-
"watch": "yarn test --watch",
13+
"watch": "yarn jest --watch",
1414
"test:mixed-versions": "jest --testRegex mixed-versions",
1515
"test:all": "yarn small-build && yarn jest -i && yarn test:flow && yarn test:mixed-versions",
1616
"test:webpack": "node scripts/webpack-regression-tests.js",

src/api/flow.ts

Lines changed: 74 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ import { BabelDescriptor } from "../utils/decorators2"
22
import { addHiddenFinalProp } from "../utils/utils"
33
import { action } from "./action"
44

5+
export type CancellablePromise<T> = Promise<T> & { cancel(): void }
6+
57
// method decorator:
68
export function flow(
79
target: Object,
810
propertyKey: string,
911
descriptor: PropertyDescriptor
1012
): PropertyDescriptor
11-
1213
// non-decorator forms
13-
export function flow<R>(generator: () => IterableIterator<any>): () => Promise<R>
14-
export function flow<A1>(generator: (a1: A1) => IterableIterator<any>): (a1: A1) => Promise<any> // Ideally we want to have R instead of Any, but cannot specify R without specifying A1 etc... 'any' as result is better then not specifying request args
14+
export function flow<R>(generator: () => IterableIterator<any>): () => CancellablePromise<R>
15+
export function flow<A1>(
16+
generator: (a1: A1) => IterableIterator<any>
17+
): (a1: A1) => CancellablePromise<any> // Ideally we want to have R instead of Any, but cannot specify R without specifying A1 etc... 'any' as result is better then not specifying request args
1518
export function flow<A1, A2, A3, A4, A5, A6, A7, A8>(
1619
generator: (
1720
a1: A1,
@@ -23,32 +26,37 @@ export function flow<A1, A2, A3, A4, A5, A6, A7, A8>(
2326
a7: A7,
2427
a8: A8
2528
) => IterableIterator<any>
26-
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6, a7: A7, a8: A8) => Promise<any>
29+
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6, a7: A7, a8: A8) => CancellablePromise<any>
2730
export function flow<A1, A2, A3, A4, A5, A6, A7>(
2831
generator: (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6, a7: A7) => IterableIterator<any>
29-
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6, a7: A7) => Promise<any>
32+
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6, a7: A7) => CancellablePromise<any>
3033
export function flow<A1, A2, A3, A4, A5, A6>(
3134
generator: (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6) => IterableIterator<any>
32-
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6) => Promise<any>
35+
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6) => CancellablePromise<any>
3336
export function flow<A1, A2, A3, A4, A5>(
3437
generator: (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5) => IterableIterator<any>
35-
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5) => Promise<any>
38+
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5) => CancellablePromise<any>
3639
export function flow<A1, A2, A3, A4>(
3740
generator: (a1: A1, a2: A2, a3: A3, a4: A4) => IterableIterator<any>
38-
): (a1: A1, a2: A2, a3: A3, a4: A4) => Promise<any>
41+
): (a1: A1, a2: A2, a3: A3, a4: A4) => CancellablePromise<any>
3942
export function flow<A1, A2, A3>(
4043
generator: (a1: A1, a2: A2, a3: A3) => IterableIterator<any>
41-
): (a1: A1, a2: A2, a3: A3) => Promise<any>
44+
): (a1: A1, a2: A2, a3: A3) => CancellablePromise<any>
4245
export function flow<A1, A2>(
4346
generator: (a1: A1, a2: A2) => IterableIterator<any>
44-
): (a1: A1, a2: A2) => Promise<any>
45-
export function flow<A1>(generator: (a1: A1) => IterableIterator<any>): (a1: A1) => Promise<any>
47+
): (a1: A1, a2: A2) => CancellablePromise<any>
48+
export function flow<A1>(
49+
generator: (a1: A1) => IterableIterator<any>
50+
): (a1: A1) => CancellablePromise<any>
4651
// ... with name
47-
export function flow<R>(name: string, generator: () => IterableIterator<any>): () => Promise<R>
52+
export function flow<R>(
53+
name: string,
54+
generator: () => IterableIterator<any>
55+
): () => CancellablePromise<R>
4856
export function flow<A1>(
4957
name: string,
5058
generator: (a1: A1) => IterableIterator<any>
51-
): (a1: A1) => Promise<any> // Ideally we want to have R instead of Any, but cannot specify R without specifying A1 etc... 'any' as result is better then not specifying request args
59+
): (a1: A1) => CancellablePromise<any> // Ideally we want to have R instead of Any, but cannot specify R without specifying A1 etc... 'any' as result is better then not specifying request args
5260
export function flow<A1, A2, A3, A4, A5, A6, A7, A8>(
5361
name: string,
5462
generator: (
@@ -61,101 +69,35 @@ export function flow<A1, A2, A3, A4, A5, A6, A7, A8>(
6169
a7: A7,
6270
a8: A8
6371
) => IterableIterator<any>
64-
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6, a7: A7, a8: A8) => Promise<any>
72+
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6, a7: A7, a8: A8) => CancellablePromise<any>
6573
export function flow<A1, A2, A3, A4, A5, A6, A7>(
6674
name: string,
6775
generator: (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6, a7: A7) => IterableIterator<any>
68-
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6, a7: A7) => Promise<any>
76+
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6, a7: A7) => CancellablePromise<any>
6977
export function flow<A1, A2, A3, A4, A5, A6>(
7078
name: string,
7179
generator: (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6) => IterableIterator<any>
72-
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6) => Promise<any>
80+
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5, a6: A6) => CancellablePromise<any>
7381
export function flow<A1, A2, A3, A4, A5>(
7482
name: string,
7583
generator: (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5) => IterableIterator<any>
76-
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5) => Promise<any>
84+
): (a1: A1, a2: A2, a3: A3, a4: A4, a5: A5) => CancellablePromise<any>
7785
export function flow<A1, A2, A3, A4>(
7886
name: string,
7987
generator: (a1: A1, a2: A2, a3: A3, a4: A4) => IterableIterator<any>
80-
): (a1: A1, a2: A2, a3: A3, a4: A4) => Promise<any>
88+
): (a1: A1, a2: A2, a3: A3, a4: A4) => CancellablePromise<any>
8189
export function flow<A1, A2, A3>(
8290
name: string,
8391
generator: (a1: A1, a2: A2, a3: A3) => IterableIterator<any>
84-
): (a1: A1, a2: A2, a3: A3) => Promise<any>
92+
): (a1: A1, a2: A2, a3: A3) => CancellablePromise<any>
8593
export function flow<A1, A2>(
8694
name: string,
8795
generator: (a1: A1, a2: A2) => IterableIterator<any>
88-
): (a1: A1, a2: A2) => Promise<any>
96+
): (a1: A1, a2: A2) => CancellablePromise<any>
8997
export function flow<A1>(
9098
name: string,
9199
generator: (a1: A1) => IterableIterator<any>
92-
): (a1: A1) => Promise<any>
93-
94-
/**
95-
* `asyncAction` takes a generator function and automatically wraps all parts of the process in actions. See the examples below.
96-
* `asyncAction` can be used both as decorator or to wrap functions.
97-
*
98-
* - It is important that `asyncAction should always be used with a generator function (recognizable as `function*` or `*name` syntax)
99-
* - Each yield statement should return a Promise. The generator function will continue as soon as the promise settles, with the settled value
100-
* - When the generator function finishes, you can return a normal value. The `asyncAction` wrapped function will always produce a promise delivering that value.
101-
*
102-
* When using the mobx devTools, an asyncAction will emit `action` events with names like:
103-
* * `"fetchUsers - runid: 6 - init"`
104-
* * `"fetchUsers - runid: 6 - yield 0"`
105-
* * `"fetchUsers - runid: 6 - yield 1"`
106-
*
107-
* The `runId` represents the generator instance. In other words, if `fetchUsers` is invoked multiple times concurrently, the events with the same `runid` belong toghether.
108-
* The `yield` number indicates the progress of the generator. `init` indicates spawning (it won't do anything, but you can find the original arguments of the `asyncAction` here).
109-
* `yield 0` ... `yield n` indicates the code block that is now being executed. `yield 0` is before the first `yield`, `yield 1` after the first one etc. Note that yield numbers are not determined lexically but by the runtime flow.
110-
*
111-
* `asyncActions` requires `Promise` and `generators` to be available on the target environment. Polyfill `Promise` if needed. Both TypeScript and Babel can compile generator functions down to ES5.
112-
*
113-
* N.B. due to a [babel limitation](https://github.com/loganfsmyth/babel-plugin-transform-decorators-legacy/issues/26), in Babel generatos cannot be combined with decorators. See also [#70](https://github.com/mobxjs/mobx-utils/issues/70)
114-
*
115-
* @example
116-
* import {asyncAction} from "mobx-utils"
117-
*
118-
* let users = []
119-
*
120-
* const fetchUsers = asyncAction("fetchUsers", function* (url) {
121-
* const start = Date.now()
122-
* const data = yield window.fetch(url)
123-
* users = yield data.json()
124-
* return start - Date.now()
125-
* })
126-
*
127-
* fetchUsers("http://users.com").then(time => {
128-
* console.dir("Got users", users, "in ", time, "ms")
129-
* })
130-
*
131-
* @example
132-
* import {asyncAction} from "mobx-utils"
133-
*
134-
* mobx.useStrict(true) // don't allow state modifications outside actions
135-
*
136-
* class Store {
137-
* \@observable githubProjects = []
138-
* \@state = "pending" // "pending" / "done" / "error"
139-
*
140-
* \@asyncAction
141-
* *fetchProjects() { // <- note the star, this a generator function!
142-
* this.githubProjects = []
143-
* this.state = "pending"
144-
* try {
145-
* const projects = yield fetchGithubProjectsSomehow() // yield instead of await
146-
* const filteredProjects = somePreprocessing(projects)
147-
* // the asynchronous blocks will automatically be wrapped actions
148-
* this.state = "done"
149-
* this.githubProjects = filteredProjects
150-
* } catch (error) {
151-
* this.state = "error"
152-
* }
153-
* }
154-
* }
155-
*
156-
* @export
157-
* @returns {Promise}
158-
*/
100+
): (a1: A1) => CancellablePromise<any>
159101
export function flow(arg1: any, arg2?: any): any {
160102
// decorator
161103
if (typeof arguments[1] === "string") return flowDecorator.apply(null, arguments)
@@ -173,47 +115,56 @@ export function createFlowGenerator(name: string, generator: Function) {
173115
return function() {
174116
const ctx = this
175117
const args = arguments
176-
return new Promise(function(resolve, reject) {
177-
const runId = ++generatorId
178-
let stepId = 0
179-
const gen = action(`${name} - runid: ${runId} - init`, generator).apply(ctx, args)
180-
onFulfilled(undefined) // kick off the process
118+
const runId = ++generatorId
119+
const gen = action(`${name} - runid: ${runId} - init`, generator).apply(ctx, args)
120+
let stepId = 0
121+
let resolver: (value: any) => void
122+
let rejector: (error: any) => void
181123

182-
function onFulfilled(res: any) {
183-
let ret
184-
try {
185-
ret = action(`${name} - runid: ${runId} - yield ${stepId++}`, gen.next).call(
186-
gen,
187-
res
188-
)
189-
} catch (e) {
190-
return reject(e)
191-
}
192-
next(ret)
193-
return null
124+
function onFulfilled(res: any) {
125+
let ret
126+
try {
127+
ret = action(`${name} - runid: ${runId} - yield ${stepId++}`, gen.next).call(
128+
gen,
129+
res
130+
)
131+
} catch (e) {
132+
return rejector(e)
194133
}
134+
next(ret)
135+
return null
136+
}
195137

196-
function onRejected(err: any) {
197-
let ret
198-
try {
199-
ret = action(`${name} - runid: ${runId} - yield ${stepId++}`, gen.throw).call(
200-
gen,
201-
err
202-
)
203-
} catch (e) {
204-
return reject(e)
205-
}
206-
next(ret)
138+
function onRejected(err: any) {
139+
let ret
140+
try {
141+
ret = action(`${name} - runid: ${runId} - yield ${stepId++}`, gen.throw).call(
142+
gen,
143+
err
144+
)
145+
} catch (e) {
146+
return rejector(e)
207147
}
148+
next(ret)
149+
}
208150

209-
function next(ret: any) {
210-
if (ret.done) return resolve(ret.value)
211-
// TODO: support more type of values? See https://github.com/tj/co/blob/249bbdc72da24ae44076afd716349d2089b31c4c/index.js#L100
212-
if (!ret.value || typeof ret.value.then !== "function")
213-
fail("Only promises can be yielded to asyncAction, got: " + ret)
214-
return ret.value.then(onFulfilled, onRejected)
215-
}
216-
})
151+
function next(ret: any) {
152+
if (ret.done) return resolver(ret.value)
153+
// TODO: support more type of values? See https://github.com/tj/co/blob/249bbdc72da24ae44076afd716349d2089b31c4c/index.js#L100
154+
if (!ret.value || typeof ret.value.then !== "function")
155+
return fail("Only promises can be yielded to asyncAction, got: " + ret)
156+
return ret.value.then(onFulfilled, onRejected)
157+
}
158+
159+
const res = new Promise(function(resolve, reject) {
160+
resolver = resolve
161+
rejector = reject
162+
onFulfilled(undefined) // kick off the process
163+
}) as any
164+
res.cancel = function() {
165+
onRejected(new Error("FLOW_CANCELLED"))
166+
}
167+
return res
217168
}
218169
}
219170

test/base/flow.js

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
var mobx = require("../../src/mobx.ts")
1+
import * as mobx from "../../src/mobx.ts"
2+
const { flow } = mobx
23

34
function delay(time, value, shouldThrow = false) {
45
return new Promise((resolve, reject) => {
@@ -158,3 +159,101 @@ function stripEvents(events) {
158159
return e
159160
})
160161
}
162+
163+
test("flows can be cancelled - 1 - uncatched cancellation", done => {
164+
let steps = 0
165+
const start = flow(function*() {
166+
steps = 1
167+
yield Promise.resolve()
168+
steps = 2
169+
})
170+
171+
const promise = start()
172+
promise.then(
173+
() => fail(),
174+
err => {
175+
expect(steps).toBe(1)
176+
expect("" + err).toBe("Error: FLOW_CANCELLED")
177+
done()
178+
}
179+
)
180+
promise.cancel()
181+
})
182+
183+
test("flows can be cancelled - 2 - catch cancellation in generator", done => {
184+
let steps = 0
185+
const start = flow(function*() {
186+
steps = 1
187+
try {
188+
yield Promise.resolve()
189+
steps = 2
190+
} catch (e) {
191+
expect(steps).toBe(1)
192+
expect(e.toString()).toBe("Error: FLOW_CANCELLED")
193+
return 4
194+
}
195+
})
196+
const promise = start()
197+
promise.then(
198+
res => {
199+
expect(res).toBe(4)
200+
done()
201+
},
202+
err => {
203+
fail()
204+
}
205+
)
206+
promise.cancel()
207+
})
208+
209+
test("flows can be cancelled - 3 - rethrow cancellation", done => {
210+
let steps = 0
211+
const start = flow(function*() {
212+
steps = 1
213+
try {
214+
yield Promise.resolve()
215+
steps = 2
216+
} catch (e) {
217+
expect(steps).toBe(1)
218+
expect(e.toString()).toBe("Error: FLOW_CANCELLED")
219+
throw e // rethrow
220+
}
221+
})
222+
223+
const promise = start()
224+
promise.then(
225+
() => fail(),
226+
err => {
227+
expect(steps).toBe(1)
228+
expect("" + err).toBe("Error: FLOW_CANCELLED")
229+
done()
230+
}
231+
)
232+
promise.cancel()
233+
})
234+
235+
test("flows can be cancelled - 3 - pending Promise will be ignored", done => {
236+
let steps = 0
237+
const start = flow(function*() {
238+
steps = 1
239+
try {
240+
yield Promise.reject("This won't be catched anywhere!") // cancel will resolve this flow before this one is throw, so this promise goes uncatched
241+
steps = 2
242+
} catch (e) {
243+
expect(steps).toBe(1)
244+
expect(e.toString()).toBe("Error: FLOW_CANCELLED")
245+
throw e
246+
}
247+
})
248+
249+
const promise = start()
250+
promise.then(
251+
() => fail(),
252+
err => {
253+
expect(steps).toBe(1)
254+
expect("" + err).toBe("Error: FLOW_CANCELLED")
255+
done()
256+
}
257+
)
258+
promise.cancel()
259+
})

0 commit comments

Comments
 (0)