Skip to content

Commit

Permalink
feat: make renderer state initialization synchronous by default (#280)
Browse files Browse the repository at this point in the history
* feat: make renderer state initialization synchronous by default

Implements #278

* code review comments

* chore: spelling

Co-authored-by: Burkhard Reffeling <burkhard.reffeling@gmail.com>

* chore: code review comments

Co-authored-by: Burkhard Reffeling <burkhard.reffeling@gmail.com>
  • Loading branch information
matmalkowski and hardchor committed Nov 27, 2020
1 parent 2e0dbbb commit 09017fe
Show file tree
Hide file tree
Showing 16 changed files with 185 additions and 136 deletions.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@
},
"renderer": {
"sourceDirectory": "tests/e2e/renderer"
},
"commonDistDirectory": "e2e_dist"
}
},
"husky": {
"hooks": {
Expand Down
5 changes: 5 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum IPCEvents {
INIT_STATE = 'electron-redux.INIT_STATE',
INIT_STATE_ASYNC = 'electron-redux.INIT_STATE_ASYNC',
ACTION = 'electron-redux.ACTION',
}
10 changes: 10 additions & 0 deletions src/fetchState/fetchInitialState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ipcRenderer } from 'electron'
import { IPCEvents } from '../constants'
import { RendererStateSyncEnhancerOptions } from '../options/RendererStateSyncEnhancerOptions'

function fetchInitialState<T>(options: RendererStateSyncEnhancerOptions): T {
const state = ipcRenderer.sendSync(IPCEvents.INIT_STATE)
return JSON.parse(state, options.deserializer)
}

export default fetchInitialState
22 changes: 22 additions & 0 deletions src/fetchState/fetchInitialStateAsync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ipcRenderer } from 'electron'
import { IPCEvents } from '../constants'
import { RendererStateSyncEnhancerOptions } from '../options/RendererStateSyncEnhancerOptions'

async function fetchInitialStateAsync(
options: RendererStateSyncEnhancerOptions,
callback: (state: unknown) => void
): Promise<void> {
// Electron will throw an error if there isn't a handler for the channel.
// We catch it so that we can throw a more useful error
try {
const state = await ipcRenderer.invoke(IPCEvents.INIT_STATE_ASYNC)
callback(JSON.parse(state, options.deserializer))
} catch (error) {
console.warn(error)
throw new Error(
'No Redux store found in main process. Did you use the mainStateSyncEnhancer in the MAIN process?'
)
}
}

export default fetchInitialStateAsync
4 changes: 4 additions & 0 deletions src/fetchState/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import fetchInitialState from './fetchInitialState'
import fetchInitialStateAsync from './fetchInitialStateAsync'

export { fetchInitialState, fetchInitialStateAsync }
32 changes: 32 additions & 0 deletions src/fetchState/replaceState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { AnyAction, Reducer } from 'redux'
import { FluxStandardAction } from '../utils/isFSA'
import { ActionMeta } from '../utils'

interface ReplaceStateAction<S> extends FluxStandardAction<ActionMeta> {
payload: S
}

const REPLACE_STATE = 'electron-redux.REPLACE_STATE'

/**
* Creates an action that will replace the current state with the provided
* state. The scope is set to local in this creator function to make sure it is
* never forwarded.
*/
export const replaceState = <S>(state: S): ReplaceStateAction<S> => ({
type: REPLACE_STATE,
payload: state,
meta: { scope: 'local' },
})

export const withStoreReplacer = <S, A extends AnyAction>(reducer: Reducer<S, A>) => (
state: S | undefined,
action: A
): S => {
switch (action.type) {
case REPLACE_STATE:
return (action as any).payload
default:
return reducer(state, action)
}
}
33 changes: 15 additions & 18 deletions src/mainStateSyncEnhancer.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import { ipcMain, webContents } from 'electron'
import { Action, applyMiddleware, Middleware, StoreCreator, StoreEnhancer } from 'redux'
import { IPCEvents } from './constants'
import {
defaultMainOptions,
MainStateSyncEnhancerOptions,
} from './options/MainStateSyncEnhancerOptions'

import { preventDoubleInitialization, stopForwarding, validateAction } from './utils'

function createMiddleware(options: MainStateSyncEnhancerOptions) {
const middleware: Middleware = (store) => {
ipcMain.handle('electron-redux.INIT_STATE', async () => {
// Serialize the initial state using custom replacer
return JSON.stringify(store.getState(), options.replacer)
ipcMain.handle(IPCEvents.INIT_STATE_ASYNC, async () => {
return JSON.stringify(store.getState(), options.serializer)
})

ipcMain.on(IPCEvents.INIT_STATE, (event) => {
event.returnValue = JSON.stringify(store.getState(), options.serializer)
})

// When receiving an action from a renderer
ipcMain.on('electron-redux.ACTION', (event, action: Action) => {
ipcMain.on(IPCEvents.ACTION, (event, action: Action) => {
const localAction = stopForwarding(action)
store.dispatch(localAction)

Expand All @@ -22,7 +30,7 @@ function createMiddleware(options: MainStateSyncEnhancerOptions) {
contents.id !== event.sender.id &&
!contents.getURL().startsWith('devtools://')
) {
contents.send('electron-redux.ACTION', localAction)
contents.send(IPCEvents.ACTION, localAction)
}
})
})
Expand All @@ -32,7 +40,7 @@ function createMiddleware(options: MainStateSyncEnhancerOptions) {
webContents.getAllWebContents().forEach((contents) => {
// Ignore chromium devtools
if (contents.getURL().startsWith('devtools://')) return
contents.send('electron-redux.ACTION', action)
contents.send(IPCEvents.ACTION, action)
})
}

Expand All @@ -42,23 +50,12 @@ function createMiddleware(options: MainStateSyncEnhancerOptions) {
return middleware
}

export type MainStateSyncEnhancerOptions = {
/**
* Custom store serializaton 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.
*/
replacer?: (this: unknown, key: string, value: unknown) => unknown
}

const defaultOptions: MainStateSyncEnhancerOptions = {}

/**
* Creates new instance of main process redux enhancer.
* @param {MainStateSyncEnhancerOptions} options Additional enhancer options
* @returns StoreEnhancer
*/
export const mainStateSyncEnhancer = (options = defaultOptions): StoreEnhancer => (
export const mainStateSyncEnhancer = (options = defaultMainOptions): StoreEnhancer => (
createStore: StoreCreator
) => {
preventDoubleInitialization()
Expand Down
10 changes: 10 additions & 0 deletions src/options/MainStateSyncEnhancerOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type MainStateSyncEnhancerOptions = {
/**
* 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
}

export const defaultMainOptions: MainStateSyncEnhancerOptions = {}
18 changes: 18 additions & 0 deletions src/options/RendererStateSyncEnhancerOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type RendererStateSyncEnhancerOptions = {
/**
* 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

/**
* 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
* from the main process, it might be better with huge stores to initialize them in an asynchronous manner,
* by setting this flag to true
*/
lazyInit?: boolean
}

export const defaultRendererOptions: RendererStateSyncEnhancerOptions = {}
87 changes: 16 additions & 71 deletions src/rendererStateSyncEnhancer.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,52 @@
import { ipcRenderer } from 'electron'
import { Action, applyMiddleware, Middleware, Reducer, StoreCreator, StoreEnhancer } from 'redux'
import { Action, applyMiddleware, Middleware, StoreCreator, StoreEnhancer } from 'redux'
import { IPCEvents } from './constants'
import { fetchInitialState, fetchInitialStateAsync } from './fetchState'
import { replaceState, withStoreReplacer } from './fetchState/replaceState'
import { defaultRendererOptions } from './options/RendererStateSyncEnhancerOptions'

import { preventDoubleInitialization, stopForwarding, validateAction } from './utils'

async function fetchInitialState(
options: RendererStateSyncEnhancerOptions,
callback: (state: unknown) => void
) {
// Electron will throw an error if there isn't a handler for the channel.
// We catch it so that we can throw a more useful error
const state = await ipcRenderer.invoke('electron-redux.INIT_STATE').catch((error) => {
console.warn(error)
throw new Error(
'No Redux store found in main process. Did you use the mainStateSyncEnhancer in the MAIN process?'
)
})

// We do some fancy hydration on certain types like Map and Set.
// See also `freeze`
callback(JSON.parse(state, options.reviver))
}

/**
* This next bit is all just for being able to fill the store with the correct
* state asynchronously, because blocking the thread feels bad for potentially
* large stores.
*/
type InternalAction = ReturnType<typeof replaceState>

/**
* Creates an action that will replace the current state with the provided
* state. The scope is set to local in this creator function to make sure it is
* never forwarded.
*/
const replaceState = <S>(state: S) => ({
type: 'electron-redux.REPLACE_STATE' as const,
payload: state,
meta: { scope: 'local' },
})

const wrapReducer = (reducer: Reducer) => <S, A extends Action>(
state: S,
action: InternalAction | A
) => {
switch (action.type) {
case 'electron-redux.REPLACE_STATE':
return (action as InternalAction).payload
default:
return reducer(state, action)
}
}

const middleware: Middleware = (store) => {
// When receiving an action from main
ipcRenderer.on('electron-redux.ACTION', (_, action: Action) => {
ipcRenderer.on(IPCEvents.ACTION, (_, action: Action) => {
store.dispatch(stopForwarding(action))
})

return (next) => (action) => {
if (validateAction(action)) {
ipcRenderer.send('electron-redux.ACTION', action)
ipcRenderer.send(IPCEvents.ACTION, action)
}

return next(action)
}
}

export type RendererStateSyncEnhancerOptions = {
/**
* 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.
*/
reviver?: (this: unknown, key: string, value: unknown) => unknown
}

const defaultOptions: RendererStateSyncEnhancerOptions = {}

/**
* Creates new instance of renderer process redux enhancer.
* Upon initialization, it will fetch the state from the main process & subscribe for event
* communication required to keep the actions in sync.
* @param {RendererStateSyncEnhancerOptions} options Additional settings for enhancer
* @returns StoreEnhancer
*/
export const rendererStateSyncEnhancer = (options = defaultOptions): StoreEnhancer => (
export const rendererStateSyncEnhancer = (options = defaultRendererOptions): StoreEnhancer => (
createStore: StoreCreator
) => {
preventDoubleInitialization()

return (reducer, state) => {
const initialState = options.lazyInit ? state : fetchInitialState<typeof state>(options)
const store = createStore(
wrapReducer(reducer as any), // TODO: this needs some ❤️
state,
options.lazyInit ? withStoreReplacer(reducer) : reducer,
initialState,
applyMiddleware(middleware)
)

// This is the reason we need to be an enhancer, rather than a middleware.
// We use this (along with the wrapReducer function above) to dispatch an
// action that initializes the store without needing to fetch it synchronously.
fetchInitialState(options, (state) => {
store.dispatch(replaceState(state))
})
if (options.lazyInit) {
fetchInitialStateAsync(options, (asyncState) => {
store.dispatch(replaceState(asyncState) as never)
})
}

// TODO: this needs some ❤️
// XXX: TypeScript is dumb. If you return the call to createStore
Expand Down
2 changes: 1 addition & 1 deletion src/utils/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { isFSA, FluxStandardAction } from './isFSA'
const blacklist = [/^@@/, /^redux-form/]

// Gives us just enough action type info to work for the functions below
type ActionMeta = {
export type ActionMeta = {
scope?: 'local' | string
}

Expand Down
16 changes: 9 additions & 7 deletions tests/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe('End to End Tests', () => {
jest.setTimeout(6000)
app = new Application({
path: './node_modules/.bin/electron',
args: ['./e2e_dist/main/main.js'],
args: ['./dist/main/main.js'],
startTimeout: 5000,
host: process.env.CHROMEDRIVER_HOST || 'localhost',
port: parseInt(process.env.CHROMEDRIVER_PORT || '9515'),
Expand All @@ -28,22 +28,24 @@ describe('End to End Tests', () => {
})

it('+ button should increse counter on both renderer & main thread', async () => {
expect(await getText('#value')).toEqual('0')
expect(await getText('#value')).toEqual('10')
expect(await app.browserWindow.getTitle()).toEqual('10')

await click('#increment')

expect(await getText('#value')).toEqual('1')
expect(await getText('#value')).toEqual('11')
// eslint-disable-next-line @typescript-eslint/await-thenable
expect(await app.browserWindow.getTitle()).toEqual('1')
expect(await app.browserWindow.getTitle()).toEqual('11')
})

it('- button should decrease counter on both renderer & main thread', async () => {
expect(await getText('#value')).toEqual('0')
expect(await getText('#value')).toEqual('10')
expect(await app.browserWindow.getTitle()).toEqual('10')

await click('#decrement')

expect(await getText('#value')).toEqual('-1')
expect(await getText('#value')).toEqual('9')
// eslint-disable-next-line @typescript-eslint/await-thenable
expect(await app.browserWindow.getTitle()).toEqual('-1')
expect(await app.browserWindow.getTitle()).toEqual('9')
})
})
7 changes: 6 additions & 1 deletion tests/e2e/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { mainStateSyncEnhancer } from '../../..'

const isDevelopment = process.env.NODE_ENV !== 'production'

const store = createStore(reducer, mainStateSyncEnhancer())
const defaultState = {
count: 10,
}

const store = createStore(reducer, defaultState, mainStateSyncEnhancer())

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
Expand Down Expand Up @@ -38,6 +42,7 @@ function createWindow() {
})
)
}
renderValue()

// Emitted when the window is closed.
mainWindow.on('closed', () => {
Expand Down
Loading

0 comments on commit 09017fe

Please sign in to comment.