Skip to content

Commit

Permalink
fix(#285): add composeWithStateSync to resolve issues with enhancer o…
Browse files Browse the repository at this point in the history
…rder (#296)

* fix(#285): add composeWithStateSync to resolve issues with enhancer order

* fix: resolve PR comments

* chore: add type tests

* docs: update README getting started  and add changes to FAQ

Co-authored-by: Maciej Małkowski <monkey3310@gmail.com>
  • Loading branch information
sneljo1 and matmalkowski committed Feb 16, 2021
1 parent 134d290 commit cce8018
Show file tree
Hide file tree
Showing 15 changed files with 260 additions and 134 deletions.
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
13 changes: 13 additions & 0 deletions docs/docs/faq/faq-general.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 25 additions & 5 deletions docs/docs/introduction/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
44 changes: 44 additions & 0 deletions src/composeWithStateSync.ts
Original file line number Diff line number Diff line change
@@ -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)
}
51 changes: 51 additions & 0 deletions src/forwardAction.ts
Original file line number Diff line number Diff line change
@@ -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 = <A>(
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 = <A>(
action: A,
options: RendererStateSyncEnhancerOptions = {}
): void => {
if (validateAction(action, options.denyList)) {
ipcRenderer.send(IPCEvents.ACTION, action)
}
}

export const forwardAction = <S extends Store<any, any>>(
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
},
}
}
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
}
74 changes: 17 additions & 57 deletions src/mainStateSyncEnhancer.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Expand All @@ -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
Expand All @@ -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<Dispatch<any>> = {
getState: store.getState,
dispatch,
}

dispatch = compose<Dispatch>(middleware(middlewareAPI))(dispatch)

return {
...store,
dispatch,
}
return forwardAction(store, options)
}
}
11 changes: 3 additions & 8 deletions src/options/MainStateSyncEnhancerOptions.ts
Original file line number Diff line number Diff line change
@@ -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 = {}
11 changes: 3 additions & 8 deletions src/options/RendererStateSyncEnhancerOptions.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
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,
* the nested objects are transformed before the parent object is.
*/
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
Expand All @@ -19,5 +16,3 @@ export type RendererStateSyncEnhancerOptions = {
*/
lazyInit?: boolean
}

export const defaultRendererOptions: RendererStateSyncEnhancerOptions = {}
11 changes: 11 additions & 0 deletions src/options/StateSyncOptions.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit cce8018

Please sign in to comment.