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

feat: state value added to transition type #4043

Merged
merged 6 commits into from
Jun 6, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/modern-spies-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@xstate/fsm': patch
---

Fixed state types when initializing the machine, as well as when switching to another state. Previously, the "string" type was used for this, which allowed any value to be passed and led to a subtle error in a running application. After this change, the error will be seen at the development stage.
Andarist marked this conversation as resolved.
Show resolved Hide resolved
26 changes: 15 additions & 11 deletions packages/xstate-fsm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ function handleActions<
export function createMachine<
TContext extends object,
TEvent extends EventObject = EventObject,
TState extends Typestate<TContext> = { value: any; context: TContext }
TState extends Typestate<TContext> = Typestate<TContext>
>(
fsmConfig: StateMachine.Config<TContext, TEvent, TState>,
implementations: {
Expand Down Expand Up @@ -182,18 +182,21 @@ export function createMachine<

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

for (const transition of transitions) {
if (transition === undefined) {
return createUnchangedState(value, context);
}

const { target, actions = [], cond = () => true } =
typeof transition === 'string'
? { target: transition }
: transition;
const {
target,
actions = [],
cond = () => true
} = typeof transition === 'string'
? { target: transition }
: transition;

const isTargetless = target === undefined;

Expand All @@ -209,11 +212,12 @@ export function createMachine<
}

if (cond(context, eventObject)) {
const allActions = (isTargetless
? toArray(actions)
: ([] as any[])
.concat(stateConfig.exit, actions, nextStateConfig.entry)
.filter((a) => a)
const allActions = (
isTargetless
? toArray(actions)
: ([] as any[])
.concat(stateConfig.exit, actions, nextStateConfig.entry)
.filter((a) => a)
).map<StateMachine.ActionObject<TContext, TEvent>>((action) =>
toActionObject(action, (machine as any)._options.actions)
);
Expand Down
36 changes: 24 additions & 12 deletions packages/xstate-fsm/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,14 @@ export namespace StateMachine {
assignment: Assigner<TContext, TEvent> | PropertyAssigner<TContext, TEvent>;
}

export type Transition<TContext extends object, TEvent extends EventObject> =
| string
export type Transition<
TContext extends object,
TEvent extends EventObject,
TStateValue extends string = string
> =
| TStateValue
| {
target?: string;
target?: TStateValue;
Copy link
Member

Choose a reason for hiding this comment

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

What exactly does this solve for you? Could you share some code that is affected by this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Andarist
I decided that it would be better to show in dynamics. This file describes the state machine. The creation takes place elsewhere, here I wanted to show the development process. Please ask more questions if you need to explain in more detail what is happening in the video.

2023-05-27.13-10-34.mp4
import { StateMachine } from '@xstate/fsm';

export type TestContext = {
  readonly value: number;
};

export type TestStateValue = 'init' | 'loading' | 'idle' | 'process';

export type TestStateMachine = StateMachine.Config<
  TestContext,
  { type: 'NEXT' | 'STOP' | 'COMPLETE' },
  { value: TestStateValue; context: TestContext }
>;

export const createStateMachine = (): TestStateMachine => ({
  context: { value: 42 },
  initial: 'init',
  states: {
    init: {
      on: {
        COMPLETE: 'loading',
      },
    },
    loading: {
      on: {
        COMPLETE: { target: 'idle' },
      },
    },
    idle: {
      on: {
        NEXT: 'process',
      },
    },
    process: {
      on: {
        STOP: { target: 'idle' },
      },
    },
  },
});

actions?: SingleOrArray<Action<TContext, TEvent>>;
cond?: (context: TContext, event: TEvent) => boolean;
};
Expand All @@ -104,9 +108,15 @@ export namespace StateMachine {
changed?: boolean | undefined;
matches: <TSV extends TState['value']>(
value: TSV
) => this is TState extends { value: TSV }
? TState & { value: TSV }
: never;
) => this is State<
(TState extends any
? { value: TSV; context: any } extends TState
? TState
: never
: never)['context'],
TEvent,
TState
> & { value: TSV };
}

export type AnyMachine = StateMachine.Machine<any, any, any>;
Expand All @@ -118,16 +128,20 @@ export namespace StateMachine {
export interface Config<
TContext extends object,
TEvent extends EventObject,
TState extends Typestate<TContext> = { value: any; context: TContext }
TState extends Typestate<TContext> = Typestate<TContext>
> {
id?: string;
initial: string;
initial: TState['value'];
context?: TContext;
states: {
[key in TState['value']]: {
on?: {
[K in TEvent['type']]?: SingleOrArray<
Transition<TContext, TEvent extends { type: K } ? TEvent : never>
Transition<
TContext,
TEvent extends { type: K } ? TEvent : never,
TState['value']
>
>;
};
exit?: SingleOrArray<Action<TContext, TEvent>>;
Expand Down Expand Up @@ -157,9 +171,7 @@ export namespace StateMachine {
TState extends Typestate<TContext> = { value: any; context: TContext }
> {
send: (event: TEvent | TEvent['type']) => void;
subscribe: (
listener: StateListener<State<TContext, TEvent, TState>>
) => {
subscribe: (listener: StateListener<State<TContext, TEvent, TState>>) => {
unsubscribe: () => void;
};
start: (
Expand Down
40 changes: 40 additions & 0 deletions packages/xstate-fsm/test/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,44 @@ describe('matches', () => {
((_accept: string) => {})(state.context.count);
}
});

it('should narrow context using typestates', () => {
type MyContext = { user: { name: string } | null; count: number };
type Typestates =
| {
value: 'idle';
context: { user: null; count: number };
}
| {
value: 'fetched';
context: { user: { name: string }; count: number };
};
const machine = createMachine<MyContext, { type: string }, Typestates>({
context: {
user: null,
count: 0
},
initial: 'idle',
states: { idle: {}, fetched: {} }
});
const state = machine.initialState;

if (state.matches('idle')) {
((_accept: null) => {})(state.context.user);
// @ts-expect-error
((_accept: { name: string }) => {})(state.context.user);

((_accept: number) => {})(state.context.count);
// @ts-expect-error
((_accept: string) => {})(state.context.count);
} else if (state.matches('fetched')) {
// @ts-expect-error
((_accept: null) => {})(state.context.user);
((_accept: { name: string }) => {})(state.context.user);

((_accept: number) => {})(state.context.count);
// @ts-expect-error
((_accept: string) => {})(state.context.count);
}
});
});