cassiozen / useStateMachine

The <1 kb state machine hook for React
MIT License
2.37k stars 47 forks source link

Types Roadmap #61

Open devanshj opened 2 years ago

devanshj commented 2 years ago
devanshj commented 2 years ago

@cassiozen I want to provide a safe version in v1.0.0 itself. Which one of the two options you like? And what should we name it? "safe" is very ambiguous.

cassiozen commented 2 years ago

What if we flip? We make "safe" the default version and create an "edge" version form more experienced/adventurous users?

cassiozen commented 2 years ago

more?

We need nested states - hierarchical/recursive states are the next big feature I want to add.

devanshj commented 2 years ago

What if we flip? We make "safe" the default version and create an "edge" version form more experienced/adventurous users?

We could do that but the problem is that these features to the users are not "edge" but really basic and beginner-friendly, for TypeScript it's "edge".

What I mean when I say "really basic and beginner-friendly"? Example -

useStateMachine({
  schema: { events: { X: t<{ a: number }>(), Y: t<{ a: number }>(), Z: t<{ b: number }>() } },
  initial: "a",
  states: {
    a: { on: { X: "c" } },
    b: { on: { Z: "a" } },
    c: {
      entry: ({ event }) => {
        let x = event.a
        // event here is narrowed down to { type: "X", a: number } | { type: "Y", a: number }
      }
    }
  },
  on: { Y: "c" }
})

It's only because of txstate-like types we can do that narrowing, with the "safe" version beginners would go "Umm why does it not allow accessing property a in event, the compiler says it could not be present there? The compiler says the event can be { type: "Z", b: number } but that's not true!". Here they are right and the types are wrong. In fact only experienced users will be able to deal with "safe" version because they'd be more confident than the compiler.

So my suggestion would be to not flip it.

devanshj commented 2 years ago

We need nested states - hierarchical/recursive states are the next big feature I want to add.

Yep sure. The "more?" was only for more linter rules haha. Will add another top-level more xD

Also I'm thinking of this roadmap to only include typelevel features meaning once we have runtime hierarchical states it's obvious the types will have to support that. So it's not a "type-level" feature but rather a "runtime-level" feature

devanshj commented 2 years ago

@davidkpiano @Andarist Yall should keep an eye on what I'm doing here :P because all these things would eventually come to txstate too ;)

cassiozen commented 2 years ago

The "more?" was only for more linter rules haha

I think these lint rules already go a long way. I can't think of any others. The "more" that I referred to was for the 1.2.0 release (or later) šŸ˜

So it's not a "type-level" feature but rather a "runtime-level" feature

Very true. The types need to support that, and I'll work on the "runtime" code.

cassiozen commented 2 years ago

Regarding the "safe" version: I'm having an internal debate about it. My initial thought is that it would be confusing for a beginner to understand why a "safe" version exists.

As an exercise: What's the worst that can happen? A new version of TypeScript breaks useStateMachine?

devanshj commented 2 years ago

What's the worst that can happen? A new version of TypeScript breaks useStateMachine?

Yep that. Or to be precise, the users using useStateMachine would have their builds failing when they upgrade TypeScript. And the worst is this happens and we don't have any workarounds to commit and publish a new version of useStateMachine.

In that case the users would have to switch to safe version (and maybe we'll have to make edge same as safe and publish) and the existing code will have to be edited with assertions. We could provide a few helpers that make it a little easier. So they'd have to do something like...

effect: ({ event }) => {
  let x = (event as XEvent | YEvent).a
}

Or with helpers...

effect: ({ event }) => {
  assertEventType(event, ["X", "Y"])
  let x = event.a
}

In theory one could write a codemod for this but would be quite a task :P

Edit: Rethinking about the codemod thing - I think it could be actually be a thing we can provide that edits your edge code to make you able to degrade to safe, in that case users would use edge version with confidence as they'd know the codemod has their back. Wdyt? Is this something worth exploring?

cassiozen commented 2 years ago

Let's go with the safe version then. Let's call it "boring"? As in "@cassionzen/usestatemachine/boring"

devanshj commented 2 years ago

Ahahaha xD well... It's kinda better than safe at least, but sounds way too informal to me. Maybe "lite-typed" or something like that?

Other options that come to mind (will keep editing): imprecise-types, imprecisely-typed, meagerly-typed, degraded-types, safe-types, hackfree-types

cassiozen commented 2 years ago

I think "light-typed" is the best we could come up with

devanshj commented 2 years ago

Sounds good. We can always change it last minute if we come up with something better ;)

How about lightly-typed tho? light-typed feel grammatically incorrect to say. Or even light-types would work. And how about "lite" instead of "light", I think the former is more popular? (mobx-react-lite, caniuse-lite come to mind)

cassiozen commented 2 years ago

I don't really care if it's "lite" or "light" - both works for me.

lightly-typed sounds good.

devanshj commented 2 years ago

I think I like "lite-types" more. Wanna go with that or "lightly-typed"? I don't have a strong preference tho, just lightly-typed has more syllables :P

But then I think lite-types in not an adjective argh xD idk anymore whatever works for me

cassiozen commented 2 years ago

Final decision: lite-types

I like that it's short and I don't care whether it's grammatically correct or not.

devanshj commented 2 years ago

Done, it's good!

devanshj commented 2 years ago
let [state, send] = useStateMachine({
  schema: {
    events: {
      A: t<{}>()
    }
  },
  initial: "a",
  states: { a: {} }
})
send("A")

Should we allow this? The user has defined an event in schema but it uses it nowhere in the definition. Or this should be a lint rule? It could be they might have kept it for later so an error seems too restrictive to me, not sure tho.

cassiozen commented 2 years ago

It should be consistent with what happens if the user adds a context schema before adding the context initial value in the definition. Right now it's an error (if I'm not mistaken).

devanshj commented 2 years ago

It does give an error right now but I don't see how the comparison is 1:1. But going with that and even generally it looks like a mistake which should give an error. Later we could add an allowExtraEventsInSchema rule if we want.

cassiozen commented 2 years ago

Yeah, agreed

cassiozen commented 2 years ago

@devanshj how hard/quick would it be to make the state definition recursive in the type level? I have a decent idea of how I would implement it at "runtime" level - and I'm starting to play with the idea of including it on the final 1.0 release.

devanshj commented 2 years ago

It's definitely doable but to answer your question you'd have to tell me what the runtime behavior is. Basically what happens when the machine in node a.b.c takes an event X (in attempt to transition it to node a.d.e) ?

In case of xstate, in nutshell (nutshell being the keyword here :P) this happens -

  1. A transition is selected by looking up for X from the innermost node to the root node: Check if a.b.c takes X? Yes, select. No, check if a.b takes X? Yes, select. No, check if a takes X? Yes, select. No, check if root takes X? Yes, select. No, return as no transitions were selected. And note here "takes X" means it should be present in on AND the gaurd (if present) should return true (let's say a.b takes X and it was selected)
  2. Exit all nodes starting from innermost to the least common ancestor (in our case a): exit a.b.c, exit a.b. Note this is assuming the transition is "internal", if it were external then the root would be the least common ancestor.
  3. Enter all nodes starting from the least common ancestor to the target node: enter a.d, enter a.d.e

(It's hilarious and ironic that I haven't used xstate even once, have written zero machines with it yet I know the spec quite well because of txstate šŸ¤£)

If the behavior going to be same as this, well then it might take some considerable time and effort. Especially writing the tests, implementation might be written quicker.

And if the behavior is going to be simpler then might take less time and effort.

Also, I'm not an expert by any means nor do I have strong opinions about it but be careful when not following the spec because my guess is it's been developed by many heuristics that might not be obvious right off the bat. You might also want to check what Steve does with state-designer or even other libraries for that matter.

So yeah the question is this: what happens when the machine in node a.b.c takes an event X?

cassiozen commented 2 years ago

Also, I'm not an expert by any means nor do I have strong opinions about it but be careful when not following the spec because my guess is it's been developed by many heuristics that might not be obvious right off the bat.

Yes, absolutely. When I say we don't adhere to the spec is not out of lacking knowledge or will, it's a conscious decision. My impression is that the scxml spec (assuming that's the spec we're talking about šŸ˜) is too broad - in certain cases (like actions), it gives many ways to achieve the same thing. And that's totally fine, nothing wrong with this concept, but it does imply that a library that fully follows the spec will necessarily have a bigger footprint (and bigger docs, etc). useStateMachine has a premise of simplicity: I like that almost all documentation fits in a README.

You might also want to check what Steve does with state-designer

Oh, I've been following state-designer, Robot, react-states and others for a long time. My initial experience with State Machines was with Ruby on Rails back in the day, and I also had a fairly popular state machine library for ActionScript many years ago.

What happens when the machine in node a.b.c takes an event X?

What you described is correct. The library will check if the current state node or any of its parents listens to the "X" event (meaning they have "X" in their "ON" definition). If they do, it will exit all nested states up until a common ancestor (if any), then enter each nested state until the target.

Let's say we have this:

{
  initial: 'A',
  states: {
    A: {
      initial: 'A1',
      states: {
        A1: {
          initial: 'A12',
          states: {
            A12: {  
            },
          },
          on: {
            GOTOA2: 'A2',
          },
        },
        A2: {},
      },
    },
    B: {},
  },
}

Initial state is A.A1.A12

If I send ("GOTOA2), it will check recursively if the states take this event: A12? No. A1? Yes. So the transition will happen.

Exit A12 Exit A1 (A is the common ancestor, so it does not exit nor enter) Enter A2

cassiozen commented 2 years ago

If the behavior going to be same as this, well then it might take some considerable time and effort. Especially writing the tests, implementation might be written quicker.

No problem, we can do this on a later release.

devanshj commented 2 years ago

Yeah my impression of the spec is similar and I never meant following the spec exactly - what I meant was let's not do something too out of the way and we aren't as you said the transition behavior is going to be same. And anyways you're more expert at this than me so no worries about that :P

Cool then we'll keep this for a later release, so this will be my next task after I'm done with lite-types.