diff --git a/src/client/async/action.ts b/src/client/async/action.ts new file mode 100644 index 0000000..4fa3865 --- /dev/null +++ b/src/client/async/action.ts @@ -0,0 +1,59 @@ +import { Action } from 'redux' + +export type PendingAction = Action & Promise

& { + status: 'pending' +} + +export type ResolvedAction = Action & { + payload: P + status: 'resolved' +} + +export type RejectedAction = Action & { + payload: Error + status: 'rejected' +} + +export type AsyncAction = + PendingAction | + ResolvedAction | + RejectedAction + +export type GetAsyncAction = + A extends PendingAction + ? AsyncAction + : A extends ResolvedAction + ? AsyncAction + : never + +export type GetAllActions = { + [K in keyof T]: T[K] extends (...args: any[]) => infer R + ? R + : never +}[keyof T] + +export type GetAllAsyncActions = GetAsyncAction> + +function isPromise(value: unknown): value is Promise { + return value && typeof value === 'object' && + typeof (value as Promise).then === 'function' +} + +export function isPendingAction( + value: unknown, +): value is PendingAction { + return isPromise(value) && + typeof (value as unknown as { type: 'string' }).type === 'string' +} + +export function makeAction( + type: T, + impl: (...args: A) => Promise

, +): (...args: A) => PendingAction{ + return (...args: A) => { + const pendingAction= impl(...args) as PendingAction + pendingAction.type = type + pendingAction.status = 'pending' + return pendingAction + } +} diff --git a/src/client/async/middleware.test.ts b/src/client/async/middleware.test.ts new file mode 100644 index 0000000..8025883 --- /dev/null +++ b/src/client/async/middleware.test.ts @@ -0,0 +1,128 @@ +import { GetAllAsyncActions, makeAction } from './action' +import { middleware } from './middleware' +import { reduce } from './reducer' +import { createStore, applyMiddleware, combineReducers } from 'redux' + +describe('middleware', () => { + + interface State { + sum: number + status: 'pending' | 'resolved' | 'rejected' + } + + const defaultState: State = { + status: 'resolved', + sum: 0, + } + + const actions = { + add: makeAction('add', async (a: number, b: number) => { + return {a, b} + }), + subtract: makeAction('subtract', async (a: number, b: number) => { + return {a, b} + }), + reject: makeAction('reject', async (a: number, b: number) => { + throw new Error('Test reject') + }), + } + + type Action = GetAllAsyncActions + + function result(state = defaultState, action: Action): State { + switch (action.type) { + case 'add': + return reduce( + state, + action, + (state, pending) => ({ + status: pending.status, + sum: state.sum, + }), + (state, resolved) => ({ + status: resolved.status, + sum: resolved.payload.a + resolved.payload.b, + }), + (state, rejected) => ({status: rejected.status, sum: 0}), + ) + case 'subtract': + return reduce( + state, + action, + (state, pending) => ({ + status: pending.status, + sum: state.sum, + }), + (state, resolved) => ({ + status: resolved.status, + sum: resolved.payload.a - resolved.payload.b, + }), + (state, rejected) => ({status: rejected.status, sum: 0}), + ) + case 'reject': + return reduce( + state, + action, + (state, pending) => ({ + status: pending.status, + sum: state.sum, + }), + (state, resolved) => ({ + status: resolved.status, + sum: 0, + }), + (state, rejected) => ({status: rejected.status, sum: 0}), + ) + default: + return state + } + } + + function getStore() { + return createStore( + combineReducers({ result }), + applyMiddleware(middleware), + ) + } + + describe('pending and resolved', () => { + it('makes it easy to dispatch async actions for redux', async () => { + const store = getStore() + await store.dispatch(actions.add(1, 2)) + expect(store.getState()).toEqual({ + result: { + status: 'resolved', + sum: 3, + }, + }) + await store.dispatch(actions.subtract(1, 2)) + expect(store.getState()).toEqual({ + result: { + status: 'resolved', + sum: -1, + }, + }) + }) + }) + + describe('rejected', () => { + it('handles rejected actions', async () => { + const store = getStore() + let error!: Error + try { + await store.dispatch(actions.reject(1, 2)) + } catch (err) { + error = err + } + expect(error).toBeTruthy() + expect(error.message).toBe('Test reject') + expect(store.getState()).toEqual({ + result: { + status: 'rejected', + sum: 0, + }, + }) + }) + }) + +}) diff --git a/src/client/async/middleware.ts b/src/client/async/middleware.ts new file mode 100644 index 0000000..cad7720 --- /dev/null +++ b/src/client/async/middleware.ts @@ -0,0 +1,36 @@ +import { AnyAction, Middleware } from 'redux' +import { isPendingAction, ResolvedAction, PendingAction, RejectedAction } from './action' + +export const middleware: Middleware = store => next => (action: AnyAction) => { + if (!isPendingAction(action)) { + return next(action) + } + + const promise = action + .then(payload => { + const resolvedAction: ResolvedAction = { + payload, + type: action.type, + status: 'resolved', + } + store.dispatch(resolvedAction) + }) + + // Propagate this action. Only attach listeners to the promise. + next({ + type: action.type, + status: 'pending', + }) + + const promise2 = promise + .catch((err: Error) => { + const rejectedAction: RejectedAction = { + payload: err, + type: action.type, + status: 'rejected', + } + store.dispatch(rejectedAction) + }) + + return promise2.then(() => action) +} diff --git a/src/client/async/reducer.ts b/src/client/async/reducer.ts new file mode 100644 index 0000000..9c2c39a --- /dev/null +++ b/src/client/async/reducer.ts @@ -0,0 +1,18 @@ +import { AsyncAction, PendingAction, ResolvedAction, RejectedAction } from './action' + +export function reduce( + state: State, + action: AsyncAction, + handlePending: (state: State, action: PendingAction) => State, + handleResolved: (state: State, action: ResolvedAction) => State, + handleRejected: (state: State, action: RejectedAction) => State, +): State { + switch (action.status) { + case 'pending': + return handlePending(state, action) + case 'resolved': + return handleResolved(state, action) + case 'rejected': + return handleRejected(state, action) + } +}