diff --git a/packages/client/src/middleware/WaitMiddleware.test.ts b/packages/client/src/middleware/WaitMiddleware.test.ts index e2240bd..1846d85 100644 --- a/packages/client/src/middleware/WaitMiddleware.test.ts +++ b/packages/client/src/middleware/WaitMiddleware.test.ts @@ -15,95 +15,118 @@ describe('WaitMiddleware', () => { applyMiddleware(wm.handle), ) - it('waits for certain async actions to be resolved', async () => { - const wm = new WaitMiddleware() - const store = getStore(wm) - const promise = wm.wait(['B', 'C']) - store.dispatch({ - payload: undefined, - type: 'A', - status: 'resolved', + describe('wait', () => { + it('waits for certain async actions to be resolved', async () => { + const wm = new WaitMiddleware() + const store = getStore(wm) + const promise = wm.wait(['B', 'C']) + store.dispatch({ + payload: undefined, + type: 'A', + status: 'resolved', + }) + store.dispatch({ + payload: undefined, + type: 'B', + status: 'resolved', + }) + store.dispatch({ + payload: undefined, + type: 'C', + status: 'resolved', + }) + await promise + expect(store.getState().slice(1)).toEqual(['A', 'B', 'C']) }) - store.dispatch({ - payload: undefined, - type: 'B', - status: 'resolved', + + it('times out when actions do not happen', async () => { + const wm = new WaitMiddleware() + const store = getStore(wm) + const promise = wm.wait(['B', 'C'], 5) + store.dispatch({ + payload: undefined, + type: 'A', + status: 'resolved', + }) + store.dispatch({ + payload: undefined, + type: 'B', + status: 'resolved', + }) + const error = await getError(promise) + expect(error.message).toMatch(/timed/) }) - store.dispatch({ - payload: undefined, - type: 'C', - status: 'resolved', + + it('errors out when a promise is rejected', async () => { + const wm = new WaitMiddleware() + const store = getStore(wm) + const promise = wm.wait(['B', 'C']) + store.dispatch({ + payload: undefined, + type: 'A', + status: 'resolved', + }) + store.dispatch({ + payload: new Error('test'), + type: 'B', + status: 'rejected', + }) + const error = await getError(promise) + expect(error.message).toMatch(/test/) + }) + + it('errors out when wait is called twice', async () => { + const wm = new WaitMiddleware() + const store = getStore(wm) + const promise = wm.wait(['B']) + const error = await getError(wm.wait(['B'])) + expect(error.message).toMatch(/already waiting/) + store.dispatch({ + payload: new Error('test'), + type: 'B', + status: 'resolved', + }) + await promise + }) + + it('does nothing when pending is dispatched', async () => { + const wm = new WaitMiddleware() + const store = getStore(wm) + const promise = wm.wait(['B'], 1) + store.dispatch({ + payload: undefined, + type: 'B', + status: 'pending', + }) + const error = await getError(promise) + expect(error.message).toMatch(/timed/) + }) + + it('resolved immediately when no actions are defined', async () => { + const wm = new WaitMiddleware() + await wm.wait([]) }) - await promise - expect(store.getState().slice(1)).toEqual(['A', 'B', 'C']) }) - it('times out when actions do not happen', async () => { - const wm = new WaitMiddleware() - const store = getStore(wm) - const promise = wm.wait(['B', 'C'], 5) - store.dispatch({ - payload: undefined, - type: 'A', - status: 'resolved', - }) - store.dispatch({ - payload: undefined, - type: 'B', - status: 'resolved', - }) - const error = await getError(promise) - expect(error.message).toMatch(/timed/) - }) + describe('record', () => { - it('errors out when a promise is rejected', async () => { - const wm = new WaitMiddleware() - const store = getStore(wm) - const promise = wm.wait(['B', 'C']) - store.dispatch({ - payload: undefined, - type: 'A', - status: 'resolved', + it('records pending actions', async () => { + const wm = new WaitMiddleware() + const store = getStore(wm) + const recorder = wm.record() + store.dispatch({ + payload: undefined, + type: 'B', + status: 'pending', + }) + const promise = wm.waitForRecorded(recorder) + store.dispatch({ + payload: undefined, + type: 'B', + status: 'resolved', + }) + await promise }) - store.dispatch({ - payload: new Error('test'), - type: 'B', - status: 'rejected', - }) - const error = await getError(promise) - expect(error.message).toMatch(/test/) - }) - - it('errors out when wait is called twice', async () => { - const wm = new WaitMiddleware() - const store = getStore(wm) - const promise = wm.wait(['B']) - const error = await getError(wm.wait(['B'])) - expect(error.message).toMatch(/already waiting/) - store.dispatch({ - payload: new Error('test'), - type: 'B', - status: 'resolved', - }) - await promise - }) - - it('does nothing when pending is dispatched', async () => { - const wm = new WaitMiddleware() - const store = getStore(wm) - const promise = wm.wait(['B'], 1) - store.dispatch({ - payload: undefined, - type: 'B', - status: 'pending', - }) - const error = await getError(promise) - expect(error.message).toMatch(/timed/) - }) - - it('resolved immediately when no actions are defined', async () => { - const wm = new WaitMiddleware() - await wm.wait([]) }) }) diff --git a/packages/client/src/middleware/WaitMiddleware.ts b/packages/client/src/middleware/WaitMiddleware.ts index 67a59ab..a195249 100644 --- a/packages/client/src/middleware/WaitMiddleware.ts +++ b/packages/client/src/middleware/WaitMiddleware.ts @@ -3,14 +3,46 @@ import {AnyAction, Middleware} from 'redux' export class WaitMiddleware { protected notify?: (action: TAsyncAction) => void + protected recorders: Recorder[] = [] handle: Middleware = store => next => (action: AnyAction) => { next(action) + this.recorders.forEach(recorder => recorder.record(action)) if (this.notify && 'status' in action) { this.notify(action as TAsyncAction) } } + /** + * Starts recording pending actions and returns the recorder. + */ + record(): Recorder { + const recorder = new Recorder() + this.recorders.push(recorder) + return recorder + } + + /** + * Stops recording pending actions + */ + stopRecording(recorder: Recorder): void { + const index = this.recorders.indexOf(recorder) + this.recorders.splice(index, index + 1) + } + + /** + * Stops recording, Waits for recorded pending actions to be resolved or + * rejected. Throws an error if any actions is rejected. + */ + async waitForRecorded(recorder: Recorder, timeout?: number): Promise { + this.stopRecording(recorder) + await this.wait(recorder.getActionTypes(), timeout) + } + + /** + * Waits for actions to be resolved or rejected. Times out after 10 seconds + * by default. + */ async wait(actions: string[], timeout = 10000): Promise { if (this.notify) { throw new Error('WaitMiddleware.wait - already waiting!') @@ -59,3 +91,17 @@ export class WaitMiddleware { }) } } + +class Recorder { + protected actionTypes: string[] = [] + + getActionTypes(): string[] { + return this.actionTypes.slice() + } + + record(action: AnyAction) { + if ('status' in action && action.status === 'pending') { + this.actionTypes.push(action.type) + } + } +} diff --git a/packages/client/src/renderer/ClientRenderer.tsx b/packages/client/src/renderer/ClientRenderer.tsx index 2d219e4..6dfc93e 100644 --- a/packages/client/src/renderer/ClientRenderer.tsx +++ b/packages/client/src/renderer/ClientRenderer.tsx @@ -7,31 +7,29 @@ import {IHTTPClient, HTTPClient} from '../http' import {IRenderer} from './IRenderer' import {Provider} from 'react-redux' import {Router} from 'react-router-dom' -import {TStoreFactory} from './TStoreFactory' +import {Store} from 'redux' import {createBrowserHistory} from 'history' -export interface IClientRendererParams< - State, A extends Action, D extends IAPIDef> { - readonly createStore: TStoreFactory, +export interface IClientRendererParams { readonly RootComponent: React.ComponentType<{ config: IClientConfig, http: IHTTPClient }>, readonly target?: HTMLElement + readonly hydrate: boolean // TODO make this better } -export class ClientRenderer +export class ClientRenderer implements IRenderer { - constructor(readonly params: IClientRendererParams) {} + constructor(readonly params: IClientRendererParams) {} - render( + render( url: string, + store: Store, config = (window as any).__APP_CONFIG__ as IClientConfig, - state = (window as any).__PRELOADED_STATE__, ) { const { RootComponent, - createStore, target = document.getElementById('container'), } = this.params @@ -41,8 +39,7 @@ export class ClientRenderer basename: config.baseUrl, }) - if (state) { - const store = createStore(state) + if (this.params.hydrate) { ReactDOM.hydrate( @@ -52,7 +49,6 @@ export class ClientRenderer target, ) } else { - const store = createStore() ReactDOM.render( diff --git a/packages/client/src/renderer/IRenderer.ts b/packages/client/src/renderer/IRenderer.ts index 2aa76f0..31e7450 100644 --- a/packages/client/src/renderer/IRenderer.ts +++ b/packages/client/src/renderer/IRenderer.ts @@ -1,10 +1,11 @@ import {IAPIDef} from '@rondo/common' import {IClientConfig} from './IClientConfig' +import {Store} from 'redux' export interface IRenderer { - render( + render( url: string, + store: Store, config: IClientConfig, - state?: any, ): any } diff --git a/packages/client/src/renderer/ServerRenderer.tsx b/packages/client/src/renderer/ServerRenderer.tsx index d7fb3b6..6141ef5 100644 --- a/packages/client/src/renderer/ServerRenderer.tsx +++ b/packages/client/src/renderer/ServerRenderer.tsx @@ -7,26 +7,24 @@ import {IRenderer} from './IRenderer' import {Provider} from 'react-redux' import {StaticRouterContext} from 'react-router' import {StaticRouter} from 'react-router-dom' -import {TStoreFactory} from './TStoreFactory' +import {Store} from 'redux' import {renderToNodeStream} from 'react-dom/server' -export class ServerRenderer +export class ServerRenderer implements IRenderer { constructor( - readonly createStore: TStoreFactory, readonly RootComponent: React.ComponentType<{ config: IClientConfig, http: IHTTPClient }>, ) {} - render( + render( url: string, + store: Store, config: IClientConfig, - state?: any, host: string = '', ) { const {RootComponent} = this - const store = this.createStore(state) const http = new HTTPClient(host + config.baseUrl + '/api') const context: StaticRouterContext = {} diff --git a/packages/client/src/renderer/TStoreFactory.ts b/packages/client/src/renderer/TStoreFactory.ts deleted file mode 100644 index 7588a9d..0000000 --- a/packages/client/src/renderer/TStoreFactory.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {Action, Store} from 'redux' - -// TODO maybe Store should also be typed -export type TStoreFactory = - (state?: State) => Store diff --git a/packages/client/src/renderer/index.ts b/packages/client/src/renderer/index.ts index c94a045..51f9bc3 100644 --- a/packages/client/src/renderer/index.ts +++ b/packages/client/src/renderer/index.ts @@ -1,5 +1,4 @@ export * from './ClientRenderer' export * from './IClientConfig' export * from './IRenderer' -export * from './TStoreFactory' export * from './isClientSide' diff --git a/packages/client/src/store/createStore.ts b/packages/client/src/store/createStore.ts index dc56c09..fcf3a96 100644 --- a/packages/client/src/store/createStore.ts +++ b/packages/client/src/store/createStore.ts @@ -10,8 +10,9 @@ import { export interface ICreateStoreParams { reducer: Reducer - state?: DeepPartial + state?: Partial middleware?: Middleware[] + extraMiddleware?: Middleware[] } /** @@ -27,8 +28,10 @@ export function createStore( && typeof window.localStorage.log !== 'undefined', ).handle, new PromiseMiddleware().handle, - new WaitMiddleware().handle, ] + if (params.extraMiddleware) { + middleware.push(...params.extraMiddleware) + } return (state?: DeepPartial) => create( params.reducer, state,