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 support for wildcard transitions to xstate-fsm #4065

Merged
merged 7 commits into from
Jun 16, 2023

Conversation

benblank
Copy link
Contributor

@benblank benblank commented Jun 8, 2023

After a recent discussion regarding how to handle unexpected events (#4057) brought wildcard transitions to my attention, I wanted to see what it would take to add them to the @xstate/fsm package. Fortunately, it turned out to be pretty straightforward.

This PR introduces three behavioral changes, in addition to the supporting tests and documentation:

  1. If a machine is created with transitions whose event type is a single asterisk, those transitions are added to the list of transitions attempted regardless of event type. This has the effect that, if no other transition has occurred, the wildcard transitions are checked before giving up and returning an unchanged state.
  2. To prevent the above from causing typing problems, a single asterisk is considered a valid key in the transitions objects in the machine config.
  3. If the transition method is called with an event whose type is a single asterisk, it will throw in non-production mode. (This matches the behavior of the full XState library.)

(Also, while updating "docs/packages/xstate-fsm/index.md", I noticed that it had gotten out of sync with "packages/xstate-fsm/README.md"; I corrected that while I was in there, but as a separate commit, so that it can be easily dropped if you'd prefer to keep the PR clean.)

@changeset-bot
Copy link

changeset-bot bot commented Jun 8, 2023

🦋 Changeset detected

Latest commit: 73592a0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@xstate/fsm Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codesandbox-ci
Copy link

codesandbox-ci bot commented Jun 8, 2023

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 73592a0:

Sandbox Source
XState Example Template Configuration
XState React Template Configuration

@ghost
Copy link

ghost commented Jun 8, 2023

👇 Click on the image for a new way to code review

Review these changes using an interactive CodeSee Map

Legend

CodeSee Map legend

Copy link
Member

@davidkpiano davidkpiano left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great, thanks for adding this!

@@ -136,7 +136,7 @@ export namespace StateMachine {
states: {
[key in TState['value']]: {
on?: {
[K in TEvent['type']]?: SingleOrArray<
[K in TEvent['type'] | '*']?: SingleOrArray<
Transition<
TContext,
TEvent extends { type: K } ? TEvent : never,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bit here wont be correct anymore with this change. With '*' u dont want to "filter" from TEvent based on { type: K }, you just want to grab TEvent as a whole

Copy link
Contributor Author

@benblank benblank Jun 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid you've lost me. 🙃

If I understand the typings correctly, this change is from "valid keys are of type K, where K is any type assignable to the value of property "type" on type TEvent" to "…where K is the union of any type assignable to the value of property "type" on type TEvent and "*"". The change is made here rather than on TEvent["type"] because "*" has a valid meaning when describing a transition, but is not valid as an event type in general, such as when calling Machine.transition(…).

If that doesn't address your concern, let me know!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand the typings correctly, this change is from "valid keys are of type K, where K is any type assignable to the value of property "type" on type TEvent" to "…where K is the union of any type assignable to the value of property "type" on type TEvent and "*""

This change is correct. The problem was created here though because the "value position" of this mapped type has not been adjusted anyhow and it was defined (and still is) as:

Transition<
  TContext,
  TEvent extends { type: K } ? TEvent : never,
  TState['value']
>

If we substitute K for '*' here then we end up with:

Transition<
  TContext,
  TEvent extends { type: '*' } ? TEvent : never,
  TState['value']
>

So in this case we'll try to filter TEvent members that extend { type: '*' } and such should never exist (we can't truly disallow them at type-level but we already disallow them at runtime with "If the transition method is called with an event whose type is a single asterisk, it will throw in non-production mode. (This matches the behavior of the full XState library.)")

What I'm saying is that we should end up here with something closer to this:

Transition<
  TContext,
  K extends '*' ? TEvent : TEvent extends { type: K } ? TEvent : never,
  TState['value']
>

Take a look at how we handle this in the XState's types:

export type TransitionsConfigMap<TContext, TEvent extends EventObject> = {
[K in TEvent['type'] | '' | '*']?: K extends '' | '*'
? TransitionConfigOrTarget<TContext, TEvent>
: TransitionConfigOrTarget<TContext, ExtractEvent<TEvent, K>, TEvent>;
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it! I hadn't noticed earlier that K was being used deeper in the type. 🤦

I spent some time playing around with the types and in the end, I'm not sure how this should be addressed. If K were being used to specify the type for an event, it would certainly cause trouble, but because it's sole use is in an extends clause, the typing of Transition<…> doesn't actually change.

In fact, I wasn't able to convince myself that TEvent extends { type: K } ? TEvent : never can actually result in never here, with or without adding the wildcard. TEvent is constrained to { type: string } via extending EventObject, which means that TEvent["type"] always extends string; "*" also extends string, which means that K does, as well. A subtype of string will always extend the union of that same subtype with any subtype of string. In other words, ("a" | "b" | "c") extends ("a" | "b" | "c" | "*") ? true : false is always true.

Of course, knowing that requires more than just a glance at the types; having K show up in the type passed to Transition<…> after explicitly including "*" in K certainly suggests that it has some effect there, even though it doesn't. I can think of three ways to address that:

  1. Remove "*" from the type in the conditional, e.g. TEvent extends { type: Exclude<K, "*"> } ? TEvent : never.
  2. Separate the types of on[K in TEvent['type']] and on["*"], though if I'm correct, they'd simply be identical.
  3. Get rid of the whole conditional; change it to just Transition<TContext, TEvent, TState["value"]>, which I believe it's already equivalent to. Unsurprisingly, this is the one I'm most partial to. 🙂

I also put together a CodePen sandbox to demonstrate a bunch of this. Let me know what you think!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that K is distributed through instantiations of a mapped type's template. That conditional type is not testing things like ("a" | "b" | "c") extends ("a" | "b" | "c" | "*"), it just tests a concrete K, a single member of the union over which we are iterating there. Without that you wouldn't be able to write the simplest:

type KeysAsValues<T> = {
  [K in keyof T]: K
}

type Mapped = KeysAsValues<{ a: 1, b: 2 }> // actual: { a: "a", b: "b" }, without instantiated/distributed `K`: { a: "a" | "b", b: "a" | "b" }

Of course, knowing that requires more than just a glance at the types; having K show up in the type passed to Transition<…> after explicitly including "*" in K certainly suggests that it has some effect there, even though it doesn't.

The effect, and the fact that it is narrowed to never for wildcards, can be easily observed on this TS playground

I can think of three ways to address that

We should just route this through a conditional type to a branch that narrows down the event type or to the one that skips the narrowing and just forwards the whole TEvent, just like in the quoted types from the core module:

export type TransitionsConfigMap<TContext, TEvent extends EventObject> = {
[K in TEvent['type'] | '' | '*']?: K extends '' | '*'
? TransitionConfigOrTarget<TContext, TEvent>
: TransitionConfigOrTarget<TContext, ExtractEvent<TEvent, K>, TEvent>;
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bah; it's very frustrating that I wasn't able to come up with an example of adding "*" having an effect on Transition<…>. Thanks for providing one! I'll need to spend some time studying how TS handles concrete type variables.

In the meantime, I've updated the PR to fix the types; I was also finally able to add that related test I was looking for.

Thanks for helping me through this type stuff! I'm learning a lot. 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, I just learned that VS Code wasn't fully honoring the package's TypeScript configuration. 🤦

Because the TEvent parameter to Transition<…> is only used in function signatures, the test doesn't work unless "strictFunctionTypes" is enabled in the TypeScript config. Unsurprisingly, enabling it for the entire repo blows up, but enabling it for just the @xstate/fsm package works just fine, so that's what I've done.

Let me know if that isn't a desirable change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, I just learned that VS Code wasn't fully honoring the package's TypeScript configuration. 🤦

Yeah, this is pretty annoying. I execute "Select TypeScript version..." command right after opening just any new project on my machine. There are ways to configure VS Code to prompt you to do this - they don't want to run the installed TS version without explicit consent for security reasons.

Because the test checks the `TEvent` parameter to `Transition`, which
is only used in function signatures, the TypeScript configuration must
have `strictFunctionTypes` enabled for the test to function.
@@ -131,6 +131,88 @@ describe('@xstate/fsm', () => {
expect(nextState.actions).toEqual([]);
});

describe('when a wildcard transition is defined', () => {
type ParserContext = { entries: Array<Record<string, string>> };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd copy-paste this stuff into each added test and trim down redundant bits for each particular test. Don't be afraid of some repetition, embrace it! 😉

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. I was mostly trying for a setup similar to the lightFSM used in many of the tests, but it really did end up complicating things unnecessarily.

The Event and State types still ended up being the same across all five tests (but much simpler), so those are still declared just once, but the config and machine creation have been moved into each test. I think it's easier to follow what's going on in each one, now.

@@ -2,6 +2,7 @@
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"],
"compilerOptions": {
"strictFunctionTypes": true,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid merge conflicts with the next branch, I'd like to leave this off - even though that in general this is a good change. On the next branch we'll work on enabling this setting globally soon.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. I've reverted this change, disabled the @ts-expect-error comment as you suggested, and added a comment to the test saying that it isn't actually working at the moment.

target: 'one',
actions: (_context: Context, _event: InitEvent | FooEvent) => {}
},
// @ts-expect-error
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to accompany the other suggested change I'd change this to smth like:

Suggested change
// @ts-expect-error
// @x-ts-expect-error

We'll "turn this on" at later point in time.

Transition<
TContext,
TEvent extends { type: K } ? TEvent : never,
K extends '*'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think that by routing this earlier it becomes a little bit more readable, so I'd use this variant:

      on?: {
        [K in TEvent["type"] | "*"]?: SingleOrArray<
          K extends "*"
            ? Transition<TContext, TEvent, TState["value"]>
            : Transition<
                TContext,
                TEvent extends { type: K } ? TEvent : never,
                TState["value"]
              >
        >;
      };

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

- Simplified the types used in the wildcard bevavior tests to make it
  easier to follow them.
- Refactored `StateMachine.Config` to make it more readable.
- Reverted enabling "strictFunctionTypes" and added a note to the
  relevant test saying that it will need updated when that option is
  reenabled. (Enabling "strictFunctionTypes" globally will be part of a
  separate effort.)
@Andarist Andarist merged commit 3b4b130 into statelyai:main Jun 16, 2023
2 of 3 checks passed
@github-actions github-actions bot mentioned this pull request Jun 16, 2023
@benblank benblank deleted the fsm-wildcards branch June 16, 2023 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants