Extract store creation

This commit is contained in:
Jerko Steiner 2019-05-09 15:34:08 +12:00
parent 279b859e7b
commit 871018684b
8 changed files with 171 additions and 110 deletions

View File

@ -15,95 +15,118 @@ describe('WaitMiddleware', () => {
applyMiddleware(wm.handle), applyMiddleware(wm.handle),
) )
it('waits for certain async actions to be resolved', async () => { describe('wait', () => {
const wm = new WaitMiddleware() it('waits for certain async actions to be resolved', async () => {
const store = getStore(wm) const wm = new WaitMiddleware()
const promise = wm.wait(['B', 'C']) const store = getStore(wm)
store.dispatch({ const promise = wm.wait(['B', 'C'])
payload: undefined, store.dispatch({
type: 'A', payload: undefined,
status: 'resolved', 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, it('times out when actions do not happen', async () => {
type: 'B', const wm = new WaitMiddleware()
status: 'resolved', 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, it('errors out when a promise is rejected', async () => {
type: 'C', const wm = new WaitMiddleware()
status: 'resolved', 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 () => { describe('record', () => {
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/)
})
it('errors out when a promise is rejected', async () => { it('records pending actions', async () => {
const wm = new WaitMiddleware() const wm = new WaitMiddleware()
const store = getStore(wm) const store = getStore(wm)
const promise = wm.wait(['B', 'C']) const recorder = wm.record()
store.dispatch({ store.dispatch({
payload: undefined, payload: undefined,
type: 'A', type: 'B',
status: 'resolved', 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([])
}) })
}) })

View File

@ -3,14 +3,46 @@ import {AnyAction, Middleware} from 'redux'
export class WaitMiddleware { export class WaitMiddleware {
protected notify?: (action: TAsyncAction<any, string>) => void protected notify?: (action: TAsyncAction<any, string>) => void
protected recorders: Recorder[] = []
handle: Middleware = store => next => (action: AnyAction) => { handle: Middleware = store => next => (action: AnyAction) => {
next(action) next(action)
this.recorders.forEach(recorder => recorder.record(action))
if (this.notify && 'status' in action) { if (this.notify && 'status' in action) {
this.notify(action as TAsyncAction<any, string>) this.notify(action as TAsyncAction<any, string>)
} }
} }
/**
* 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<void> {
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<void> { async wait(actions: string[], timeout = 10000): Promise<void> {
if (this.notify) { if (this.notify) {
throw new Error('WaitMiddleware.wait - already waiting!') 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)
}
}
}

View File

@ -7,31 +7,29 @@ import {IHTTPClient, HTTPClient} from '../http'
import {IRenderer} from './IRenderer' import {IRenderer} from './IRenderer'
import {Provider} from 'react-redux' import {Provider} from 'react-redux'
import {Router} from 'react-router-dom' import {Router} from 'react-router-dom'
import {TStoreFactory} from './TStoreFactory' import {Store} from 'redux'
import {createBrowserHistory} from 'history' import {createBrowserHistory} from 'history'
export interface IClientRendererParams< export interface IClientRendererParams<A extends Action, D extends IAPIDef> {
State, A extends Action, D extends IAPIDef> {
readonly createStore: TStoreFactory<State, A | any>,
readonly RootComponent: React.ComponentType<{ readonly RootComponent: React.ComponentType<{
config: IClientConfig, config: IClientConfig,
http: IHTTPClient<D> http: IHTTPClient<D>
}>, }>,
readonly target?: HTMLElement readonly target?: HTMLElement
readonly hydrate: boolean // TODO make this better
} }
export class ClientRenderer<State, A extends Action, D extends IAPIDef> export class ClientRenderer<A extends Action, D extends IAPIDef>
implements IRenderer { implements IRenderer {
constructor(readonly params: IClientRendererParams<State, A, D>) {} constructor(readonly params: IClientRendererParams<A, D>) {}
render( render<State>(
url: string, url: string,
store: Store<State>,
config = (window as any).__APP_CONFIG__ as IClientConfig, config = (window as any).__APP_CONFIG__ as IClientConfig,
state = (window as any).__PRELOADED_STATE__,
) { ) {
const { const {
RootComponent, RootComponent,
createStore,
target = document.getElementById('container'), target = document.getElementById('container'),
} = this.params } = this.params
@ -41,8 +39,7 @@ export class ClientRenderer<State, A extends Action, D extends IAPIDef>
basename: config.baseUrl, basename: config.baseUrl,
}) })
if (state) { if (this.params.hydrate) {
const store = createStore(state)
ReactDOM.hydrate( ReactDOM.hydrate(
<Provider store={store}> <Provider store={store}>
<Router history={history}> <Router history={history}>
@ -52,7 +49,6 @@ export class ClientRenderer<State, A extends Action, D extends IAPIDef>
target, target,
) )
} else { } else {
const store = createStore()
ReactDOM.render( ReactDOM.render(
<Provider store={store}> <Provider store={store}>
<Router history={history}> <Router history={history}>

View File

@ -1,10 +1,11 @@
import {IAPIDef} from '@rondo/common' import {IAPIDef} from '@rondo/common'
import {IClientConfig} from './IClientConfig' import {IClientConfig} from './IClientConfig'
import {Store} from 'redux'
export interface IRenderer { export interface IRenderer {
render( render<State>(
url: string, url: string,
store: Store<State>,
config: IClientConfig, config: IClientConfig,
state?: any,
): any ): any
} }

View File

@ -7,26 +7,24 @@ import {IRenderer} from './IRenderer'
import {Provider} from 'react-redux' import {Provider} from 'react-redux'
import {StaticRouterContext} from 'react-router' import {StaticRouterContext} from 'react-router'
import {StaticRouter} from 'react-router-dom' import {StaticRouter} from 'react-router-dom'
import {TStoreFactory} from './TStoreFactory' import {Store} from 'redux'
import {renderToNodeStream} from 'react-dom/server' import {renderToNodeStream} from 'react-dom/server'
export class ServerRenderer<State, A extends Action, D extends IAPIDef> export class ServerRenderer<A extends Action, D extends IAPIDef>
implements IRenderer { implements IRenderer {
constructor( constructor(
readonly createStore: TStoreFactory<State, A | any>,
readonly RootComponent: React.ComponentType<{ readonly RootComponent: React.ComponentType<{
config: IClientConfig, config: IClientConfig,
http: IHTTPClient<D> http: IHTTPClient<D>
}>, }>,
) {} ) {}
render( render<State>(
url: string, url: string,
store: Store<State>,
config: IClientConfig, config: IClientConfig,
state?: any,
host: string = '', host: string = '',
) { ) {
const {RootComponent} = this const {RootComponent} = this
const store = this.createStore(state)
const http = new HTTPClient<D>(host + config.baseUrl + '/api') const http = new HTTPClient<D>(host + config.baseUrl + '/api')
const context: StaticRouterContext = {} const context: StaticRouterContext = {}

View File

@ -1,5 +0,0 @@
import {Action, Store} from 'redux'
// TODO maybe Store should also be typed
export type TStoreFactory<State, A extends Action> =
(state?: State) => Store<State, A | any>

View File

@ -1,5 +1,4 @@
export * from './ClientRenderer' export * from './ClientRenderer'
export * from './IClientConfig' export * from './IClientConfig'
export * from './IRenderer' export * from './IRenderer'
export * from './TStoreFactory'
export * from './isClientSide' export * from './isClientSide'

View File

@ -10,8 +10,9 @@ import {
export interface ICreateStoreParams<State, A extends Action> { export interface ICreateStoreParams<State, A extends Action> {
reducer: Reducer<State, A> reducer: Reducer<State, A>
state?: DeepPartial<State> state?: Partial<State>
middleware?: Middleware[] middleware?: Middleware[]
extraMiddleware?: Middleware[]
} }
/** /**
@ -27,8 +28,10 @@ export function createStore<State, A extends Action>(
&& typeof window.localStorage.log !== 'undefined', && typeof window.localStorage.log !== 'undefined',
).handle, ).handle,
new PromiseMiddleware().handle, new PromiseMiddleware().handle,
new WaitMiddleware().handle,
] ]
if (params.extraMiddleware) {
middleware.push(...params.extraMiddleware)
}
return (state?: DeepPartial<State>) => create( return (state?: DeepPartial<State>) => create(
params.reducer, params.reducer,
state, state,