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

Proposal: Observable finite state machine #368

Open
mjstahl opened this Issue Aug 15, 2018 · 5 comments

Comments

Projects
None yet
4 participants
@mjstahl
Copy link
Member

mjstahl commented Aug 15, 2018

This was discussed on a recent live stream (33:18) and a previous live stream.

Problem:
I found ViewModels with many properties to be error prone when switching between logical states in the ViewModel. Forgetting which properties to update when the state was updated was problematic.

Example API:

import DefineGraph from "can-define/graph/graph";

const H20 = DefineGraph.extend('H20', {
  initial: {
    // actions must reference existing states
    frozen: 'ice',
    boiling: 'steam',

    // DefineMap
    values: {
      temp: '60F'
    }
  },
  ice: {
    boiling: 'steam',
    warm: 'initial',

    values: {
      temp: '32F'
    }
  },
  steam: {
    cool: 'initial',
    frozen: 'ice',

    values: {
      temp: '212F'
    }
  }
})

const water = new H20()
water.temp //-> '60F'

// 'is' switches from once state to the next
// and the ViewModel would then be set to values defined in that state's `values` property.
// (Not happy with this function name either)
water.is('frozen')
water.state //-> 'ice'
water.temp //-> '32F'

// 'to' also allows to the developer to set the values of the new state. This would be a merge.
water.is('warm', { temp: '75F' })
water.state //-> 'default'
water.temp //-> '75F'
@justinbmeyer

This comment has been minimized.

Copy link
Contributor

justinbmeyer commented Aug 15, 2018

@mjstahl Thanks! I've got a lot of random thoughts on this.

Throat clearing on how to do FSM-ish stuff in define-map right now

The ATM example is a close approximation to the technique I normally use. CanJS usually "flips" the state machine to focus on the "extended state variables". So instead of focusing on the transitions, we derive the "state" from the variables. It looks like this:

DefineMap.extend("H20",{
  get state(){
    if(this.temp < 32 } return "freeze";
    if(this.temp < 212 } return "default";
    return "steam"
  },
  temp: {type: "number", default: 60}, // <-- extended state variable
  freeze(){ this.temp = 32 }
})

Imo, this approach works well for when there is a "source of truth" (in this case temp) that the states can readily be derived from. This is not true of many FSMs where the transition matters quite a bit. This FSM, for example, does not require someone to call a heat() method twice before making steam. It doesn't tell you if you "can" switch from one state to another.

I think a FSM would be great for CanJS. (I just want to give people some tools in the meantime).

Other thougths

  • I'm not sure if you are, but I wouldn't really limit yourself to something DefineMap-like. It's fairly straightforward to make things observable so they can work in stache and as a component's VM now.
  • Have you thought about just making something like https://github.com/jakesgordon/javascript-state-machine observable? This doesn't seem to support extended state variables. How important are those to you? In my experience, they are often important.
  • I think functions like .heat(), .freeze() will look better in stache than to(state, variables). on:click="heat()" compared to on:click="to('heat').
@mjstahl

This comment has been minimized.

Copy link
Member

mjstahl commented Aug 15, 2018

The ATM example is how I do this sort of thing on client work and prior to writing my little library. To answer your other thoughts:

  • I am not really sure what you mean by DefineMap-like, or rather, what something looks like that is not that.

  • I did look up other implementations but I hadn't written an FSM since undergrad, and I had never written one in JavaScript. So I created my library more for fun than anything else.

  • The support for extending state variables only came after thinking about the use case where the Application would switch from an unauthenticated state with a null user property to an authenticated state with a truthy user property after making a login request to the server.

  • I do like the idea of edges being turned into functions. I originally just used strings because I was defining events names as constants and then used those event names as the names of the edges, reducing potential typos. Good idea.

@justinbmeyer

This comment has been minimized.

Copy link
Contributor

justinbmeyer commented Aug 15, 2018

To make something like stated work as an observable, it needs to add a few symbols (and call ObservationRecorder.add()). An example can be seen here:

https://github.com/canjs/can-observation/blob/master/test/simple.js#L148

And here:

https://github.com/canjs/can-simple-map/blob/master/can-simple-map.js#L144

The critical symbols are:

Then if someone reads .state you'll want something like:

get state(){
  ObservationRecorder.add(this, "state");
  return this._state;
}

And when state changes, you'll want to make sure you dispatch all the right events:

somethingInternalThatSetsState(){
  queues.enqueueByQueue(this.handlers.getNode(["value"]), this, [value], function(){
				return {};
  });
}

Much of this (and more) can be mixed in via https://canjs.com/doc/can-event-queue/map/map.html

That might look like:

CanStated extends Stated {
  has(action, updateValue) {
    const transitionTo = this.states[this.state][action];
    if (!transitionTo) {
      throw `'${action}' does not exist as an action of '${this.state}'`;
    }
    if (typeof transitionTo !== 'string') {
      throw `'${transitionTo}' is not a valid state. It must be a string.`
    }
    if (!this.states[transitionTo]) {
      throw `'${transitionTo}' does not exist`;
    }
    var oldState = this._state;
    this._state = transitionTo;
    this[canSymbol.for("can.dispatch")]("state", [this._state, oldState]);
    if (updateValue) this.value = updateValue;
    return this;
  }
  get state(){
    ObservationRecorder.add(this, "state");
  }
}

mixinValueBindings(CanStated.prototype);

@mjstahl mjstahl changed the title Proposal: DefineGraph - Observable finite state machine to be used as a ViewModel Proposal: Observable finite state machine Oct 5, 2018

@eben-roux

This comment has been minimized.

Copy link
Contributor

eben-roux commented Oct 6, 2018

State machines are usually quite trivial to implement. DefineMap should front this as with any other state but it certainly should not be a state machine. Well, I cannot see why.

If you need this functionality directly on a DefineMap then I'd create a derived type to do this.

@christopherjbaker

This comment has been minimized.

Copy link
Contributor

christopherjbaker commented Nov 16, 2018

While state machines are usually trivial to implement, I like the idea of having a consistent way to define them, especially when the simple definition creates a more complex object that is easy to use.

Along those lines, I would be in favor of the edges (.frozen() or .toFrozen()) and checks (.isFrozen()) being methods rather than strings (with the possibility to overload these functions to take extra params for changing related values).

Similarly, I would be in favor of a way to specify what state transitions are valid. Using the main example above, you cannot go from ice to steam (at least at STP), and being able to prevent this and present a useful error message would be nice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment