diff --git a/README.md b/README.md index 9106c55e..126a10dd 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Test Coverage](https://api.codeclimate.com/v1/badges/bade509a61c126d7f488/test_coverage)](https://codeclimate.com/github/tdreyno/fizz/test_coverage) [![npm latest version](https://img.shields.io/npm/v/@tdreyno/fizz/latest.svg)](https://www.npmjs.com/package/@tdreyno/fizz) +[![Minified Size](https://badgen.net/bundlephobia/minzip/@tdreyno/fizz)](https://bundlephobia.com/result?p=@tdreyno/fizz) Fizz is a small library for building state machines that can effectively manage complex sequences of events. [Learn more about state machines (and charts).](https://statecharts.github.io) diff --git a/src/__tests__/boundActions.spec.ts b/src/__tests__/boundActions.spec.ts index 9bcc0e85..c6acd573 100644 --- a/src/__tests__/boundActions.spec.ts +++ b/src/__tests__/boundActions.spec.ts @@ -1,7 +1,7 @@ import { ActionCreatorType, Enter, createAction } from "../action" import { StateReturn, stateWrapper } from "../state" -import { createInitialContext } from "./createInitialContext" +import { createInitialContext } from "../context" import { createRuntime } from "../runtime" import { noop } from "../effect" diff --git a/src/__tests__/core.spec.ts b/src/__tests__/core.spec.ts index 6818a314..3e05ea7c 100644 --- a/src/__tests__/core.spec.ts +++ b/src/__tests__/core.spec.ts @@ -1,25 +1,7 @@ -import { Action, Enter, Exit, enter } from "../action" -import { - Context, - createInitialContext as originalCreateInitialContext, -} from "../context" -import { StateDidNotRespondToAction, UnknownStateReturnType } from "../errors" -import { StateTransition, state } from "../state" -import { goBack, log, noop } from "../effect" - -import { execute } from "../core" -/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ -import serializeJavascript from "serialize-javascript" - -function createInitialContext( - history: Array>, - options = {}, -) { - return originalCreateInitialContext(history, { - disableLogging: true, - ...options, - }) -} +import type { Enter } from "../action" +import { createInitialContext } from "../context" +import { noop } from "../effect" +import { state } from "../state" describe("Fizz core", () => { describe("States", () => { @@ -40,322 +22,48 @@ describe("Fizz core", () => { expect(result.is(Entry)).toBeTruthy() expect(result.isNamed("Entry")).toBeTruthy() }) - - test("should throw exception when getting invalid action", () => { - expect(() => - execute( - { type: "Fake" } as Action<"Fake", undefined>, - createInitialContext([Entry()], { - allowUnhandled: false, - }), - ), - ).toThrowError(StateDidNotRespondToAction) - }) - - test("should not throw exception when allowing invalid actions", () => { - expect(() => - execute( - { type: "Fake" } as Action<"Fake", undefined>, - createInitialContext([Entry()], { - allowUnhandled: true, - }), - ), - ).not.toThrowError(StateDidNotRespondToAction) - }) - }) - - describe("Transitions", () => { - test("should flatten nested state transitions", () => { - const A = state( - { - Enter: () => [log("Enter A"), B()], - }, - { name: "A" }, - ) - - const B = state( - { - Enter: () => [log("Enter B"), C()], - }, - { name: "B" }, - ) - - const C = state( - { - Enter: () => log("Entered C"), - }, - { name: "C" }, - ) - - const { effects } = execute(enter(), createInitialContext([A()])) - - expect(effects).toBeInstanceOf(Array) - - const gotos = effects.filter(r => r.label === "entered") - expect(gotos).toHaveLength(3) - - const gotoLogs = effects.filter( - r => r.label === "log" && r.data[0].match(/^Enter:/), - ) - expect(gotoLogs).toHaveLength(3) - - const normalLogs = effects.filter( - r => r.label === "log" && r.data[0].match(/^Enter /), - ) - expect(normalLogs).toHaveLength(2) - }) - }) - - describe("Exit events", () => { - test("should fire exit events", () => { - const A = state( - { - Enter: () => [log("Enter A"), B()], - Exit: () => log("Exit A"), - }, - { name: "A" }, - ) - - const B = state( - { - Enter: noop, - Exit: () => log("Exit B"), - }, - { name: "B" }, - ) - - const { effects } = execute( - enter(), - createInitialContext([A()], { - allowUnhandled: true, - }), - ) - - expect(effects).toBeInstanceOf(Array) - - const events = effects.filter(r => - ["entered", "exited"].includes(r.label), - ) - - expect(events[0]).toMatchObject({ - label: "entered", - data: { name: "A" }, - }) - expect(events[1]).toMatchObject({ - label: "exited", - data: { name: "A" }, - }) - expect(events[2]).toMatchObject({ - label: "entered", - data: { name: "B" }, - }) - }) - }) - - describe("Reenter", () => { - interface ReEnterReplace { - type: "ReEnterReplace" - payload: undefined - } - - interface ReEnterAppend { - type: "ReEnterAppend" - payload: undefined - } - - const A = state({ - Enter: noop, - Exit: noop, - ReEnterReplace: (bool, _, { update }) => update(bool), - ReEnterAppend: (bool, _, { reenter }) => reenter(bool), - }) - - test("should exit and re-enter the current state, replacing itself in history", () => { - const context = createInitialContext([A(true)]) - - const { effects } = execute( - { type: "ReEnterReplace" } as Action<"ReEnterReplace", undefined>, - context, - ) - - expect(effects).toBeInstanceOf(Array) - expect(context.history).toHaveLength(1) - }) - - test("should exit and re-enter the current state, appending itself to history", () => { - const context = createInitialContext([A(true)]) - - const { effects } = execute( - { type: "ReEnterAppend" } as Action<"ReEnterAppend", undefined>, - context, - ) - - expect(effects).toBeInstanceOf(Array) - expect(context.history).toHaveLength(2) - }) - }) - - describe("goBack", () => { - interface GoBack { - type: "GoBack" - payload: undefined - } - - const A = state( - { - Enter: noop, - }, - { name: "A" }, - ) - - const B = state( - { - Enter: noop, - GoBack: () => goBack(), - }, - { name: "B" }, - ) - - test("should return to previous state", () => { - const context = createInitialContext([B(), A("Test")]) - - const { effects } = execute( - { type: "GoBack" } as Action<"GoBack", undefined>, - context, - ) - expect(effects).toBeInstanceOf(Array) - - const events = effects.filter(r => - ["entered", "exited"].includes(r.label), - ) - - expect(events[0]).toMatchObject({ - label: "exited", - data: { name: "B" }, - }) - expect(events[1]).toMatchObject({ - label: "entered", - data: { name: "A" }, - }) - - expect(context.currentState.is(A)).toBeTruthy() - expect(context.currentState.data).toBe("Test") - }) }) - describe("update", () => { - type Data = [str: string, bool: boolean, num: number, fn: () => string] - - type Update = Action<"Update", undefined> - - const A = state({ - Enter: noop, - Update: (data, _, { update }) => update(data), - }) - - test("should pass through original values", () => { - const context = createInitialContext([ - A(["Test", false, 5, () => "Inside"]), - ]) - - const action: Update = { - type: "Update", - payload: undefined, - } - - execute(action, context) - - expect(context.currentState.data[0]).toBe("Test") - expect(context.currentState.data[1]).toBe(false) - expect(context.currentState.data[2]).toBe(5) - expect(context.currentState.data[3]()).toBe("Inside") - }) - }) - - describe("Serialization", () => { - test("should be able to serialize and deserialize state", () => { - interface Next { - type: "Next" - payload: undefined - } - - const A = state( - { - Enter: () => B({ name: "Test" }), - }, - { name: "A" }, - ) - - const B = state( - { - Enter: noop, - Next: ({ name }) => C(name), - }, - { name: "B" }, - ) - - const C = state( - { - Enter: noop, - }, - { name: "C" }, - ) - - function serializeContext(c: Context) { - return serializeJavascript( - c.history.map(({ data, name }) => ({ - data, - name, - })), - ) - } - - const STATES = { A, B, C } - - function deserializeContext(s: string) { - const unboundHistory: Array<{ data: Array; name: string }> = eval( - "(" + s + ")", - ) - - return createInitialContext( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return - unboundHistory.map(({ data, name }) => (STATES as any)[name](data)), - ) - } - - const context = createInitialContext([A()], { - allowUnhandled: false, - }) - - execute(enter(), context) - - expect(context.currentState.is(B)).toBeTruthy() - const serialized = serializeContext(context) - - const newContext = deserializeContext(serialized) - - execute({ type: "Next" } as Action<"Next", undefined>, newContext) - - expect(newContext.currentState.is(C)).toBeTruthy() - expect(newContext.currentState.data).toBe("Test") - }) - }) - - describe("Unknown effect", () => { - test("should throw error on unknown effect", () => { - const A = state({ - Enter: () => - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - (() => { - // fake effect - }) as any, - }) - - const context = createInitialContext([A()]) - - expect(() => execute(enter(), context)).toThrowError( - UnknownStateReturnType, - ) - }) - }) + // describe.skip("Reenter", () => { + // interface ReEnterReplace { + // type: "ReEnterReplace" + // payload: undefined + // } + + // interface ReEnterAppend { + // type: "ReEnterAppend" + // payload: undefined + // } + + // const A = state({ + // Enter: noop, + // Exit: noop, + // ReEnterReplace: (bool, _, { update }) => update(bool), + // ReEnterAppend: (bool, _, { reenter }) => reenter(bool), + // }) + + // test("should exit and re-enter the current state, replacing itself in history", () => { + // const context = createInitialContext([A(true)]) + + // const effects = execute( + // { type: "ReEnterReplace" } as Action<"ReEnterReplace", undefined>, + // context, + // ) + + // expect(effects).toBeInstanceOf(Array) + // expect(context.history).toHaveLength(1) + // }) + + // test("should exit and re-enter the current state, appending itself to history", () => { + // const context = createInitialContext([A(true)]) + + // const effects = execute( + // { type: "ReEnterAppend" } as Action<"ReEnterAppend", undefined>, + // context, + // ) + + // expect(effects).toBeInstanceOf(Array) + // expect(context.history).toHaveLength(2) + // }) + // }) }) diff --git a/src/__tests__/createInitialContext.ts b/src/__tests__/createInitialContext.ts deleted file mode 100644 index b44b33ea..00000000 --- a/src/__tests__/createInitialContext.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { StateTransition } from "../state" -import { createInitialContext as originalCreateInitialContext } from "../context" - -export const createInitialContext = ( - history: Array>, - options = {}, -) => - originalCreateInitialContext(history, { - disableLogging: true, - ...options, - }) diff --git a/src/__tests__/loadingMachine/core/states/Ready.ts b/src/__tests__/loadingMachine/core/states/Ready.ts index 5dc56df6..f6ebb374 100644 --- a/src/__tests__/loadingMachine/core/states/Ready.ts +++ b/src/__tests__/loadingMachine/core/states/Ready.ts @@ -1,11 +1,11 @@ -import type { ReEnter, Reset } from "../actions" import { goBack, noop } from "../effects" import type { Enter } from "../../../../action" +import type { Reset } from "../actions" import type { Shared } from "../types" import { state } from "../../../../state" -type Actions = Enter | Reset | ReEnter +type Actions = Enter | Reset // | ReEnter type Data = [Shared] export default state( @@ -14,7 +14,7 @@ export default state( Reset: goBack, - ReEnter: (data, _, { reenter }) => reenter(data), + // ReEnter: (data, _, { reenter }) => reenter(data), }, { name: "Ready" }, ) diff --git a/src/__tests__/onContextChange.spec.ts b/src/__tests__/onContextChange.spec.ts index e0359bd5..ce829ee3 100644 --- a/src/__tests__/onContextChange.spec.ts +++ b/src/__tests__/onContextChange.spec.ts @@ -1,19 +1,18 @@ -import { Action, Enter, enter } from "../action" -import { StateReturn, stateWrapper } from "../state" +import { ActionCreatorType, Enter, createAction, enter } from "../action" -import { createInitialContext } from "./createInitialContext" +import { createInitialContext } from "../context" import { createRuntime } from "../runtime" import { noop } from "../effect" +import { state } from "../state" describe("onContextChange", () => { test("should run callback once after changes", async () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const A = stateWrapper("A", (action: Enter, _name: string) => { - switch (action.type) { - case "Enter": - return noop() - } - }) + const A = state( + { + Enter: noop, + }, + { name: "A" }, + ) const context = createInitialContext([A("Test")]) @@ -29,18 +28,15 @@ describe("onContextChange", () => { }) test("should run callback once on update", async () => { - interface Trigger { - type: "Trigger" - } - - const A = stateWrapper( - "A", - (action: Trigger, name: string, { update }): StateReturn => { - switch (action.type) { - case "Trigger": - return update(name + name) - } + const trigger = createAction("Trigger") + type Trigger = ActionCreatorType + + const A = state( + { + Enter: noop, + Trigger: (name, _, { update }) => update(name + name), }, + { name: "A" }, ) const context = createInitialContext([A("Test")]) @@ -51,7 +47,7 @@ describe("onContextChange", () => { runtime.onContextChange(onChange) - await runtime.run({ type: "Trigger" } as Action<"Trigger", undefined>) + await runtime.run(trigger()) expect(onChange).toHaveBeenCalledTimes(1) }) diff --git a/src/__tests__/promises.spec.ts b/src/__tests__/promises.spec.ts index c463f0d1..33cc5f89 100644 --- a/src/__tests__/promises.spec.ts +++ b/src/__tests__/promises.spec.ts @@ -1,7 +1,7 @@ import { ActionCreatorType, Enter, createAction, enter } from "../action" import { effect, noop } from "../effect" -import { createInitialContext } from "./createInitialContext" +import { createInitialContext } from "../context" import { createRuntime } from "../runtime" import { state } from "../state" diff --git a/src/__tests__/runtime.spec.ts b/src/__tests__/runtime.spec.ts index 434c2a1c..f42995f9 100644 --- a/src/__tests__/runtime.spec.ts +++ b/src/__tests__/runtime.spec.ts @@ -1,8 +1,11 @@ -import { ActionCreatorType, Enter, createAction, enter } from "../action" +import { ActionCreatorType, Enter, Exit, createAction, enter } from "../action" +import { goBack, log, noop } from "../effect" -import { createInitialContext } from "./createInitialContext" +import type { Context } from "../context" +import { UnknownStateReturnType } from "../errors" +import { createInitialContext } from "../context" import { createRuntime } from "../runtime" -import { noop } from "../effect" +import serializeJavascript from "serialize-javascript" import { state } from "../state" describe("Runtime", () => { @@ -28,6 +31,7 @@ describe("Runtime", () => { expect(runtime.currentState().is(A)).toBeTruthy() await runtime.run(enter()) + expect(runtime.currentState().is(B)).toBeTruthy() }) @@ -53,24 +57,21 @@ describe("Runtime", () => { expect(runtime.currentState().is(B)).toBeTruthy() }) - test("onEnter actions should run in correct order", async () => { + test("should run in correct order", async () => { const trigger = createAction("Trigger") type Trigger = ActionCreatorType - const onEnter = jest.fn() - const A = state({ Enter: (shared, __, { update }) => { - onEnter() - return [update({ ...shared, num: shared.num + 1 }), trigger()] + return [update({ ...shared, num: shared.num * 5 }), trigger()] }, Trigger: (shared, __, { update }) => { - return update({ ...shared, num: shared.num + 1 }) + return update({ ...shared, num: shared.num - 2 }) }, }) - const context = createInitialContext([A({ num: 1 })]) + const context = createInitialContext([A({ num: 3 })]) const runtime = createRuntime(context, ["Trigger"]) @@ -78,9 +79,390 @@ describe("Runtime", () => { expect(runtime.currentState().is(A)).toBeTruthy() - expect(onEnter).toHaveBeenCalledTimes(1) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(runtime.currentState().data.num).toBe(3) + expect(runtime.currentState().data.num).toBe(13) + }) + + test("should not throw exception when sending unhandled actions", async () => { + const trigger = createAction("Trigger") + + const A = state({ + Enter: noop, + }) + + const context = createInitialContext([A()]) + + const runtime = createRuntime(context, ["Trigger"]) + + await runtime.run(enter()) + + await expect(runtime.run(trigger())).resolves.toBeUndefined() + }) + + test("should treat void return as noop", async () => { + const A = state({ + Enter: () => { + return + }, + }) + + const context = createInitialContext([A()]) + + const runtime = createRuntime(context, ["Trigger"]) + + await expect(runtime.run(enter())).resolves.toBeUndefined() + }) + + describe("Entering", () => { + test("should enter a state and log", async () => { + const A = state( + { + Enter: () => log("Hello A"), + }, + { name: "A" }, + ) + + const customLogger = jest.fn() + + const context = createInitialContext([A()], { + customLogger, + }) + + const runtime = createRuntime(context) + await runtime.run(enter()) + + expect(runtime.currentState().is(A)).toBeTruthy() + expect(customLogger).toHaveBeenCalledWith(["Hello A"], "log") + }) + + test("should enter a state and immediately run an action", async () => { + const next = createAction("Next") + type Next = ActionCreatorType + + const A = state( + { + Enter: () => next(), + Next: () => log("Hello A"), + }, + { name: "A" }, + ) + + const customLogger = jest.fn() + + const context = createInitialContext([A()], { + customLogger, + }) + + const runtime = createRuntime(context) + await runtime.run(enter()) + + expect(runtime.currentState().is(A)).toBeTruthy() + expect(customLogger).toHaveBeenCalledWith(["Hello A"], "log") + }) + + test("should enter a state and immediately go to another state", async () => { + const A = state( + { + Enter: () => B(), + }, + { name: "A" }, + ) + + const B = state( + { + Enter: () => log("Hello B"), + }, + { name: "B" }, + ) + + const customLogger = jest.fn() + + const context = createInitialContext([A()], { + customLogger, + }) + + const runtime = createRuntime(context) + await runtime.run(enter()) + + expect(runtime.currentState().is(B)).toBeTruthy() + expect(customLogger).toHaveBeenCalledWith(["Hello B"], "log") + }) + + test("should enter a state and immediately update", async () => { + const A = state( + { + Enter: (n, _, { update }) => update(n + 1), + }, + { name: "A" }, + ) + + const context = createInitialContext([A(1)]) + + const runtime = createRuntime(context) + await runtime.run(enter()) + + expect(runtime.currentState().is(A)).toBeTruthy() + expect(runtime.currentState().data).toBe(2) + }) + + test("should enter a state and immediately transition then run an action", async () => { + const next = createAction("Next") + type Next = ActionCreatorType + + const A = state( + { + Enter: () => [B(), next()], + }, + { name: "A" }, + ) + + const B = state( + { + Enter: () => noop(), + Next: () => log("Next"), + }, + { name: "B" }, + ) + + const customLogger = jest.fn() + + const context = createInitialContext([A()], { + customLogger, + }) + + const runtime = createRuntime(context) + await runtime.run(enter()) + + expect(runtime.currentState().is(B)).toBeTruthy() + expect(customLogger).toHaveBeenCalledWith(["Next"], "log") + }) + }) + + describe("Exit events", () => { + test("should fire exit events", async () => { + const A = state( + { + Enter: () => [log("Enter A"), B()], + Exit: () => log("Exit A"), + }, + { name: "A" }, + ) + + const B = state( + { + Enter: noop, + }, + { name: "B" }, + ) + + const customLogger = jest.fn() + + const context = createInitialContext([A()], { customLogger }) + + const runtime = createRuntime(context) + + await runtime.run(enter()) + + expect(runtime.currentState().is(B)).toBeTruthy() + + expect(customLogger).toHaveBeenCalledWith(["Exit A"], "log") + }) + }) + + describe("Transitions", () => { + test("should flatten nested state transitions", async () => { + const A = state( + { + Enter: () => [log("Enter A"), B()], + }, + { name: "A" }, + ) + + const B = state( + { + Enter: () => [log("Enter B"), C()], + }, + { name: "B" }, + ) + + const C = state( + { + Enter: () => log("Enter C"), + }, + { name: "C" }, + ) + + const context = createInitialContext([A()]) + + const runtime = createRuntime(context) + + await runtime.run(enter()) + + expect(runtime.currentState().is(C)).toBeTruthy() + }) + }) + + describe("update", () => { + type Data = [str: string, bool: boolean, num: number, fn: () => string] + + const update = createAction("Update") + type Update = ActionCreatorType + + const A = state({ + Enter: noop, + Update: (data, _, { update }) => update(data), + }) + + test("should pass through original values", async () => { + const context = createInitialContext([ + A(["Test", false, 5, () => "Inside"]), + ]) + + const runtime = createRuntime(context) + await runtime.run(update()) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const [a, b, c, d] = runtime.currentState().data + + expect(a).toBe("Test") + expect(b).toBe(false) + expect(c).toBe(5) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + expect(d()).toBe("Inside") + }) + }) + + describe("goBack", () => { + const hi = createAction("Hi") + type Hi = ActionCreatorType + + const trigger = createAction("Trigger") + type Trigger = ActionCreatorType + + const A = state( + { + Enter: noop, + Hi: () => log("Hi"), + }, + { name: "A" }, + ) + + const B = state( + { + Enter: noop, + Trigger: () => [goBack(), hi()], + }, + { name: "B" }, + ) + + test("should return to previous state", async () => { + const customLogger = jest.fn() + + const context = createInitialContext([B(), A("Test")], { customLogger }) + + const runtime = createRuntime(context) + + await runtime.run(trigger()) + + expect(runtime.currentState().is(A)).toBeTruthy() + expect(runtime.currentState().data).toBe("Test") + expect(customLogger).toHaveBeenCalledWith(["Hi"], "log") + }) + }) + + describe("Unknown effect", () => { + test("should throw error on unknown effect", async () => { + const A = state({ + Enter: () => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + (() => { + // fake effect + }) as any, + }) + + const context = createInitialContext([A()]) + + const runtime = createRuntime(context) + + await expect(runtime.run(enter())).rejects.toBeInstanceOf( + UnknownStateReturnType, + ) + }) + }) + + describe("Serialization", () => { + test("should be able to serialize and deserialize state", async () => { + const next = createAction("Next") + type Next = ActionCreatorType + + const A = state( + { + Enter: () => B({ name: "Test" }), + }, + { name: "A" }, + ) + + const B = state( + { + Enter: noop, + Next: ({ name }) => C(name), + }, + { name: "B" }, + ) + + const C = state( + { + Enter: noop, + }, + { name: "C" }, + ) + + function serializeContext(c: Context) { + return serializeJavascript( + c.history.map(({ data, name }) => ({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + data, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + name, + })), + ) + } + + const STATES = { A, B, C } + + function deserializeContext(s: string) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const unboundHistory: Array<{ data: Array; name: string }> = eval( + "(" + s + ")", + ) + + return createInitialContext( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return + unboundHistory.map(({ data, name }) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + return (STATES as any)[name](data) + }), + ) + } + + const context = createInitialContext([A()]) + + const runtime = createRuntime(context) + + await runtime.run(enter()) + + expect(context.currentState.is(B)).toBeTruthy() + + const serialized = serializeContext(context) + const newContext = deserializeContext(serialized) + + const runtime2 = createRuntime(newContext) + + await runtime2.run(next()) + + expect(newContext.currentState.is(C)).toBeTruthy() + expect(newContext.currentState.data).toBe("Test") + }) }) }) diff --git a/src/context.ts b/src/context.ts index 4df816fd..eeb5b79d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -60,9 +60,8 @@ export class History< interface Options { maxHistory: number - allowUnhandled: boolean onAsyncEnterExit: "throw" | "warn" | "silent" - disableLogging: boolean + enableLogging: boolean customLogger?: | undefined | ((msgs: Array, level: "error" | "warn" | "log") => void) @@ -74,16 +73,12 @@ export class Context { private options_: Omit, ) {} - get allowUnhandled() { - return this.options_.allowUnhandled - } - get onAsyncEnterExit() { return this.options_.onAsyncEnterExit } - get disableLogging() { - return this.options_.disableLogging + get enableLogging() { + return this.options_.enableLogging } get customLogger() { @@ -100,8 +95,7 @@ export const createInitialContext = ( options?: Partial, ) => new Context(new History(history, options?.maxHistory ?? Infinity), { - allowUnhandled: options?.allowUnhandled ?? false, onAsyncEnterExit: options?.onAsyncEnterExit ?? "warn", - disableLogging: options?.disableLogging ?? false, + enableLogging: options?.enableLogging ?? false, customLogger: options?.customLogger, }) diff --git a/src/core.ts b/src/core.ts deleted file mode 100644 index 88f3548a..00000000 --- a/src/core.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* eslint-disable @typescript-eslint/no-use-before-define, @typescript-eslint/no-misused-promises */ - -import { Action, enter, exit, isAction } from "./action.js" -import { Effect, __internalEffect, isEffect, log } from "./effect.js" -import { ExecuteResult, executeResultfromPromise } from "./execute-result.js" -import { - MissingCurrentState, - StateDidNotRespondToAction, - UnknownStateReturnType, -} from "./errors.js" -import { StateReturn, StateTransition, isStateTransition } from "./state.js" - -import type { Context } from "./context.js" -import { arraySingleton } from "./util.js" - -const enterState = ( - context: Context, - targetState: StateTransition, - exitState?: StateTransition, -): ExecuteResult => { - let exitEffects: Array = [] - let exitFutures: Array< - () => Promise> - > = [] - - if (exitState) { - exitEffects.push(__internalEffect("exited", exitState, () => void 0)) - - try { - const result = execute(exit(), context, exitState) - - exitEffects = exitEffects.concat(result.effects) - exitFutures = result.futures - } catch (e) { - if (!(e instanceof StateDidNotRespondToAction)) { - throw e - } - } - } - - return ExecuteResult( - [ - ...exitEffects, - - // Add a log effect. - log(`Enter: ${targetState.name as string}`, targetState.data), - - // Add a goto effect for testing. - __internalEffect("entered", targetState, () => void 0), - ], - - exitFutures, - ) -} - -export const execute = >( - action: A, - context: Context, - targetState = context.currentState, - exitState = context.history.previous, -): ExecuteResult => { - if (!targetState) { - throw new MissingCurrentState("Must provide a current state") - } - - const isUpdating = - exitState && - exitState.name === targetState.name && - targetState.mode === "update" && - action.type === "Enter" - - if (isUpdating) { - // TODO: Needs to be lazy - context.history.removePrevious() - - return ExecuteResult([ - // Add a log effect. - log(`Update: ${targetState.name as string}`, targetState.data), - - // Add a goto effect for testing. - __internalEffect("update", targetState, () => void 0), - ]) - } - - const isReentering = - exitState && - exitState.name === targetState.name && - targetState.mode === "append" && - action.type === "Enter" - - const isEnteringNewState = - !isUpdating && !isReentering && action.type === "Enter" - - const prefix = isEnteringNewState - ? enterState(context, targetState, exitState) - : ExecuteResult() - - const result = targetState.executor(action) - - // State transition produced no side-effects - if (!result) { - if (context.allowUnhandled) { - return ExecuteResult() - } - - throw new StateDidNotRespondToAction(targetState, action) - } - - return processStateReturn(context, prefix, result) -} - -export const processStateReturn = ( - context: Context, - prefix: ExecuteResult, - result: void | StateReturn | Array, -): ExecuteResult => - arraySingleton(result).reduce( - (sum, item) => sum.concat(processIndividualStateReturn(context, item)), - prefix, - ) - -const processIndividualStateReturn = ( - context: Context, - item: StateReturn, -): ExecuteResult => { - const targetState = context.currentState - - if (isEffect(item)) { - if (item.label === "reenter") { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (!item.data.replaceHistory) { - // Insert onto front of history array. - context.history.push(targetState) - } - - return execute(enter(), context, targetState, targetState).prependEffect( - item, - ) - } - - if (item.label === "goBack") { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const previousState = context.history.previous! - - // Insert onto front of history array. - context.history.push(previousState) - - return execute(enter(), context, previousState).prependEffect(item) - } - - return ExecuteResult(item) - } - - // If we get a state handler, transition to it. - if (isStateTransition(item)) { - // TODO: Make async. - // Insert onto front of history array. - context.history.push(item) - - return execute(enter(), context) - } - - // If we get an action, convert to promise. - if (isAction(item)) { - return executeResultfromPromise(Promise.resolve(item)) - } - - if (item instanceof Promise) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - return executeResultfromPromise(item) - } - - // Should be impossible to get here with TypeScript, - // but could happen with plain JS. - throw new UnknownStateReturnType(item) -} - -export const runEffects = (context: Context, effects: Effect[]): void => - effects.forEach(e => e.executor(context)) diff --git a/src/effect.ts b/src/effect.ts index 5e7a98d7..94e7ee78 100644 --- a/src/effect.ts +++ b/src/effect.ts @@ -1,5 +1,6 @@ import type { Action } from "./action.js" import type { Context } from "./context.js" +import type { StateTransition } from "./state.js" export interface Effect { label: string @@ -24,6 +25,8 @@ const RESERVED_EFFECTS = [ "warn", "noop", "timeout", + "runTransition", + "runAction", ] export const __internalEffect = void>( @@ -54,6 +57,14 @@ export const effect = void>( export const goBack = (): Effect => __internalEffect("goBack", undefined, () => void 0) +export const runTransition = >( + transition: T, +): Effect => __internalEffect("runTransition", transition, () => void 0) + +export const runAction = >( + action: T, +): Effect => __internalEffect("runAction", action, () => void 0) + const handleLog = >( msgs: T, @@ -63,7 +74,7 @@ const handleLog = (context: Context) => { if (context.customLogger) { context.customLogger(msgs, type) - } else if (!context.disableLogging) { + } else if (context.enableLogging) { logger(...msgs) } } diff --git a/src/execute-result.ts b/src/execute-result.ts deleted file mode 100644 index 77184817..00000000 --- a/src/execute-result.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Effect } from "./effect.js" -import type { StateReturn } from "./state.js" -import { arraySingleton } from "./util.js" - -class ExecuteResult_ { - constructor( - public effects: Array, - public futures: Array< - () => Promise> - >, - ) {} - - concat({ effects, futures }: ExecuteResult) { - return ExecuteResult( - this.effects.concat(effects), - this.futures.concat(futures), - ) - } - - pushEffect(effect: Effect) { - this.effects.push(effect) - return this - } - - prependEffect(effect: Effect) { - this.effects.unshift(effect) - return this - } - - pushFuture(future: () => Promise>) { - this.futures.push(future) - return this - } -} - -export const ExecuteResult = ( - effects: Effect | Array = [], - promises: Array<() => Promise>> = [], -) => new ExecuteResult_(arraySingleton(effects), promises) -export type ExecuteResult = ExecuteResult_ - -export const isExecuteResult = (value: unknown): value is ExecuteResult => - value instanceof ExecuteResult_ - -export const executeResultfromPromise = ( - promise: Promise>, -) => ExecuteResult([], [() => promise]) diff --git a/src/index.ts b/src/index.ts index c4f65599..ccc1b461 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,7 @@ export * from "./action.js" export * from "./context.js" -export * from "./core.js" export * from "./effect.js" export * from "./errors.js" export * from "./runtime.js" export * from "./state.js" -export * from "./execute-result.js" export * from "./svelte/index.js" diff --git a/src/react/__tests__/react.spec.tsx b/src/react/__tests__/react.spec.tsx index 95d64b1f..41afb160 100644 --- a/src/react/__tests__/react.spec.tsx +++ b/src/react/__tests__/react.spec.tsx @@ -12,9 +12,7 @@ import { render, screen, waitFor } from "@testing-library/react" import React from "react" import { switch_ } from "../../state" -const LoadingMachine = createFizzContext(Core.States, Core.Actions, { - disableLogging: true, -}) +const LoadingMachine = createFizzContext(Core.States, Core.Actions) const { Initializing, Loading, Ready } = Core.States diff --git a/src/react/createFizzContext.tsx b/src/react/createFizzContext.tsx index 8efb16a3..9b4b49bb 100644 --- a/src/react/createFizzContext.tsx +++ b/src/react/createFizzContext.tsx @@ -40,7 +40,7 @@ export interface ContextValue< interface Options { maxHistory: number restartOnInitialStateChange?: boolean - disableLogging?: boolean + enableLogging?: boolean } export function createFizzContext< @@ -50,12 +50,12 @@ export function createFizzContext< const { restartOnInitialStateChange, maxHistory = 5, - disableLogging = false, + enableLogging = false, } = options const defaultContext = createInitialContext( [stateWrapper("Placeholder", () => noop())()], - { maxHistory, disableLogging }, + { maxHistory, enableLogging }, ) const MachineContext = React.createContext>({ @@ -79,7 +79,7 @@ export function createFizzContext< const runtime = useMemo( () => createRuntime( - createInitialContext([initialState], { maxHistory, disableLogging }), + createInitialContext([initialState], { maxHistory, enableLogging }), Object.keys(actions), ), [initialState], diff --git a/src/runtime.ts b/src/runtime.ts index 289d6e88..642d321f 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,112 +1,193 @@ -import { Action, isAction } from "./action.js" -import { ExternalPromise, externalPromise, isNotEmpty } from "./util.js" +import { Action, enter, exit, isAction } from "./action.js" +import { Effect, __internalEffect, isEffect, log } from "./effect.js" import { + MissingCurrentState, NoStatesRespondToAction, - StateDidNotRespondToAction, + UnknownStateReturnType, } from "./errors.js" -import type { StateReturn, StateTransition } from "./state.js" -import { execute, processStateReturn, runEffects } from "./core.js" +import { StateReturn, StateTransition, isStateTransition } from "./state.js" +import { arraySingleton, externalPromise } from "./util.js" import type { Context } from "./context.js" -import type { Effect } from "./effect.js" -import { ExecuteResult } from "./execute-result.js" type ContextChangeSubscriber = (context: Context) => void -export interface Runtime { - currentState: () => StateTransition - onContextChange: (fn: ContextChangeSubscriber) => () => void - bindActions: < - AM extends { [key: string]: (...args: Array) => Action }, - >( - actions: AM, - ) => AM - disconnect: () => void - run: (action: Action) => Promise> - canHandle: (action: Action) => boolean - context: Context +type QueueItem = { + onComplete: () => void + onError: (e: unknown) => void + item: Action | StateTransition | Effect } -export const createRuntime = ( - context: Context, - validActionNames: Array = [], -): Runtime => { - const pendingActions_: Array<[Action, ExternalPromise]> = [] +export class Runtime { + context: Context + #contextChangeSubscribers: Set = new Set() + #validActions: Set + #queue: QueueItem[] = [] + #isRunning = false + + constructor(context: Context, validActionNames: Array = []) { + this.context = context + this.#validActions = validActionNames.reduce( + (sum, action) => sum.add(action.toLowerCase()), + new Set(), + ) + } - const contextChangeSubscribers_: Set = new Set() + currentState(): StateTransition { + return this.context.currentState + } + + currentHistory() { + return this.context.history + } - let timeoutId_: number | NodeJS.Timeout | undefined + onContextChange(fn: ContextChangeSubscriber): () => void { + this.#contextChangeSubscribers.add(fn) - const validActions_ = validActionNames.reduce( - (sum, action) => sum.add(action.toLowerCase()), - new Set(), - ) + return () => this.#contextChangeSubscribers.delete(fn) + } - const chainResults_ = async ({ - effects, - futures, - }: ExecuteResult): Promise> => { - const results: Array = [] + disconnect(): void { + this.#contextChangeSubscribers.clear() + } - for (const future of futures) { - results.push((await future()) as void | StateReturn | StateReturn[]) - } + canHandle(action: Action): boolean { + return this.#validActions.has((action.type as string).toLowerCase()) + } - const joinedResults = results.reduce( - (sum, item) => - isAction(item) - ? sum.pushFuture(() => run(item)) - : processStateReturn(context, sum, item), - ExecuteResult(effects), - ) + bindActions< + AM extends { [key: string]: (...args: Array) => Action }, + >(actions: AM): AM { + return Object.keys(actions).reduce((sum, key) => { + sum[key] = (...args: Array) => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-non-null-assertion + return this.run(actions[key]!(...args)) + } catch (e) { + if (e instanceof NoStatesRespondToAction) { + if (this.context.customLogger) { + this.context.customLogger([e.toString()], "error") + } else if (this.context.enableLogging) { + console.error(e.toString()) + } - let effectsToRun: Promise> + return + } - if (joinedResults.futures.length > 0) { - effectsToRun = chainResults_(joinedResults) - } else { - effectsToRun = Promise.resolve(joinedResults.effects) - } + throw e + } + } + + return sum + }, {} as Record) as AM + } + + async run(action: Action): Promise { + const promise = new Promise((resolve, reject) => { + this.#queue.push({ + onComplete: resolve, + onError: reject, + item: action, + }) + }) - const effectResults = await effectsToRun + if (!this.#isRunning) { + this.#isRunning = true + void this.#processQueueHead() + } - runEffects(context, effectResults) + await promise - return effectResults + this.#contextDidChange() } - const flushPendingActions_ = () => { - if (!isNotEmpty(pendingActions_)) { + async #processQueueHead(): Promise { + const head = this.#queue.shift() + + if (!head) { + this.#isRunning = false return } - const [action, extPromise] = pendingActions_.shift() + const { item, onComplete, onError } = head // Make sure we're in a valid state. - validateCurrentState_() + this.#validateCurrentState() - void chainResults_(executeAction_(action)) - .then(results => { - extPromise.resolve(results) + try { + let results: StateReturn[] = [] + + if (isAction(item)) { + results = await this.#executeAction(item) + } else if (isStateTransition(item)) { + results = this.#handleState(item) + } else if (isEffect(item)) { + if (item.label === "goBack") { + results = this.#handleGoBack() + } else { + this.#runEffect(item) + } + } else { + // Should be impossible to get here with TypeScript, + // but could happen with plain JS. + throw new UnknownStateReturnType(item) + } - contextChangeSubscribers_.forEach(sub => sub(context)) + const { promise, items } = this.#stateReturnsToQueueItems(results) - flushPendingActions_() - }) - .catch(e => { - extPromise.reject(e) + // New items go to front of queue + this.#queue = [...items, ...this.#queue] - pendingActions_.length = 0 - }) + void promise.then(() => onComplete()).catch(e => onError(e)) + + setTimeout(() => { + void this.#processQueueHead() + }, 0) + } catch (e) { + onError(e) + this.#isRunning = false + this.#queue.length = 0 + } } - const validateCurrentState_ = () => { - const runCurrentState = currentState() + #stateReturnsToQueueItems(stateReturns: StateReturn[]): { + promise: Promise + items: QueueItem[] + } { + const { promises, items } = stateReturns.reduce( + (acc, item) => { + const { promise, resolve, reject } = externalPromise() + + acc.promises.push(promise) + + acc.items.push({ + onComplete: resolve, + onError: reject, + item, + }) + + return acc + }, + { + promises: [] as Promise[], + items: [] as QueueItem[], + }, + ) + + return { promise: Promise.all(promises), items } + } + + #contextDidChange() { + this.#contextChangeSubscribers.forEach(sub => sub(this.context)) + } + + #validateCurrentState() { + const runCurrentState = this.currentState() if (!runCurrentState) { throw new Error( `Fizz could not find current state to run action on. History: ${JSON.stringify( - currentHistory() + this.currentHistory() .map(({ name }) => name as string) .join(" -> "), )}`, @@ -114,85 +195,107 @@ export const createRuntime = ( } } - const executeAction_ = (action: Action): ExecuteResult => { - // Try this runtime. - try { - return execute(action, context) - } catch (e) { - // If it failed to handle optional actions like OnFrame, continue. - if (!(e instanceof StateDidNotRespondToAction)) { - throw e - } - - throw new NoStatesRespondToAction([currentState()], e.action) - } + #runEffect(e: Effect) { + e.executor(this.context) } - const onContextChange = (fn: ContextChangeSubscriber) => { - contextChangeSubscribers_.add(fn) + async #executeAction>( + action: A, + ): Promise { + const targetState = this.context.currentState - return () => contextChangeSubscribers_.delete(fn) - } + if (!targetState) { + throw new MissingCurrentState("Must provide a current state") + } - const disconnect = () => contextChangeSubscribers_.clear() + // const isReentering = + // exitState && + // exitState.name === targetState.name && + // targetState.mode === "append" && + // action.type === "Enter" - const currentState = () => context.currentState + //!isUpdating && !isReentering && + // const isEnteringNewState = action.type === "Enter" - const currentHistory = () => context.history + // const prefix = isEnteringNewState + // ? this.enterState_(targetState, exitState) + // : [] - const canHandle = (action: Action): boolean => - validActions_.has((action.type as string).toLowerCase()) + const result = await targetState.executor(action) - const run = (action: Action): Promise> => { - const extPromise = externalPromise>() + return arraySingleton(result) + } - pendingActions_.push([action, extPromise]) + #handleState(targetState: StateTransition): StateReturn[] { + const exitState = this.context.currentState - if (timeoutId_) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - clearTimeout(timeoutId_ as any) - } + const isUpdating = + exitState && + exitState.name === targetState.name && + targetState.mode === "update" - timeoutId_ = setTimeout(flushPendingActions_, 0) + return isUpdating + ? this.#updateState(targetState) + : this.#enterState(targetState) + } - return extPromise.promise + #updateState(targetState: StateTransition): StateReturn[] { + return [ + // Update history + __internalEffect("nextState", targetState, () => { + this.context.history.removePrevious() + this.context.history.push(targetState) + }), + + // Add a log effect. + log(`Update: ${targetState.name as string}`, targetState.data), + ] } - const bindActions = < - AM extends { [key: string]: (...args: Array) => Action }, - >( - actions: AM, - ): AM => - Object.keys(actions).reduce((sum, key) => { - sum[key] = (...args: Array) => { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-non-null-assertion - return run(actions[key]!(...args)) - } catch (e) { - if (e instanceof NoStatesRespondToAction) { - if (context.customLogger) { - context.customLogger([e.toString()], "error") - } else if (!context.disableLogging) { - console.error(e.toString()) - } + #enterState(targetState: StateTransition): StateReturn[] { + const exitState = this.context.currentState - return - } + const effects: StateReturn[] = [ + // Update history + __internalEffect("nextState", targetState, () => + this.context.history.push(targetState), + ), - throw e - } - } + // Add a log effect. + log(`Enter: ${targetState.name as string}`, targetState.data), - return sum - }, {} as Record) as AM + // Run enter on next state + enter(), - return { - currentState, - onContextChange, - bindActions, - disconnect, - run, - canHandle, - context, + // Notify listeners of change + // __internalEffect("contextChange", undefined, () => { + // // Only state changes (and updates) can change context + // this.onContextChange_() + // }), + ] + + // Run exit on prior state first + if (exitState) { + effects.unshift(exit()) + } + + return effects + } + + #handleGoBack(): StateReturn[] { + return [ + // Update history + __internalEffect("updateHistory", undefined, () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.context.history.push(this.context.history.previous!) + }), + + enter(), + ] } } + +export const createRuntime = ( + context: Context, + validActionNames: Array = [], +) => new Runtime(context, validActionNames) diff --git a/src/state.ts b/src/state.ts index 70f43317..1f9a1fd7 100644 --- a/src/state.ts +++ b/src/state.ts @@ -13,7 +13,9 @@ export type StateReturn = | Effect | Action | StateTransition - | Promise + +type SyncHandlerReturn = void | StateReturn | Array +export type HandlerReturn = SyncHandlerReturn | Promise /** * State handlers are objects which contain a serializable list of bound @@ -30,8 +32,8 @@ export interface StateTransition< data: Data isStateTransition: true mode: "append" | "update" - reenter: (data: Data) => StateTransition - executor: (action: A) => void | StateReturn | Array + // reenter: (data: Data) => StateTransition + executor: (action: A) => HandlerReturn state: BoundStateFn is(state: BoundStateFn): boolean isNamed(name: string): boolean @@ -60,9 +62,9 @@ export type State, Data> = ( data: Data, utils: { update: (data: Data) => StateTransition - reenter: (data: Data) => StateTransition + // reenter: (data: Data) => StateTransition }, -) => StateReturn | Array | undefined +) => HandlerReturn export interface BoundStateFn< Name extends string, @@ -96,15 +98,15 @@ export const stateWrapper = < isStateTransition: true, mode: "append", - reenter: (reenterData: Data) => { - const bound = fn(reenterData) - bound.mode = "append" - return bound - }, + // reenter: (reenterData: Data) => { + // const bound = fn(reenterData) + // bound.mode = "append" + // return bound + // }, executor: (action: A) => { // Run state executor - return executor(action, data, { reenter, update }) + return executor(action, data, { /*reenter,*/ update }) }, state: fn, @@ -114,11 +116,11 @@ export const stateWrapper = < Object.defineProperty(fn, "name", { value: name }) - const reenter = (data: Data): StateTransition => { - const bound = fn(data) - bound.mode = "append" - return bound as unknown as StateTransition - } + // const reenter = (data: Data): StateTransition => { + // const bound = fn(data) + // bound.mode = "append" + // return bound as unknown as StateTransition + // } const update = (data: Data): StateTransition => { const bound = fn(data) @@ -136,27 +138,27 @@ const matchAction = payload: ActionPayload, utils: { update: (data: Data) => StateTransition - reenter: (data: Data) => StateTransition + // reenter: (data: Data) => StateTransition }, - ) => StateReturn | Array + ) => HandlerReturn }) => ( action: Actions, data: Data, utils: { update: (data: Data) => StateTransition - reenter: (data: Data) => StateTransition + // reenter: (data: Data) => StateTransition }, - ): StateReturn | Array | undefined => { + ): HandlerReturn => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const handler = (handlers as never)[action.type] as ( data: Data, payload: ActionPayload, utils: { update: (data: Data) => StateTransition - reenter: (data: Data) => StateTransition + // reenter: (data: Data) => StateTransition }, - ) => StateReturn | Array + ) => HandlerReturn if (!handler) { return undefined @@ -175,9 +177,9 @@ export const state = , Data = undefined>( payload: ActionPayload, utils: { update: (data: Data) => StateTransition - reenter: (data: Data) => StateTransition + // reenter: (data: Data) => StateTransition }, - ) => StateReturn | Array + ) => HandlerReturn }, options?: { name?: string }, ): BoundStateFn => diff --git a/src/svelte/__tests__/machine.ts b/src/svelte/__tests__/machine.ts index c3084678..7ab960b5 100644 --- a/src/svelte/__tests__/machine.ts +++ b/src/svelte/__tests__/machine.ts @@ -6,7 +6,4 @@ export const machine = createStore( Core.States, Core.Actions, Core.States.Initializing([{ message: "Loading" }, true]), - { - disableLogging: true, - }, ) diff --git a/src/svelte/createStore.ts b/src/svelte/createStore.ts index 0158ecd8..9a36decd 100644 --- a/src/svelte/createStore.ts +++ b/src/svelte/createStore.ts @@ -17,7 +17,7 @@ export interface ContextValue< interface Options { maxHistory: number restartOnInitialStateChange?: boolean - disableLogging?: boolean + enableLogging?: boolean } export const createStore = < @@ -30,11 +30,11 @@ export const createStore = < initialState: StateTransition, options: Partial = {}, ): R => { - const { maxHistory = 5, disableLogging = false } = options + const { maxHistory = 5, enableLogging = false } = options const defaultContext = createInitialContext([initialState], { maxHistory, - disableLogging, + enableLogging, }) const runtime = createRuntime(defaultContext, Object.keys(actions))