From d07e7b834831230b53860d0919a68edc2d36193d Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sat, 8 Jan 2022 21:36:43 +0100 Subject: build: Eslint fixes for multi-module project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kristóf Marussy --- .../src/contextBridge/SophieRendererImpl.ts | 96 -------- .../__tests__/SophieRendererImpl.spec.ts | 258 --------------------- .../__tests__/createSophieRenderer.spec.ts | 258 +++++++++++++++++++++ .../src/contextBridge/createSophieRenderer.ts | 96 ++++++++ 4 files changed, 354 insertions(+), 354 deletions(-) delete mode 100644 packages/preload/src/contextBridge/SophieRendererImpl.ts delete mode 100644 packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts create mode 100644 packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts create mode 100644 packages/preload/src/contextBridge/createSophieRenderer.ts (limited to 'packages/preload/src/contextBridge') diff --git a/packages/preload/src/contextBridge/SophieRendererImpl.ts b/packages/preload/src/contextBridge/SophieRendererImpl.ts deleted file mode 100644 index f3c07c5..0000000 --- a/packages/preload/src/contextBridge/SophieRendererImpl.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (C) 2021-2022 Kristóf Marussy - * - * This file is part of Sophie. - * - * Sophie is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ipcRenderer } from 'electron'; -import log from 'loglevel'; -import type { IJsonPatch } from 'mobx-state-tree'; -import { - Action, - action, - MainToRendererIpcMessage, - RendererToMainIpcMessage, - sharedStore, - SharedStoreListener, - SophieRenderer, -} from '@sophie/shared'; - -class SophieRendererImpl implements SophieRenderer { - private onSharedStoreChangeCalled: boolean = false; - - private listener: SharedStoreListener | null = null; - - constructor(private readonly allowReplaceListener: boolean) { - ipcRenderer.on(MainToRendererIpcMessage.SharedStorePatch, (_event, patch) => { - try { - // `mobx-state-tree` will validate the patch, so we can safely cast here. - this.listener?.onPatch(patch as IJsonPatch); - } catch (err) { - log.error('Shared store listener onPatch failed', err); - this.listener = null; - } - }); - } - - async onSharedStoreChange(listener: SharedStoreListener): Promise { - if (this.onSharedStoreChangeCalled && !this.allowReplaceListener) { - throw new Error('Shared store change listener was already set'); - } - this.onSharedStoreChangeCalled = true; - let success = false; - let snapshot: unknown | null = null; - try { - snapshot = await ipcRenderer.invoke(RendererToMainIpcMessage.GetSharedStoreSnapshot); - success = true; - } catch (err) { - log.error('Failed to get initial shared store snapshot', err); - } - if (success) { - if (sharedStore.is(snapshot)) { - listener.onSnapshot(snapshot); - this.listener = listener; - return; - } - log.error('Got invalid initial shared store snapshot', snapshot); - } - throw new Error('Failed to connect to shared store'); - } - - dispatchAction(actionToDispatch: Action): void { - // Let the full zod parse error bubble up to the main world, - // since all data it may contain was provided from the main world. - const parsedAction = action.parse(actionToDispatch); - try { - ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction); - } catch (err) { - // Do not leak IPC failure details into the main world. - const message = 'Failed to dispatch action'; - log.error(message, actionToDispatch, err); - throw new Error(message); - } - } -} - -export function createSophieRenderer(allowReplaceListener: boolean): SophieRenderer { - const impl = new SophieRendererImpl(allowReplaceListener); - return { - onSharedStoreChange: impl.onSharedStoreChange.bind(impl), - dispatchAction: impl.dispatchAction.bind(impl), - }; -} diff --git a/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts b/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts deleted file mode 100644 index ff77a63..0000000 --- a/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright (C) 2021-2022 Kristóf Marussy - * - * This file is part of Sophie. - * - * Sophie is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { jest } from '@jest/globals'; -import { mocked } from 'jest-mock'; -import log from 'loglevel'; -import type { IJsonPatch } from 'mobx-state-tree'; -import { - Action, - MainToRendererIpcMessage, - RendererToMainIpcMessage, - SharedStoreSnapshotIn, - SophieRenderer, -} from '@sophie/shared'; - -jest.unstable_mockModule('electron', () => ({ - ipcRenderer: { - invoke: jest.fn(), - on: jest.fn(), - send: jest.fn(), - }, -})); - -const { ipcRenderer } = await import('electron'); - -const { createSophieRenderer } = await import('../SophieRendererImpl'); - -const event: Electron.IpcRendererEvent = null as unknown as Electron.IpcRendererEvent; - -const snapshot: SharedStoreSnapshotIn = { - shouldUseDarkColors: true, -}; - -const invalidSnapshot = { - shouldUseDarkColors: -1, -} as unknown as SharedStoreSnapshotIn; - -const patch: IJsonPatch = { - op: 'replace', - path: 'foo', - value: 'bar', -}; - -const action: Action = { - action: 'set-theme-source', - themeSource: 'dark', -}; - -const invalidAction = { - action: 'not-a-valid-action', -} as unknown as Action; - -beforeAll(() => { - log.disableAll(); -}); - -describe('createSophieRenderer', () => { - it('registers a shared store patch listener', () => { - createSophieRenderer(false); - expect(ipcRenderer.on).toHaveBeenCalledWith( - MainToRendererIpcMessage.SharedStorePatch, - expect.anything(), - ); - }); -}); - -describe('SophieRendererImpl', () => { - let sut: SophieRenderer; - let onSharedStorePatch: (event1: Electron.IpcRendererEvent, patch1: unknown) => void; - let listener = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}), - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onPatch: jest.fn((_patch: IJsonPatch) => {}), - }; - - beforeEach(() => { - sut = createSophieRenderer(false); - onSharedStorePatch = mocked(ipcRenderer.on).mock.calls.find(([channel]) => { - return channel === MainToRendererIpcMessage.SharedStorePatch; - })?.[1]!; - }); - - describe('onSharedStoreChange', () => { - it('should request a snapshot from the main process', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); - await sut.onSharedStoreChange(listener); - expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); - expect(listener.onSnapshot).toBeCalledWith(snapshot); - }); - - it('should catch IPC errors without exposing them', async () => { - mocked(ipcRenderer.invoke).mockRejectedValue(new Error('s3cr3t')); - await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty( - 'message', - expect.stringMatching(/s3cr3t/), - ); - expect(listener.onSnapshot).not.toBeCalled(); - }); - - it('should not pass on invalid snapshots', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); - await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); - expect(listener.onSnapshot).not.toBeCalled(); - }); - }); - - describe('dispatchAction', () => { - it('should dispatch valid actions', () => { - sut.dispatchAction(action); - expect(ipcRenderer.send).toBeCalledWith(RendererToMainIpcMessage.DispatchAction, action); - }); - - it('should not dispatch invalid actions', () => { - expect(() => sut.dispatchAction(invalidAction)).toThrowError(); - expect(ipcRenderer.send).not.toBeCalled(); - }); - }); - - describe('when no listener is registered', () => { - it('should discard the received patch without any error', () => { - onSharedStorePatch(event, patch); - }); - }); - - function itRefusesToRegisterAnotherListener() { - it('should refuse to register another listener', async () => { - await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); - }); - } - - function itDoesNotPassPatchesToTheListener( - name: string = 'should not pass patches to the listener', - ) { - it(name, () => { - onSharedStorePatch(event, patch); - expect(listener.onPatch).not.toBeCalled(); - }); - } - - describe('when a listener registered successfully', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); - await sut.onSharedStoreChange(listener); - }); - - it('should pass patches to the listener', () => { - onSharedStorePatch(event, patch); - expect(listener.onPatch).toBeCalledWith(patch); - }); - - it('should catch listener errors', () => { - mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); - onSharedStorePatch(event, patch); - }); - - itRefusesToRegisterAnotherListener(); - - describe('after the listener threw in onPatch', () => { - beforeEach(() => { - mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); - onSharedStorePatch(event, patch); - listener.onPatch.mockRestore(); - }); - - itDoesNotPassPatchesToTheListener('should not pass on patches any more'); - }); - }); - - describe('when a listener failed to register due to IPC error', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockRejectedValue(new Error()); - try { - await sut.onSharedStoreChange(listener); - } catch { - // Ignore error. - } - }); - - itRefusesToRegisterAnotherListener(); - - itDoesNotPassPatchesToTheListener(); - }); - - describe('when a listener failed to register due to an invalid snapshot', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); - try { - await sut.onSharedStoreChange(listener); - } catch { - // Ignore error. - } - }); - - itRefusesToRegisterAnotherListener(); - - itDoesNotPassPatchesToTheListener(); - }); - - describe('when a listener failed to register due to listener error', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); - mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); }); - try { - await sut.onSharedStoreChange(listener); - } catch { - // Ignore error. - } - }); - - itRefusesToRegisterAnotherListener(); - - itDoesNotPassPatchesToTheListener(); - }); - - describe('when it is allowed to replace listeners', () => { - const snapshot2 = { - shouldUseDarkColors: false, - }; - const listener2 = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => { }), - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onPatch: jest.fn((_patch: IJsonPatch) => { }), - }; - - it('should fetch a second snapshot', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); - await sut.onSharedStoreChange(listener2); - expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); - expect(listener2.onSnapshot).toBeCalledWith(snapshot2); - }); - - it('should pass the second snapshot to the new listener', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); - await sut.onSharedStoreChange(listener2); - onSharedStorePatch(event, patch); - expect(listener2.onPatch).toBeCalledWith(patch); - }); - }); -}); diff --git a/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts new file mode 100644 index 0000000..a38dbac --- /dev/null +++ b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2021-2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { + Action, + MainToRendererIpcMessage, + RendererToMainIpcMessage, + SharedStoreSnapshotIn, + SophieRenderer, +} from '@sophie/shared'; +import { mocked } from 'jest-mock'; +import log from 'loglevel'; +import type { IJsonPatch } from 'mobx-state-tree'; + +jest.unstable_mockModule('electron', () => ({ + ipcRenderer: { + invoke: jest.fn(), + on: jest.fn(), + send: jest.fn(), + }, +})); + +const { ipcRenderer } = await import('electron'); + +const { default: createSophieRenderer } = await import('../createSophieRenderer'); + +const event: Electron.IpcRendererEvent = null as unknown as Electron.IpcRendererEvent; + +const snapshot: SharedStoreSnapshotIn = { + shouldUseDarkColors: true, +}; + +const invalidSnapshot = { + shouldUseDarkColors: -1, +} as unknown as SharedStoreSnapshotIn; + +const patch: IJsonPatch = { + op: 'replace', + path: 'foo', + value: 'bar', +}; + +const action: Action = { + action: 'set-theme-source', + themeSource: 'dark', +}; + +const invalidAction = { + action: 'not-a-valid-action', +} as unknown as Action; + +beforeAll(() => { + log.disableAll(); +}); + +describe('createSophieRenderer', () => { + it('registers a shared store patch listener', () => { + createSophieRenderer(false); + expect(ipcRenderer.on).toHaveBeenCalledWith( + MainToRendererIpcMessage.SharedStorePatch, + expect.anything(), + ); + }); +}); + +describe('SharedStoreConnector', () => { + let sut: SophieRenderer; + let onSharedStorePatch: (eventArg: Electron.IpcRendererEvent, patchArg: unknown) => void; + const listener = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}), + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onPatch: jest.fn((_patch: IJsonPatch) => {}), + }; + + beforeEach(() => { + sut = createSophieRenderer(false); + [, onSharedStorePatch] = mocked(ipcRenderer.on).mock.calls.find( + ([channel]) => channel === MainToRendererIpcMessage.SharedStorePatch, + )!; + }); + + describe('onSharedStoreChange', () => { + it('should request a snapshot from the main process', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); + await sut.onSharedStoreChange(listener); + expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); + expect(listener.onSnapshot).toBeCalledWith(snapshot); + }); + + it('should catch IPC errors without exposing them', async () => { + mocked(ipcRenderer.invoke).mockRejectedValue(new Error('s3cr3t')); + await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty( + 'message', + expect.stringMatching(/s3cr3t/), + ); + expect(listener.onSnapshot).not.toBeCalled(); + }); + + it('should not pass on invalid snapshots', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); + await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); + expect(listener.onSnapshot).not.toBeCalled(); + }); + }); + + describe('dispatchAction', () => { + it('should dispatch valid actions', () => { + sut.dispatchAction(action); + expect(ipcRenderer.send).toBeCalledWith(RendererToMainIpcMessage.DispatchAction, action); + }); + + it('should not dispatch invalid actions', () => { + expect(() => sut.dispatchAction(invalidAction)).toThrowError(); + expect(ipcRenderer.send).not.toBeCalled(); + }); + }); + + describe('when no listener is registered', () => { + it('should discard the received patch without any error', () => { + onSharedStorePatch(event, patch); + }); + }); + + function itRefusesToRegisterAnotherListener(): void { + it('should refuse to register another listener', async () => { + await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); + }); + } + + function itDoesNotPassPatchesToTheListener( + name = 'should not pass patches to the listener', + ): void { + it(name, () => { + onSharedStorePatch(event, patch); + expect(listener.onPatch).not.toBeCalled(); + }); + } + + describe('when a listener registered successfully', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); + await sut.onSharedStoreChange(listener); + }); + + it('should pass patches to the listener', () => { + onSharedStorePatch(event, patch); + expect(listener.onPatch).toBeCalledWith(patch); + }); + + it('should catch listener errors', () => { + mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); + onSharedStorePatch(event, patch); + }); + + itRefusesToRegisterAnotherListener(); + + describe('after the listener threw in onPatch', () => { + beforeEach(() => { + mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); + onSharedStorePatch(event, patch); + listener.onPatch.mockRestore(); + }); + + itDoesNotPassPatchesToTheListener('should not pass on patches any more'); + }); + }); + + describe('when a listener failed to register due to IPC error', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockRejectedValue(new Error()); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when a listener failed to register due to an invalid snapshot', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when a listener failed to register due to listener error', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); + mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); }); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when it is allowed to replace listeners', () => { + const snapshot2 = { + shouldUseDarkColors: false, + }; + const listener2 = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => { }), + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onPatch: jest.fn((_patch: IJsonPatch) => { }), + }; + + it('should fetch a second snapshot', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); + await sut.onSharedStoreChange(listener2); + expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); + expect(listener2.onSnapshot).toBeCalledWith(snapshot2); + }); + + it('should pass the second snapshot to the new listener', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); + await sut.onSharedStoreChange(listener2); + onSharedStorePatch(event, patch); + expect(listener2.onPatch).toBeCalledWith(patch); + }); + }); +}); diff --git a/packages/preload/src/contextBridge/createSophieRenderer.ts b/packages/preload/src/contextBridge/createSophieRenderer.ts new file mode 100644 index 0000000..2055080 --- /dev/null +++ b/packages/preload/src/contextBridge/createSophieRenderer.ts @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2021-2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + Action, + action, + MainToRendererIpcMessage, + RendererToMainIpcMessage, + sharedStore, + SharedStoreListener, + SophieRenderer, +} from '@sophie/shared'; +import { ipcRenderer } from 'electron'; +import log from 'loglevel'; +import type { IJsonPatch } from 'mobx-state-tree'; + +class SharedStoreConnector { + private onSharedStoreChangeCalled = false; + + private listener: SharedStoreListener | null = null; + + constructor(private readonly allowReplaceListener: boolean) { + ipcRenderer.on(MainToRendererIpcMessage.SharedStorePatch, (_event, patch) => { + try { + // `mobx-state-tree` will validate the patch, so we can safely cast here. + this.listener?.onPatch(patch as IJsonPatch); + } catch (err) { + log.error('Shared store listener onPatch failed', err); + this.listener = null; + } + }); + } + + async onSharedStoreChange(listener: SharedStoreListener): Promise { + if (this.onSharedStoreChangeCalled && !this.allowReplaceListener) { + throw new Error('Shared store change listener was already set'); + } + this.onSharedStoreChangeCalled = true; + let success = false; + let snapshot: unknown | null = null; + try { + snapshot = await ipcRenderer.invoke(RendererToMainIpcMessage.GetSharedStoreSnapshot); + success = true; + } catch (err) { + log.error('Failed to get initial shared store snapshot', err); + } + if (success) { + if (sharedStore.is(snapshot)) { + listener.onSnapshot(snapshot); + this.listener = listener; + return; + } + log.error('Got invalid initial shared store snapshot', snapshot); + } + throw new Error('Failed to connect to shared store'); + } +} + +function dispatchAction(actionToDispatch: Action): void { + // Let the full zod parse error bubble up to the main world, + // since all data it may contain was provided from the main world. + const parsedAction = action.parse(actionToDispatch); + try { + ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction); + } catch (err) { + // Do not leak IPC failure details into the main world. + const message = 'Failed to dispatch action'; + log.error(message, actionToDispatch, err); + throw new Error(message); + } +} + +export default function createSophieRenderer(allowReplaceListener: boolean): SophieRenderer { + const connector = new SharedStoreConnector(allowReplaceListener); + return { + onSharedStoreChange: connector.onSharedStoreChange.bind(connector), + dispatchAction, + }; +} -- cgit v1.2.3-54-g00ecf