diff --git a/README.md b/README.md index be983be..86847d0 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ This library brings the elmish pattern to react. - [Combine update and execCmd](#combine-update-and-execcmd) - [Testing subscriptions](#testing-subscriptions) - [UI Tests](#ui-tests) +- [Redux Dev Tools](#redux-dev-tools) - [Migrations](#migrations) - [From v1.x to v2.x](#from-v1x-to-v2x) - [From v2.x to v3.x](#from-v2x-to-v3x) @@ -1077,6 +1078,20 @@ it("dispatches the correct message", async () => { This works for function components using the `useElmish` hook and class components. +## Redux Dev Tools + +If you have the Redux Dev Tools installed in your browser, you can enable support for this extension by setting the `enableDevTools` option to `true` in the `init` function. + +```ts +import { init } from "react-elmish"; + +init({ + enableDevTools: true, +}); +``` + +Hint: You should only enable this in development mode. + ## Migrations ### From v1.x to v2.x diff --git a/src/Init.ts b/src/Init.ts index 26d9b4e..2664a7d 100644 --- a/src/Init.ts +++ b/src/Init.ts @@ -27,12 +27,15 @@ interface ElmOptions { * @type {DispatchMiddlewareFunc} */ dispatchMiddleware?: DispatchMiddlewareFunc; + + enableDevTools?: boolean; } const Services: ElmOptions = { logger: undefined, errorMiddleware: undefined, dispatchMiddleware: undefined, + enableDevTools: false, }; /** @@ -44,6 +47,7 @@ function init(options: ElmOptions): void { Services.logger = options.logger; Services.errorMiddleware = options.errorMiddleware; Services.dispatchMiddleware = options.dispatchMiddleware; + Services.enableDevTools = options.enableDevTools; } export type { DispatchMiddlewareFunc, ElmOptions, ErrorMiddlewareFunc, Logger }; diff --git a/src/reduxDevTools.ts b/src/reduxDevTools.ts new file mode 100644 index 0000000..fb68315 --- /dev/null +++ b/src/reduxDevTools.ts @@ -0,0 +1,46 @@ +interface ReduxDevToolsExtension { + connect: (options?: ReduxOptions) => ReduxDevTools; + disconnect: () => void; +} + +interface ReduxOptions { + name?: string; + serialize?: { + options?: boolean; + }; +} + +interface ReduxDevTools { + init: (state: unknown) => void; + send: (message: string, state: unknown, options?: ReduxOptions) => void; + subscribe: (callback: (message: ReduxMessage) => void) => () => void; + unsubscribe: () => void; +} + +interface ReduxMessage { + type: string; + payload: { + type: string; + }; + state: string; +} + +interface ReduxDevToolsExtensionWindow extends Window { + // biome-ignore lint/style/useNamingConvention: Predefined + __REDUX_DEVTOOLS_EXTENSION__: ReduxDevToolsExtension; +} + +declare global { + interface Window { + // biome-ignore lint/style/useNamingConvention: Predefined + __REDUX_DEVTOOLS_EXTENSION__?: ReduxDevToolsExtension; + } +} + +function isReduxDevToolsEnabled(window: Window | undefined): window is ReduxDevToolsExtensionWindow { + return window !== undefined && "__REDUX_DEVTOOLS_EXTENSION__" in window; +} + +export type { ReduxDevTools, ReduxDevToolsExtension }; + +export { isReduxDevToolsEnabled }; diff --git a/src/useElmish.ts b/src/useElmish.ts index eece48b..fc5e353 100644 --- a/src/useElmish.ts +++ b/src/useElmish.ts @@ -16,6 +16,7 @@ import type { 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. @@ -81,13 +82,31 @@ function useElmish({ 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 TModel); + } + }); + } + isMountedRef.current = true; return () => { isMountedRef.current = false; + + reduxUnsubscribe?.(); }; - }, []); + }, [name]); let initializedModel = model; @@ -115,6 +134,10 @@ function useElmish({ modified = true; } + if (devTools.current) { + devTools.current.send(nextMsg.name, { ...initializedModel, ...currentModel }); + } + nextMsg = buffer.shift(); } while (nextMsg); @@ -140,6 +163,8 @@ function useElmish({ initializedModel = initModel; setModel(initializedModel); + devTools.current?.init(initializedModel); + Services.logger?.debug("Initial model for", name, initializedModel); execCmd(dispatch, ...initCommands);