Skip to content

Commit

Permalink
feat: add waitState helper and action async triggers
Browse files Browse the repository at this point in the history
  • Loading branch information
tdreyno committed May 5, 2023
1 parent 2aaf157 commit 9c5e566
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 8 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

123 changes: 123 additions & 0 deletions src/__tests__/waitState.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { createAction, enter, type Enter } from "../action"
import { createInitialContext } from "../context"
import { noop } from "../effect"
import { createRuntime } from "../runtime"
import { isState, state, waitState } from "../state"
import { timeout } from "./util"

const INITIAL_COUNT = 5
const RETURN_COUNT = 10

const fetchThing = createAction<"FetchThing", number>("FetchThing")

const thingFetched = createAction<"ThingFetched", number>("ThingFetched")

type D = {
count: number
}

const AfterThing = state<Enter, D>(
{
Enter: noop,
},
{ name: "AfterThing" },
)

describe("waitState", () => {
it("should run a wait state", async () => {
const BeforeThing = state<Enter, D>(
{
Enter: data => WaitForThing([data, RETURN_COUNT]),
},
{ name: "BeforeThing" },
)

const WaitForThing = waitState(
fetchThing,
thingFetched,
(data: D, payload) => {
return AfterThing({ ...data, count: data.count + payload })
},
{
name: "WaitForThing",
},
)
const context = createInitialContext([
BeforeThing({
count: INITIAL_COUNT,
}),
])

const runtime = createRuntime(context, {}, { fetchThing })

runtime.onOutput(action => {
if (action.type === "FetchThing") {
setTimeout(() => {
void runtime.run(thingFetched(action.payload))
}, 250)
}
})

expect(isState(runtime.currentState(), BeforeThing)).toBeTruthy()

await runtime.run(enter())

expect(isState(runtime.currentState(), WaitForThing)).toBeTruthy()

await timeout(500)

expect(isState(runtime.currentState(), AfterThing)).toBeTruthy()
expect((runtime.currentState().data as D).count).toBe(
INITIAL_COUNT + RETURN_COUNT,
)
})

it("should timeout", async () => {
const BeforeThing = state<Enter, D>(
{
Enter: data => WaitForThing([data, RETURN_COUNT]),
},
{ name: "BeforeThing" },
)

const TimedOutState = state<Enter, D>(
{
Enter: noop,
},
{ name: "TimedOutState" },
)

const WaitForThing = waitState(
fetchThing,
thingFetched,
(data: D, payload) => {
return AfterThing({ ...data, count: data.count + payload })
},
{
name: "WaitForThing",
timeout: 1000,
onTimeout: data => {
return TimedOutState(data)
},
},
)
const context = createInitialContext([
BeforeThing({
count: INITIAL_COUNT,
}),
])

const runtime = createRuntime(context, {}, { fetchThing })

expect(isState(runtime.currentState(), BeforeThing)).toBeTruthy()

await runtime.run(enter())

expect(isState(runtime.currentState(), WaitForThing)).toBeTruthy()

await timeout(2000)

expect(isState(runtime.currentState(), TimedOutState)).toBeTruthy()
expect((runtime.currentState().data as D).count).toBe(INITIAL_COUNT)
})
})
12 changes: 10 additions & 2 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface MatchAction<T extends string, P> {
is(action: Action<any, any>): action is Action<T, P>
}

export interface GetActionCreatorType<T extends string> {
type: T
}

type Optional<T> = [T]

export type ActionCreator<T extends string, P> = P extends undefined
Expand All @@ -33,13 +37,17 @@ export type ActionCreatorType<F extends ActionCreator<any, any>> = ReturnType<F>

export const createAction = <T extends string, P = undefined>(
type: T,
): ActionCreator<T, P> & MatchAction<T, P> => {
): ActionCreator<T, P> & MatchAction<T, P> & GetActionCreatorType<T> => {
const fn = (payload?: P) => action(type, payload)

fn.is = (action: Action<any, any>): action is Action<T, P> =>
action.type === type

return fn as unknown as ActionCreator<T, P> & MatchAction<T, P>
fn.type = type

return fn as unknown as ActionCreator<T, P> &
MatchAction<T, P> &
GetActionCreatorType<T>
}

export const beforeEnter = createAction<
Expand Down
2 changes: 1 addition & 1 deletion src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export class Runtime<
// ? this.enterState_(targetState, exitState)
// : []

const result = await targetState.executor(action)
const result = await targetState.executor(action, this)

return arraySingleton(result)
}
Expand Down
70 changes: 67 additions & 3 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import {
type ActionPayload,
type BeforeEnter,
enter,
type Enter,
type ActionCreatorType,
type GetActionCreatorType,
createAction,
} from "./action.js"
import { createInitialContext } from "./context.js"
import { Effect, noop } from "./effect.js"
import { Effect, noop, output } from "./effect.js"
import { createRuntime, Runtime } from "./runtime.js"

/**
Expand Down Expand Up @@ -40,7 +44,7 @@ export interface StateTransition<
data: Data
isStateTransition: true
mode: "append" | "update"
executor: (action: A) => HandlerReturn
executor: (action: A, runtime?: Runtime<any, any>) => HandlerReturn
state: BoundStateFn<Name, A, Data>
isNamed(name: string): boolean
}
Expand Down Expand Up @@ -83,6 +87,7 @@ export type State<Name extends string, A extends Action<any, any>, Data> = (
utils: {
update: (data: Data) => StateTransition<Name, A, Data>
parentRuntime?: Runtime<any, any>
trigger: (action: A) => void
},
) => HandlerReturn

Expand Down Expand Up @@ -120,10 +125,13 @@ export const stateWrapper = <
isStateTransition: true,
mode: "append",

executor: (action: A) => {
executor: (action: A, runtime?: Runtime<any, any>) => {
// Run state executor
return executor(action, data, {
update,
trigger: (a: A) => {
void runtime?.run(a)
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
parentRuntime: data ? (data as any)[PARENT_RUNTIME] : undefined,
})
Expand Down Expand Up @@ -152,6 +160,7 @@ const matchAction =
utils: {
update: (data: Data) => StateTransition<string, Actions, Data>
parentRuntime?: Runtime<any, any>
trigger: (action: Actions) => void
},
) => HandlerReturn
}) =>
Expand All @@ -161,6 +170,7 @@ const matchAction =
utils: {
update: (data: Data) => StateTransition<string, Actions, Data>
parentRuntime?: Runtime<any, any>
trigger: (action: Actions) => void
},
): HandlerReturn => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
Expand All @@ -170,6 +180,7 @@ const matchAction =
utils: {
update: (data: Data) => StateTransition<string, Actions, Data>
parentRuntime?: Runtime<any, any>
trigger: (action: Actions) => void
},
) => HandlerReturn

Expand All @@ -191,6 +202,7 @@ export const state = <Actions extends Action<string, any>, Data = undefined>(
utils: {
update: (data: Data) => StateTransition<string, Actions, Data>
parentRuntime?: Runtime<any, any>
trigger: (action: Actions) => void
},
) => HandlerReturn
},
Expand Down Expand Up @@ -305,3 +317,55 @@ class Matcher<S extends StateTransition<string, any, any>, T> {

export const switch_ = <T>(state: StateTransition<string, any, any>) =>
new Matcher<typeof state, T>(state)

const timedOut = createAction("TimedOut")
type TimedOut = ActionCreatorType<typeof timedOut>

export const waitState = <
Data,
ReqAC extends ActionCreator<any, any>,
ReqA extends ActionCreatorType<ReqAC>,
RespAC extends ActionCreator<any, any> & GetActionCreatorType<any>,
RespA extends ActionCreatorType<RespAC>,
>(
requestAction: ReqAC,
responseActionCreator: RespAC,
transition: (data: Data, payload: RespA["payload"]) => StateReturn,
options?: {
name?: string
timeout?: number
onTimeout?: (data: Data) => StateReturn
},
) => {
const name = options?.name

return state<Enter | TimedOut, [Data, ReqA["payload"]]>(
{
Enter: ([, payload], _, { trigger }) => {
if (options?.timeout) {
setTimeout(() => {
trigger(timedOut())
}, options.timeout)
}

return output(requestAction(payload))
},

TimedOut: ([data]) => {
if (options?.onTimeout) {
return options?.onTimeout(data)
}

return noop()
},

[responseActionCreator.type]: (
[data]: [Data],
payload: RespA["payload"],
) => {
return transition(data, payload)
},
},
name ? { name } : {},
)
}

0 comments on commit 9c5e566

Please sign in to comment.