Skip to content

Commit

Permalink
Require input in integration packages when defined inside types (#…
Browse files Browse the repository at this point in the history
…5055)

* `useActor` required input when defined

* `useMachine` required input when defined

* required input in svelte and vue

* `useActorRef` input required (but optional `options`)

* `useActionRef` options type

* rename type to `RequiredActorInstanceOptions`

* type tests for required/not required input

* required input in `xstate-solid`

* added changeset

* move machines inside tests

* rename type and split changeset

---------

Co-authored-by: Mateusz Burzyński <[email protected]>
  • Loading branch information
SandroMaglione and Andarist authored Sep 6, 2024
1 parent ec156a3 commit ad38c35
Show file tree
Hide file tree
Showing 19 changed files with 360 additions and 55 deletions.
5 changes: 5 additions & 0 deletions .changeset/smooth-ravens-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'xstate': patch
---

Exported `RequiredActorOptionsKeys` type meant to be used by integration packages like `@xstate/react`
43 changes: 43 additions & 0 deletions .changeset/smooth-ravens-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@xstate/svelte': patch
'@xstate/react': patch
'@xstate/solid': patch
'@xstate/vue': patch
---

Updated types of `useActor`, `useMachine`, and `useActorRef` to require `input` when defined inside `types/input`.

Previously even when `input` was defined inside `types`, `useActor`, `useMachine`, and `useActorRef` would **not** make the input required:

```tsx
const machine = setup({
types: {
input: {} as { value: number }
}
}).createMachine({});

function App() {
// Event if `input` is not defined, `useMachine` works at compile time, but risks crashing at runtime
const _ = useMachine(machine);
return <></>;
}
```

With this change the above code will show a type error, since `input` is now required:

```tsx
const machine = setup({
types: {
input: {} as { value: number }
}
}).createMachine({});

function App() {
const _ = useMachine(machine, {
input: { value: 1 } // Now input is required at compile time!
});
return <></>;
}
```

This avoids runtime errors when forgetting to pass `input` when defined inside `types`.
6 changes: 3 additions & 3 deletions packages/core/src/createActor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@ export class Actor<TLogic extends AnyActorLogic>
}
}

type RequiredOptions<TLogic extends AnyActorLogic> =
export type RequiredActorOptionsKeys<TLogic extends AnyActorLogic> =
undefined extends InputFrom<TLogic> ? never : 'input';

/**
Expand Down Expand Up @@ -792,10 +792,10 @@ export function createActor<TLogic extends AnyActorLogic>(
...[options]: ConditionalRequired<
[
options?: ActorOptions<TLogic> & {
[K in RequiredOptions<TLogic>]: unknown;
[K in RequiredActorOptionsKeys<TLogic>]: unknown;
}
],
IsNotNever<RequiredOptions<TLogic>>
IsNotNever<RequiredActorOptionsKeys<TLogic>>
>
): Actor<TLogic> {
return new Actor(logic, options);
Expand Down
11 changes: 6 additions & 5 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
export { SimulatedClock } from './SimulatedClock.ts';
export { isMachineSnapshot, type MachineSnapshot } from './State.ts';
export { StateMachine } from './StateMachine.ts';
export { StateNode } from './StateNode.ts';
export * from './actions.ts';
export * from './actors/index.ts';
export { assertEvent } from './assert.ts';
export {
Actor,
createActor,
interpret,
type Interpreter
type Interpreter,
type RequiredActorOptionsKeys as RequiredActorOptionsKeys
} from './createActor.ts';
export { createMachine } from './createMachine.ts';
export { getInitialSnapshot, getNextSnapshot } from './getNextSnapshot.ts';
Expand All @@ -21,7 +18,11 @@ export type {
InspectionEvent
} from './inspection.ts';
export { setup } from './setup.ts';
export { SimulatedClock } from './SimulatedClock.ts';
export { type Spawner } from './spawn.ts';
export { isMachineSnapshot, type MachineSnapshot } from './State.ts';
export { StateMachine } from './StateMachine.ts';
export { StateNode } from './StateNode.ts';
export { getStateNodes } from './stateUtils.ts';
export type { ActorSystem } from './system.ts';
export { toPromise } from './toPromise.ts';
Expand Down
36 changes: 26 additions & 10 deletions packages/core/test/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,23 @@ import {
} from '../src/actors';
import {
ActorRefFrom,
InputFrom,
ActorRefFromLogic,
AnyActorLogic,
MachineContext,
ProvidedActor,
Spawner,
StateMachine,
UnknownActorRef,
assign,
createActor,
createMachine,
enqueueActions,
not,
sendTo,
setup,
spawnChild,
stateIn,
setup,
toPromise,
UnknownActorRef,
AnyActorLogic,
ActorRef,
SnapshotFrom,
EmittedFrom,
EventFrom,
ActorRefFromLogic
toPromise
} from '../src/index';

function noop(_x: unknown) {
Expand Down Expand Up @@ -3526,6 +3521,27 @@ describe('input', () => {
}
});
});

it('should require input to be specified when defined', () => {
const machine = createMachine({
types: {
input: {} as {
count: number;
}
}
});

// @ts-expect-error
createActor(machine);
});

it('should not require input when not defined', () => {
const machine = createMachine({
types: {}
});

createActor(machine);
});
});

describe('guards', () => {
Expand Down
19 changes: 17 additions & 2 deletions packages/xstate-react/src/useActor.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import isDevelopment from '#is-development';
import { useCallback, useEffect } from 'react';
import { useSyncExternalStore } from 'use-sync-external-store/shim';
import { Actor, ActorOptions, AnyActorLogic, SnapshotFrom } from 'xstate';
import {
Actor,
ActorOptions,
AnyActorLogic,
SnapshotFrom,
type ConditionalRequired,
type IsNotNever,
type RequiredActorOptionsKeys
} from 'xstate';
import { stopRootWithRehydration } from './stopRootWithRehydration.ts';
import { useIdleActorRef } from './useActorRef.ts';

export function useActor<TLogic extends AnyActorLogic>(
logic: TLogic,
options: ActorOptions<TLogic> = {}
...[options]: ConditionalRequired<
[
options?: ActorOptions<TLogic> & {
[K in RequiredActorOptionsKeys<TLogic>]: unknown;
}
],
IsNotNever<RequiredActorOptionsKeys<TLogic>>
>
): [SnapshotFrom<TLogic>, Actor<TLogic>['send'], Actor<TLogic>] {
if (
isDevelopment &&
Expand Down
35 changes: 29 additions & 6 deletions packages/xstate-react/src/useActorRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,23 @@ import {
Observer,
SnapshotFrom,
createActor,
toObserver
toObserver,
type ConditionalRequired,
type IsNotNever,
type RequiredActorOptionsKeys
} from 'xstate';
import { stopRootWithRehydration } from './stopRootWithRehydration';

export function useIdleActorRef<TLogic extends AnyActorLogic>(
logic: TLogic,
options: Partial<ActorOptions<TLogic>>
...[options]: ConditionalRequired<
[
options?: ActorOptions<TLogic> & {
[K in RequiredActorOptionsKeys<TLogic>]: unknown;
}
],
IsNotNever<RequiredActorOptionsKeys<TLogic>>
>
): Actor<TLogic> {
let [[currentConfig, actorRef], setCurrent] = useState(() => {
const actorRef = createActor(logic, options);
Expand Down Expand Up @@ -44,10 +54,23 @@ export function useIdleActorRef<TLogic extends AnyActorLogic>(

export function useActorRef<TLogic extends AnyActorLogic>(
machine: TLogic,
options: ActorOptions<TLogic> = {},
observerOrListener?:
| Observer<SnapshotFrom<TLogic>>
| ((value: SnapshotFrom<TLogic>) => void)
...[options, observerOrListener]: IsNotNever<
RequiredActorOptionsKeys<TLogic>
> extends true
? [
options: ActorOptions<TLogic> & {
[K in RequiredActorOptionsKeys<TLogic>]: unknown;
},
observerOrListener?:
| Observer<SnapshotFrom<TLogic>>
| ((value: SnapshotFrom<TLogic>) => void)
]
: [
options?: ActorOptions<TLogic>,
observerOrListener?:
| Observer<SnapshotFrom<TLogic>>
| ((value: SnapshotFrom<TLogic>) => void)
]
): Actor<TLogic> {
const actorRef = useIdleActorRef(machine, options);

Expand Down
19 changes: 17 additions & 2 deletions packages/xstate-react/src/useMachine.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { Actor, ActorOptions, AnyStateMachine, StateFrom } from 'xstate';
import {
Actor,
ActorOptions,
AnyStateMachine,
StateFrom,
type ConditionalRequired,
type IsNotNever,
type RequiredActorOptionsKeys
} from 'xstate';
import { useActor } from './useActor.ts';

/** @alias useActor */
export function useMachine<TMachine extends AnyStateMachine>(
machine: TMachine,
options: ActorOptions<TMachine> = {}
...[options]: ConditionalRequired<
[
options?: ActorOptions<TMachine> & {
[K in RequiredActorOptionsKeys<TMachine>]: unknown;
}
],
IsNotNever<RequiredActorOptionsKeys<TMachine>>
>
): [StateFrom<TMachine>, Actor<TMachine>['send'], Actor<TMachine>] {
return useActor(machine, options);
}
81 changes: 79 additions & 2 deletions packages/xstate-react/test/types.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { render } from '@testing-library/react';
import { ActorRefFrom, assign, createMachine, setup } from 'xstate';
import { useMachine, useSelector } from '../src/index.ts';
import { useEffect, useMemo } from 'react';
import {
useActor,
useActorRef,
useMachine,
useSelector
} from '../src/index.ts';

describe('useMachine', () => {
interface YesNoContext {
Expand Down Expand Up @@ -121,6 +125,79 @@ describe('useMachine', () => {
});
});

describe('useActor', () => {
it('should require input to be specified when defined', () => {
const withInputMachine = createMachine({
types: {} as { input: { value: number } },
initial: 'idle',
states: {
idle: {}
}
});

const Component = () => {
// @ts-expect-error
const _ = useActor(withInputMachine);
return <></>;
};

render(<Component />);
});

it('should not require input when not defined', () => {
const noInputMachine = createMachine({
types: {} as {},
initial: 'idle',
states: {
idle: {}
}
});
const Component = () => {
const _ = useActor(noInputMachine);
return <></>;
};

render(<Component />);
});
});

describe('useActorRef', () => {
it('should require input to be specified when defined', () => {
const withInputMachine = createMachine({
types: {} as { input: { value: number } },
initial: 'idle',
states: {
idle: {}
}
});

const Component = () => {
// @ts-expect-error
const _ = useActorRef(withInputMachine);
return <></>;
};

render(<Component />);
});

it('should not require input when not defined', () => {
const noInputMachine = createMachine({
types: {} as {},
initial: 'idle',
states: {
idle: {}
}
});

const Component = () => {
const _ = useActorRef(noInputMachine);
return <></>;
};

render(<Component />);
});
});

it('useMachine types work for machines with a specified id and state with an after property #5008', () => {
// https://github.com/statelyai/xstate/issues/5008
const cheatCodeMachine = setup({}).createMachine({
Expand Down
10 changes: 5 additions & 5 deletions packages/xstate-react/test/useActorRef.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
fireEvent,
screen,
waitFor as testWaitFor
} from '@testing-library/react';
import * as React from 'react';
import {
ActorRefFrom,
Expand All @@ -9,11 +14,6 @@ import {
sendParent,
sendTo
} from 'xstate';
import {
fireEvent,
screen,
waitFor as testWaitFor
} from '@testing-library/react';
import { useActorRef, useMachine, useSelector } from '../src/index.ts';
import { describeEachReactMode } from './utils.tsx';

Expand Down
Loading

0 comments on commit ad38c35

Please sign in to comment.