Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 14 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,47 +415,45 @@ Then we write our `subscription` function:

```ts
function subscription (model: Model): SubscriptionResult<Message> {
const sub = (dispatch: Dispatch<Message>): void => {
setInterval(() => dispatch(Msg.timer(new Date())), 1000) as unknown as number;
const sub = (dispatch: Dispatch<Message>) => {
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:

```ts
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<Message> {
let timer: NodeJS.Timer;
const sub = (dispatch: Dispatch<Message>) => {
const timer = setInterval(() => dispatch(Msg.timer(new Date())), 1000);

const sub = (dispatch: Dispatch<Message>): 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

Expand Down
16 changes: 14 additions & 2 deletions src/Testing/execSubscription.ts
Original file line number Diff line number Diff line change
@@ -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<TProps, TModel, TMessage extends Message>(
Expand All @@ -17,7 +17,19 @@ function execSubscription<TProps, TModel, TMessage extends Message>(
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<TMessage>(dispatch, cmd);

Expand Down
41 changes: 36 additions & 5 deletions src/mergeSubscriptions.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,10 +46,12 @@ describe("mergeSubscriptions", () => {
const sub2 = (): SubscriptionResult<Message> => [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);
Expand All @@ -59,19 +60,49 @@ describe("mergeSubscriptions", () => {

it("executes all disposer functions", () => {
// arrange
const mockDispatch = jest.fn();

const dispose1 = jest.fn();
const sub1 = (): SubscriptionResult<Message> => [cmd.ofSub(jest.fn()), dispose1];
const dispose2 = jest.fn();
const sub2 = (): SubscriptionResult<Message> => [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<Message> => [cmd.ofSub(sub1Fn), sub1Dispose];
const sub2Dispose = jest.fn();
const sub2Fn = jest.fn().mockReturnValue(sub2Dispose);
const sub2 = (): SubscriptionResult<Message> => [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);
});
});
41 changes: 24 additions & 17 deletions src/mergeSubscriptions.ts
Original file line number Diff line number Diff line change
@@ -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<TProps, TModel, TMessage> = (model: TModel, props: TProps) => SubscriptionFunction<TMessage>[];

function mergeSubscriptions<TProps, TModel, TMessage extends Message>(
...subscriptions: (Subscription<TProps, TModel, TMessage> | undefined)[]
): Subscription<TProps, TModel, TMessage> {
): MergedSubscription<TProps, TModel, TMessage> {
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<TMessage>(cmd: Sub<TMessage>): (dispatch: Dispatch<TMessage>) => undefined {
return (dispatch) => {
cmd(dispatch);
};
}

Expand Down
22 changes: 22 additions & 0 deletions src/useElmish.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 21 additions & 4 deletions src/useElmish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TMessage> = [Cmd<TMessage>, (() => void)?];
type SubscriptionResult<TMessage> = [Cmd<TMessage>, (() => void)?] | SubscriptionFunction<TMessage>[];
type SubscriptionFunction<TMessage> = (dispatch: Dispatch<TMessage>) => (() => void) | undefined;
type Subscription<TProps, TModel, TMessage> = (model: TModel, props: TProps) => SubscriptionResult<TMessage>;

function subscriptionIsFunctionArray(subscription: SubscriptionResult<unknown>): subscription is SubscriptionFunction<unknown>[] {
return typeof subscription[0] === "function";
}

/**
* Options for the `useElmish` hook.
* @interface UseElmishOptions
Expand Down Expand Up @@ -173,7 +178,19 @@ function useElmish<TProps, TModel, TMessage extends Message>({
// 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);

Expand Down Expand Up @@ -239,6 +256,6 @@ function callUpdateMap<TProps, TModel, TMessage extends Message>(
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 };