Skip to content

Commit

Permalink
actionAsync (#217)
Browse files Browse the repository at this point in the history
* actionasync

* fix docs indentation

* fix typo

* fixed more typos

* slight improvement to unit tests

* add fail function

* fix dangling promises, improve unit tests

* fix recursivity, some edge cases

* removed wrong unit test

* silly commit to retrigger build

* silly commit to retrigger build

* try to fix unit test

* update some dev-deps
  • Loading branch information
xaviergonz authored Oct 13, 2019
1 parent 75733b1 commit 41246ea
Show file tree
Hide file tree
Showing 14 changed files with 1,780 additions and 884 deletions.
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
* Added `actionAsync` (not to be confused with `asyncAction`) as an alternative to flows.

# 5.4.1

* Fixed `cannot read property enumerable of undefined` error, [#191](https://github.com/mobxjs/mobx-utils/issues/191) through [#198](https://github.com/mobxjs/mobx-utils/pull/198) by [@dr0p](https://github.com/dr0p)
Expand All @@ -14,7 +16,7 @@ Introduced `computedFn`, to support using arbitrary functions as computed! Imple
# 5.2.0

* `createViewModel` now has an additional field `changedValues` on the returned viewmodel, that returns a map with all the pending changes. See [#172](https://github.com/mobxjs/mobx-utils/pull/172) by [@ItamarShDev](https://github.com/ItamarShDev). Fixes [#171](https://github.com/mobxjs/mobx-utils/issues/171) and [#173](https://github.com/mobxjs/mobx-utils/issues/173)
* `fromPromise().case`: if the `onFullfilled` handler is omitted, `case` will now return the resolved value, rather than `undefined`. See [#167](https://github.com/mobxjs/mobx-utils/pull/167/) by [@JefHellemans](https://github.com/JefHellemans)
* `fromPromise().case`: if the `onFulfilled` handler is omitted, `case` will now return the resolved value, rather than `undefined`. See [#167](https://github.com/mobxjs/mobx-utils/pull/167/) by [@JefHellemans](https://github.com/JefHellemans)
* `createViewModel` will now respect the enumerability of properties. See [#169](https://github.com/mobxjs/mobx-utils/pull/169) by [dr0p](https://github.com/dr0p)

# 5.1.0
Expand Down Expand Up @@ -83,7 +85,7 @@ Updated mobx-utils to use MobX 4. No futher changes
* **BREAKING** Fixed #54, the resolved value of a promise is no longer deeply converted to an observable
* **BREAKING** Dropped `fromPromise().reason`
* **BREAKING** Improved typings of `fromPromise`. For example, the `value` property is now only available if `.state === "resolved"` (#41)
* **BREAKING** Dropped optional `initialvalue` param from `fromPromise`. use `fromPromise.fullfilled(value)` instead to create a promise in some ready state
* **BREAKING** Dropped optional `initialvalue` param from `fromPromise`. use `fromPromise.fulfilled(value)` instead to create a promise in some ready state
* Introduced `fromPromise.reject(reason)` and `fromPromise.resolve(value?)` to create a promise based observable in a certain state, see #39
* Fixed #56, observable promises attributes `state` and `value` are now explicit observables

Expand Down
78 changes: 76 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ CDN: <https://unpkg.com/mobx-utils/mobx-utils.umd.js>
- [Examples](#examples-17)
- [DeepMapEntry](#deepmapentry)
- [DeepMap](#deepmap)
- [actionAsync](#actionasync)
- [Parameters](#parameters-19)
- [Examples](#examples-18)

## fromPromise

Expand Down Expand Up @@ -608,7 +611,7 @@ When using the mobx devTools, an asyncAction will emit `action` events with name
- `"fetchUsers - runid: 6 - yield 0"`
- `"fetchUsers - runid: 6 - yield 1"`

The `runId` represents the generator instance. In other words, if `fetchUsers` is invoked multiple times concurrently, the events with the same `runid` belong toghether.
The `runId` represents the generator instance. In other words, if `fetchUsers` is invoked multiple times concurrently, the events with the same `runid` belong together.
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).
`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.

Expand Down Expand Up @@ -756,7 +759,7 @@ Note that this might introduce memory leaks!
### Parameters

- `fn`
- `keepAlive`
- `keepAliveOrOptions`

### Examples

Expand All @@ -779,3 +782,74 @@ console.log((store.m(3) * store.c))
## DeepMapEntry

## DeepMap

## actionAsync

Alternative syntax for async actions, similar to `flow` but more compatible with
Typescript typings. Not to be confused with `asyncAction`, which is deprecated.

`actionAsync` can be used either as a decorator or as a function.
It takes an async function that internally must use `await task(promise)` rather than
the standard `await promise`.

When using the mobx devTools, an asyncAction will emit `action` events with names like:

- `"fetchUsers - runid 6 - step 0"`
- `"fetchUsers - runid 6 - step 1"`
- `"fetchUsers - runid 6 - step 2"`

The `runId` represents the action instance. In other words, if `fetchUsers` is invoked
multiple times concurrently, the events with the same `runid` belong together.
The `step` number indicates the code block that is now being executed.

### Parameters

- `arg1`
- `arg2`
- `arg3`

### Examples

```javascript
import {actionAsync, task} from "mobx-utils"

let users = []

const fetchUsers = actionAsync("fetchUsers", async (url) => {
const start = Date.now()
// note the use of task when awaiting!
const data = await task(window.fetch(url))
users = await task(data.json())
return start - Date.now()
})

const time = await fetchUsers("http://users.com")
console.log("Got users", users, "in ", time, "ms")
```

```javascript
import {actionAsync, task} from "mobx-utils"

mobx.configure({ enforceActions: "observed" }) // don't allow state modifications outside actions

class Store {
@observable githubProjects = []
@state = "pending" // "pending" / "done" / "error"

@actionAsync
async fetchProjects() {
this.githubProjects = []
this.state = "pending"
try {
// note the use of task when awaiting!
const projects = await task(fetchGithubProjectsSomehow())
const filteredProjects = somePreprocessing(projects)
// the asynchronous blocks will automatically be wrapped actions
this.state = "done"
this.githubProjects = filteredProjects
} catch (error) {
this.state = "error"
}
}
}
```
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"mobx": "^5.5.0",
"prettier": "^1.7.2",
"rollup": "^0.50.0",
"rxjs": "^5.0.2",
"rxjs": "^6.0.0",
"shelljs": "^0.8.3",
"ts-jest": "^24.0.2",
"typescript": "*"
Expand Down Expand Up @@ -96,4 +96,4 @@
}
}
}
}
}
216 changes: 216 additions & 0 deletions src/action-async.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { _startAction, _endAction, IActionRunInfo } from "mobx"
import { invariant } from "./utils"
import { decorateMethodOrField } from "./decorator-utils"
import { fail } from "./utils"

let runId = 0
const unfinishedIds = new Set<number>()
const currentlyActiveIds = new Set<number>()

interface IActionAsyncContext {
runId: number
step: number
actionRunInfo: IActionRunInfo
actionName: string
scope: any
args: IArguments
}

const actionAsyncContextStack: IActionAsyncContext[] = []

function getCurrentActionAsyncContext() {
if (actionAsyncContextStack.length <= 0) {
fail(
"'actionAsync' context not present. did you await inside an 'actionAsync' without using 'task(promise)'?"
)
}
return actionAsyncContextStack[actionAsyncContextStack.length - 1]!
}

export async function task<R>(promise: Promise<R>): Promise<R> {
invariant(
typeof promise === "object" && typeof promise.then === "function",
"'task' expects a promise"
)

const ctx = getCurrentActionAsyncContext()

const { runId, actionName, args, scope, actionRunInfo, step } = ctx
const nextStep = step + 1
actionAsyncContextStack.pop()
_endAction(actionRunInfo)
currentlyActiveIds.delete(runId)

try {
return await promise
} finally {
// only restart if it not a dangling promise (the action is not yet finished)
if (unfinishedIds.has(runId)) {
const actionRunInfo = _startAction(
getActionAsyncName(actionName, runId, nextStep),
this,
args
)

actionAsyncContextStack.push({
runId,
step: nextStep,
actionRunInfo,
actionName,
args,
scope
})
currentlyActiveIds.add(runId)
}
}
}

// method decorator
export function actionAsync(
target: object,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor

// field decorator
export function actionAsync(target: object, propertyKey: string): void

// non-decorator forms
export function actionAsync<F extends (...args: any[]) => Promise<any>>(name: string, fn: F): F
export function actionAsync<F extends (...args: any[]) => Promise<any>>(fn: F): F

// base

/**
* Alternative syntax for async actions, similar to `flow` but more compatible with
* Typescript typings. Not to be confused with `asyncAction`, which is deprecated.
*
* `actionAsync` can be used either as a decorator or as a function.
* It takes an async function that internally must use `await task(promise)` rather than
* the standard `await promise`.
*
* When using the mobx devTools, an asyncAction will emit `action` events with names like:
* * `"fetchUsers - runid 6 - step 0"`
* * `"fetchUsers - runid 6 - step 1"`
* * `"fetchUsers - runid 6 - step 2"`
*
* The `runId` represents the action instance. In other words, if `fetchUsers` is invoked
* multiple times concurrently, the events with the same `runid` belong together.
* The `step` number indicates the code block that is now being executed.
*
* @example
* import {actionAsync, task} from "mobx-utils"
*
* let users = []
*
* const fetchUsers = actionAsync("fetchUsers", async (url) => {
* const start = Date.now()
* // note the use of task when awaiting!
* const data = await task(window.fetch(url))
* users = await task(data.json())
* return start - Date.now()
* })
*
* const time = await fetchUsers("http://users.com")
* console.log("Got users", users, "in ", time, "ms")
*
* @example
* import {actionAsync, task} from "mobx-utils"
*
* mobx.configure({ enforceActions: "observed" }) // don't allow state modifications outside actions
*
* class Store {
* \@observable githubProjects = []
* \@state = "pending" // "pending" / "done" / "error"
*
* \@actionAsync
* async fetchProjects() {
* this.githubProjects = []
* this.state = "pending"
* try {
* // note the use of task when awaiting!
* const projects = await task(fetchGithubProjectsSomehow())
* const filteredProjects = somePreprocessing(projects)
* // the asynchronous blocks will automatically be wrapped actions
* this.state = "done"
* this.githubProjects = filteredProjects
* } catch (error) {
* this.state = "error"
* }
* }
* }
*/
export function actionAsync(arg1?: any, arg2?: any, arg3?: any): any {
// decorator
if (typeof arguments[1] === "string") {
return decorateMethodOrField(
"@actionAsync",
(prop, v) => {
return actionAsyncFn(prop, v)
},
arg1,
arg2,
arg3
)
}

// direct invocation
const actionName = typeof arg1 === "string" ? arg1 : arg1.name || "<unnamed action>"
const fn = typeof arg1 === "function" ? arg1 : arg2

return actionAsyncFn(actionName, fn)
}

function actionAsyncFn(actionName: string, fn: Function): Function {
if (!_startAction || !_endAction) {
fail("'actionAsync' requires mobx >=5.13.1 or >=4.13.1")
}

invariant(typeof fn === "function", "'asyncAction' expects a function")
if (typeof actionName !== "string" || !actionName)
fail(`actions should have valid names, got: '${actionName}'`)

return async function(this: any, ...args: any) {
const nextRunId = runId++
unfinishedIds.add(nextRunId)

const actionRunInfo = _startAction(getActionAsyncName(actionName, nextRunId, 0), this, args)

actionAsyncContextStack.push({
runId: nextRunId,
step: 0,
actionRunInfo,
actionName,
args,
scope: this
})
currentlyActiveIds.add(nextRunId)

let errThrown: any
try {
const ret = await fn.apply(this, args)
return ret
} catch (err) {
errThrown = err
throw err
} finally {
unfinishedIds.delete(nextRunId)

if (currentlyActiveIds.has(nextRunId)) {
const ctx = actionAsyncContextStack.pop()
if (!ctx || ctx.runId !== nextRunId) {
fail(
"'actionAsync' context not present or invalid. did you await inside an 'actionAsync' without using 'task(promise)'?"
)
}
ctx.actionRunInfo.error = errThrown
_endAction(ctx.actionRunInfo)
currentlyActiveIds.delete(nextRunId)
}
}
}
}

function getActionAsyncName(actionName: string, runId: number, step: number) {
return `${actionName} - runid ${runId} - step ${step}`
}
2 changes: 1 addition & 1 deletion src/async-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export function asyncAction<A1>(
* * `"fetchUsers - runid: 6 - yield 0"`
* * `"fetchUsers - runid: 6 - yield 1"`
*
* The `runId` represents the generator instance. In other words, if `fetchUsers` is invoked multiple times concurrently, the events with the same `runid` belong toghether.
* The `runId` represents the generator instance. In other words, if `fetchUsers` is invoked multiple times concurrently, the events with the same `runid` belong together.
* 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).
* `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.
*
Expand Down
Loading

0 comments on commit 41246ea

Please sign in to comment.