diff --git a/packages/runtime/src/app.ts b/packages/runtime/src/app.ts new file mode 100644 index 0000000..5c6bda4 --- /dev/null +++ b/packages/runtime/src/app.ts @@ -0,0 +1,62 @@ +import { destroyDom } from "./destroy-dom"; +import { Dispatcher } from "./dispatcher"; +import { VNodes } from "./h"; +import { mountDOM } from "./mount-dom"; + +type Props = { + state: any; + view: (state: any, emit: any) => VNodes; + reducers: Record; +}; + +export function createApp({ state, view, reducers }: Props) { + let parentEl: HTMLElement | null = null; + let vdom: VNodes | null = null; + + const dispatcher = new Dispatcher(); + + const emit = (eventName: string, payload: any) => { + dispatcher.dispatch(eventName, payload); + }; + + const renderApp = () => { + if (vdom) { + destroyDom(vdom); + } + + vdom = view(state, emit); + + if (vdom && parentEl) { + mountDOM(vdom, parentEl); + } + }; + + const subscriptions = [dispatcher.afterEveryCommand(renderApp)]; + + for (const actionName in reducers) { + const reducer = reducers[actionName]; + const subs = dispatcher.subscribe(actionName, (payload: any) => { + state = reducer(state, payload); + }); + subscriptions.push(subs); + } + + return { + mount: (_parentEl: HTMLElement) => { + parentEl = _parentEl; + renderApp(); + }, + + unmount: () => { + if (vdom) { + destroyDom(vdom); + } + + vdom = null; + + for (const subscription of subscriptions) { + subscription(); + } + }, + }; +} diff --git a/packages/runtime/src/dispatcher.ts b/packages/runtime/src/dispatcher.ts new file mode 100644 index 0000000..dd25574 --- /dev/null +++ b/packages/runtime/src/dispatcher.ts @@ -0,0 +1,52 @@ +export class Dispatcher { + private subs = new Map(); + private afterHandlers: Function[] = []; + + public subscribe = (commandName: string, handler: Function) => { + if (!this.subs.has(commandName)) { + this.subs.set(commandName, []); + } + + const handlers = this.subs.get(commandName); + + if (!handlers) { + throw new Error("handlers cannot be undefined"); + } + + if (handlers.includes(handler)) { + return () => {}; + } + + handlers.push(handler); + + return () => { + const idx = handlers.indexOf(handler); + handlers.splice(idx, 1); + }; + }; + + public afterEveryCommand = (handler: Function) => { + this.afterHandlers.push(handler); + + return () => { + const idx = this.afterHandlers.indexOf(handler); + this.afterHandlers.splice(idx, 1); + }; + }; + + public dispatch = (commandName: string, payload: any) => { + const handlers = this.subs.get(commandName); + + if (handlers) { + for (const handler of handlers) { + handler(payload); + } + } else { + console.warn(`No handlers for command: ${commandName}`); + } + + for (const afterHandler of this.afterHandlers) { + afterHandler(); + } + }; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6931632..e4cfd1d 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,2 +1,3 @@ export * from "./h"; export * from "./mount-dom"; +export * from "./app"; diff --git a/packages/runtime/tests/dispatcher.test.ts b/packages/runtime/tests/dispatcher.test.ts new file mode 100644 index 0000000..42d63b6 --- /dev/null +++ b/packages/runtime/tests/dispatcher.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { Dispatcher } from "../src/dispatcher"; + +const commandName = "test-event"; +const payload = { test: "payload" }; + +describe("A command dispatcher", () => { + it("can register and unregister handlers to specific commands", () => { + const dispatcher = new Dispatcher(); + const handler = vi.fn(); + + const unsubscribe = dispatcher.subscribe(commandName, handler); + dispatcher.dispatch(commandName, payload); + + expect(handler).toHaveBeenCalledWith(payload); + + unsubscribe(); + dispatcher.dispatch(commandName, payload); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("can't register the same handler twice", () => { + const dispatcher = new Dispatcher(); + const handler = vi.fn(); + + const unsubscribe = dispatcher.subscribe(commandName, handler); + dispatcher.subscribe(commandName, handler); + dispatcher.subscribe(commandName, handler); + + dispatcher.dispatch(commandName, payload); + + expect(handler).toHaveBeenCalledTimes(1); + + unsubscribe(); + dispatcher.dispatch(commandName, payload); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("unsubscribe multiple handlers", () => { + const dispatcher = new Dispatcher(); + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + const unsubscribe1 = dispatcher.subscribe(commandName, handler1); + const unsubscribe2 = dispatcher.subscribe(commandName, handler2); + dispatcher.dispatch(commandName, payload); + + expect(handler1).toHaveBeenCalledWith(payload); + expect(handler2).toHaveBeenCalledWith(payload); + + unsubscribe1(); + unsubscribe2(); + dispatcher.dispatch(commandName, payload); + + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + }); + + it("can register and unregister handlers that run after each command", () => { + const dispatcher = new Dispatcher(); + const handler = vi.fn(); + + const unsubscribe = dispatcher.afterEveryCommand(handler); + dispatcher.dispatch(commandName, payload); + + expect(handler).toHaveBeenCalled(); + + unsubscribe(); + dispatcher.dispatch(commandName, payload); + + expect(handler).toHaveBeenCalledTimes(1); + }); +});