diff --git a/README.md b/README.md index 86847d0..51bf40d 100644 --- a/README.md +++ b/README.md @@ -415,15 +415,17 @@ Then we write our `subscription` function: ```ts function subscription (model: Model): SubscriptionResult { - const sub = (dispatch: Dispatch): void => { - setInterval(() => dispatch(Msg.timer(new Date())), 1000) as unknown as number; + const sub = (dispatch: Dispatch) => { + setInterval(() => dispatch(Msg.timer(new Date())), 1000); } - return [cmd.ofSub(sub)]; + return [sub]; } ``` -This function gets the initialized model as parameter and returns a command. +This function gets the initialized model as parameter and returns a function that gets the `dispatch` function as parameter. This function is called when the component is mounted. + +Because the return type of the `subscription` function is an array, you can define and return multiple functions. In the function component we call `useElmish` and pass the subscription to it: @@ -431,31 +433,27 @@ In the function component we call `useElmish` and pass the subscription to it: const [{ date }] = useElmish({ name: "Subscriptions", props, init, update, subscription }) ``` -You can define and aggregate multiple subscriptions with a call to `cmd.batch(...)`. - ### Cleanup subscriptions -In the solution above `setInterval` will trigger events even if the component is removed from the DOM. To cleanup subscriptions, we can return a `destructor` function from the subscription the same as in the `useEffect` hook. +In the solution above `setInterval` will trigger events even if the component is removed from the DOM. To cleanup subscriptions, we can return a `destructor` function the same way as in the `useEffect` hook. Let's rewrite our `subscription` function: ```ts function subscription (model: Model): SubscriptionResult { - let timer: NodeJS.Timer; + const sub = (dispatch: Dispatch) => { + const timer = setInterval(() => dispatch(Msg.timer(new Date())), 1000); - const sub = (dispatch: Dispatch): void => { - timer = setInterval(() => dispatch(Msg.timer(new Date())), 1000); - } - - const destructor = () => { - clearInterval(timer); + return () => { + clearInterval(timer); + } } - return [cmd.ofSub(sub), destructor]; + return [sub]; } ``` -Here we save the return value of `setInterval` and clear that interval in the returned `destructor` function. +The destructor is called when the component is removed from the DOM. ## Setup diff --git a/src/Testing/execSubscription.ts b/src/Testing/execSubscription.ts index ac7b202..2fd19ca 100644 --- a/src/Testing/execSubscription.ts +++ b/src/Testing/execSubscription.ts @@ -1,6 +1,6 @@ import type { Dispatch } from "react"; import type { Message } from "../Types"; -import type { Subscription } from "../useElmish"; +import { subscriptionIsFunctionArray, type Subscription } from "../useElmish"; import { execCmdWithDispatch } from "./execCmd"; function execSubscription( @@ -17,7 +17,19 @@ function execSubscription( return noop; } - const [cmd, dispose] = subscription(model, props); + 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); diff --git a/src/mergeSubscriptions.spec.ts b/src/mergeSubscriptions.spec.ts index d1e0fd3..4eef394 100644 --- a/src/mergeSubscriptions.spec.ts +++ b/src/mergeSubscriptions.spec.ts @@ -1,5 +1,4 @@ import { cmd } from "./cmd"; -import { execCmd } from "./Common"; import { mergeSubscriptions } from "./mergeSubscriptions"; import type { Message } from "./Types"; import type { SubscriptionResult } from "./useElmish"; @@ -47,10 +46,12 @@ describe("mergeSubscriptions", () => { const sub2 = (): SubscriptionResult => [cmd.ofSub(sub2Fn)]; const subscription = mergeSubscriptions(sub1, sub2); - const [command] = subscription(model, props); + const commands = subscription(model, props); // act - execCmd(mockDispatch, command); + for (const command of commands) { + command(mockDispatch); + } // assert expect(sub1Fn).toHaveBeenCalledWith(mockDispatch); @@ -59,19 +60,49 @@ describe("mergeSubscriptions", () => { it("executes all disposer functions", () => { // arrange + const mockDispatch = jest.fn(); + const dispose1 = jest.fn(); const sub1 = (): SubscriptionResult => [cmd.ofSub(jest.fn()), dispose1]; const dispose2 = jest.fn(); const sub2 = (): SubscriptionResult => [cmd.ofSub(jest.fn()), dispose2]; const subscription = mergeSubscriptions(sub1, sub2); - const [, dispose] = subscription(model, props); + const commands = subscription(model, props); // act - dispose?.(); + for (const command of commands) { + command(mockDispatch)?.(); + } // assert expect(dispose1).toHaveBeenCalledTimes(1); expect(dispose2).toHaveBeenCalledTimes(1); }); + + it("works with subscription functions", () => { + // arrange + const mockDispatch = jest.fn(); + + const sub1Fn = jest.fn(); + const sub1Dispose = jest.fn(); + const sub1 = (): SubscriptionResult => [cmd.ofSub(sub1Fn), sub1Dispose]; + const sub2Dispose = jest.fn(); + const sub2Fn = jest.fn().mockReturnValue(sub2Dispose); + const sub2 = (): SubscriptionResult => [sub2Fn]; + + const subscription = mergeSubscriptions(sub1, sub2); + const commands = subscription(model, props); + + // act + for (const command of commands) { + command(mockDispatch)?.(); + } + + // assert + expect(sub1Fn).toHaveBeenCalledTimes(1); + expect(sub2Fn).toHaveBeenCalledTimes(1); + expect(sub1Dispose).toHaveBeenCalledTimes(1); + expect(sub2Dispose).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/mergeSubscriptions.ts b/src/mergeSubscriptions.ts index 1384861..df2f1e7 100644 --- a/src/mergeSubscriptions.ts +++ b/src/mergeSubscriptions.ts @@ -1,24 +1,31 @@ -import { cmd } from "./cmd"; -import type { Message } from "./Types"; -import type { Subscription } from "./useElmish"; +import type { Dispatch, Message, Sub } from "./Types"; +import { subscriptionIsFunctionArray, type Subscription, type SubscriptionFunction } from "./useElmish"; + +type MergedSubscription = (model: TModel, props: TProps) => SubscriptionFunction[]; function mergeSubscriptions( ...subscriptions: (Subscription | undefined)[] -): Subscription { +): MergedSubscription { return function mergedSubscription(model, props) { - const results = subscriptions.map((sub) => sub?.(model, props)); - - const commands = results.map((sub) => sub?.[0]); - const disposers = results.map((sub) => sub?.[1]); - - return [ - cmd.batch(...commands), - () => { - for (const disposer of disposers) { - disposer?.(); - } - }, - ]; + const results = subscriptions.map((sub) => sub?.(model, props)).filter((subscription) => subscription !== undefined); + + const subscriptionFunctions = results.flatMap((result) => { + if (subscriptionIsFunctionArray(result)) { + return result; + } + + const [subCmd, dispose] = result; + + return [...subCmd.map(mapToFn), () => () => dispose?.()]; + }); + + return subscriptionFunctions; + }; +} + +function mapToFn(cmd: Sub): (dispatch: Dispatch) => undefined { + return (dispatch) => { + cmd(dispatch); }; } diff --git a/src/useElmish.spec.tsx b/src/useElmish.spec.tsx index 2de0e9e..013d701 100644 --- a/src/useElmish.spec.tsx +++ b/src/useElmish.spec.tsx @@ -162,6 +162,28 @@ describe("useElmish", () => { // 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 { diff --git a/src/useElmish.ts b/src/useElmish.ts index fc5e353..1d83377 100644 --- a/src/useElmish.ts +++ b/src/useElmish.ts @@ -22,9 +22,14 @@ 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)?]; +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 @@ -173,7 +178,19 @@ function useElmish({ // biome-ignore lint/correctness/useExhaustiveDependencies: We want to run this effect only once useEffect(() => { if (subscription) { - const [subCmd, destructor] = subscription(initializedModel, props); + 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); @@ -239,6 +256,6 @@ function callUpdateMap( return updateMap[msgName](msg, model, props, options); } -export type { Subscription, SubscriptionResult, UseElmishOptions }; +export type { Subscription, SubscriptionFunction, SubscriptionResult, UseElmishOptions }; -export { callUpdate, callUpdateMap, useElmish }; +export { callUpdate, callUpdateMap, subscriptionIsFunctionArray, useElmish };