diff --git a/README.md b/README.md index fd42c734..7370f56f 100644 --- a/README.md +++ b/README.md @@ -35,20 +35,40 @@ electron-redux docs are located at **electron-redux.js.org**. You can find there ## Quick start -electron-redux comes as a [Redux StoreEnhancer](https://redux.js.org/understanding/thinking-in-redux/glossary#store-enhancer). To initialize your stores, you just need to decorate them in the `main` and `renderer` processes of electron with their respective enhancers: +### Basic setup + +If you have a setup without any enhancers, also including middleware, you can use the basic setup. For the basic setup, electron redux exposes a [Redux StoreEnhancer](https://redux.js.org/understanding/thinking-in-redux/glossary#store-enhancer). You simply add the enhancer to your createStore function to set it up. ```ts // main.ts -import { mainStateSyncEnhancer } from 'electron-redux' +import { stateSyncEnhancer } from 'electron-redux' -const store = createStore(reducer, mainStateSyncEnhancer()) +const store = createStore(reducer, stateSyncEnhancer()) ``` ```ts // renderer.ts -import { rendererStateSyncEnhancer } from 'electron-redux' +import { stateSyncEnhancer } from 'electron-redux' + +const store = createStore(reducer, stateSyncEnhancer()) +``` + +### Multi-enhancer setup + +> This setup is required when you have other enhancers/middleware. This is especially the case for enhancers or middleware which dispatch actions, such as **redux-saga** and **redux-observable** + +For this setup we will use the `composeWithStateSync` function. This function is created to wrap around your enhancers, just like the [compose](https://redux.js.org/api/compose) function from redux. When using this, you will not need `stateSyncEnhancer` as this does the same thing under the hood. If you do, it will throw an error. + +```ts +import { createStore, applyMiddleware, compose } from 'redux' +import { composeWithStateSync } from 'electron-redux' + +const middleware = applyMiddleware(...middleware) + +// add other enhances here if you have any, works like `compose` from redux +const enhancer: StoreEnhancer = composeWithStateSync(middleware /* ... other enhancers ... */) -const store = createStore(reducer, rendererStateSyncEnhancer()) +const store = createStore(reducer, enhancer) ``` That's it! diff --git a/docs/docs/faq/faq-general.md b/docs/docs/faq/faq-general.md index ac671891..0ab2d771 100644 --- a/docs/docs/faq/faq-general.md +++ b/docs/docs/faq/faq-general.md @@ -6,3 +6,16 @@ hide_title: true --- # TODO + +## Errors + +### Received error "electron-redux has already been attached to a store" + +There are 2 scenario's for you to receive this error message. + +1. If you are using the `composeWithStateSync` function to install electron-redux, you do not need to manually add the `stateSyncEnhancer` as it does the same thing. It will throw an error if you try. +2. If you are using `stateSyncEnhancer`, `rendererStateSyncEnhancer` or `mainStateSyncEnhancer` in your createStore function, you may only add one of these in EACH process. + +### Received error "Unsupported process: process.type = ..." + +If you use `composeWithStateSync` or `stateSyncEnhancer`, we will determine in which process you are, the main or renderer process. We do this by checking the [process.type](https://www.electronjs.org/docs/api/process#processtype-readonly) variable which has been set by Electron. If you receive this error, you are either using this package in a non-supported environment, or this variable is not set properly diff --git a/docs/docs/introduction/getting-started.md b/docs/docs/introduction/getting-started.md index fc810f41..bfdf776e 100644 --- a/docs/docs/introduction/getting-started.md +++ b/docs/docs/introduction/getting-started.md @@ -23,20 +23,40 @@ npm install electron-redux@alpha # Configuration -electron-redux comes as a [Redux store enhancer](https://redux.js.org/understanding/thinking-in-redux/glossary#store-enhancer). To initialize your stores, you just need to decorate them in the `main` and `renderer` processes of electron with their respective enhancers: +### Basic setup + +If you have a setup without any enhancers, also including middleware, you can use the basic setup. For the basic setup, electron redux exposes a [Redux StoreEnhancer](https://redux.js.org/understanding/thinking-in-redux/glossary#store-enhancer). You simply add the enhancer to your createStore function to set it up. ```ts // main.ts -import { mainStateSyncEnhancer } from 'electron-redux' +import { stateSyncEnhancer } from 'electron-redux' -const store = createStore(reducer, mainStateSyncEnhancer()) +const store = createStore(reducer, stateSyncEnhancer()) ``` ```ts // renderer.ts -import { rendererStateSyncEnhancer } from 'electron-redux' +import { stateSyncEnhancer } from 'electron-redux' + +const store = createStore(reducer, stateSyncEnhancer()) +``` + +### Multi-enhancer setup + +> This setup is required when you have other enhancers/middleware. This is especially the case for enhancers or middleware which dispatch actions, such as **redux-saga** and **redux-observable** + +For this setup we will use the `composeWithStateSync` function. This function is created to wrap around your enhancers, just like the [compose](https://redux.js.org/api/compose) function from redux. When using this, you will not need `stateSyncEnhancer` as this does the same thing under the hood. If you do, it will throw an error. + +```ts +import { createStore, applyMiddleware, compose } from 'redux' +import { composeWithStateSync } from 'electron-redux' + +const middleware = applyMiddleware(...middleware) + +// add other enhances here if you have any, works like `compose` from redux +const enhancer: StoreEnhancer = composeWithStateSync(middleware /* ... other enhancers ... */) -const store = createStore(reducer, rendererStateSyncEnhancer()) +const store = createStore(reducer, enhancer) ``` That's it! diff --git a/src/composeWithStateSync.ts b/src/composeWithStateSync.ts new file mode 100644 index 00000000..b512d4c3 --- /dev/null +++ b/src/composeWithStateSync.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/ban-types */ + +import { StoreEnhancer } from 'redux' +import { forwardAction } from './forwardAction' +import { StateSyncOptions } from './options/StateSyncOptions' +import { stateSyncEnhancer } from './stateSyncEnhancer' + +const forwardActionEnhancer = (options?: StateSyncOptions): StoreEnhancer => (createStore) => ( + reducer, + preloadedState +) => { + const store = createStore(reducer, preloadedState) + + return forwardAction(store, options) +} + +const extensionCompose = (options: StateSyncOptions) => ( + ...funcs: StoreEnhancer[] +): StoreEnhancer => { + return (createStore) => { + return [ + stateSyncEnhancer({ ...options, preventActionReplay: true }), + ...funcs, + forwardActionEnhancer(options), + ].reduceRight((composed, f) => f(composed), createStore) + } +} + +export function composeWithStateSync( + options: StateSyncOptions +): (...funcs: Function[]) => StoreEnhancer +export function composeWithStateSync(...funcs: StoreEnhancer[]): StoreEnhancer +export function composeWithStateSync( + firstFuncOrOpts: StoreEnhancer | StateSyncOptions, + ...funcs: StoreEnhancer[] +): StoreEnhancer | ((...funcs: StoreEnhancer[]) => StoreEnhancer) { + if (arguments.length === 0) { + return stateSyncEnhancer() + } + if (arguments.length === 1 && typeof firstFuncOrOpts === 'object') { + return extensionCompose(firstFuncOrOpts) + } + return extensionCompose({})(firstFuncOrOpts as StoreEnhancer, ...funcs) +} diff --git a/src/forwardAction.ts b/src/forwardAction.ts new file mode 100644 index 00000000..5bb73bd9 --- /dev/null +++ b/src/forwardAction.ts @@ -0,0 +1,51 @@ +import { ipcRenderer, webContents } from 'electron' +import { Store } from 'redux' +import { IPCEvents } from './constants' +import { MainStateSyncEnhancerOptions } from './options/MainStateSyncEnhancerOptions' +import { RendererStateSyncEnhancerOptions } from './options/RendererStateSyncEnhancerOptions' +import { StateSyncOptions } from './options/StateSyncOptions' +import { isMain, isRenderer, validateAction } from './utils' + +export const processActionMain = ( + action: A, + options: MainStateSyncEnhancerOptions = {} +): void => { + if (validateAction(action, options.denyList)) { + webContents.getAllWebContents().forEach((contents) => { + // Ignore chromium devtools + if (contents.getURL().startsWith('devtools://')) return + contents.send(IPCEvents.ACTION, action) + }) + } +} + +export const processActionRenderer = ( + action: A, + options: RendererStateSyncEnhancerOptions = {} +): void => { + if (validateAction(action, options.denyList)) { + ipcRenderer.send(IPCEvents.ACTION, action) + } +} + +export const forwardAction = >( + store: S, + options?: StateSyncOptions +): S => { + return { + ...store, + dispatch: (action) => { + const value = store.dispatch(action) + + if (!options?.preventActionReplay) { + if (isMain) { + processActionMain(action, options) + } else if (isRenderer) { + processActionRenderer(action, options) + } + } + + return value + }, + } +} diff --git a/src/index.ts b/src/index.ts index a4a98709..23e259f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,13 @@ import { mainStateSyncEnhancer } from './mainStateSyncEnhancer' import { stopForwarding } from './utils' import { rendererStateSyncEnhancer } from './rendererStateSyncEnhancer' +import { stateSyncEnhancer } from './stateSyncEnhancer' +import { composeWithStateSync } from './composeWithStateSync' -export { mainStateSyncEnhancer, rendererStateSyncEnhancer, stopForwarding } +export { + mainStateSyncEnhancer, + rendererStateSyncEnhancer, + stopForwarding, + stateSyncEnhancer, + composeWithStateSync, +} diff --git a/src/mainStateSyncEnhancer.ts b/src/mainStateSyncEnhancer.ts index f175f3c5..ef966916 100644 --- a/src/mainStateSyncEnhancer.ts +++ b/src/mainStateSyncEnhancer.ts @@ -1,22 +1,21 @@ import { ipcMain, webContents } from 'electron' -import { - Action, - compose, - Dispatch, - Middleware, - MiddlewareAPI, - StoreCreator, - StoreEnhancer, -} from 'redux' +import { Action, StoreEnhancer } from 'redux' import { IPCEvents } from './constants' -import { - defaultMainOptions, - MainStateSyncEnhancerOptions, -} from './options/MainStateSyncEnhancerOptions' -import { preventDoubleInitialization, stopForwarding, validateAction } from './utils' +import { forwardAction } from './forwardAction' +import { MainStateSyncEnhancerOptions } from './options/MainStateSyncEnhancerOptions' +import { stopForwarding } from './utils' + +/** + * Creates new instance of main process redux enhancer. + * @param {MainStateSyncEnhancerOptions} options Additional enhancer options + * @returns StoreEnhancer + */ +export const mainStateSyncEnhancer = ( + options: MainStateSyncEnhancerOptions = {} +): StoreEnhancer => (createStore) => { + return (reducer, preloadedState) => { + const store = createStore(reducer, preloadedState) -function createMiddleware(options: MainStateSyncEnhancerOptions) { - const middleware: Middleware = (store) => { ipcMain.handle(IPCEvents.INIT_STATE_ASYNC, async () => { return JSON.stringify(store.getState(), options.serializer) }) @@ -28,6 +27,7 @@ function createMiddleware(options: MainStateSyncEnhancerOptions) { // When receiving an action from a renderer ipcMain.on(IPCEvents.ACTION, (event, action: Action) => { const localAction = stopForwarding(action) + store.dispatch(localAction) // Forward it to all of the other renderers @@ -42,46 +42,6 @@ function createMiddleware(options: MainStateSyncEnhancerOptions) { }) }) - return (next) => (action) => { - if (validateAction(action, options.denyList)) { - webContents.getAllWebContents().forEach((contents) => { - // Ignore chromium devtools - if (contents.getURL().startsWith('devtools://')) return - contents.send(IPCEvents.ACTION, action) - }) - } - - return next(action) - } - } - return middleware -} - -/** - * Creates new instance of main process redux enhancer. - * @param {MainStateSyncEnhancerOptions} options Additional enhancer options - * @returns StoreEnhancer - */ -export const mainStateSyncEnhancer = (options = defaultMainOptions): StoreEnhancer => ( - createStore: StoreCreator -) => { - preventDoubleInitialization() - const middleware = createMiddleware(options) - return (reducer, preloadedState) => { - const store = createStore(reducer, preloadedState) - - let dispatch = store.dispatch - - const middlewareAPI: MiddlewareAPI> = { - getState: store.getState, - dispatch, - } - - dispatch = compose(middleware(middlewareAPI))(dispatch) - - return { - ...store, - dispatch, - } + return forwardAction(store, options) } } diff --git a/src/options/MainStateSyncEnhancerOptions.ts b/src/options/MainStateSyncEnhancerOptions.ts index 971bcfce..e3f9f823 100644 --- a/src/options/MainStateSyncEnhancerOptions.ts +++ b/src/options/MainStateSyncEnhancerOptions.ts @@ -1,15 +1,10 @@ -export type MainStateSyncEnhancerOptions = { +import { StateSyncOptions } from './StateSyncOptions' + +export interface MainStateSyncEnhancerOptions extends StateSyncOptions { /** * Custom store serialization function. * This function is called for each member of the object. If a member contains nested objects, * the nested objects are transformed before the parent object is. */ serializer?: (this: unknown, key: string, value: unknown) => unknown - - /** - * Custom list for actions that should never replay across stores - */ - denyList?: RegExp[] } - -export const defaultMainOptions: MainStateSyncEnhancerOptions = {} diff --git a/src/options/RendererStateSyncEnhancerOptions.ts b/src/options/RendererStateSyncEnhancerOptions.ts index 270d49c1..f6160b08 100644 --- a/src/options/RendererStateSyncEnhancerOptions.ts +++ b/src/options/RendererStateSyncEnhancerOptions.ts @@ -1,4 +1,6 @@ -export type RendererStateSyncEnhancerOptions = { +import { StateSyncOptions } from './StateSyncOptions' + +export interface RendererStateSyncEnhancerOptions extends StateSyncOptions { /** * Custom function used during de-serialization of the redux store to transform the object. * This function is called for each member of the object. If a member contains nested objects, @@ -6,11 +8,6 @@ export type RendererStateSyncEnhancerOptions = { */ deserializer?: (this: unknown, key: string, value: unknown) => unknown - /** - * Custom list for actions that should never replay across stores - */ - denyList?: RegExp[] - /** * By default, the renderer store is initialized from the main store synchronously. * Since the synchronous fetching of the state is blocking the renderer process until it gets the state @@ -19,5 +16,3 @@ export type RendererStateSyncEnhancerOptions = { */ lazyInit?: boolean } - -export const defaultRendererOptions: RendererStateSyncEnhancerOptions = {} diff --git a/src/options/StateSyncOptions.ts b/src/options/StateSyncOptions.ts new file mode 100644 index 00000000..57d016ba --- /dev/null +++ b/src/options/StateSyncOptions.ts @@ -0,0 +1,11 @@ +export interface StateSyncOptions { + /** + * Custom list for actions that should never replay across stores + */ + denyList?: RegExp[] + + /** + * Prevent replaying actions in the current process + */ + preventActionReplay?: boolean +} diff --git a/src/rendererStateSyncEnhancer.ts b/src/rendererStateSyncEnhancer.ts index 0c6df751..402cd522 100644 --- a/src/rendererStateSyncEnhancer.ts +++ b/src/rendererStateSyncEnhancer.ts @@ -1,36 +1,11 @@ import { ipcRenderer } from 'electron' -import { - Action, - compose, - Dispatch, - Middleware, - MiddlewareAPI, - StoreCreator, - StoreEnhancer, -} from 'redux' +import { Action, StoreEnhancer } from 'redux' import { IPCEvents } from './constants' +import { forwardAction } from './forwardAction' import { fetchInitialState, fetchInitialStateAsync } from './fetchState' import { replaceState, withStoreReplacer } from './fetchState/replaceState' -import { - defaultRendererOptions, - RendererStateSyncEnhancerOptions, -} from './options/RendererStateSyncEnhancerOptions' -import { preventDoubleInitialization, stopForwarding, validateAction } from './utils' - -const createMiddleware = (options: RendererStateSyncEnhancerOptions): Middleware => (store) => { - // When receiving an action from main - ipcRenderer.on(IPCEvents.ACTION, (_, action: Action) => { - store.dispatch(stopForwarding(action)) - }) - - return (next) => (action) => { - if (validateAction(action, options.denyList)) { - ipcRenderer.send(IPCEvents.ACTION, action) - } - - return next(action) - } -} +import { RendererStateSyncEnhancerOptions } from './options/RendererStateSyncEnhancerOptions' +import { stopForwarding } from './utils' /** * Creates new instance of renderer process redux enhancer. @@ -39,15 +14,12 @@ const createMiddleware = (options: RendererStateSyncEnhancerOptions): Middleware * @param {RendererStateSyncEnhancerOptions} options Additional settings for enhancer * @returns StoreEnhancer */ -export const rendererStateSyncEnhancer = (options = defaultRendererOptions): StoreEnhancer => ( - createStore: StoreCreator -) => { - preventDoubleInitialization() - +export const rendererStateSyncEnhancer = ( + options: RendererStateSyncEnhancerOptions = {} +): StoreEnhancer => (createStore) => { return (reducer, state) => { - const middleware = createMiddleware(options) - const initialState = options.lazyInit ? state : fetchInitialState(options) + const store = createStore( options.lazyInit ? withStoreReplacer(reducer) : reducer, initialState @@ -59,18 +31,11 @@ export const rendererStateSyncEnhancer = (options = defaultRendererOptions): Sto }) } - let dispatch = store.dispatch - - const middlewareAPI: MiddlewareAPI> = { - getState: store.getState, - dispatch, - } - - dispatch = compose(middleware(middlewareAPI))(dispatch) + // When receiving an action from main + ipcRenderer.on(IPCEvents.ACTION, (_, action: Action) => { + store.dispatch(stopForwarding(action)) + }) - return { - ...store, - dispatch, - } + return forwardAction(store, options) } } diff --git a/src/stateSyncEnhancer.ts b/src/stateSyncEnhancer.ts new file mode 100644 index 00000000..b8f89bd9 --- /dev/null +++ b/src/stateSyncEnhancer.ts @@ -0,0 +1,17 @@ +import { StoreEnhancer } from 'redux' +import { mainStateSyncEnhancer } from './mainStateSyncEnhancer' +import { StateSyncOptions } from './options/StateSyncOptions' +import { rendererStateSyncEnhancer } from './rendererStateSyncEnhancer' +import { isMain, isRenderer, preventDoubleInitialization } from './utils' + +export const stateSyncEnhancer = (config: StateSyncOptions = {}): StoreEnhancer => { + preventDoubleInitialization() + + if (isRenderer) { + return rendererStateSyncEnhancer(config) + } else if (isMain) { + return mainStateSyncEnhancer(config) + } + + throw new Error(`Unsupported process: process.type = ${process?.type}`) +} diff --git a/src/utils/actions.ts b/src/utils/actions.ts index fcbe2615..d6c75c19 100644 --- a/src/utils/actions.ts +++ b/src/utils/actions.ts @@ -1,4 +1,4 @@ -import { isFSA, FluxStandardAction } from './isFSA' +import { FluxStandardAction, isFSA } from './isFSA' // Gives us just enough action type info to work for the functions below export type ActionMeta = { @@ -9,7 +9,7 @@ export type ActionMeta = { * stopForwarding allows you to give it an action, and it will return an * equivalent action that will only play in the current process */ -export const stopForwarding = (action: FluxStandardAction) => ({ +export const stopForwarding = (action: FluxStandardAction): any => ({ ...action, meta: { ...action.meta, diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 8fa915b3..2b75a2b6 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -31,3 +31,6 @@ export const trimProperties = (props: T[], obj: X) => { Object.entries(obj).filter(([key]) => !props.includes(key as T)) ) as Omit } + +export const isRenderer = process.type === 'renderer' +export const isMain = process.type === 'browser' diff --git a/tests/typescript/composeWithStateSync.ts b/tests/typescript/composeWithStateSync.ts new file mode 100644 index 00000000..ea517e7c --- /dev/null +++ b/tests/typescript/composeWithStateSync.ts @@ -0,0 +1,24 @@ +import { composeWithStateSync } from '../../types' +import { applyMiddleware, createStore, Store, StoreEnhancer } from 'redux' +import { reducer, CounterState, Actions } from '../counter' +import { countMiddleware } from '../middleware' + +// This is just a dummy enhancer, this does nothing +const someOtherEnhancer: StoreEnhancer = (next) => { + return (reducer, state) => { + return next(reducer, state) + } +} + +const middleware = applyMiddleware(countMiddleware) + +const enhancerWithoutOptions: StoreEnhancer = composeWithStateSync(middleware, someOtherEnhancer) + +const store: Store = createStore(reducer, enhancerWithoutOptions) + +const enhancerWithOptions: StoreEnhancer = composeWithStateSync({ denyList: [] })( + middleware, + someOtherEnhancer +) + +const store2: Store = createStore(reducer, enhancerWithOptions)