Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add mstRunInAction #1240

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,12 +465,7 @@ someModel.actions(self => {
})
```

Note that, since MST v3.9, TypeScript correctly infers `flow` arguments and usually infers correctly `flow` return types,
but one exception to this case is when a `Promise` is returned as final value. In this case (and only in this case) this construct needs to be used:

```ts
return castFlowReturn(somePromise)
```
Note that, since MST v3.9, TypeScript correctly infers `flow` arguments and usually infers correctly `flow` final return types, however intermediate yielsd will have `any` as type.

#### Action listeners versus middleware

Expand Down Expand Up @@ -1126,7 +1121,6 @@ See the [full API docs](docs/API/README.md) for more details.
| [`destroy(node)`](docs/API/README.md#destroy) | Kills `node`, making it unusable. Removes it from any parent in the process |
| [`detach(node)`](docs/API/README.md#detach) | Removes `node` from its current parent, and lets it live on as standalone tree |
| [`flow(generator)`](docs/API/README.md#flow) | Creates an asynchronous flow based on a generator function |
| [`castFlowReturn(value)`](docs/API/README.md#castflowreturn) | Casts a flow return value so it can be correctly inferred as return type. Only needed when using TypeScript and when returning a Promise. |
| [`getChildType(node, property?)`](docs/API/README.md#getchildtype) | Returns the declared type of the given `property` of `node`. For arrays and maps `property` can be omitted as they all have the same type |
| [`getEnv(node)`](docs/API/README.md#getenv) | Returns the environment of `node`, see [environments](#environments) |
| [`getParent(node, depth=1)`](docs/API/README.md#getparent) | Returns the intermediate parent of the `node`, or a higher one if `depth > 1` |
Expand All @@ -1147,6 +1141,7 @@ See the [full API docs](docs/API/README.md) for more details.
| [`isValidReference(() => node \| null \| undefined, checkIfAlive = true)`](docs/API/README.md#isvalidreference) | Tests if a reference is valid (pointing to an existing node and optionally if alive) and returns if the check passes or not. |
| [`isRoot(node)`](docs/API/README.md#isroot) | Returns true if `node` has no parents |
| [`joinJsonPath(parts)`](docs/API/README.md#joinjsonpath) | Joins and escapes the given path `parts` into a JSON path |
| [`mstRunInAction<T>(node, name?: string, thunk: () => T): T`](docs/API/README.md#mstruninaction) | Similar to mobx `runInAction`, it allows you to run an anonymous action as if it were part of a given type instance. |
| [`onAction(node, (actionDescription) => void)`](docs/API/README.md#onaction) | A built-in middleware that calls the provided callback with an action description upon each invocation. Returns disposer |
| [`onPatch(node, (patch) => void)`](docs/API/README.md#onpatch) | Attach a JSONPatch listener, that is invoked for each change in the tree. Returns disposer |
| [`onSnapshot(node, (snapshot) => void)`](docs/API/README.md#onsnapshot) | Attach a snapshot listener, that is invoked for each change in the tree. Returns disposer |
Expand Down Expand Up @@ -1369,7 +1364,7 @@ Actually, the more strict options that are enabled, the better the type system w

We recommend using TypeScript together with MST, but since the type system of MST is more dynamic than the TypeScript system, there are cases that cannot be expressed neatly and occasionally you will need to fallback to `any` or manually adding type annotations.

Flow is not supported.
Flow is partially supported.

#### Using a MST type at design time

Expand Down
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
- Added `mstRunInAction`, similar to mobx's `runInAction` but for MST node instances.
- Removed `castFlowReturn` since when a flow returns a promise what is actually returned from the flow is the resolved value.
- Through PR [#1196](https://github.com/mobxjs/mobx-state-tree/pull/1196) by [@xaviergonz](https://github.com/xaviergonz)
- Added `createActionTrackerMiddleware2`, a more easy to use version of the first one, which makes creating middlewares for both sync and async actions more universal.
- Added an optional filter to `recordPatches` to be able to skip recording certain patches.
Expand Down
60 changes: 39 additions & 21 deletions docs/API/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 52 additions & 9 deletions docs/async-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@
Asynchronous actions are a first class concept in Mobx-State-Tree. Modelling an asynchronous flow can be done in two ways:

1. Model each step of the flow as separate action
2. Use generators
2. Model each step of the flow with `mstRunInAction`
3. Use generators (`flow`)

The recommended approach is to use _generators_, for reasons mentioned below.
But let's take a look at modelling asynchronous actions as a set of actions first.

## Using separate actions
## 1. Using separate actions

MST doesn't allow changing state outside actions (except when the tree is unprotected).
This means that each step in an asynchronous flow that needs to actually change the model needs to become a separate action.
For example:

```javascript
const Store = types.model({
const Store = types
.model({
githubProjects: types.array(types.frozen),
state: types.enumeration("State", ["pending", "done", "error"])
})
Expand All @@ -38,8 +40,7 @@ const Store = types.model({
console.error("Failed to fetch projects", error)
self.state = "error"
}
}
))
}))
```

This approach works fine and has great type inference, but comes with a few downsides:
Expand All @@ -48,19 +49,61 @@ This approach works fine and has great type inference, but comes with a few down
2. Each step of the flow is exposed as action to the outside world. In the above example, one could (but shouldn't) directly invoke `store.fetchProjectsSuccess([])`
3. Middleware cannot distinguish the flow initiating action from the handler actions. This means that actions like `fetchProjectsSuccess` will become part of the recorded action list, although you probably never want to replay it (as replaying `fetchProjects` itself will cause the handler actions to be fired in the end).

## Using generators
## 2. Using `mstRunInAction`

Like the previous one, except that sub-actions don't need to be exposed publicly.
For example:

```javascript
const Store = types
.model({
githubProjects: types.array(types.frozen),
state: types.enumeration("State", ["pending", "done", "error"])
})
.actions(self => ({
async fetchProjects() {
self.githubProjects = []
self.state = "pending"
try {
const projects = await fetchGithubProjectsSomehow()
// after each await we are "outside" the action, so we need to get
// inside an action again
mstRunInAction(self, "fetchProjectsSuccess", () => {
self.state = "done"
self.githubProjects = projects
})
} catch (error) {
// after each await we are "outside" the action, so we need to get
// inside an action again
mstRunInAction(self, "fetchProjectsError", () => {
console.error("Failed to fetch projects", error)
self.state = "error"
})
}
}
}))
```

This approach also works fine and has great type inference, but comes with a few downsides as well:

1. For complex flows, then `mstRunInAction` has to be used fairly often.
2. Middleware cannot distinguish the flow initiating action from the handler actions. This means that actions like `fetchProjectsSuccess` will become part of the recorded action list, although you probably never want to replay it (as replaying `fetchProjects` itself will cause the handler actions to be fired in the end).

## 3. Using generators (`flow`)

Generators might sound scary, but they are very suitable for expressing asynchronous flows. The above example looks as follows when using generators:

```javascript
import { flow } from "mobx-state-tree"

const Store = types.model({
const Store = types
.model({
githubProjects: types.array(types.frozen),
state: types.enumeration("State", ["pending", "done", "error"])
})
.actions(self => ({
fetchProjects: flow(function* fetchProjects() { // <- note the star, this a generator function!
fetchProjects: flow(function* fetchProjects() {
// <- note the star, this a generator function!
self.githubProjects = []
self.state = "pending"
try {
Expand Down Expand Up @@ -105,7 +148,7 @@ Using generators requires Promises and generators to be available. Promises can

To see how `flows`s can be monitored and detected in middleware, see the [middleware docs](middleware.md).

## What about async / await?
## What about async / await insie flows?

Async/await can only be used in trees that are unprotected. Async / await is not flexible enough to allow MST to wrap asynchronous steps in actions automatically, as is done for the generator functions.
Luckily, using generators in combination with `flow` is very similar to `async / await`: `async function() {}` becomes `flow(function* () {})`, and `await promise` becomes `yield promise`, and further behavior should be the same.
91 changes: 90 additions & 1 deletion packages/mobx-state-tree/__tests__/core/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
cast,
IMiddlewareEvent,
ISerializedActionCall,
Instance
Instance,
mstRunInAction
} from "../../src"

/// Simple action replay and invocation
Expand Down Expand Up @@ -421,3 +422,91 @@ test("after attach action should work correctly", () => {
}
])
})

describe("mstRunInAction", () => {
test("over model", () => {
const T = types.model({
done: false
})

const t = T.create()
expect(t.done).toBe(false)
const ret = mstRunInAction(t, "someAction", () => {
t.done = !t.done
return t.done
})
expect(ret).toBe(true)
expect(t.done).toBe(true)
})

test("to create async actions", async () => {
const T = types
.model({
done: false
})
.actions(self => ({
async toggleDoneAsync() {
await Promise.resolve()
return mstRunInAction(t, () => {
self.done = !self.done
return self.done
})
}
}))

const t = T.create()
expect(t.done).toBe(false)
const ret = await t.toggleDoneAsync()
expect(ret).toBe(true)
expect(t.done).toBe(true)
})

test("over array", () => {
const T = types.array(types.number)

const t = T.create([1])
expect(getSnapshot(t)).toEqual([1])
const ret = mstRunInAction(t, "someAction", () => {
t.push(2)
return true
})
expect(ret).toBe(true)
expect(getSnapshot(t)).toEqual([1, 2])
})

test("over map", () => {
const T = types.map(types.number)

const t = T.create()
expect(getSnapshot(t)).toEqual({})
const ret = mstRunInAction(t, "someAction", () => {
t.set("0", 1)
return true
})
expect(ret).toBe(true)
expect(getSnapshot(t)).toEqual({ "0": 1 })
})

test("checks", () => {
if (process.env.NODE_ENV === "production") {
return
}

expect(() => mstRunInAction(5 as any, () => {})).toThrow(
"expected mobx-state-tree node as argument 1"
)

const T = types.array(types.number)
const t = T.create()
expect(() => mstRunInAction(t, 5 as any)).toThrow("expected function as argument 1")
expect(() => mstRunInAction(t, "", () => {})).toThrow(
"expected non-empty string as argument 1"
)
expect(() => mstRunInAction(t, "afterCreate", () => {})).toThrow(
"invalid action name (a hook cannot be used as name)"
)
expect(() => mstRunInAction(t, "push", () => {})).toThrow(
"invalid action name (there's an action/property/view with the same name on the node already)"
)
})
})
2 changes: 1 addition & 1 deletion packages/mobx-state-tree/__tests__/core/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ const METHODS_AND_INTERNAL_TYPES = stringToArray(`
addMiddleware,
isStateTreeNode,
flow,
castFlowReturn,
applyAction,
onAction,
recordActions,
Expand Down Expand Up @@ -75,6 +74,7 @@ const METHODS_AND_INTERNAL_TYPES = stringToArray(`
isValidReference,
tryReference,
getNodeId,
mstRunInAction,

types
`)
Expand Down
11 changes: 4 additions & 7 deletions packages/mobx-state-tree/__tests__/core/async.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import {
destroy,
IMiddlewareHandler,
IMiddlewareEvent,
IMiddlewareEventType,
castFlowReturn
IMiddlewareEventType
// TODO: export IRawActionCall
} from "../../src"
import { reaction, configure } from "mobx"
Expand All @@ -24,9 +23,7 @@ function delay<TV>(time: number, value: TV, shouldThrow = false): Promise<TV> {

function testCoffeeTodo(
done: () => void,
generator: (
self: any
) => ((str: string) => IterableIterator<Promise<any> | string | undefined>),
generator: (self: any) => (str: string) => IterableIterator<Promise<any> | string | undefined>,
shouldError: boolean,
resultValue: string | undefined,
producedCoffees: any[]
Expand Down Expand Up @@ -334,10 +331,10 @@ test("flow typings", async () => {
numberToNumber: flow(function*(val: number) {
yield promise
return val
}), // should be () => Promise<number>
}), // should be () => Promise<2>
voidToNumber: flow(function*() {
yield promise
return castFlowReturn(Promise.resolve(2))
return 2
})
}))

Expand Down
Loading