diff --git a/src/index.ts b/src/index.ts index a82f081..f9189a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ import { useEffect, useReducer } from "react"; -import { UseStateMachine, Machine, $$t } from "./types"; +import { UseStateMachine, Machine, $$t, O } from "./types"; import { assertNever, R, useConstant } from "./extras"; + const useStateMachineImpl = (definition: Machine.Definition.Impl) => { - const [state, dispatch] = useReducer(createReducer(definition), createInitialState(definition)); + const [machineInstant, dispatch] = useReducer(createReducer(definition), createInitialState(definition)); const send = useConstant(() => (sendable: Machine.Sendable.Impl) => dispatch({ type: "SEND", sendable })); @@ -13,30 +14,33 @@ const useStateMachineImpl = (definition: Machine.Definition.Impl) => { }; useEffect(() => { - const entry = R.get(definition.states, state.value)!.effect; + const entry = R.get(definition.states, machineInstant.state)!.effect; let exit = entry?.({ send, setContext, - event: state.event, - context: state.context, + event: machineInstant.event, + context: machineInstant.context, }); return typeof exit === "function" - ? () => exit?.({ send, setContext, event: state.event, context: state.context }) + ? () => exit?.({ send, setContext, event: machineInstant.event, context: machineInstant.context }) : undefined; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.value, state.event]); + }, [machineInstant.state, machineInstant.event]); - return [state, send]; + return { ...machineInstant, send }; }; -const createInitialState = (definition: Machine.Definition.Impl): Machine.State.Impl => { +type MachineInstant = + O.OmitKey + +const createInitialState = (definition: Machine.Definition.Impl): MachineInstant => { let nextEvents = R.keys(R.concat( R.fromMaybe(R.get(definition.states, definition.initial)!.on), R.fromMaybe(definition.on) )) return { - value: definition.initial, + state: definition.initial, context: definition.context as Machine.Context.Impl, event: { type: "$$initial" } as Machine.Event.Impl, nextEvents: nextEvents, @@ -46,56 +50,56 @@ const createInitialState = (definition: Machine.Definition.Impl): Machine.State. const createReducer = (definition: Machine.Definition.Impl) => { let log = createLogger(definition); - return (machineState: Machine.State.Impl, internalEvent: InternalEvent): Machine.State.Impl => { + return (machineInstant: MachineInstant, internalEvent: InternalEvent): MachineInstant => { if (internalEvent.type === "SET_CONTEXT") { - let nextContext = internalEvent.updater(machineState.context); - log("Context update", ["Previous Context", machineState.context], ["Next Context", nextContext]); + let nextContext = internalEvent.updater(machineInstant.context); + log("Context update", ["Previous Context", machineInstant.context], ["Next Context", nextContext]); - return { ...machineState, context: nextContext }; + return { ...machineInstant, context: nextContext }; } if (internalEvent.type === "SEND") { let sendable = internalEvent.sendable; let event = typeof sendable === "string" ? { type: sendable } : sendable; - let context = machineState.context; - let stateNode = R.get(definition.states, machineState.value)!; + let context = machineInstant.context; + let stateNode = R.get(definition.states, machineInstant.state)!; let resolvedTransition = R.get(R.fromMaybe(stateNode.on), event.type) ?? R.get(R.fromMaybe(definition.on), event.type); if (!resolvedTransition) { log( `Current state doesn't listen to event type "${event.type}".`, - ["Current State", machineState], + ["Current State", machineInstant], ["Event", event] ); - return machineState; + return machineInstant; } - let [nextStateValue, didGuardDeny = false] = (() => { + let [nextState, didGuardDeny = false] = (() => { if (typeof resolvedTransition === "string") return [resolvedTransition]; if (resolvedTransition.guard === undefined) return [resolvedTransition.target]; if (resolvedTransition.guard({ context, event })) return [resolvedTransition.target]; return [resolvedTransition.target, true] - })() as [Machine.StateValue.Impl, true?] + })() as [Machine.State.Impl, true?] if (didGuardDeny) { log( - `Transition from "${machineState.value}" to "${nextStateValue}" denied by guard`, + `Transition from "${machineInstant.state}" to "${nextState}" denied by guard`, ["Event", event], ["Context", context] ); - return machineState; + return machineInstant; } - log(`Transition from "${machineState.value}" to "${nextStateValue}"`, ["Event", event]); + log(`Transition from "${machineInstant.state}" to "${nextState}"`, ["Event", event]); - let resolvedStateNode = R.get(definition.states, nextStateValue)!; + let resolvedStateNode = R.get(definition.states, nextState)!; let nextEvents = R.keys(R.concat( R.fromMaybe(resolvedStateNode.on), R.fromMaybe(definition.on) )); return { - value: nextStateValue, + state: nextState, context, event, nextEvents, diff --git a/src/types.ts b/src/types.ts index f05cbe8..f2a2d37 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,15 +3,46 @@ import { R } from "./extras" export type UseStateMachine = >(definition: A.InferNarrowestObject) => - [ state: A.Instantiated>> - , send: A.Instantiated>> - ] + Machine> export const $$t = Symbol("$$t"); type $$t = typeof $$t; export type CreateType = () => { [$$t]: T } +export type Machine, + NextEvents = + ( State extends any + ? A.Get, "type"> + : never + )[] + > = + & A.Instantiated< + { nextEvents: NextEvents + , send: Machine.Send + }> + & ( State extends any + ? A.Instantiated< + { state: State + , context: Machine.Context + , event: Machine.EntryEventForState + , nextEventsT: A.Get, "type">[] + }> + : never + ) + +interface MachineImpl + { state: Machine.State.Impl + , context: Machine.Context.Impl + , event: Machine.Event.Impl + , nextEvents: Machine.Event.Impl["type"][] + , nextEventsT: Machine.Event.Impl["type"][] + , send: Machine.Send.Impl + } + export namespace Machine { + export type Impl = MachineImpl + export type Definition< Self, States = A.Get, @@ -52,8 +83,8 @@ export namespace Machine { ) interface DefinitionImp - { initial: StateValue.Impl - , states: R.Of + { initial: State.Impl + , states: R.Of , on?: Definition.On.Impl , schema?: { context?: null, events?: R.Of } , verbose?: boolean @@ -64,7 +95,7 @@ export namespace Machine { export namespace Definition { export type Impl = DefinitionImp - export type FromTypeParamter = + export type FromTypeParameter = "$$internalIsConstraint" extends keyof D ? D extends infer X ? X extends Definition ? X : never : never : D @@ -120,7 +151,7 @@ export namespace Machine { } export type Transition, + TargetString = Machine.State, Event = { type: L.Pop

} > = | TargetString @@ -135,12 +166,12 @@ export namespace Machine { } type TransitionImpl = - | State.Impl["value"] - | { target: State.Impl["value"] + | Machine.Impl["state"] + | { target: Machine.Impl["state"] , guard?: ( parameter: - { context: State.Impl["context"] - , event: State.Impl["event"] + { context: Machine.Impl["context"] + , event: Machine.Impl["event"] } ) => boolean } @@ -149,10 +180,10 @@ export namespace Machine { } - export type Effect>> = - (parameter: A.Instantiated>) => + export type Effect>> = + (parameter: A.Instantiated>) => | void - | ((parameter: A.Instantiated>) => void) + | ((parameter: A.Instantiated>) => void) type EffectImpl = (parameter: EffectParameter.Impl) => @@ -218,15 +249,15 @@ export namespace Machine { export type InitialEventType = "$$initial"; } - export type StateValue = + export type State = keyof A.Get - export type InitialStateValue = + export type InitialState = A.Get - type StateValueImpl = string & A.Tag<"Machine.StateValue"> - export namespace StateValue { - export type Impl = StateValueImpl; + type StateImpl = string & A.Tag<"Machine.State"> + export namespace State { + export type Impl = StateImpl; } export type Context = @@ -240,7 +271,7 @@ export namespace Machine { export type Event> = | O.Value<{ [T in U.Exclude]: A.Get extends infer P - ? P extends any ? O.ShallowMerge<{ type: T } & P> : never + ? P extends any ? O.ShallowClean<{ type: T } & P> : never : never }> | ( A.Get extends true ? never : @@ -271,7 +302,17 @@ export namespace Machine { } export namespace EffectParameter { + export interface EffectParameterForState + extends BaseEffectParameter + { event: Machine.EntryEventForState + } + export namespace Cleanup { + export interface ForState + extends BaseEffectParameter + { event: Machine.ExitEventForState + } + export type Impl = EffectParameter.Impl } @@ -284,14 +325,14 @@ export namespace Machine { , setContext: SetContext.Impl } - export interface EffectParameterForStateValue + export interface EffectParameterForState extends BaseEffectParameter - { event: A.Uninstantiated> + { event: A.Uninstantiated> } - export interface EffectCleanupParameterForStateValue + export interface EffectCleanupParameterForState extends BaseEffectParameter - { event: A.Uninstantiated> + { event: A.Uninstantiated> } export interface BaseEffectParameter @@ -300,8 +341,8 @@ export namespace Machine { , setContext: Machine.SetContext } - export type EntryEventForStateValue = - | ( StateValue extends InitialStateValue + export type EntryEventForState = + | ( State extends InitialState ? { type: Definition.InitialEventType } : never ) @@ -311,7 +352,7 @@ export namespace Machine { | O.Value<{ [S in keyof A.Get]: O.Value<{ [E in keyof A.Get]: A.Get extends infer T - ? (T extends A.String ? T : A.Get) extends StateValue + ? (T extends A.String ? T : A.Get) extends State ? E : never : never @@ -319,7 +360,7 @@ export namespace Machine { }> | O.Value<{ [E in keyof A.Get]: A.Get extends infer T - ? (T extends A.String ? T : A.Get) extends StateValue + ? (T extends A.String ? T : A.Get) extends State ? E : never : never @@ -327,11 +368,11 @@ export namespace Machine { } > - export type ExitEventForStateValue = + export type ExitEventForState = U.Extract< Event, { type: - | keyof A.Get + | keyof A.Get | keyof A.Get } > @@ -378,34 +419,6 @@ export namespace Machine { export namespace ContextUpdater { export type Impl = ContextUpdaterImpl; } - - export type State, - NextEvents = - ( Value extends any - ? A.Get, "type"> - : never - )[] - > = - Value extends any - ? { value: Value - , context: A.Uninstantiated> - , event: A.Uninstantiated> - , nextEventsT: A.Get, "type">[] - , nextEvents: NextEvents - } - : never - - interface StateImpl - { value: StateValue.Impl - , context: Context.Impl - , event: Event.Impl - , nextEvents: Event.Impl["type"][] - , nextEventsT: Event.Impl["type"][] - } - export namespace State { - export type Impl = StateImpl - } } export namespace L { @@ -439,7 +452,8 @@ export namespace U { export namespace O { export type Value = T[keyof T]; - export type ShallowMerge = { [K in keyof T]: T[K] } & unknown + export type ShallowClean = { [K in keyof T]: T[K] } + export type OmitKey = { [P in U.Exclude]: T[P] } } export namespace A { diff --git a/test/index.test.ts b/test/index.test.ts index f3defc6..62d3d2f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -6,7 +6,7 @@ const logger: Console["log"] = (...xs) => log += xs.reduce( (a, x) => a + (typeof x === "string" ? x : JSON.stringify(x)), "" - ) + ) + "\n" const clearLog = () => log = ""; @@ -32,12 +32,13 @@ describe("useStateMachine", () => { }) ); - expect(result.current[0]).toStrictEqual({ + expect(result.current).toStrictEqual({ context: undefined, event: { type: "$$initial" }, - value: "inactive", + state: "inactive", nextEvents: ["ACTIVATE"], nextEventsT: ["ACTIVATE"], + send: expect.any(Function) }); }); @@ -57,17 +58,18 @@ describe("useStateMachine", () => { ); act(() => { - result.current[1]("ACTIVATE"); + result.current.send("ACTIVATE"); }); - expect(result.current[0]).toStrictEqual({ + expect(result.current).toStrictEqual({ context: undefined, event: { type: "ACTIVATE", }, - value: "active", + state: "active", nextEvents: ["DEACTIVATE"], nextEventsT: ["DEACTIVATE"], + send: expect.any(Function) }); }); @@ -90,17 +92,18 @@ describe("useStateMachine", () => { ); act(() => { - result.current[1]("FORCE_ACTIVATE"); + result.current.send("FORCE_ACTIVATE"); }); - expect(result.current[0]).toStrictEqual({ + expect(result.current).toStrictEqual({ context: undefined, event: { type: "FORCE_ACTIVATE", }, - value: "active", + state: "active", nextEvents: ["DEACTIVATE", "FORCE_ACTIVATE"], nextEventsT: ["DEACTIVATE", "FORCE_ACTIVATE"], + send: expect.any(Function), }); }); @@ -120,17 +123,18 @@ describe("useStateMachine", () => { ); act(() => { - result.current[1]({ type: "ACTIVATE" }); + result.current.send({ type: "ACTIVATE" }); }); - expect(result.current[0]).toStrictEqual({ + expect(result.current).toStrictEqual({ context: undefined, event: { type: "ACTIVATE", }, - value: "active", + state: "active", nextEvents: ["DEACTIVATE"], nextEventsT: ["DEACTIVATE"], + send: expect.any(Function), }); }); @@ -152,15 +156,16 @@ describe("useStateMachine", () => { act(() => { // TypeScript won"t allow me to type "ON" because it knows it"s not a valid event // @ts-expect-error - result.current[1]("ON"); + result.current.send("ON"); }); - expect(result.current[0]).toStrictEqual({ + expect(result.current).toStrictEqual({ context: undefined, event: { type: "$$initial" }, - value: "inactive", + state: "inactive", nextEvents: ["TOGGLE"], nextEventsT: ["TOGGLE"], + send: expect.any(Function), }); }); @@ -188,17 +193,18 @@ describe("useStateMachine", () => { ); act(() => { - result.current[1]("TOGGLE"); + result.current.send("TOGGLE"); }); - expect(result.current[0]).toStrictEqual({ + expect(result.current).toStrictEqual({ context: undefined, event: { type: "TOGGLE", }, - value: "active", + state: "active", nextEvents: ["TOGGLE"], nextEventsT: ["TOGGLE"], + send: expect.any(Function), }); }); it("should invoke effect callbacks", () => { @@ -227,7 +233,7 @@ describe("useStateMachine", () => { ); act(() => { - result.current[1]("TOGGLE"); + result.current.send("TOGGLE"); }); expect(entry.mock.calls.length).toBe(2); @@ -260,14 +266,15 @@ describe("useStateMachine", () => { }) ); - expect(result.current[0]).toStrictEqual({ + expect(result.current).toStrictEqual({ context: undefined, event: { type: "TOGGLE", }, - value: "active", + state: "active", nextEvents: ["TOGGLE"], nextEventsT: ["TOGGLE"], + send: expect.any(Function), }); }); @@ -295,7 +302,7 @@ describe("useStateMachine", () => { ); act(() => { - result.current[1]({ type: "ACTIVATE", number: 10 }); + result.current.send({ type: "ACTIVATE", number: 10 }); }); expect(effect.mock.calls[0][0]["event"]).toStrictEqual({ type: "ACTIVATE", number: 10 }); }); @@ -352,16 +359,17 @@ describe("useStateMachine", () => { ); act(() => { - result.current[1]("TOGGLE"); + result.current.send("TOGGLE"); }); expect(guard).toHaveBeenCalled(); - expect(result.current[0]).toStrictEqual({ + expect(result.current).toStrictEqual({ context: undefined, event: { type: "$$initial" }, - value: "inactive", + state: "inactive", nextEvents: ["TOGGLE"], nextEventsT: ["TOGGLE"], + send: expect.any(Function), }); }); @@ -388,18 +396,19 @@ describe("useStateMachine", () => { ); act(() => { - result.current[1]("TOGGLE"); + result.current.send("TOGGLE"); }); expect(guard).toHaveBeenCalled(); - expect(result.current[0]).toStrictEqual({ + expect(result.current).toStrictEqual({ context: undefined, event: { type: "TOGGLE", }, - value: "active", + state: "active", nextEvents: ["TOGGLE"], nextEventsT: ["TOGGLE"], + send: expect.any(Function), }); }); }); @@ -420,12 +429,13 @@ describe("useStateMachine", () => { }) ); - expect(result.current[0]).toStrictEqual({ - value: "inactive", + expect(result.current).toStrictEqual({ + state: "inactive", context: { foo: "bar" }, event: { type: "$$initial" }, nextEvents: ["TOGGLE"], nextEventsT: ["TOGGLE"], + send: expect.any(Function), }); }); @@ -453,12 +463,13 @@ describe("useStateMachine", () => { }) ); - expect(result.current[0]).toStrictEqual({ - value: "inactive", + expect(result.current).toStrictEqual({ + state: "inactive", context: { foo: "bar" }, event: { type: "$$initial" }, nextEvents: ["TOGGLE"], nextEventsT: ["TOGGLE"], + send: expect.any(Function), }); }); @@ -482,17 +493,18 @@ describe("useStateMachine", () => { ); act(() => { - result.current[1]("TOGGLE"); + result.current.send("TOGGLE"); }); - expect(result.current[0]).toStrictEqual({ - value: "active", + expect(result.current).toStrictEqual({ + state: "active", context: { toggleCount: 1 }, event: { type: "TOGGLE", }, nextEvents: ["TOGGLE"], nextEventsT: ["TOGGLE"], + send: expect.any(Function), }); }); it("should update context on exit", () => { @@ -515,17 +527,18 @@ describe("useStateMachine", () => { ); act(() => { - result.current[1]("TOGGLE"); + result.current.send("TOGGLE"); }); - expect(result.current[0]).toStrictEqual({ - value: "active", + expect(result.current).toStrictEqual({ + state: "active", context: { toggleCount: 1 }, event: { type: "TOGGLE", }, nextEvents: ["TOGGLE"], nextEventsT: ["TOGGLE"], + send: expect.any(Function), }); }); }); @@ -592,7 +605,7 @@ describe("useStateMachine", () => { if (result.all[0] instanceof Error) throw result.all[0]; else if (result.all[1] instanceof Error) throw result.all[1]; - else expect(result.all[0][1]).toBe(result.all[1][1]); + else expect(result.all[0].send).toBe(result.all[1].send); }); }); }); diff --git a/test/types.twoslash-test.ts b/test/types.twoslash-test.ts index 72bca5d..512985a 100644 --- a/test/types.twoslash-test.ts +++ b/test/types.twoslash-test.ts @@ -432,13 +432,13 @@ describe("Machine.Definition", () => { }) it("doesn't infer narrowest", () => { - let [state] = useStateMachine({ + let machine = useStateMachine({ schema: {}, context: { foo: "hello" }, initial: "a", states: { a: {} } }) - A.test(A.areEqual()) + A.test(A.areEqual()) }) }) @@ -1123,8 +1123,8 @@ describe("Machine.Definition", () => { }) }) -describe("UseStateMachine", () => { - let [state, send] = useStateMachine({ +describe("Machine", () => { + let machine = useStateMachine({ schema: { events: { X: t<{ foo: number }>(), @@ -1152,46 +1152,40 @@ describe("UseStateMachine", () => { } }) - describe("Machine.State", () => { - A.test(A.areEqual< - typeof state, - | { value: "a" + A.test(A.areEqual< + typeof machine, + & { nextEvents: ("X" | "Y" | "Z")[] + , send: + { ( sendable: + | { type: "X", foo: number } + | { type: "Y", bar?: number } + | { type: "Z" } + ): void + , ( sendable: + | "Y" + | "Z" + ): void + } + } + & ( { state: "a" , context: { foo?: number } , event: | { type: "$$initial" } | { type: "Y", bar?: number } | { type: "Z" } , nextEventsT: ("X" | "Z")[] - , nextEvents: ("X" | "Y" | "Z")[] } - | { value: "b" + | { state: "b" , context: { foo?: number } , event: { type: "X", foo: number } , nextEventsT: ("Y" | "Z")[] - , nextEvents: ("X" | "Y" | "Z")[] } - >()) - }) - - describe("Machine.Send", () => { - A.test(A.areEqual< - typeof send, - { ( sendable: - | { type: "X", foo: number } - | { type: "Y", bar?: number } - | { type: "Z" } - ): void - , ( sendable: - | "Y" - | "Z" - ): void - } - >()) - }) + ) + >()) }) describe("Machine.Definition.FromTypeParamter", () => { - let [state, send] = useStateMachine({ + let machine = useStateMachine({ context: { toggleCount: 0 }, initial: "inactive", states: { @@ -1208,33 +1202,31 @@ describe("Machine.Definition.FromTypeParamter", () => { }) A.test(A.areEqual< - typeof state, - | { value: "inactive" - , context: { toggleCount: number } - , event: - | { type: "$$initial" } - | { type: "TOGGLE" } - , nextEventsT: "TOGGLE"[] - , nextEvents: "TOGGLE"[] - } - | { value: "active" - , context: { toggleCount: number } - , event: { type: "TOGGLE" } - , nextEventsT: "TOGGLE"[] - , nextEvents: "TOGGLE"[] + typeof machine, + & { nextEvents: "TOGGLE"[] + , send: + { (sendable: { type: "TOGGLE" }): void + , (sendable: "TOGGLE"): void + } } - >()) - - A.test(A.areEqual< - typeof send, - { (sendable: { type: "TOGGLE" }): void - , (sendable: "TOGGLE"): void - } + & ( { state: "inactive" + , context: { toggleCount: number } + , event: + | { type: "$$initial" } + | { type: "TOGGLE" } + , nextEventsT: "TOGGLE"[] + } + | { state: "active" + , context: { toggleCount: number } + , event: { type: "TOGGLE" } + , nextEventsT: "TOGGLE"[] + } + ) >()) }) describe("fix(Machine.State['nextEvents']): only normalize don't widen", () => { - let [state] = useStateMachine({ + let machine = useStateMachine({ schema: { events: { Y: t<{}>() } }, @@ -1246,11 +1238,11 @@ describe("fix(Machine.State['nextEvents']): only normalize don't widen", () => { } }) - A.test(A.areEqual()) + A.test(A.areEqual()) }) describe("workaround for #65", () => { - let [_, send] = useStateMachine({ + let machine = useStateMachine({ schema: { events: { A: t<{ value: string }>() @@ -1267,7 +1259,7 @@ describe("workaround for #65", () => { }) A.test(A.areEqual< - typeof send, + typeof machine.send, { (sendable: { type: "A", value: string } | { type: "B" }): void , (sendable: "B"): void } @@ -1284,26 +1276,28 @@ describe("A.Instantiated", () => { it("does not instantiate context", () => { interface Something { foo: string } - let [_state] = useStateMachine({ + let _machine = useStateMachine({ // ^? context: { foo: "" } as Something, initial: "a", states: { a: {} } }) + _machine; expect(query().text).toContain("Something") }) it("does not instantiate event payloads deeply", () => { interface Something { foo: string } - let [_, _send] = useStateMachine({ - // ^? + let _machine = useStateMachine({ + // ^? schema: { events: { A: t<{ bar: Something }>() } }, initial: "a", states: { a: { on: { A: "a" } } } }) + _machine; expect(query().text).toContain("Something") })