Skip to content

Commit 546bd50

Browse files
committed
refactor: extract equal parts of useElmish and immutable useElmish
1 parent 44c27de commit 546bd50

File tree

4 files changed

+267
-245
lines changed

4 files changed

+267
-245
lines changed

src/Common.ts

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1+
import { type RefObject, useEffect, useRef } from "react";
12
import { Services } from "./Init";
2-
import type { Cmd, Dispatch, Message } from "./Types";
3+
import { isReduxDevToolsEnabled, type ReduxDevTools } from "./reduxDevTools";
4+
import {
5+
type Cmd,
6+
type Dispatch,
7+
type InitFunction,
8+
type Message,
9+
type Nullable,
10+
type Subscription,
11+
subscriptionIsFunctionArray,
12+
} from "./Types";
313

414
function logMessage(name: string, msg: Message): void {
515
Services.logger?.info("Message from", name, msg.name);
@@ -20,4 +30,156 @@ function execCmd<TMessage>(dispatch: Dispatch<TMessage>, ...commands: (Cmd<TMess
2030
}
2131
}
2232

23-
export { execCmd, logMessage, modelHasChanged };
33+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- We need to type Model.
34+
function useRedux<TModel>(name: string, setModel: (model: TModel) => void): RefObject<Nullable<ReduxDevTools>> {
35+
const devTools = useRef<Nullable<ReduxDevTools>>(null);
36+
37+
useEffect(() => {
38+
let reduxUnsubscribe: (() => void) | undefined;
39+
40+
if (Services.enableDevTools === true && isReduxDevToolsEnabled(window)) {
41+
// eslint-disable-next-line no-underscore-dangle
42+
devTools.current = window.__REDUX_DEVTOOLS_EXTENSION__.connect({ name, serialize: { options: true } });
43+
44+
reduxUnsubscribe = devTools.current.subscribe((message) => {
45+
if (message.type === "DISPATCH" && message.payload.type === "JUMP_TO_ACTION") {
46+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
47+
setModel(JSON.parse(message.state) as TModel);
48+
}
49+
});
50+
}
51+
52+
return () => {
53+
reduxUnsubscribe?.();
54+
};
55+
}, [name, setModel]);
56+
57+
return devTools;
58+
}
59+
60+
function useIsMounted(): RefObject<boolean> {
61+
const isMountedRef = useRef(true);
62+
63+
useEffect(() => {
64+
isMountedRef.current = true;
65+
66+
return () => {
67+
isMountedRef.current = false;
68+
};
69+
}, []);
70+
71+
return isMountedRef;
72+
}
73+
74+
function useSubscription<TProps, TModel, TMessage extends Message>(
75+
subscription: Subscription<TProps, TModel, TMessage> | undefined,
76+
model: TModel,
77+
props: TProps,
78+
dispatch: Dispatch<TMessage>,
79+
reInitOn: unknown[] | undefined,
80+
): void {
81+
useEffect(() => {
82+
if (subscription) {
83+
const subscriptionResult = subscription(model, props);
84+
85+
if (subscriptionIsFunctionArray(subscriptionResult)) {
86+
const destructors = subscriptionResult.map((sub) => sub(dispatch)).filter((destructor) => destructor !== undefined);
87+
88+
return function combinedDestructor() {
89+
for (const destructor of destructors) {
90+
destructor();
91+
}
92+
};
93+
}
94+
95+
const [subCmd, destructor] = subscriptionResult;
96+
97+
execCmd(dispatch, subCmd);
98+
99+
return destructor;
100+
}
101+
/* eslint-disable react-hooks/exhaustive-deps */
102+
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to reinitialize when the reInitOn dependencies change
103+
}, reInitOn ?? []);
104+
/* eslint-enable react-hooks/exhaustive-deps */
105+
}
106+
107+
function useReInit(setModel: (model: null) => void, reInitOn: unknown[] | undefined): void {
108+
const firstCallRef = useRef(true);
109+
110+
useEffect(() => {
111+
if (firstCallRef.current) {
112+
firstCallRef.current = false;
113+
114+
return;
115+
}
116+
117+
setModel(null);
118+
/* eslint-disable react-hooks/exhaustive-deps */
119+
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to reinitialize when the reInitOn dependencies change
120+
}, reInitOn ?? []);
121+
/* eslint-enable react-hooks/exhaustive-deps */
122+
}
123+
124+
function getDispatch<TMessage extends Message>(
125+
handleMessage: (msg: TMessage) => boolean,
126+
callSetModel: () => void,
127+
callDevTools?: (msg: TMessage) => void,
128+
fakeDispatch?: Dispatch<TMessage>,
129+
): Dispatch<TMessage> {
130+
let running = false;
131+
const buffer: TMessage[] = [];
132+
133+
return (
134+
fakeDispatch ??
135+
((msg: TMessage): void => {
136+
if (running) {
137+
buffer.push(msg);
138+
139+
return;
140+
}
141+
142+
running = true;
143+
144+
let nextMsg: TMessage | undefined = msg;
145+
let modified = false;
146+
147+
do {
148+
if (handleMessage(nextMsg)) {
149+
modified = true;
150+
}
151+
152+
callDevTools?.(nextMsg);
153+
154+
nextMsg = buffer.shift();
155+
} while (nextMsg);
156+
157+
running = false;
158+
159+
if (modified) {
160+
callSetModel();
161+
}
162+
})
163+
);
164+
}
165+
166+
function runInit<TModel, TProps, TMessage extends Message>(
167+
name: string,
168+
initFn: InitFunction<TProps, TModel, TMessage>,
169+
props: TProps,
170+
setModel: (model: TModel) => void,
171+
dispatch: Dispatch<TMessage>,
172+
devTools: Nullable<ReduxDevTools>,
173+
fakeModel: TModel | undefined,
174+
): TModel {
175+
const [initModel, ...initCommands] = fakeModel ? [fakeModel] : initFn(props);
176+
177+
setModel(initModel);
178+
devTools?.init(initModel);
179+
Services.logger?.debug("Initial model for", name, initModel);
180+
execCmd(dispatch, ...initCommands);
181+
182+
return initModel;
183+
}
184+
185+
export { execCmd, logMessage, modelHasChanged, useRedux, useIsMounted, useSubscription, useReInit, getDispatch, runInit };

src/immutable/testing/getUpdateFn.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1+
import type { Nullable } from "../../Types";
12
import type { UpdateMap } from "../Types";
23
import { getUpdateFn } from "./getUpdateFn";
34

45
type Message = { name: "foo" } | { name: "bar" } | { name: "foobar" };
56

67
interface Model {
78
foo: string;
8-
bar: {
9+
bar: Nullable<{
910
foo: string;
1011
bar: number;
11-
} | null;
12+
}>;
1213
foobar: string[];
1314
}
1415

src/immutable/useElmish.ts

Lines changed: 39 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
/* eslint-disable react-hooks/exhaustive-deps */
21
import { castImmutable, type Draft, enablePatches, freeze, type Immutable, produce } from "immer";
3-
import { useCallback, useEffect, useRef, useState } from "react";
4-
import { execCmd, logMessage } from "../Common";
2+
import { useCallback, useRef, useState } from "react";
3+
import { execCmd, getDispatch, logMessage, runInit, useIsMounted, useRedux, useReInit, useSubscription } from "../Common";
54
import { getFakeOptionsOnce } from "../fakeOptions";
65
import { Services } from "../Init";
7-
import { isReduxDevToolsEnabled, type ReduxDevTools } from "../reduxDevTools";
8-
import { type Cmd, type Dispatch, type InitFunction, type Message, type Nullable, subscriptionIsFunctionArray } from "../Types";
6+
import type { Cmd, Dispatch, InitFunction, Message, Nullable } from "../Types";
97
import { createCallBase } from "./createCallBase";
108
import { createDefer } from "./createDefer";
119
import type { Subscription, UpdateFunction, UpdateFunctionOptions, UpdateMap, UpdateReturnType } from "./Types";
@@ -64,39 +62,10 @@ function useElmish<TProps, TModel, TMessage extends Message>({
6462
update,
6563
subscription,
6664
}: UseElmishOptions<TProps, TModel, TMessage>): [Immutable<TModel>, Dispatch<TMessage>] {
67-
let running = false;
68-
const buffer: TMessage[] = [];
69-
70-
const firstCallRef = useRef(true);
7165
const [model, setModel] = useState<Nullable<Immutable<TModel>>>(null);
7266
const propsRef = useRef(props);
73-
const isMountedRef = useRef(true);
74-
75-
const devTools = useRef<ReduxDevTools | null>(null);
76-
77-
useEffect(() => {
78-
let reduxUnsubscribe: (() => void) | undefined;
79-
80-
if (Services.enableDevTools === true && isReduxDevToolsEnabled(window)) {
81-
// eslint-disable-next-line no-underscore-dangle
82-
devTools.current = window.__REDUX_DEVTOOLS_EXTENSION__.connect({ name, serialize: { options: true } });
83-
84-
reduxUnsubscribe = devTools.current.subscribe((message) => {
85-
if (message.type === "DISPATCH" && message.payload.type === "JUMP_TO_ACTION") {
86-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
87-
setModel(JSON.parse(message.state) as Immutable<TModel>);
88-
}
89-
});
90-
}
91-
92-
isMountedRef.current = true;
93-
94-
return () => {
95-
isMountedRef.current = false;
96-
97-
reduxUnsubscribe?.();
98-
};
99-
}, [name]);
67+
const devToolsRef = useRedux(name, setModel);
68+
const isMountedRef = useIsMounted();
10069

10170
let currentModel = model;
10271

@@ -105,96 +74,53 @@ function useElmish<TProps, TModel, TMessage extends Message>({
10574
}
10675

10776
const fakeOptions = getFakeOptionsOnce<TModel, TMessage>();
108-
const dispatch = useCallback(
109-
fakeOptions?.dispatch ??
110-
((msg: TMessage): void => {
111-
if (running) {
112-
buffer.push(msg);
11377

78+
// eslint-disable-next-line react-hooks/exhaustive-deps
79+
const dispatch = useCallback(
80+
getDispatch(
81+
handleMessage,
82+
() => {
83+
if (!isMountedRef.current) {
11484
return;
11585
}
11686

117-
running = true;
118-
119-
let nextMsg: TMessage | undefined = msg;
120-
let modified = false;
121-
122-
do {
123-
if (handleMessage(nextMsg)) {
124-
modified = true;
125-
}
126-
127-
if (devTools.current) {
128-
devTools.current.send(nextMsg.name, currentModel);
129-
}
130-
131-
nextMsg = buffer.shift();
132-
} while (nextMsg);
133-
134-
running = false;
135-
136-
if (isMountedRef.current && modified) {
137-
setModel(() => {
138-
Services.logger?.debug("Update model for", name, currentModel);
139-
140-
return currentModel;
141-
});
142-
}
143-
}),
87+
setModel(() => {
88+
Services.logger?.debug("Update model for", name, currentModel);
89+
90+
return currentModel;
91+
});
92+
},
93+
(msg) => {
94+
devToolsRef.current?.send(msg.name, currentModel);
95+
},
96+
fakeOptions?.dispatch,
97+
),
14498
[],
14599
);
146100

147-
let initializedModel = model;
101+
let initializedModel = currentModel;
148102

149103
if (!initializedModel) {
150104
enablePatches();
151105

152-
const [initModel, ...initCommands] = fakeOptions?.model ? [fakeOptions.model] : init(props);
153-
154-
initializedModel = castImmutable(freeze(initModel, true));
155-
currentModel = initializedModel;
156-
setModel(initializedModel);
157-
158-
devTools.current?.init(initializedModel);
159-
160-
Services.logger?.debug("Initial model for", name, initializedModel);
161-
162-
execCmd(dispatch, ...initCommands);
106+
initializedModel = castImmutable(
107+
runInit(
108+
name,
109+
init,
110+
props,
111+
(initModel) => {
112+
currentModel = castImmutable(freeze(initModel, true));
113+
setModel(currentModel);
114+
},
115+
dispatch,
116+
devToolsRef.current,
117+
fakeOptions?.model,
118+
),
119+
);
163120
}
164121

165-
useEffect(() => {
166-
if (firstCallRef.current) {
167-
firstCallRef.current = false;
168-
169-
return;
170-
}
171-
172-
setModel(null);
173-
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to reinitialize when the reInitOn dependencies change
174-
}, reInitOn ?? []);
175-
176-
useEffect(() => {
177-
if (subscription) {
178-
const subscriptionResult = subscription(initializedModel, props);
179-
180-
if (subscriptionIsFunctionArray(subscriptionResult)) {
181-
const destructors = subscriptionResult.map((sub) => sub(dispatch)).filter((destructor) => destructor !== undefined);
182-
183-
return function combinedDestructor() {
184-
for (const destructor of destructors) {
185-
destructor();
186-
}
187-
};
188-
}
189-
190-
const [subCmd, destructor] = subscriptionResult;
191-
192-
execCmd(dispatch, subCmd);
193-
194-
return destructor;
195-
}
196-
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to reinitialize when the reInitOn dependencies change
197-
}, reInitOn ?? []);
122+
useReInit(setModel, reInitOn);
123+
useSubscription(subscription, initializedModel, props, dispatch, reInitOn);
198124

199125
return [initializedModel, dispatch];
200126

0 commit comments

Comments
 (0)