Xstate: Await asynchronous result in a (final) state before transitioning

Created on 24 Apr 2019  路  15Comments  路  Source: davidkpiano/xstate

Bug or feature request?

Bug

Description:

XState does not wait for the results of asynchronous actions before transitioning to the next state. This breaks the causality of the flow since actions of the lastest state can be executed before those of the previous state. (https://codesandbox.io/s/jz042nj3wy)

In case of a final state, it even ignores the invoke statement completely (https://codesandbox.io/s/j2wqo5965y)

Link to reproduction or proof-of-concept:

(using onEntry) https://codesandbox.io/s/jz042nj3wy
(using invoke) https://codesandbox.io/s/j2wqo5965y

(Bug) Expected result:

  1. onEntry statement - final state child1
  2. onEntry statement - initial state child2

(Bug) Actual result:

  1. onEntry statement - initial state child2
  2. onEntry statement - final state child1

(Bug) Potential fix:

  • Wait for all asynchronous actions to be completed before moving to the next state
  • Wait for the invoke action to be completed in a final state before moving to the next state
invalid

Most helpful comment

Nice.
Thank you so much for your patience

All 15 comments

The bug here is that invoke isn't supported _in_ final states. SCXML allow onentry and onexit, but not <invoke>, which is reserved for "being in a state", and is not allowed in final states. So probably the documentation should be updated to reflect that, and perhaps throw an error if "final" and "invoke" are both defined in a state.

As for the async onentry handler, I don't think you should be doing async stuff in a final state; a final state signifies that "you're done" and should quit as fast as possible. If you do something async, then the machine will (synchronously) transition to the next state and invoke its entry actions immediately, so I believe this is the expected behaviour.

This is by design. Actions should be fire-and-forget, and "waiting" until an async action resolves would not make it a fire-and-forget action.

Additionally, think about the states. If you have a Promise, and you want to wait for it, there is another state you have to consider -- the state of waiting for that Promise to resolve.

The way to do this (SCXML-compatible) is to invoke Promises.

You can explicitly model some ending state to accomplish this:

  red: {
    invoke: {
      src: async () => {
        await true;
        console.log("invoke statement - final state child1");
      },
      onDone: 'endCycle'
    }
  },
  endCycle: {
    type: 'final'
  }

I'm having some trouble waiting for an async function in src to be resolved as well.
Picture this, in context with the previous example(now red is the initial state):

red: {
  invoke: {
    id: 'validate',
    src: validateThis,
    onDone: {
      actions: 'ready',
    },
    onError: {
      actions: 'notReady',
    },
  },
  on: {
    CALL: {
      target: 'green',
      cond: 'isReady',
    },
  },
}

And here's the async function

async function validateThis (context, event) {
  return someOtherAsyncFunction(context.data)
}

Debug hits a breakpoint at validateThis body
But action ready is never executed.
Neither is notReady

at the FSM creation in Machine function, I have:

Machine(SMObject, {
  guards: {
    isReady: (context, event) => context.ready,
  },
  actions: {
    ready: (context, event) => {
      context.ready = true
      context.validated = event.value
    },
    notReady: (context, event) => {
      context.ready = false
    },
  },
})

Is this valid?

鉁忥笍 Please create a CodeSandbox (XState template) reproduction of this. Thanks!

https://codesandbox.io/s/xstate-example-template-us188?fontsize=14

It looks like it goes "red red red"
Whereas I expected "red green".
Clearly I'm missing something. Can you help me?

First things first: you should never mutate context directly. Always do it through assign(...) (see docs: https://xstate.js.org/docs/guides/context.html#notes)

Let's refactor first and then revisit if the problem still persists.

Updated. I believe it is not a problem to call assign inside an action.
Still the same.

assign(...) are not imperative. The action config should look like this:

const machine = Machine(lightSM, {
  guards: {
    isReady: (context, event) => context.ready
  },
  actions: {
    // assign(...) returns an object!
    ready: assign({
      ready: (context, event) => true
    }),
    notReady: assign({
      ready: (context, event) => false
    })
  }
});

I updated the example to my best effort.
It is not working yet. But that's maybe because I'm missing something.

I'm not understanding the assign function.
How do you change the "ready" property in context?
Isn't it only with

assign({
  ready: (context, event) => true
}

?

Why do you need to return the assign result to an action?
What if the action was named readyTrue?

readyTrue: (context, event) => {
    console.log("ready");
    assign({
    ready: (context, event) => true
  });
},

wouldn't this set context.ready to true?

It would not. Again, the assign action is not imperative and it produces no side effects. It's not magic.

It returns an object description (action object) that tells XState what should be done (assign the value) and XState does it while transitioning the state.

Ok, got it. Now I believe it's compliant with the rules.
Still not working, though :(

You have a "race condition" (in terms of expectations; the machine is working perfectly fine).

Try this:

service.start();
setTimeout(() => {
  service.send("CALL");
}, 100);

It will work as expected.

When you start the service, it enters the 'red' state, and invokes the _asynchronous_ validateThis service. You are calling service.send('CALL') immediately though, while ready is still false.

hmm.
I wrongly assumed that the machine buffered all the externally triggered events...
But now it is working!

But it is fine. I only need to do two more things:

  • Wait for all events and consequent actions in the machine to be processed
  • Throw an exception when an unexpected external event is called.

For the first case, this would be ideal:

await service.start()
await service.send("CALL")

I don't really know if this is possible. Or even correct. Any ideas on the most ellegant way of achieving this?
I'm not comfortable with the setTimeout() solution. What if it takes longer?!

For the second case:
I think I understand the concept of Forbidden transitions

The problem is that it looks like you must explicitly define the transition in each state to which you want to be forbidden.
For example:

red: {
    // ... the invoke stuff that doesn't matter now
      on: {
        CALL: {
          target: "green",
          cond: "isReady"
        },
        GOBACK: {
          target: undefined,
          actions: ["badTransition"]
        }
      },

In badTransition I can throw an exception. Looks fine.

Problem is that I must create the object transition: {...} for every transition in the state machine.
A nice solution would be something like:

"*": {
  target: undefined,
  actions: ["badTransition"]
}

"*" would be some sort of wild card for every other transition.

Wait for all events and consequent actions in the machine to be processed

That's not how state machines/statecharts work. A transition based on an event must always be assumed to be zero-time (immediate). It's a change in thinking, but it makes mathematical sense. Instead of, e.g.,

idle -> FETCH -....(await response)....-> success

You would instead model that with transitions that represent the intermediate states:

idle -> FETCH -> loading -> RESOLVE -> success

All app logic can be modeled this way. async/await is just an implicit state machine.

"*" would be some sort of wild card for every other transition.

That's coming soon! Probably in 4.7. Feature issue will be made soon.

Nice.
Thank you so much for your patience

Was this page helpful?
0 / 5 - 0 ratings