Skip to content

Commit

Permalink
Add support for wildcard transitions to @xstate/fsm (#4065)
Browse files Browse the repository at this point in the history
* Add support for wildcard transitions to xstate-fsm.

* Update docs for xstate-fsm to match its README.

* Add changeset for wildcard event types.

* Fix typing of wildcard transitions.

* Fix broken type test.

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.

* Refactor types/tests, revert strictFunctionTypes.

- 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.)

* Update .changeset/tall-flies-occur.md

---------

Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
  • Loading branch information
benblank and Andarist authored Jun 16, 2023
1 parent 679a84e commit 3b4b130
Show file tree
Hide file tree
Showing 7 changed files with 135 additions and 16 deletions.
7 changes: 7 additions & 0 deletions .changeset/tall-flies-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@xstate/fsm': minor
---

This change adds support for using "\*" as a wildcard event type in machine configs.

Because that event type previously held no special meaning, it was allowed as an event type both in configs and when transitioning and matched as any other would. As a result of changing it to be a wildcard, any code which uses "\*" as an ordinary event type will break, making this a major change.
23 changes: 15 additions & 8 deletions docs/packages/xstate-fsm/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
</a>
</p>

The [@xstate/fsm package](https://github.com/statelyai/xstate/tree/main/packages/xstate-fsm) contains a minimal, 1kb implementation of [XState](https://github.com/statelyai/xstate) for **finite state machines**.
This package contains a minimal, 1kb implementation of [XState](https://github.com/statelyai/xstate) for **finite state machines**.

- [Read the full documentation in the XState docs](https://xstate.js.org/docs/packages/xstate-fsm/).
- [Read our contribution guidelines](https://github.com/statelyai/xstate/blob/main/CONTRIBUTING.md).

## Features

Expand All @@ -23,6 +26,7 @@ The [@xstate/fsm package](https://github.com/statelyai/xstate/tree/main/packages
| Transitions (string target) |||
| Delayed transitions |||
| Eventless transitions |||
| Wildcard transitions |||
| Nested states |||
| Parallel states |||
| History states |||
Expand All @@ -48,15 +52,15 @@ The [@xstate/fsm package](https://github.com/statelyai/xstate/tree/main/packages

If you want to use statechart features such as nested states, parallel states, history states, activities, invoked services, delayed transitions, transient transitions, etc. please use [`XState`](https://github.com/statelyai/xstate).

## Super quick start
## Quick start

**Installation**
### Installation

```bash
npm i @xstate/fsm
```

**Usage (machine):**
### Usage (machine)

```js
import { createMachine } from '@xstate/fsm';
Expand All @@ -79,7 +83,7 @@ untoggledState.value;
// => 'inactive'
```

**Usage (service):**
### Usage (service)

```js
import { createMachine, interpret } from '@xstate/fsm';
Expand All @@ -100,9 +104,12 @@ toggleService.stop();
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->

- [Super quick start](#super-quick-start)
- [Quick start](#quick-start)
- [Installation](#installation)
- [Usage (machine)](#usage-machine)
- [Usage (service)](#usage-service)
- [API](#api)
- [`createMachine(config)`](#createmachineconfig)
- [`createMachine(config, options)`](#createmachineconfig-options)
- [Machine config](#machine-config)
- [State config](#state-config)
- [Transition config](#transition-config)
Expand Down Expand Up @@ -149,7 +156,7 @@ The machine config has this schema:

### State config

- `on` (object) - an object mapping event types (keys) to [transitions](#transition-config)
- `on` (object) - an object mapping event types (keys) to [transitions](#transition-config); an event type of `"*"` is special, indicating a "wildcard" transition which occurs when no other transition applies

### Transition config

Expand Down
4 changes: 2 additions & 2 deletions packages/xstate-fsm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ This package contains a minimal, 1kb implementation of [XState](https://github.c
| Transitions (string target) |||
| Delayed transitions |||
| Eventless transitions |||
| Wildcard event descriptors | ||
| Wildcard transitions | ||
| Nested states |||
| Parallel states |||
| History states |||
Expand All @@ -50,7 +50,7 @@ This package contains a minimal, 1kb implementation of [XState](https://github.c
- Transition actions
- `state.changed`

If you want to use statechart features such as nested states, parallel states, history states, activities, invoked services, delayed transitions, transient transitions, wildcard event descriptors, etc. please use [`XState`](https://github.com/statelyai/xstate).
If you want to use statechart features such as nested states, parallel states, history states, activities, invoked services, delayed transitions, transient transitions, etc. please use [`XState`](https://github.com/statelyai/xstate).

## Quick start

Expand Down
11 changes: 11 additions & 0 deletions packages/xstate-fsm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {

const INIT_EVENT: InitEvent = { type: 'xstate.init' };
const ASSIGN_ACTION: StateMachine.AssignAction = 'xstate.assign';
const WILDCARD = '*';

function toArray<T>(item: T | T[] | undefined): T[] {
return item === undefined ? [] : ([] as T[]).concat(item);
Expand Down Expand Up @@ -180,11 +181,21 @@ export function createMachine<
);
}

if (!IS_PRODUCTION && eventObject.type === WILDCARD) {
throw new Error(
`An event cannot have the wildcard type ('${WILDCARD}')`
);
}

if (stateConfig.on) {
const transitions: Array<
StateMachine.Transition<TContext, TEvent, TState['value']>
> = toArray(stateConfig.on[eventObject.type]);

if (WILDCARD in stateConfig.on) {
transitions.push(...toArray(stateConfig.on[WILDCARD]));
}

for (const transition of transitions) {
if (transition === undefined) {
return createUnchangedState(value, context);
Expand Down
14 changes: 8 additions & 6 deletions packages/xstate-fsm/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,14 @@ export namespace StateMachine {
states: {
[key in TState['value']]: {
on?: {
[K in TEvent['type']]?: SingleOrArray<
Transition<
TContext,
TEvent extends { type: K } ? TEvent : never,
TState['value']
>
[K in TEvent['type'] | '*']?: SingleOrArray<
K extends '*'
? Transition<TContext, TEvent, TState['value']>
: Transition<
TContext,
TEvent extends { type: K } ? TEvent : never,
TState['value']
>
>;
};
exit?: SingleOrArray<Action<TContext, TEvent>>;
Expand Down
62 changes: 62 additions & 0 deletions packages/xstate-fsm/test/fsm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,68 @@ describe('@xstate/fsm', () => {
expect(nextState.actions).toEqual([]);
});

describe('when a wildcard transition is defined', () => {
type Event = { type: 'event' };
type State =
| { value: 'pass'; context: {} }
| { value: 'fail'; context: {} };
it('should not use a wildcard when an unguarded transition matches', () => {
const machine = createMachine<{}, Event, State>({
initial: 'fail',
states: { fail: { on: { event: 'pass', '*': 'fail' } }, pass: {} }
});
const nextState = machine.transition(machine.initialState, 'event');
expect(nextState.value).toBe('pass');
});

it('should not use a wildcard when a guarded transition matches', () => {
const machine = createMachine<{}, Event, State>({
initial: 'fail',
states: {
fail: {
on: { event: { target: 'pass', cond: () => true }, '*': 'fail' }
},
pass: {}
}
});
const nextState = machine.transition(machine.initialState, 'event');
expect(nextState.value).toBe('pass');
});

it('should use a wildcard when no guarded transition matches', () => {
const machine = createMachine<{}, Event, State>({
initial: 'fail',
states: {
fail: {
on: { event: { target: 'fail', cond: () => false }, '*': 'pass' }
},
pass: {}
}
});
const nextState = machine.transition(machine.initialState, 'event');
expect(nextState.value).toBe('pass');
});

it('should use a wildcard when no transition matches', () => {
const machine = createMachine<{}, Event, State>({
initial: 'fail',
states: { fail: { on: { event: 'fail', '*': 'pass' } }, pass: {} }
});
const nextState = machine.transition(machine.initialState, 'FAKE' as any);
expect(nextState.value).toBe('pass');
});

it("should throw an error when an event's type is the wildcard", () => {
const machine = createMachine<{}, Event, State>({
initial: 'fail',
states: { pass: {}, fail: {} }
});
expect(() => machine.transition('fail', '*' as any)).toThrow(
/wildcard type/
);
});
});

it('should throw an error for undefined states', () => {
expect(() => {
lightFSM.transition('unknown', 'TIMER');
Expand Down
30 changes: 30 additions & 0 deletions packages/xstate-fsm/test/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createMachine } from '../src';
import { InitEvent } from '../src/types';

describe('matches', () => {
it('should allow matches to be called multiple times in a single branch of code', () => {
Expand Down Expand Up @@ -58,4 +59,33 @@ describe('matches', () => {
((_accept: string) => {})(state.context.count);
}
});

// This test only works if "strictFunctionTypes" is enabled. Once that has
// been done, the ts-expect-error comment below turned on.
it('should require actions on wildcard transitions to handle all event types', () => {
type Context = {};
type FooEvent = { type: 'foo'; foo: string };
type BarEvent = { type: 'bar'; bar: number };
type Event = FooEvent | BarEvent;
type State = { value: 'one'; context: Context };
createMachine<Context, Event, State>({
context: {},
initial: 'one',
states: {
one: {
on: {
foo: {
target: 'one',
actions: (_context: Context, _event: InitEvent | FooEvent) => {}
},
// @x-ts-expect-error
'*': {
target: 'one',
actions: (_context: Context, _event: InitEvent | FooEvent) => {}
}
}
}
}
});
});
});

0 comments on commit 3b4b130

Please sign in to comment.