From c1ba485c4d4a9b861d393c87eae44c17c7d71a27 Mon Sep 17 00:00:00 2001 From: Thomas Reynolds Date: Fri, 5 May 2023 17:19:10 -0700 Subject: [PATCH] feat: add Svelte Store respondOnMount and Runtime respondToOutput --- .../loadingMachine/core/actions/World.ts | 4 ++ .../loadingMachine/core/actions/index.ts | 1 + src/__tests__/loadingMachine/core/index.ts | 3 +- .../core/outputActions/Hello.ts | 4 ++ .../core/outputActions/index.ts | 2 + .../loadingMachine/core/states/Ready.ts | 15 +++-- src/__tests__/loadingMachine/core/types.ts | 1 + .../{ports.spec.ts => onOutput.spec.ts} | 2 +- src/__tests__/waitState.spec.ts | 61 ++++++++++++++++--- src/runtime.ts | 24 ++++++-- src/svelte/__tests__/Comp.svelte | 11 +++- src/svelte/__tests__/machine.ts | 1 + src/svelte/__tests__/svelte.spec.tsx | 14 ++++- src/svelte/createStore.ts | 27 +++++++- 14 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/loadingMachine/core/actions/World.ts create mode 100644 src/__tests__/loadingMachine/core/outputActions/Hello.ts create mode 100644 src/__tests__/loadingMachine/core/outputActions/index.ts rename src/__tests__/{ports.spec.ts => onOutput.spec.ts} (96%) diff --git a/src/__tests__/loadingMachine/core/actions/World.ts b/src/__tests__/loadingMachine/core/actions/World.ts new file mode 100644 index 00000000..a3a4f532 --- /dev/null +++ b/src/__tests__/loadingMachine/core/actions/World.ts @@ -0,0 +1,4 @@ +import { type ActionCreatorType, createAction } from "../../../../action" + +export const world = createAction("World") +export type World = ActionCreatorType diff --git a/src/__tests__/loadingMachine/core/actions/index.ts b/src/__tests__/loadingMachine/core/actions/index.ts index 3d650fed..4210f46f 100644 --- a/src/__tests__/loadingMachine/core/actions/index.ts +++ b/src/__tests__/loadingMachine/core/actions/index.ts @@ -4,3 +4,4 @@ export * from "./ReEnter" export * from "./Reset" export * from "./StartLoading" export * from "./Update" +export * from "./World" diff --git a/src/__tests__/loadingMachine/core/index.ts b/src/__tests__/loadingMachine/core/index.ts index a05e7d3f..3187ca8f 100644 --- a/src/__tests__/loadingMachine/core/index.ts +++ b/src/__tests__/loadingMachine/core/index.ts @@ -1,4 +1,5 @@ import * as Actions from "./actions" +import * as OutputActions from "./outputActions" import States from "./states" -export { States, Actions } +export { States, Actions, OutputActions } diff --git a/src/__tests__/loadingMachine/core/outputActions/Hello.ts b/src/__tests__/loadingMachine/core/outputActions/Hello.ts new file mode 100644 index 00000000..407cd95d --- /dev/null +++ b/src/__tests__/loadingMachine/core/outputActions/Hello.ts @@ -0,0 +1,4 @@ +import { type ActionCreatorType, createAction } from "../../../../action" + +export const hello = createAction("Hello") +export type Hello = ActionCreatorType diff --git a/src/__tests__/loadingMachine/core/outputActions/index.ts b/src/__tests__/loadingMachine/core/outputActions/index.ts new file mode 100644 index 00000000..b0efc1c6 --- /dev/null +++ b/src/__tests__/loadingMachine/core/outputActions/index.ts @@ -0,0 +1,2 @@ +// Export local actions +export * from "./Hello" diff --git a/src/__tests__/loadingMachine/core/states/Ready.ts b/src/__tests__/loadingMachine/core/states/Ready.ts index f6ebb374..4e51f9ff 100644 --- a/src/__tests__/loadingMachine/core/states/Ready.ts +++ b/src/__tests__/loadingMachine/core/states/Ready.ts @@ -1,19 +1,24 @@ -import { goBack, noop } from "../effects" - +import { goBack } from "../effects" import type { Enter } from "../../../../action" -import type { Reset } from "../actions" +import type { Reset, World } from "../actions" import type { Shared } from "../types" import { state } from "../../../../state" +import { output } from "../../../../effect" +import { hello } from "../outputActions" -type Actions = Enter | Reset // | ReEnter +type Actions = Enter | Reset | World // | ReEnter type Data = [Shared] export default state( { - Enter: () => noop(), + Enter: () => output(hello()), Reset: goBack, + World: ([shared], __, { update }) => { + return update([{ ...shared, didWorld: true }]) + }, + // ReEnter: (data, _, { reenter }) => reenter(data), }, { name: "Ready" }, diff --git a/src/__tests__/loadingMachine/core/types.ts b/src/__tests__/loadingMachine/core/types.ts index 171aca69..f2394fa4 100644 --- a/src/__tests__/loadingMachine/core/types.ts +++ b/src/__tests__/loadingMachine/core/types.ts @@ -1,3 +1,4 @@ export interface Shared { message: string + didWorld?: boolean } diff --git a/src/__tests__/ports.spec.ts b/src/__tests__/onOutput.spec.ts similarity index 96% rename from src/__tests__/ports.spec.ts rename to src/__tests__/onOutput.spec.ts index dbb8745f..d52cdf73 100644 --- a/src/__tests__/ports.spec.ts +++ b/src/__tests__/onOutput.spec.ts @@ -4,7 +4,7 @@ import { createInitialContext } from "../context" import { createRuntime } from "../runtime" import { isState, state } from "../state" -describe("Ports", () => { +describe("onOutput", () => { test("should transition through multiple states", async () => { const enterAction = enter() diff --git a/src/__tests__/waitState.spec.ts b/src/__tests__/waitState.spec.ts index 40f9f483..55936434 100644 --- a/src/__tests__/waitState.spec.ts +++ b/src/__tests__/waitState.spec.ts @@ -25,13 +25,6 @@ const AfterThing = state( describe("waitState", () => { it("should run a wait state", async () => { - const BeforeThing = state( - { - Enter: data => WaitForThing([data, RETURN_COUNT]), - }, - { name: "BeforeThing" }, - ) - const WaitForThing = waitState( fetchThing, thingFetched, @@ -42,6 +35,14 @@ describe("waitState", () => { name: "WaitForThing", }, ) + + const BeforeThing = state( + { + Enter: data => WaitForThing([data, RETURN_COUNT]), + }, + { name: "BeforeThing" }, + ) + const context = createInitialContext([ BeforeThing({ count: INITIAL_COUNT, @@ -169,4 +170,50 @@ describe("waitState", () => { expect(isState(runtime.currentState(), TimedOutState)).toBeTruthy() expect((runtime.currentState().data as D).count).toBe(INITIAL_COUNT) }) + + it("should be able to use respondToOutput", async () => { + const WaitForThing = waitState( + fetchThing, + thingFetched, + (data: D, payload) => { + return AfterThing({ ...data, count: data.count + payload }) + }, + { + name: "WaitForThing", + }, + ) + + const BeforeThing = state( + { + Enter: data => WaitForThing([data, RETURN_COUNT]), + }, + { name: "BeforeThing" }, + ) + + const context = createInitialContext([ + BeforeThing({ + count: INITIAL_COUNT, + }), + ]) + + const runtime = createRuntime(context, { thingFetched }, { fetchThing }) + + runtime.respondToOutput("FetchThing", async payload => { + await timeout(250) + return thingFetched(payload) + }) + + 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, + ) + }) }) diff --git a/src/runtime.ts b/src/runtime.ts index f60464bb..35d94a95 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -13,7 +13,7 @@ import type { Context } from "./context.js" type ContextChangeSubscriber = (context: Context) => void type OutputSubscriber< OAM extends { [key: string]: (...args: Array) => Action }, -> = (action: ReturnType) => void +> = (action: ReturnType) => void | Promise type QueueItem = { onComplete: () => void @@ -66,6 +66,22 @@ export class Runtime< return () => this.#outputSubscribers.delete(fn) } + respondToOutput< + T extends OAM["type"], + P extends Extract["payload"], + A extends ReturnType, + >(type: T, handler: (payload: P) => Promise | A | void): () => void { + return this.onOutput(async output => { + if (output.type === type) { + const maybeAction = await handler(output.payload as P) + + if (maybeAction) { + await this.run(maybeAction) + } + } + }) + } + disconnect(): void { this.#contextChangeSubscribers.clear() } @@ -139,9 +155,9 @@ export class Runtime< if (item.label === "goBack") { results = this.#handleGoBack() } else if (item.label === "output") { - this.#outputSubscribers.forEach(sub => - sub(item.data as ReturnType), - ) + this.#outputSubscribers.forEach(sub => { + void sub(item.data as ReturnType) + }) } else { this.#runEffect(item) } diff --git a/src/svelte/__tests__/Comp.svelte b/src/svelte/__tests__/Comp.svelte index 50b80e6a..4e86ea9a 100644 --- a/src/svelte/__tests__/Comp.svelte +++ b/src/svelte/__tests__/Comp.svelte @@ -1,5 +1,12 @@ -
{$machine.currentState.name}
\ No newline at end of file +
+ {$machine.currentState.data[0].didWorld ? "true" : "false"} +
+ +
{$machine.currentState.name}
diff --git a/src/svelte/__tests__/machine.ts b/src/svelte/__tests__/machine.ts index 7ab960b5..1449e853 100644 --- a/src/svelte/__tests__/machine.ts +++ b/src/svelte/__tests__/machine.ts @@ -6,4 +6,5 @@ export const machine = createStore( Core.States, Core.Actions, Core.States.Initializing([{ message: "Loading" }, true]), + Core.OutputActions, ) diff --git a/src/svelte/__tests__/svelte.spec.tsx b/src/svelte/__tests__/svelte.spec.tsx index cc3e77fd..e841bd31 100644 --- a/src/svelte/__tests__/svelte.spec.tsx +++ b/src/svelte/__tests__/svelte.spec.tsx @@ -6,11 +6,21 @@ import "@testing-library/jest-dom/extend-expect" import Comp from "./Comp.svelte" -import { render } from "@testing-library/svelte" +import { act, render } from "@testing-library/svelte" +import { timeout } from "../../__tests__/util" describe("Svelte integration", () => { test("inital render", async () => { const { getByTestId } = render(Comp) + expect(getByTestId("name")).toHaveTextContent("Initializing") - }) + expect(getByTestId("didWorld")).toHaveTextContent("false") + + await act(async () => { + await timeout(5000) + }) + + expect(getByTestId("name")).toHaveTextContent("Ready") + expect(getByTestId("didWorld")).toHaveTextContent("true") + }, 6000) }) diff --git a/src/svelte/createStore.ts b/src/svelte/createStore.ts index d9ae2aa4..714a0a48 100644 --- a/src/svelte/createStore.ts +++ b/src/svelte/createStore.ts @@ -3,6 +3,7 @@ import type { BoundStateFn, StateTransition } from "../state.js" import { Context, createInitialContext } from "../context.js" import { type Readable, readable } from "svelte/store" import { Runtime, createRuntime } from "../runtime.js" +import { onMount } from "svelte" export interface ContextValue< SM extends { [key: string]: BoundStateFn }, @@ -30,7 +31,16 @@ export const createStore = < SM extends { [key: string]: BoundStateFn }, AM extends { [key: string]: (...args: Array) => Action }, OAM extends { [key: string]: (...args: Array) => Action }, - R extends Readable>, + R extends Readable> & { + respondOnMount: < + T extends OAM["type"], + P extends Extract["payload"], + A extends ReturnType, + >( + type: T, + handler: (payload: P) => Promise
| A | void, + ) => void + }, >( _states: SM, actions: AM, @@ -56,7 +66,7 @@ export const createStore = < runtime, } - return readable(initialContext, set => { + const store = readable(initialContext, set => { const unsub = runtime.onContextChange(context => set({ context, @@ -71,4 +81,17 @@ export const createStore = < return unsub }) as R + + store.respondOnMount = < + T extends OAM["type"], + P extends Extract["payload"], + A extends ReturnType, + >( + type: T, + handler: (payload: P) => Promise | A | void, + ) => { + onMount(() => runtime.respondToOutput(type, handler)) + } + + return store }