diff --git a/package.json b/package.json index 279371d5..2279689a 100644 --- a/package.json +++ b/package.json @@ -94,8 +94,7 @@ }, "renderer": { "sourceDirectory": "tests/e2e/renderer" - }, - "commonDistDirectory": "e2e_dist" + } }, "husky": { "hooks": { diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 00000000..4f1eb93c --- /dev/null +++ b/src/constants/index.ts @@ -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', +} diff --git a/src/fetchState/fetchInitialState.ts b/src/fetchState/fetchInitialState.ts new file mode 100644 index 00000000..88dfe693 --- /dev/null +++ b/src/fetchState/fetchInitialState.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' +import { IPCEvents } from '../constants' +import { RendererStateSyncEnhancerOptions } from '../options/RendererStateSyncEnhancerOptions' + +function fetchInitialState(options: RendererStateSyncEnhancerOptions): T { + const state = ipcRenderer.sendSync(IPCEvents.INIT_STATE) + return JSON.parse(state, options.deserializer) +} + +export default fetchInitialState diff --git a/src/fetchState/fetchInitialStateAsync.ts b/src/fetchState/fetchInitialStateAsync.ts new file mode 100644 index 00000000..ac785479 --- /dev/null +++ b/src/fetchState/fetchInitialStateAsync.ts @@ -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 { + // 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 diff --git a/src/fetchState/index.ts b/src/fetchState/index.ts new file mode 100644 index 00000000..4af0da40 --- /dev/null +++ b/src/fetchState/index.ts @@ -0,0 +1,4 @@ +import fetchInitialState from './fetchInitialState' +import fetchInitialStateAsync from './fetchInitialStateAsync' + +export { fetchInitialState, fetchInitialStateAsync } diff --git a/src/fetchState/replaceState.ts b/src/fetchState/replaceState.ts new file mode 100644 index 00000000..da10a81f --- /dev/null +++ b/src/fetchState/replaceState.ts @@ -0,0 +1,32 @@ +import { AnyAction, Reducer } from 'redux' +import { FluxStandardAction } from '../utils/isFSA' +import { ActionMeta } from '../utils' + +interface ReplaceStateAction extends FluxStandardAction { + 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 = (state: S): ReplaceStateAction => ({ + type: REPLACE_STATE, + payload: state, + meta: { scope: 'local' }, +}) + +export const withStoreReplacer = (reducer: Reducer) => ( + state: S | undefined, + action: A +): S => { + switch (action.type) { + case REPLACE_STATE: + return (action as any).payload + default: + return reducer(state, action) + } +} diff --git a/src/mainStateSyncEnhancer.ts b/src/mainStateSyncEnhancer.ts index c25b41a8..19a97935 100644 --- a/src/mainStateSyncEnhancer.ts +++ b/src/mainStateSyncEnhancer.ts @@ -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) @@ -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) } }) }) @@ -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) }) } @@ -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() diff --git a/src/options/MainStateSyncEnhancerOptions.ts b/src/options/MainStateSyncEnhancerOptions.ts new file mode 100644 index 00000000..cfc74b09 --- /dev/null +++ b/src/options/MainStateSyncEnhancerOptions.ts @@ -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 = {} diff --git a/src/options/RendererStateSyncEnhancerOptions.ts b/src/options/RendererStateSyncEnhancerOptions.ts new file mode 100644 index 00000000..593f4ea9 --- /dev/null +++ b/src/options/RendererStateSyncEnhancerOptions.ts @@ -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 = {} diff --git a/src/rendererStateSyncEnhancer.ts b/src/rendererStateSyncEnhancer.ts index aef47148..dfb6c584 100644 --- a/src/rendererStateSyncEnhancer.ts +++ b/src/rendererStateSyncEnhancer.ts @@ -1,82 +1,27 @@ 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 - -/** - * 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 = (state: S) => ({ - type: 'electron-redux.REPLACE_STATE' as const, - payload: state, - meta: { scope: 'local' }, -}) - -const wrapReducer = (reducer: Reducer) => ( - 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 @@ -84,24 +29,24 @@ const defaultOptions: RendererStateSyncEnhancerOptions = {} * @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(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 diff --git a/src/utils/actions.ts b/src/utils/actions.ts index 8673d873..e9344f7e 100644 --- a/src/utils/actions.ts +++ b/src/utils/actions.ts @@ -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 } diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index ff7bdb35..40a0a11f 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -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'), @@ -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') }) }) diff --git a/tests/e2e/main/index.ts b/tests/e2e/main/index.ts index da27e899..2591aa2c 100644 --- a/tests/e2e/main/index.ts +++ b/tests/e2e/main/index.ts @@ -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. @@ -38,6 +42,7 @@ function createWindow() { }) ) } + renderValue() // Emitted when the window is closed. mainWindow.on('closed', () => { diff --git a/tests/e2e/renderer/index.ts b/tests/e2e/renderer/index.ts index 1da575fe..dfe447a1 100644 --- a/tests/e2e/renderer/index.ts +++ b/tests/e2e/renderer/index.ts @@ -7,10 +7,9 @@ const store = createStore(reducer, rendererStateSyncEnhancer()) function mount() { document.getElementById('app')!.innerHTML = `

- Clicked: 0 times + Clicked: 0 times
-

` @@ -21,10 +20,6 @@ function mount() { document.getElementById('decrement')!.addEventListener('click', () => { store.dispatch({ type: 'DECREMENT' }) }) - - // document.getElementById('incrementAliased').addEventListener('click', () => { - // store.dispatch(createAliasedAction('INCREMENT_ALIASED', () => ({ type: 'INCREMENT' }))()); - // }); } function renderValue() { diff --git a/tests/fetchState.spec.ts b/tests/fetchState.spec.ts new file mode 100644 index 00000000..61892e6a --- /dev/null +++ b/tests/fetchState.spec.ts @@ -0,0 +1,32 @@ +import { fetchInitialState, fetchInitialStateAsync } from '../src/fetchState' +import { IPCEvents } from '../src/constants' +jest.mock('electron', () => ({ + ipcRenderer: { + sendSync: jest.fn(), + invoke: jest.fn(), + }, +})) +import { ipcRenderer } from 'electron' + +describe('Store synchronization', () => { + describe('using synchronous method', () => { + it('should receive and deserialize simple state', () => { + const ipcSpy = jest.spyOn(ipcRenderer, 'sendSync').mockReturnValue('{ "count": 1 }') + const state = fetchInitialState({}) + expect(ipcSpy).toHaveBeenCalledTimes(1) + expect(ipcSpy).toHaveBeenCalledWith(IPCEvents.INIT_STATE) + expect(state).toEqual({ count: 1 }) + }) + }) + describe('using asynchronous method', () => { + it('should receive and deserialize simple state', (done) => { + const ipcSpy = jest.spyOn(ipcRenderer, 'invoke').mockResolvedValue('{ "count": 1 }') + fetchInitialStateAsync({}, (state) => { + expect(ipcSpy).toHaveBeenCalledTimes(1) + expect(ipcSpy).toHaveBeenCalledWith(IPCEvents.INIT_STATE_ASYNC) + expect(state).toEqual({ count: 1 }) + done() + }) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index dc9a8ab7..b38b9d71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1371,15 +1371,7 @@ is-module "^1.0.0" resolve "^1.17.0" -"@rollup/plugin-replace@^2.3.3": - version "2.3.3" - resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-2.3.3.tgz#cd6bae39444de119f5d905322b91ebd4078562e7" - integrity sha512-XPmVXZ7IlaoWaJLkSCDaa0Y6uVo5XQYHhiMFzOd5qSv5rE+t/UJToPIOE56flKIxBFQI27ONsxb7dqHnwSsjKQ== - dependencies: - "@rollup/pluginutils" "^3.0.8" - magic-string "^0.25.5" - -"@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0": +"@rollup/pluginutils@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== @@ -7365,7 +7357,7 @@ jest-worker@^25.4.0: merge-stream "^2.0.0" supports-color "^7.0.0" -jest-worker@^26.2.1, jest-worker@^26.3.0: +jest-worker@^26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.3.0.tgz#7c8a97e4f4364b4f05ed8bca8ca0c24de091871f" integrity sha512-Vmpn2F6IASefL+DVBhPzI2J9/GJUsqzomdeN+P+dK8/jKxbh8R3BtFnx3FIta7wYlPU62cpJMJQo4kuOowcMnw== @@ -8125,7 +8117,7 @@ macos-release@^2.2.0: resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.4.1.tgz#64033d0ec6a5e6375155a74b1a1eba8e509820ac" integrity sha512-H/QHeBIN1fIGJX517pvK8IEK53yQOW7YcEI55oYtgjDdoCQQz7eJS94qt5kNrscReEyuD/JcdFCm2XBEcGOITg== -magic-string@^0.25.5, magic-string@^0.25.7: +magic-string@^0.25.7: version "0.25.7" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== @@ -10615,16 +10607,6 @@ rollup-plugin-babel@^4.4.0: "@babel/helper-module-imports" "^7.0.0" rollup-pluginutils "^2.8.1" -rollup-plugin-terser@^7.0.2: - version "7.0.2" - resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d" - integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ== - dependencies: - "@babel/code-frame" "^7.10.4" - jest-worker "^26.2.1" - serialize-javascript "^4.0.0" - terser "^5.0.0" - rollup-plugin-typescript2@^0.27.2: version "0.27.2" resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.27.2.tgz#871a7f5d2a774f9cef50d25da868eec72acc2ed8" @@ -11730,15 +11712,6 @@ terser@^4.1.2, terser@^4.6.12, terser@^4.6.3: source-map "~0.6.1" source-map-support "~0.5.12" -terser@^5.0.0: - version "5.3.2" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.3.2.tgz#f4bea90eb92945b2a028ceef79181b9bb586e7af" - integrity sha512-H67sydwBz5jCUA32ZRL319ULu+Su1cAoZnnc+lXnenGRYWyLE3Scgkt8mNoAsMx0h5kdo758zdoS0LG9rYZXDQ== - dependencies: - commander "^2.20.0" - source-map "~0.6.1" - source-map-support "~0.5.12" - test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e"