-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
Conversation
🦋 Changeset detectedLatest commit: 73592a0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
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:
|
There was a problem hiding this 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!
packages/xstate-fsm/src/types.ts
Outdated
@@ -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, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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:
xstate/packages/core/src/types.ts
Lines 490 to 494 in 754afa0
export type TransitionsConfigMap<TContext, TEvent extends EventObject> = { | |
[K in TEvent['type'] | '' | '*']?: K extends '' | '*' | |
? TransitionConfigOrTarget<TContext, TEvent> | |
: TransitionConfigOrTarget<TContext, ExtractEvent<TEvent, K>, TEvent>; | |
}; |
There was a problem hiding this comment.
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:
- Remove
"*"
from the type in the conditional, e.g.TEvent extends { type: Exclude<K, "*"> } ? TEvent : never
. - Separate the types of
on[K in TEvent['type']]
andon["*"]
, though if I'm correct, they'd simply be identical. - 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!
There was a problem hiding this comment.
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:
xstate/packages/core/src/types.ts
Lines 490 to 494 in 754afa0
export type TransitionsConfigMap<TContext, TEvent extends EventObject> = { | |
[K in TEvent['type'] | '' | '*']?: K extends '' | '*' | |
? TransitionConfigOrTarget<TContext, TEvent> | |
: TransitionConfigOrTarget<TContext, ExtractEvent<TEvent, K>, TEvent>; | |
}; |
There was a problem hiding this comment.
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. 🙂
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
packages/xstate-fsm/test/fsm.test.ts
Outdated
@@ -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>> }; |
There was a problem hiding this comment.
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! 😉
There was a problem hiding this comment.
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.
packages/xstate-fsm/tsconfig.json
Outdated
@@ -2,6 +2,7 @@ | |||
"extends": "../../tsconfig.base.json", | |||
"include": ["src/**/*"], | |||
"compilerOptions": { | |||
"strictFunctionTypes": true, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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:
// @ts-expect-error | |
// @x-ts-expect-error |
We'll "turn this on" at later point in time.
packages/xstate-fsm/src/types.ts
Outdated
Transition< | ||
TContext, | ||
TEvent extends { type: K } ? TEvent : never, | ||
K extends '*' |
There was a problem hiding this comment.
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"]
>
>;
};
There was a problem hiding this comment.
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.)
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:
(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.)