diff --git a/README.md b/README.md index 7ab4ee3..b78ba2c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ This library brings the elmish pattern to react. - [Subscriptions](#subscriptions) - [Working with external sources of events](#working-with-external-sources-of-events) - [Cleanup subscriptions](#cleanup-subscriptions) +- [Immutability](#immutability) + - [Testing](#testing) - [Setup](#setup) - [Error handling](#error-handling) - [React life cycle management](#react-life-cycle-management) @@ -26,7 +28,7 @@ This library brings the elmish pattern to react. - [With an `UpdateMap`](#with-an-updatemap) - [With an update function](#with-an-update-function) - [Merge multiple subscriptions](#merge-multiple-subscriptions) -- [Testing](#testing) +- [Testing](#testing-1) - [Testing the init function](#testing-the-init-function) - [Testing the update handler](#testing-the-update-handler) - [Combine update and execCmd](#combine-update-and-execcmd) @@ -456,6 +458,58 @@ function subscription (model: Model): SubscriptionResult { The destructor is called when the component is removed from the DOM. +## Immutability + +If you want to use immutable data structures, you can use the imports from "react-elmish/immutable". This version of the `useElmish` hook returns an immutable model. + +```tsx +import { useElmish } from "react-elmish/immutable"; + +function App(props: Props): JSX.Element { + const [model, dispatch] = useElmish({ props, init, update, name: "App" }); + + model.value = 42; // This will throw an error + + return ( + // ... + ); +} +``` + +You can simply update the draft of the model like this: + +```ts +import { type UpdateMap } from "react-elmish/immutable"; + +const updateMap: UpdateMap = { + increment(_msg, model) { + model.value += 1; + + return []; + }, + + decrement(_msg, model) { + model.value -= 1; + + return []; + }, + + commandOnly() { + // This will not update the model but only dispatch a command + return [cmd.ofMsg(Msg.increment())]; + }, + + doNothing() { + // This does nothing + return []; + }, +}; +``` + +### Testing + +If you want to test your component with immutable data structures, you can use the `react-elmish/testing/immutable` module. This module provides the same functions as the normal testing module. + ## Setup **react-elmish** works without a setup. But if you want to use logging or some middleware, you can setup **react-elmish** at the start of your program. @@ -912,7 +966,7 @@ const subscription = mergeSubscriptions(LoadSettings.subscription, localSubscrip ## Testing -To test your **update** handler you can use some helper functions in `react-elmish/dist/Testing`: +To test your **update** handler you can use some helper functions in `react-elmish/testing`: | Function | Description | | --- | --- | @@ -926,7 +980,7 @@ To test your **update** handler you can use some helper functions in `react-elmi ### Testing the init function ```ts -import { initAndExecCmd } from "react-elmish/dist/Testing"; +import { initAndExecCmd } from "react-elmish/testing"; import { init, Msg } from "./MyComponent"; it("initializes the model correctly", async () => { @@ -947,7 +1001,7 @@ it("initializes the model correctly", async () => { **Note**: When using an `UpdateMap`, you can get an `update` function by calling `getUpdateFn`: ```ts -import { getUpdateFn } from "react-elmish/dist/Testing"; +import { getUpdateFn } from "react-elmish/testing"; import { updateMap } from "./MyComponent"; const updateFn = getUpdateFn(updateMap); @@ -959,7 +1013,7 @@ const [model, cmd] = updateFn(msg, model, props); A simple test: ```ts -import { getCreateUpdateArgs, createUpdateArgsFactory, execCmd } from "react-elmish/dist/Testing"; +import { getCreateUpdateArgs, createUpdateArgsFactory, execCmd } from "react-elmish/testing"; import { init, Msg } from "./MyComponent"; const createUpdateArgs = getCreateUpdateArgs(init, () => ({ /* initial props */ })); @@ -992,7 +1046,7 @@ It also resolves for `attempt` functions if the called functions succeed. And it There is an alternative function `getUpdateAndExecCmdFn` to get the `update` function for an update map, which immediately invokes the command and returns the messages. ```ts -import { createUpdateArgs, getUpdateAndExecCmdFn } from "react-elmish/dist/Testing"; +import { createUpdateArgs, getUpdateAndExecCmdFn } from "react-elmish/testing"; const updateAndExecCmdFn = getUpdateAndExecCmdFn(updateMap); @@ -1019,7 +1073,7 @@ it("returns the correct cmd", async () => { It is almost the same as testing the `update` function. You can use the `getCreateModelAndProps` function to create a factory for the model and the props. Then use `execSubscription` to execute the subscriptions: ```ts -import { getCreateModelAndProps, execSubscription } from "react-elmish/dist/Testing"; +import { getCreateModelAndProps, execSubscription } from "react-elmish/testing"; import { init, Msg, subscription } from "./MyComponent"; const createModelAndProps = getCreateModelAndProps(init, () => ({ /* initial props */ })); @@ -1046,7 +1100,7 @@ it("dispatches the eventTriggered message", async () => { To test UI components with a fake model you can use `renderWithModel` from the Testing namespace. The first parameter is a function to render your component (e.g. with **@testing-library/react**). The second parameter is the fake model. The third parameter is an optional options object, where you can also pass a fake `dispatch` function. ```tsx -import { renderWithModel } from "react-elmish/dist/Testing"; +import { renderWithModel } from "react-elmish/testing"; import { fireEvent, render, screen } from "@testing-library/react"; it("renders the correct value", () => { diff --git a/package-lock.json b/package-lock.json index 26dbebf..3820ea7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "react-elmish", "version": "3.0.0", "license": "MIT", + "dependencies": { + "immer": "10.1.1" + }, "devDependencies": { "@babel/cli": "7.27.2", "@babel/core": "7.27.1", @@ -7635,6 +7638,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", diff --git a/package.json b/package.json index 9529895..fb671a3 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "update": "npx -y npm-check-updates -i --install never && npx -y npm-check-updates -i --target minor --install never && npx -y npm-check-updates -i --target patch --install never && npm update", "semantic-release": "semantic-release" }, + "dependencies": { + "immer": "10.1.1" + }, "peerDependencies": { "react": ">=16.8.0 <20" }, @@ -53,6 +56,11 @@ "files": [ "dist/**/*" ], - "main": "dist/index.js", + "exports": { + ".": "./dist/index.js", + "./testing": "./dist/testing/index.js", + "./immutable": "./dist/immutable/index.js", + "./immutable/testing": "./dist/immutable/testing/index.js" + }, "types": "dist/index.d.ts" } \ No newline at end of file diff --git a/src/Types.ts b/src/Types.ts index a773009..60d07e6 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -79,6 +79,18 @@ type UpdateMap = { ) => UpdateReturnType; }; +/** + * The return type of the `subscription` function. + * @template TMessage The type of the messages discriminated union. + */ +type SubscriptionResult = [Cmd, (() => void)?] | SubscriptionFunction[]; +type SubscriptionFunction = (dispatch: Dispatch) => (() => void) | undefined; +type Subscription = (model: TModel, props: TProps) => SubscriptionResult; + +function subscriptionIsFunctionArray(subscription: SubscriptionResult): subscription is SubscriptionFunction[] { + return typeof subscription[0] === "function"; +} + export type { CallBaseFunction, Cmd, @@ -91,9 +103,14 @@ export type { MsgSource, Nullable, Sub, + Subscription, + SubscriptionFunction, + SubscriptionResult, UpdateFunction, UpdateFunctionOptions, UpdateMap, UpdateMapFunction, UpdateReturnType, }; + +export { subscriptionIsFunctionArray }; diff --git a/src/immutable/ElmComponent.spec.tsx b/src/immutable/ElmComponent.spec.tsx new file mode 100644 index 0000000..7849b25 --- /dev/null +++ b/src/immutable/ElmComponent.spec.tsx @@ -0,0 +1,71 @@ +import { render, type RenderResult } from "@testing-library/react"; +import type { JSX } from "react"; +import { cmd } from "../cmd"; +import type { Cmd } from "../Types"; +import { ElmComponent } from "./ElmComponent"; +import type { UpdateReturnType } from "./Types"; + +describe("ElmComponent", () => { + it("calls the init function", () => { + // arrange + const init = jest.fn().mockReturnValue([{}, []]); + const update = jest.fn(); + const props: Props = { + init, + update, + }; + + // act + renderComponent(props); + + // assert + expect(init).toHaveBeenCalledWith(props); + }); + + it("calls the initial command", () => { + // arrange + const message: Message = { name: "Test" }; + const init = jest.fn().mockReturnValue([{ value: 42 }, cmd.ofMsg(message)]); + const update = jest.fn((): UpdateReturnType => []); + const props: Props = { + init, + update, + }; + + // act + renderComponent(props); + + // assert + expect(update).toHaveBeenCalledTimes(1); + }); +}); + +interface Message { + name: "Test"; +} + +interface Model { + value: number; +} + +interface Props { + init: () => [Model, Cmd]; + update: (model: Model, msg: Message, props: Props) => UpdateReturnType; +} + +class TestComponent extends ElmComponent { + public constructor(props: Props) { + super(props, props.init, "Test"); + } + + public update = this.props.update; + + // eslint-disable-next-line @typescript-eslint/class-methods-use-this + public override render(): JSX.Element { + return
; + } +} + +function renderComponent(props: Props): RenderResult { + return render(); +} diff --git a/src/immutable/ElmComponent.ts b/src/immutable/ElmComponent.ts new file mode 100644 index 0000000..1c0b9fe --- /dev/null +++ b/src/immutable/ElmComponent.ts @@ -0,0 +1,146 @@ +import { castImmutable, freeze, produce, type Draft, type Immutable } from "immer"; +import React from "react"; +import { execCmd, logMessage } from "../Common"; +import { getFakeOptionsOnce } from "../fakeOptions"; +import { Services } from "../Init"; +import type { Cmd, InitFunction, Message, Nullable } from "../Types"; +import { createCallBase } from "./createCallBase"; +import { createDefer } from "./createDefer"; +import type { UpdateFunction, UpdateReturnType } from "./Types"; + +/** + * Abstract class for a react class component using the Elmish pattern. + * @export + * @abstract + * @class ElmComponent + * @extends {Component} + * @template TModel The type of the model. + * @template TMessage The type of the messages. + * @template TProps The type of the props. + */ +abstract class ElmComponent extends React.Component { + private initCommands: Nullable<(Cmd | undefined)[]> | undefined; + private readonly componentName: string; + private readonly buffer: TMessage[] = []; + private running = false; + private mounted = false; + private currentModel: Immutable; + + /** + * Creates an instance of ElmComponent. + * @param {TProps} props The props for the component. + * @param {() => TModel} init The initializer function. + * @param name The name of the component. + * @memberof ElmComponent + */ + public constructor(props: TProps, init: InitFunction, name: string) { + super(props); + + const fakeOptions = getFakeOptionsOnce(); + + if (fakeOptions?.dispatch) { + this.dispatch = fakeOptions.dispatch; + } + + const [model, ...commands] = fakeOptions?.model ? [fakeOptions.model as TModel] : init(this.props); + + Services.logger?.debug("Initial model for", name, model); + + this.componentName = name; + this.currentModel = castImmutable(freeze(model, true)); + this.initCommands = commands; + } + + /** + * Is called when the component is loaded. + * When implementing this method, the base implementation has to be called. + * @memberof ElmComponent + */ + public componentDidMount(): void { + this.mounted = true; + + if (this.initCommands) { + execCmd(this.dispatch, ...this.initCommands); + this.initCommands = null; + } + } + + /** + * Is called before unloading the component. + * When implementing this method, the base implementation has to be called. + * @memberof ElmComponent + */ + public componentWillUnmount(): void { + this.mounted = false; + } + + /** + * Returns the current model. + * @readonly + * @type {Readonly} + * @memberof ElmComponent + */ + // eslint-disable-next-line react/no-unused-class-component-methods -- We need it internally. + public get model(): Immutable { + return this.currentModel; + } + + /** + * Dispatches a message. + * @param {TMessage} msg The message to dispatch. + * @memberof ElmComponent + */ + public readonly dispatch = (msg: TMessage): void => { + if (this.running) { + this.buffer.push(msg); + + return; + } + + this.running = true; + + let nextMsg: TMessage | undefined = msg; + + do { + const currentMessage = nextMsg; + + logMessage(this.componentName, currentMessage); + + const [defer, getDeferred] = createDefer(); + const callBase = createCallBase(currentMessage, this.currentModel, this.props, { defer }); + + const commands: UpdateReturnType = []; + + this.currentModel = produce(this.currentModel, (draft: Draft) => { + commands.push(...this.update(draft, currentMessage, this.props, { defer, callBase })); + }); + + const deferredCommands = getDeferred(); + + execCmd(this.dispatch, ...commands, ...deferredCommands); + + nextMsg = this.buffer.shift(); + } while (nextMsg); + + this.running = false; + + if (this.mounted) { + Services.logger?.debug("Update model for", this.componentName, this.currentModel); + this.forceUpdate(); + } + }; + + /** + * Function to modify the model based on a message. + * @param {TModel} model The current model. + * @param {TMessage} msg The message to process. + * @param {TProps} props The props of the component. + * @param options The options for the update function. + * @returns The new model (can also be an empty object {}) and an optional new message to dispatch. + * @abstract + * @memberof ElmComponent + */ + public abstract update: UpdateFunction; +} + +export { ElmComponent }; diff --git a/src/immutable/ErrorHandling.ts b/src/immutable/ErrorHandling.ts new file mode 100644 index 0000000..63efb1a --- /dev/null +++ b/src/immutable/ErrorHandling.ts @@ -0,0 +1,42 @@ +import type { ErrorMessage } from "../ErrorHandling"; +import { Services } from "../Init"; +import type { UpdateReturnType } from "./Types"; + +/** + * Creates an object to handle error messages in an update map. + * Spread the object returned by this function into your `UpdateMap`. + * @returns An object containing an error handler function. + * @example + * ```ts + * const update: UpdateMap = { + * // ... + * ...errorHandler(), + * }; + * ``` + */ +function errorHandler(): { + error: (msg: ErrorMessage) => UpdateReturnType; +} { + return { + error({ error }) { + return handleError(error); + }, + }; +} + +/** + * Handles an error. + * Logs the error if a Logger was specified. + * Calls the error handling middleware if specified. + * @param {Error} error The error. + */ +function handleError(error: Error): UpdateReturnType { + if (Services.errorMiddleware) { + Services.errorMiddleware(error); + } + Services.logger?.error(error); + + return []; +} + +export { errorHandler, handleError }; diff --git a/src/immutable/Types.ts b/src/immutable/Types.ts new file mode 100644 index 0000000..e3a6414 --- /dev/null +++ b/src/immutable/Types.ts @@ -0,0 +1,62 @@ +import type { Draft, Immutable } from "immer"; +import type { Cmd, Message, SubscriptionResult } from "../Types"; + +/** + * Type for the return value of the `update` function. + */ +type UpdateReturnType = (Cmd | undefined)[]; + +type DeferFunction = (...commands: (Cmd | undefined)[]) => void; +type CallBaseFunction = ( + fn: ( + msg: TMessage, + model: Draft, + props: TProps, + options: UpdateFunctionOptions, + ) => UpdateReturnType, +) => [Immutable, UpdateReturnType]; + +type UpdateMapFunction = ( + msg: TMessage, + model: Draft, + props: TProps, + options: UpdateFunctionOptions, +) => UpdateReturnType; + +interface UpdateFunctionOptions { + defer: DeferFunction; + callBase: CallBaseFunction; +} + +type UpdateFunction = ( + model: Draft, + msg: TMessage, + props: TProps, + options: UpdateFunctionOptions, +) => UpdateReturnType; + +/** + * Type for mapping messages to functions. + * Use this type to create your update logic for the useElmish hook. + */ +type UpdateMap = { + [TMessageName in TMessage["name"]]: ( + msg: TMessage & { name: TMessageName }, + model: Draft, + props: TProps, + options: UpdateFunctionOptions, + ) => UpdateReturnType; +}; + +type Subscription = (model: Immutable, props: TProps) => SubscriptionResult; + +export type { + CallBaseFunction, + DeferFunction, + Subscription, + UpdateFunction, + UpdateFunctionOptions, + UpdateMap, + UpdateMapFunction, + UpdateReturnType, +}; diff --git a/src/immutable/createCallBase.ts b/src/immutable/createCallBase.ts new file mode 100644 index 0000000..a8fe5b7 --- /dev/null +++ b/src/immutable/createCallBase.ts @@ -0,0 +1,24 @@ +import { produce, type Draft, type Immutable } from "immer"; +import type { Message } from "../Types"; +import type { CallBaseFunction, UpdateFunctionOptions, UpdateReturnType } from "./Types"; + +function createCallBase( + msg: TMessage, + model: Immutable, + props: TProps, + options: Omit, "callBase">, +): CallBaseFunction { + const callBase: CallBaseFunction = (fn) => { + const commands: UpdateReturnType = []; + const updatedModel = produce(model, (draft: Draft) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The current TMessage must be extended from Message + commands.push(...(fn(msg, draft, props, { ...options, callBase }) as UpdateReturnType)); + }); + + return [updatedModel, commands]; + }; + + return callBase; +} + +export { createCallBase }; diff --git a/src/immutable/createDefer.ts b/src/immutable/createDefer.ts new file mode 100644 index 0000000..b072e5b --- /dev/null +++ b/src/immutable/createDefer.ts @@ -0,0 +1,14 @@ +import type { Cmd, Message } from "../Types"; +import type { DeferFunction } from "./Types"; + +function createDefer(): [DeferFunction, () => (Cmd | undefined)[]] { + const deferredCommands: (Cmd | undefined)[] = []; + + const defer: DeferFunction = (...tempDeferredCommands) => { + deferredCommands.push(...tempDeferredCommands); + }; + + return [defer, () => deferredCommands]; +} + +export { createDefer }; diff --git a/src/immutable/index.ts b/src/immutable/index.ts new file mode 100644 index 0000000..d5a9222 --- /dev/null +++ b/src/immutable/index.ts @@ -0,0 +1,19 @@ +export { cmd } from "../cmd"; +export { errorMsg, type ErrorMessage } from "../ErrorHandling"; +export { init, type ElmOptions, type Logger } from "../Init"; +export { mergeSubscriptions } from "../mergeSubscriptions"; +export type { + Cmd, + Dispatch, + InitResult, + Message, + SubscriptionResult, +} from "../Types"; +export { errorHandler, handleError } from "./ErrorHandling"; +export type { + Subscription, + UpdateFunctionOptions, + UpdateMap, + UpdateReturnType, +} from "./Types"; +export { useElmish, type UseElmishOptions } from "./useElmish"; diff --git a/src/immutable/testing/createModelAndProps.ts b/src/immutable/testing/createModelAndProps.ts new file mode 100644 index 0000000..98d40b2 --- /dev/null +++ b/src/immutable/testing/createModelAndProps.ts @@ -0,0 +1,19 @@ +import { castImmutable, freeze, type Immutable } from "immer"; +import type { InitFunction, Message } from "../../Types"; + +function createModelAndProps( + init: InitFunction, + initProps: () => TProps, + modelTemplate?: Partial, + propsTemplate?: Partial, +): [Immutable, TProps] { + const props: TProps = { + ...initProps(), + ...propsTemplate, + }; + const [model] = init(props); + + return [castImmutable(freeze({ ...model, ...modelTemplate }, true)), props]; +} + +export { createModelAndProps }; diff --git a/src/immutable/testing/createUpdateArgsFactory.ts b/src/immutable/testing/createUpdateArgsFactory.ts new file mode 100644 index 0000000..b2db80d --- /dev/null +++ b/src/immutable/testing/createUpdateArgsFactory.ts @@ -0,0 +1,53 @@ +import { castImmutable, freeze, type Immutable } from "immer"; +import type { Message } from "../../Types"; +import type { UpdateFunctionOptions } from "../Types"; + +type UpdateArgsFactory = ( + msg: TMessage, + modelTemplate?: Partial, + propsTemplate?: Partial, + optionsTemplate?: Partial>, +) => [TMessage, Immutable, TProps, Partial>?]; + +/** + * Creates a factory function to create a message, a model, props, and options which can be passed to an update function in tests. + * @param {() => TModel} initModel A function to create an initial model. + * @param {() => TProps} initProps A function to create initial props. + * @returns {UpdateArgsFactory} A function to create a message, a model, and props. + * @example + * // one time + * const createUpdateArgs = createUpdateArgsFactory(() => ({ ... }), () => ({ ... })); + * // in tests + * const [msg, model, props] = createUpdateArgs(Msg.myMessage(), { ... }, , { ... }); + */ +function createUpdateArgsFactory( + initModel: () => TModel, + initProps: () => TProps, +): UpdateArgsFactory { + return function createUpdateArgs( + msg: TMessage, + modelTemplate?: Partial, + propsTemplate?: Partial, + optionsTemplate?: Partial>, + ): [TMessage, Immutable, TProps, Partial>?] { + const model = castImmutable( + freeze( + { + ...initModel(), + ...modelTemplate, + }, + true, + ), + ); + const props = { + ...initProps(), + ...propsTemplate, + }; + + return [msg, model, props, optionsTemplate]; + }; +} + +export type { UpdateArgsFactory }; + +export { createUpdateArgsFactory }; diff --git a/src/immutable/testing/execSubscription.ts b/src/immutable/testing/execSubscription.ts new file mode 100644 index 0000000..10b9cdf --- /dev/null +++ b/src/immutable/testing/execSubscription.ts @@ -0,0 +1,39 @@ +import type { Immutable } from "immer"; +import { subscriptionIsFunctionArray, type Dispatch, type Message } from "../../Types"; +import { execCmdWithDispatch } from "../../testing"; +import type { Subscription } from "../Types"; + +function execSubscription( + subscription: Subscription | undefined, + dispatch: Dispatch, + model: Immutable, + props: TProps, +): () => void { + const noop = (): void => { + // do nothing + }; + + if (!subscription) { + return noop; + } + + const subscriptionResult = subscription(model, props); + + if (subscriptionIsFunctionArray(subscriptionResult)) { + const disposers = subscriptionResult.map((sub) => sub(dispatch)).filter((disposer) => disposer !== undefined); + + return () => { + for (const dispose of disposers) { + dispose(); + } + }; + } + + const [cmd, dispose] = subscriptionResult; + + execCmdWithDispatch(dispatch, cmd); + + return dispose ?? noop; +} + +export { execSubscription }; diff --git a/src/immutable/testing/getCreateUpdateArgs.ts b/src/immutable/testing/getCreateUpdateArgs.ts new file mode 100644 index 0000000..9079016 --- /dev/null +++ b/src/immutable/testing/getCreateUpdateArgs.ts @@ -0,0 +1,61 @@ +import type { Immutable } from "immer"; +import type { InitFunction, Message } from "../../Types"; +import type { UpdateFunctionOptions } from "../Types"; +import { createModelAndProps } from "./createModelAndProps"; +import type { UpdateArgsFactory } from "./createUpdateArgsFactory"; + +/** + * Creates a factory function to create a message, a model, props, and options which can be passed to an update function in tests. + * @param {InitFunction} init The init function which creates the model. + * @param {() => TProps} initProps A function to create initial props. + * @returns {UpdateArgsFactory} A function to create a message, a model, and props. + * @example + * // one time + * const createUpdateArgs = getCreateUpdateArgs(init, () => ({ ... })); + * // in tests + * const [msg, model, props] = createUpdateArgs(Msg.myMessage(), { ... }, , { ... }); + */ +function getCreateUpdateArgs( + init: InitFunction, + initProps: () => TProps, +): UpdateArgsFactory { + return function createUpdateArgs( + msg: TMessage, + modelTemplate?: Partial, + propsTemplate?: Partial, + optionsTemplate?: Partial>, + ): [TMessage, Immutable, TProps, Partial>?] { + const args = createModelAndProps(init, initProps, modelTemplate, propsTemplate); + + return [msg, ...args, optionsTemplate]; + }; +} + +type ModelAndPropsFactory = ( + modelTemplate?: Partial, + propsTemplate?: Partial, +) => [Immutable, TProps]; + +/** + * Creates a factory function to create a model, props, and options which can be passed to an update or subscription function in tests. + * @param {InitFunction} init The init function which creates the model. + * @param {() => TProps} initProps A function to create initial props. + * @returns {ModelAndPropsFactory} A function to create a a model and props. + * @example + * // one time + * const createModelAndProps = getCreateModelAndProps(init, () => ({ ... })); + * // in tests + * const [model, props] = createModelAndProps({ ... }, , { ... }); + */ +function getCreateModelAndProps( + init: InitFunction, + initProps: () => TProps, +): ModelAndPropsFactory { + return function create(modelTemplate?: Partial, propsTemplate?: Partial): [Immutable, TProps] { + return createModelAndProps(init, initProps, modelTemplate, propsTemplate); + }; +} + +export type { ModelAndPropsFactory }; + +export { getCreateModelAndProps, getCreateUpdateArgs }; diff --git a/src/immutable/testing/getUpdateFn.spec.ts b/src/immutable/testing/getUpdateFn.spec.ts new file mode 100644 index 0000000..dc4ff65 --- /dev/null +++ b/src/immutable/testing/getUpdateFn.spec.ts @@ -0,0 +1,72 @@ +import type { UpdateMap } from "../Types"; +import { getUpdateFn } from "./getUpdateFn"; + +type Message = { name: "foo" } | { name: "bar" } | { name: "foobar" }; + +interface Model { + foo: string; + bar: { + foo: string; + bar: number; + } | null; + foobar: string[]; +} + +interface Props {} + +const updateMap: UpdateMap = { + foo(_msg, model) { + model.foo = "bar"; + + return []; + }, + bar(_msg, model) { + model.bar = { foo: "bar", bar: 1 }; + + return []; + }, + foobar(_msg, model) { + model.foobar.push("bar"); + + return []; + }, +}; + +const initialModel: Model = { foo: "initial", bar: null, foobar: [] }; + +describe("getUpdateFn", () => { + describe("getUpdateFn", () => { + it("should update the model correctly with simple type update", () => { + const updateFn = getUpdateFn(updateMap); + + const [updatedModel] = updateFn({ name: "foo" }, { ...initialModel }, {}); + + expect(updatedModel).toStrictEqual({ foo: "bar" }); + }); + + it("should update the model correctly with complex type update replacing null", () => { + const updateFn = getUpdateFn(updateMap); + + const [updatedModel] = updateFn({ name: "bar" }, { ...initialModel }, {}); + + expect(updatedModel).toStrictEqual({ bar: { foo: "bar", bar: 1 } }); + }); + + it("should update the model correctly with complex type update replacing an existing object", () => { + const localInitialModel: Model = { ...initialModel, bar: { foo: "", bar: 1 } }; + const updateFn = getUpdateFn(updateMap); + + const [updatedModel] = updateFn({ name: "bar" }, localInitialModel, {}); + + expect(updatedModel).toStrictEqual({ bar: { foo: "bar", bar: 1 } }); + }); + + it("should update the model correctly with an update of an array", () => { + const updateFn = getUpdateFn(updateMap); + + const [updatedModel] = updateFn({ name: "foobar" }, { ...initialModel }, {}); + + expect(updatedModel).toStrictEqual({ foobar: ["bar"] }); + }); + }); +}); diff --git a/src/immutable/testing/getUpdateFn.ts b/src/immutable/testing/getUpdateFn.ts new file mode 100644 index 0000000..8716ad3 --- /dev/null +++ b/src/immutable/testing/getUpdateFn.ts @@ -0,0 +1,145 @@ +import { enablePatches, produce, type Draft, type Immutable, type Patch } from "immer"; +import { execCmd } from "../../testing"; +import type { Cmd, Message, Nullable } from "../../Types"; +import { createCallBase } from "../createCallBase"; +import { createDefer } from "../createDefer"; +import type { UpdateFunctionOptions, UpdateMap } from "../Types"; +import { callUpdateMap } from "../useElmish"; + +enablePatches(); + +/** + * Creates an update function out of an UpdateMap. + * @param updateMap The UpdateMap. + * @returns The created update function which can be used in tests. + * @example + * const updateFn = getUpdateFn(update); + * + * // in your tests: + * const [model, cmd] = updateFn(...args); + */ +function getUpdateFn( + updateMap: UpdateMap, +): ( + msg: TMessage, + model: Immutable, + props: TProps, + optionsTemplate?: Partial>, +) => [Partial, ...(Cmd | undefined)[]] { + return function updateFn(msg, model, props, optionsTemplate): [Partial, ...(Cmd | undefined)[]] { + const [defer, getDeferred] = createDefer(); + const callBase = createCallBase(msg, model, props, { defer }); + + const options: UpdateFunctionOptions = { + defer, + callBase, + ...optionsTemplate, + }; + + const commands: (Cmd | undefined)[] = []; + const recordedPatches: Patch[] = []; + const updatedModel = produce( + model, + (draft: Draft) => { + commands.push(...callUpdateMap(updateMap, msg, draft, props, options)); + }, + (patches) => { + recordedPatches.push(...patches); + }, + ); + const deferredCommands = getDeferred(); + + const diff = getDiffFromPatches(recordedPatches, updatedModel); + + return [diff, ...commands, ...deferredCommands]; + }; +} + +/** + * Creates an update function out of an UpdateMap which immediately executes the command. + * @param updateMap The UpdateMap. + * @returns The created update function which can be used in tests. + * @example + * const updateAndExecCmd = getUpdateAndExecCmdFn(update); + * + * // in your test: + * const [model, messages] = await updateAndExecCmd(...args); + */ +function getUpdateAndExecCmdFn( + updateMap: UpdateMap, +): ( + msg: TMessage, + model: Immutable, + props: TProps, + optionsTemplate?: Partial>, +) => Promise<[Partial, Nullable[]]> { + return async function updateAndExecCmdFn(msg, model, props, optionsTemplate): Promise<[Partial, Nullable[]]> { + const [defer, getDeferred] = createDefer(); + const callBase = createCallBase(msg, model, props, { defer }); + + const options: UpdateFunctionOptions = { + defer, + callBase, + ...optionsTemplate, + }; + + const commands: (Cmd | undefined)[] = []; + const recordedPatches: Patch[] = []; + const updatedModel = produce( + model, + (draft: Draft) => { + commands.push(...callUpdateMap(updateMap, msg, draft, props, options)); + }, + (patches) => { + recordedPatches.push(...patches); + }, + ); + const deferredCommands = getDeferred(); + + const diff = getDiffFromPatches(recordedPatches, updatedModel); + + const messages = await execCmd(...commands, ...deferredCommands); + + return [diff, messages]; + }; +} + +function getDiffFromPatches(patches: Patch[], model: Immutable): Partial { + const diff: Partial = {}; + + /* eslint-disable @typescript-eslint/no-unsafe-assignment */ + /* eslint-disable @typescript-eslint/ban-ts-comment */ + /* eslint-disable @typescript-eslint/no-dynamic-delete */ + for (const patch of patches) { + // biome-ignore lint/style/noNonNullAssertion: The path is always defined + const path = patch.path[0]!; + + switch (patch.op) { + case "replace": { + // @ts-expect-error + diff[path] = model[path]; + + break; + } + case "add": { + // @ts-expect-error + diff[path] = model[path]; + + break; + } + case "remove": { + // @ts-expect-error + delete diff[path]; + + break; + } + } + } + /* eslint-enable @typescript-eslint/no-dynamic-delete */ + /* eslint-enable @typescript-eslint/ban-ts-comment */ + /* eslint-enable @typescript-eslint/no-unsafe-assignment */ + + return diff; +} + +export { getUpdateAndExecCmdFn, getUpdateFn }; diff --git a/src/immutable/testing/index.ts b/src/immutable/testing/index.ts new file mode 100644 index 0000000..62c0a5c --- /dev/null +++ b/src/immutable/testing/index.ts @@ -0,0 +1,7 @@ +export type { RenderWithModelOptions } from "../../fakeOptions"; +export { execCmd, initAndExecCmd, renderWithModel } from "../../testing"; +export { createModelAndProps } from "./createModelAndProps"; +export { createUpdateArgsFactory, type UpdateArgsFactory } from "./createUpdateArgsFactory"; +export { execSubscription } from "./execSubscription"; +export { getCreateModelAndProps, getCreateUpdateArgs, type ModelAndPropsFactory } from "./getCreateUpdateArgs"; +export { getUpdateAndExecCmdFn, getUpdateFn } from "./getUpdateFn"; diff --git a/src/immutable/testing/playground.spec.tsx b/src/immutable/testing/playground.spec.tsx new file mode 100644 index 0000000..c0d6039 --- /dev/null +++ b/src/immutable/testing/playground.spec.tsx @@ -0,0 +1,107 @@ +import { render, type RenderResult } from "@testing-library/react"; +import type { JSX } from "react"; +import { cmd } from "../../cmd"; +import { errorMsg, type ErrorMessage } from "../../ErrorHandling"; +import { renderWithModel } from "../../testing"; +import type { InitResult } from "../../Types"; +import { errorHandler } from "../ErrorHandling"; +import type { UpdateMap } from "../Types"; +import { useElmish } from "../useElmish"; +import { getCreateUpdateArgs } from "./getCreateUpdateArgs"; +import { getUpdateFn } from "./getUpdateFn"; + +interface Model { + value1: string; + value2: string; + subPage: string; +} + +interface Props {} + +type Message = { name: "doubleDefer" } | { name: "first" } | { name: "second" } | { name: "third" } | ErrorMessage; + +const Msg = { + doubleDefer: (): Message => ({ name: "doubleDefer" }), + first: (): Message => ({ name: "first" }), + second: (): Message => ({ name: "second" }), + third: (): Message => ({ name: "third" }), + ...errorMsg, +}; + +function init(): InitResult { + return [ + { + value1: "", + value2: "", + subPage: "", + }, + ]; +} + +const update: UpdateMap = { + doubleDefer(_msg, model, _props, { defer }) { + defer(cmd.ofMsg(Msg.first())); + defer(cmd.ofMsg(Msg.second())); + + model.value1 = "computed"; + + return [cmd.ofMsg(Msg.third())]; + }, + + first() { + return []; + }, + + second() { + return []; + }, + + third() { + return []; + }, + + ...errorHandler(), +}; + +function Playground(props: Props): JSX.Element { + const [, dispatch] = useElmish({ name: "Playground", init, update, props }); + + return ( +
+ +
+ ); +} + +function renderPlayground(modelTemplate?: Partial): RenderResult { + const model = { + ...init()[0], + ...modelTemplate, + }; + + return renderWithModel(() => render(), model); +} + +const getUpdateArgs = getCreateUpdateArgs(init, () => ({})); +const updateFn = getUpdateFn(update); + +describe("Playground", () => { + it("renders without crashing", () => { + const { container } = renderPlayground(); + + expect(container).toBeTruthy(); + }); + + describe("doubleDefer", () => { + it("computes the values", async () => { + const args = getUpdateArgs(Msg.doubleDefer()); + + const [model, ...commands] = updateFn(...args); + + expect(model).toStrictEqual({ value1: "computed" }); + expect(commands).toHaveLength(3); + }); + }); +}); diff --git a/src/immutable/useElmish.spec.tsx b/src/immutable/useElmish.spec.tsx new file mode 100644 index 0000000..8a1d593 --- /dev/null +++ b/src/immutable/useElmish.spec.tsx @@ -0,0 +1,218 @@ +import { render, type RenderResult } from "@testing-library/react"; +import type { JSX } from "react"; +import type { Cmd, InitResult, SubscriptionResult } from "../Types"; +import { cmd } from "../cmd"; +import type { UpdateFunctionOptions, UpdateReturnType } from "./Types"; +import { useElmish } from "./useElmish"; + +type Message = { name: "Test" } | { name: "First" } | { name: "Second" } | { name: "Third" } | { name: "Defer" }; + +interface Model { + value1: string; + value2: string; +} + +interface Props { + init: () => InitResult; + update: ( + model: Model, + msg: Message, + props: Props, + options: UpdateFunctionOptions, + ) => UpdateReturnType; + subscription?: (model: Model) => SubscriptionResult; +} + +function defaultInit(msg?: Cmd): InitResult { + return [ + { + value1: "", + value2: "", + }, + msg, + ]; +} + +function defaultUpdate( + model: Model, + msg: Message, + _props: Props, + { defer }: UpdateFunctionOptions, +): UpdateReturnType { + switch (msg.name) { + case "Test": + return []; + + case "First": + model.value1 = "First"; + + return [cmd.ofMsg({ name: "Second" })]; + + case "Second": + model.value2 = "Second"; + + return []; + + case "Third": + model.value2 = "Third"; + + return []; + + case "Defer": { + model.value2 = "Defer"; + + defer(cmd.ofMsg({ name: "Third" })); + + model.value1 = "Defer"; + + return [cmd.ofMsg({ name: "Second" })]; + } + } +} + +let componentModel: Model | undefined; + +describe("useElmish", () => { + it("calls the init function", () => { + // arrange + const init = jest.fn().mockReturnValue([{}, []]); + const update = jest.fn(); + const props: Props = { + init, + update, + }; + + // act + renderComponent(props); + + // assert + expect(init).toHaveBeenCalledWith(props); + }); + + it("calls the initial command", () => { + // arrange + const message: Message = { name: "Test" }; + const init = jest.fn().mockReturnValue([{}, cmd.ofMsg(message)]); + const update = jest.fn((): UpdateReturnType => []); + const props: Props = { + init, + update, + }; + + // act + renderComponent(props); + + // assert + expect(update).toHaveBeenCalledTimes(1); + }); + + it("updates the model correctly with multiple commands in a row", () => { + // arrange + const message: Message = { name: "First" }; + const props: Props = { + init: () => defaultInit(cmd.ofMsg(message)), + update: defaultUpdate, + }; + + // act + renderComponent(props); + + // assert + expect(componentModel).toStrictEqual({ value1: "First", value2: "Second" }); + }); + + it("updates the model correctly with a call to defer", () => { + // arrange + const message: Message = { name: "Defer" }; + const props: Props = { + init: () => defaultInit(cmd.ofMsg(message)), + update: defaultUpdate, + }; + + // act + renderComponent(props); + + // assert + expect(componentModel).toStrictEqual({ value1: "Defer", value2: "Third" }); + }); + + it("calls the subscription", () => { + // arrange + const mockSub = jest.fn(); + const mockSubscription = jest.fn().mockReturnValue([cmd.ofSub(mockSub)]); + const [initModel, initCmd] = defaultInit(); + const props: Props = { + init: () => [initModel, initCmd], + update: defaultUpdate, + subscription: mockSubscription, + }; + + // act + renderComponent(props); + + // assert + expect(mockSubscription).toHaveBeenCalledWith(initModel, props); + expect(mockSub).toHaveBeenCalledWith(expect.anything()); + }); + + it("calls the subscriptions destructor if provided", () => { + // arrange + const mockDestructor = jest.fn(); + const mockSubscription = jest.fn().mockReturnValue([[], mockDestructor]); + const [initModel, initCmd] = defaultInit(); + const props: Props = { + init: () => [initModel, initCmd], + update: defaultUpdate, + subscription: mockSubscription, + }; + + // act + const api = renderComponent(props); + + api.unmount(); + + // assert + expect(mockDestructor).toHaveBeenCalledWith(); + }); + + it("calls the subscription function and its destructor", () => { + // arrange + const mockDestructor = jest.fn(); + const mockSub = jest.fn().mockReturnValue(mockDestructor); + const mockSubscription = jest.fn().mockReturnValue([mockSub]); + const [initModel, initCmd] = defaultInit(); + const props: Props = { + init: () => [initModel, initCmd], + update: defaultUpdate, + subscription: mockSubscription, + }; + + // act + const api = renderComponent(props); + + api.unmount(); + + // assert + expect(mockSub).toHaveBeenCalledTimes(1); + expect(mockDestructor).toHaveBeenCalledWith(); + }); +}); + +function TestComponent(props: Props): JSX.Element { + const { init, update, subscription } = props; + const [model] = useElmish({ + props, + init, + update, + subscription, + name: "Test", + }); + + componentModel = model; + + return
; +} + +function renderComponent(props: Props): RenderResult { + return render(); +} diff --git a/src/immutable/useElmish.ts b/src/immutable/useElmish.ts new file mode 100644 index 0000000..970691e --- /dev/null +++ b/src/immutable/useElmish.ts @@ -0,0 +1,251 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { castImmutable, enablePatches, freeze, produce, type Draft, type Immutable } from "immer"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { execCmd, logMessage } from "../Common"; +import { getFakeOptionsOnce } from "../fakeOptions"; +import { Services } from "../Init"; +import { isReduxDevToolsEnabled, type ReduxDevTools } from "../reduxDevTools"; +import { subscriptionIsFunctionArray, type Cmd, type Dispatch, type InitFunction, type Message, type Nullable } from "../Types"; +import { createCallBase } from "./createCallBase"; +import { createDefer } from "./createDefer"; +import type { Subscription, UpdateFunction, UpdateFunctionOptions, UpdateMap, UpdateReturnType } from "./Types"; + +/** + * Options for the `useElmish` hook. + * @interface UseElmishOptions + * @template TProps The type of the props. + * @template TModel The type of the model. + * @template TMessage The type of the messages discriminated union. + */ +interface UseElmishOptions { + /** + * The name of the component. This is used for logging only. + * @type {string} + */ + name: string; + /** + * The props passed to the component. + * @type {TProps} + */ + props: TProps; + /** + * The function to initialize the components model. This function is only called once. + * @type {InitFunction} + */ + init: InitFunction; + /** + * The `update` function or update map object. + * @type {(UpdateFunction | UpdateMap)} + */ + update: UpdateFunction | UpdateMap; + /** + * The optional `subscription` function. This function is only called once. + * @type {(UpdateFunction | UpdateMap)} + */ + subscription?: Subscription; +} + +/** + * Hook to use the Elm architecture pattern in a function component. + * @param {UseElmishOptions} options The options passed the the hook. + * @returns A tuple containing the current model and the dispatcher. + * @example + * const [model, dispatch] = useElmish({ props, init, update, name: "MyComponent" }); + */ +function useElmish({ + name, + props, + init, + update, + subscription, +}: UseElmishOptions): [Immutable, Dispatch] { + let running = false; + const buffer: TMessage[] = []; + + const [model, setModel] = useState>>(null); + const propsRef = useRef(props); + const isMountedRef = useRef(true); + + const devTools = useRef(null); + + useEffect(() => { + let reduxUnsubscribe: (() => void) | undefined; + + if (Services.enableDevTools === true && isReduxDevToolsEnabled(window)) { + // eslint-disable-next-line no-underscore-dangle + devTools.current = window.__REDUX_DEVTOOLS_EXTENSION__.connect({ name, serialize: { options: true } }); + + reduxUnsubscribe = devTools.current.subscribe((message) => { + if (message.type === "DISPATCH" && message.payload.type === "JUMP_TO_ACTION") { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + setModel(JSON.parse(message.state) as Immutable); + } + }); + } + + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + + reduxUnsubscribe?.(); + }; + }, [name]); + + let currentModel = model; + + if (propsRef.current !== props) { + propsRef.current = props; + } + + const fakeOptions = getFakeOptionsOnce(); + const dispatch = useCallback( + fakeOptions?.dispatch ?? + ((msg: TMessage): void => { + if (running) { + buffer.push(msg); + + return; + } + + running = true; + + let nextMsg: TMessage | undefined = msg; + let modified = false; + + do { + if (handleMessage(nextMsg)) { + modified = true; + } + + if (devTools.current) { + devTools.current.send(nextMsg.name, currentModel); + } + + nextMsg = buffer.shift(); + } while (nextMsg); + + running = false; + + if (isMountedRef.current && modified) { + setModel(() => { + Services.logger?.debug("Update model for", name, currentModel); + + return currentModel; + }); + } + }), + [], + ); + + let initializedModel = model; + + if (!initializedModel) { + enablePatches(); + + const [initModel, ...initCommands] = fakeOptions?.model ? [fakeOptions.model] : init(props); + + initializedModel = castImmutable(freeze(initModel, true)); + currentModel = initializedModel; + setModel(initializedModel); + + devTools.current?.init(initializedModel); + + Services.logger?.debug("Initial model for", name, initializedModel); + + execCmd(dispatch, ...initCommands); + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: We want to run this effect only once + useEffect(() => { + if (subscription) { + const subscriptionResult = subscription(initializedModel, props); + + if (subscriptionIsFunctionArray(subscriptionResult)) { + const destructors = subscriptionResult.map((sub) => sub(dispatch)).filter((destructor) => destructor !== undefined); + + return function combinedDestructor() { + for (const destructor of destructors) { + destructor(); + } + }; + } + + const [subCmd, destructor] = subscriptionResult; + + execCmd(dispatch, subCmd); + + return destructor; + } + }, []); + + return [initializedModel, dispatch]; + + function handleMessage(nextMsg: TMessage): boolean { + if (!currentModel) { + return false; + } + + logMessage(name, nextMsg); + + const [defer, getDeferred] = createDefer(); + const callBase = createCallBase(nextMsg, currentModel, propsRef.current, { defer }); + + const [modified, updatedModel, ...commands] = callUpdate(update, nextMsg, currentModel, propsRef.current, { + defer, + callBase, + }); + + const deferredCommands = getDeferred(); + + currentModel = updatedModel; + + execCmd(dispatch, ...commands, ...deferredCommands); + + return modified; + } +} + +function callUpdate( + update: UpdateFunction | UpdateMap, + msg: TMessage, + model: Immutable, + props: TProps, + options: UpdateFunctionOptions, +): [boolean, Immutable, ...(Cmd | undefined)[]] { + const commands: (Cmd | undefined)[] = []; + let modified = false; + const updatedModel = produce( + model, + (draft: Draft) => { + if (typeof update === "function") { + commands.push(...update(draft, msg, props, options)); + + return; + } + + commands.push(...callUpdateMap(update, msg, draft, props, options)); + }, + (patches) => { + modified = patches.length > 0; + }, + ); + + return [modified, updatedModel, ...commands]; +} + +function callUpdateMap( + updateMap: UpdateMap, + msg: TMessage, + model: Draft, + props: TProps, + options: UpdateFunctionOptions, +): UpdateReturnType { + const msgName: TMessage["name"] = msg.name; + + return updateMap[msgName](msg, model, props, options); +} + +export type { UseElmishOptions }; + +export { callUpdateMap, useElmish }; diff --git a/src/index.ts b/src/index.ts index f04d2bf..abe8b97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,5 +3,16 @@ export { ElmComponent } from "./ElmComponent"; export { errorHandler, errorMsg, handleError, type ErrorMessage } from "./ErrorHandling"; export { init, type ElmOptions, type Logger } from "./Init"; export { mergeSubscriptions } from "./mergeSubscriptions"; -export type { Cmd, Dispatch, InitResult, Message, MsgSource, UpdateFunctionOptions, UpdateMap, UpdateReturnType } from "./Types"; -export { useElmish, type Subscription, type SubscriptionResult, type UseElmishOptions } from "./useElmish"; +export type { + Cmd, + Dispatch, + InitResult, + Message, + MsgSource, + Subscription, + SubscriptionResult, + UpdateFunctionOptions, + UpdateMap, + UpdateReturnType, +} from "./Types"; +export { useElmish, type UseElmishOptions } from "./useElmish"; diff --git a/src/mergeSubscriptions.spec.ts b/src/mergeSubscriptions.spec.ts index 4eef394..b97312a 100644 --- a/src/mergeSubscriptions.spec.ts +++ b/src/mergeSubscriptions.spec.ts @@ -1,7 +1,6 @@ import { cmd } from "./cmd"; import { mergeSubscriptions } from "./mergeSubscriptions"; -import type { Message } from "./Types"; -import type { SubscriptionResult } from "./useElmish"; +import type { Message, SubscriptionResult } from "./Types"; describe("mergeSubscriptions", () => { const model = {}; diff --git a/src/mergeSubscriptions.ts b/src/mergeSubscriptions.ts index df2f1e7..5f2028d 100644 --- a/src/mergeSubscriptions.ts +++ b/src/mergeSubscriptions.ts @@ -1,5 +1,11 @@ -import type { Dispatch, Message, Sub } from "./Types"; -import { subscriptionIsFunctionArray, type Subscription, type SubscriptionFunction } from "./useElmish"; +import { + subscriptionIsFunctionArray, + type Dispatch, + type Message, + type Sub, + type Subscription, + type SubscriptionFunction, +} from "./Types"; type MergedSubscription = (model: TModel, props: TProps) => SubscriptionFunction[]; diff --git a/src/Testing/createModelAndProps.ts b/src/testing/createModelAndProps.ts similarity index 100% rename from src/Testing/createModelAndProps.ts rename to src/testing/createModelAndProps.ts diff --git a/src/Testing/createUpdateArgsFactory.ts b/src/testing/createUpdateArgsFactory.ts similarity index 89% rename from src/Testing/createUpdateArgsFactory.ts rename to src/testing/createUpdateArgsFactory.ts index ad9fb72..a9fd7b2 100644 --- a/src/Testing/createUpdateArgsFactory.ts +++ b/src/testing/createUpdateArgsFactory.ts @@ -7,11 +7,6 @@ type UpdateArgsFactory = ( optionsTemplate?: Partial>, ) => [TMessage, TModel, TProps, Partial>?]; -type ModelAndPropsFactory = ( - modelTemplate?: Partial, - propsTemplate?: Partial, -) => [TModel, TProps]; - /** * Creates a factory function to create a message, a model, props, and options which can be passed to an update function in tests. * @param {() => TModel} initModel A function to create an initial model. @@ -46,6 +41,6 @@ function createUpdateArgsFactory( }; } -export type { ModelAndPropsFactory, UpdateArgsFactory }; +export type { UpdateArgsFactory }; export { createUpdateArgsFactory }; diff --git a/src/Testing/execCmd.spec.ts b/src/testing/execCmd.spec.ts similarity index 100% rename from src/Testing/execCmd.spec.ts rename to src/testing/execCmd.spec.ts diff --git a/src/Testing/execCmd.ts b/src/testing/execCmd.ts similarity index 100% rename from src/Testing/execCmd.ts rename to src/testing/execCmd.ts diff --git a/src/Testing/execSubscription.ts b/src/testing/execSubscription.ts similarity index 87% rename from src/Testing/execSubscription.ts rename to src/testing/execSubscription.ts index 2fd19ca..3639ac8 100644 --- a/src/Testing/execSubscription.ts +++ b/src/testing/execSubscription.ts @@ -1,6 +1,5 @@ import type { Dispatch } from "react"; -import type { Message } from "../Types"; -import { subscriptionIsFunctionArray, type Subscription } from "../useElmish"; +import { subscriptionIsFunctionArray, type Message, type Subscription } from "../Types"; import { execCmdWithDispatch } from "./execCmd"; function execSubscription( diff --git a/src/Testing/getCreateUpdateArgs.ts b/src/testing/getCreateUpdateArgs.ts similarity index 90% rename from src/Testing/getCreateUpdateArgs.ts rename to src/testing/getCreateUpdateArgs.ts index 411c037..5e4639c 100644 --- a/src/Testing/getCreateUpdateArgs.ts +++ b/src/testing/getCreateUpdateArgs.ts @@ -1,6 +1,6 @@ import type { InitFunction, Message, UpdateFunctionOptions } from "../Types"; import { createModelAndProps } from "./createModelAndProps"; -import type { ModelAndPropsFactory, UpdateArgsFactory } from "./createUpdateArgsFactory"; +import type { UpdateArgsFactory } from "./createUpdateArgsFactory"; /** * Creates a factory function to create a message, a model, props, and options which can be passed to an update function in tests. @@ -29,6 +29,11 @@ function getCreateUpdateArgs( }; } +type ModelAndPropsFactory = ( + modelTemplate?: Partial, + propsTemplate?: Partial, +) => [TModel, TProps]; + /** * Creates a factory function to create a model, props, and options which can be passed to an update or subscription function in tests. * @param {InitFunction} init The init function which creates the model. @@ -49,4 +54,6 @@ function getCreateModelAndProps( }; } +export type { ModelAndPropsFactory }; + export { getCreateModelAndProps, getCreateUpdateArgs }; diff --git a/src/Testing/getUpdateFn.ts b/src/testing/getUpdateFn.ts similarity index 100% rename from src/Testing/getUpdateFn.ts rename to src/testing/getUpdateFn.ts diff --git a/src/Testing/index.ts b/src/testing/index.ts similarity index 57% rename from src/Testing/index.ts rename to src/testing/index.ts index a49aa20..1ecd9be 100644 --- a/src/Testing/index.ts +++ b/src/testing/index.ts @@ -1,9 +1,9 @@ export type { RenderWithModelOptions } from "../fakeOptions"; export { createModelAndProps } from "./createModelAndProps"; -export { createUpdateArgsFactory, type ModelAndPropsFactory, type UpdateArgsFactory } from "./createUpdateArgsFactory"; -export { execCmd } from "./execCmd"; +export { createUpdateArgsFactory, type UpdateArgsFactory } from "./createUpdateArgsFactory"; +export { execCmd, execCmdWithDispatch } from "./execCmd"; export { execSubscription } from "./execSubscription"; -export { getCreateModelAndProps, getCreateUpdateArgs } from "./getCreateUpdateArgs"; +export { getCreateModelAndProps, getCreateUpdateArgs, type ModelAndPropsFactory } from "./getCreateUpdateArgs"; export { getUpdateAndExecCmdFn, getUpdateFn } from "./getUpdateFn"; export { initAndExecCmd } from "./initAndExecCmd"; export { renderWithModel } from "./renderWithModel"; diff --git a/src/Testing/initAndExecCmd.ts b/src/testing/initAndExecCmd.ts similarity index 100% rename from src/Testing/initAndExecCmd.ts rename to src/testing/initAndExecCmd.ts diff --git a/src/Testing/playground.spec.tsx b/src/testing/playground.spec.tsx similarity index 74% rename from src/Testing/playground.spec.tsx rename to src/testing/playground.spec.tsx index 2f83a5f..adad13c 100644 --- a/src/Testing/playground.spec.tsx +++ b/src/testing/playground.spec.tsx @@ -1,5 +1,6 @@ import { render, type RenderResult } from "@testing-library/react"; import type { JSX } from "react"; +import { cmd } from "../cmd"; import { errorHandler, errorMsg, type ErrorMessage } from "../ErrorHandling"; import type { InitResult, UpdateMap } from "../Types"; import { useElmish } from "../useElmish"; @@ -15,10 +16,13 @@ interface Model { interface Props {} -type Message = { name: "doubleDefer" } | ErrorMessage; +type Message = { name: "doubleDefer" } | { name: "first" } | { name: "second" } | { name: "third" } | ErrorMessage; const Msg = { doubleDefer: (): Message => ({ name: "doubleDefer" }), + first: (): Message => ({ name: "first" }), + second: (): Message => ({ name: "second" }), + third: (): Message => ({ name: "third" }), ...errorMsg, }; @@ -34,10 +38,22 @@ function init(): InitResult { const update: UpdateMap = { doubleDefer(_msg, _model, _props, { defer }) { - defer({ value2: "deferred" }); - defer({ subPage: "subPage" }); + defer({ value2: "deferred" }, cmd.ofMsg(Msg.first())); + defer({ subPage: "subPage" }, cmd.ofMsg(Msg.second())); - return [{ value1: "computed" }]; + return [{ value1: "computed" }, cmd.ofMsg(Msg.third())]; + }, + + first() { + return [{}]; + }, + + second() { + return [{}]; + }, + + third() { + return [{}]; }, ...errorHandler(), @@ -78,9 +94,10 @@ describe("Playground", () => { it("computes the values", async () => { const args = getUpdateArgs(Msg.doubleDefer()); - const [model] = updateFn(...args); + const [model, ...commands] = updateFn(...args); expect(model).toStrictEqual({ value1: "computed", value2: "deferred", subPage: "subPage" }); + expect(commands).toHaveLength(3); }); }); }); diff --git a/src/Testing/renderWithModel.spec.tsx b/src/testing/renderWithModel.spec.tsx similarity index 100% rename from src/Testing/renderWithModel.spec.tsx rename to src/testing/renderWithModel.spec.tsx diff --git a/src/Testing/renderWithModel.ts b/src/testing/renderWithModel.ts similarity index 100% rename from src/Testing/renderWithModel.ts rename to src/testing/renderWithModel.ts diff --git a/src/useElmish.ts b/src/useElmish.ts index 1d83377..28843ac 100644 --- a/src/useElmish.ts +++ b/src/useElmish.ts @@ -2,34 +2,23 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { execCmd, logMessage, modelHasChanged } from "./Common"; import { Services } from "./Init"; -import type { - Cmd, - Dispatch, - InitFunction, - Message, - Nullable, - UpdateFunction, - UpdateFunctionOptions, - UpdateMap, - UpdateReturnType, +import { + subscriptionIsFunctionArray, + type Dispatch, + type InitFunction, + type Message, + type Nullable, + type Subscription, + type UpdateFunction, + type UpdateFunctionOptions, + type UpdateMap, + type UpdateReturnType, } from "./Types"; import { createCallBase } from "./createCallBase"; import { createDefer } from "./createDefer"; import { getFakeOptionsOnce } from "./fakeOptions"; import { isReduxDevToolsEnabled, type ReduxDevTools } from "./reduxDevTools"; -/** - * The return type of the `subscription` function. - * @template TMessage The type of the messages discriminated union. - */ -type SubscriptionResult = [Cmd, (() => void)?] | SubscriptionFunction[]; -type SubscriptionFunction = (dispatch: Dispatch) => (() => void) | undefined; -type Subscription = (model: TModel, props: TProps) => SubscriptionResult; - -function subscriptionIsFunctionArray(subscription: SubscriptionResult): subscription is SubscriptionFunction[] { - return typeof subscription[0] === "function"; -} - /** * Options for the `useElmish` hook. * @interface UseElmishOptions @@ -256,6 +245,6 @@ function callUpdateMap( return updateMap[msgName](msg, model, props, options); } -export type { Subscription, SubscriptionFunction, SubscriptionResult, UseElmishOptions }; +export type { UseElmishOptions }; -export { callUpdate, callUpdateMap, subscriptionIsFunctionArray, useElmish }; +export { callUpdate, callUpdateMap, useElmish };