From d90d72148c2d4ba186a19650d961c64df5791c55 Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Thu, 2 Jan 2020 20:04:41 -0500 Subject: [PATCH] feat!: middleware support refined --- src/actions.test.ts | 38 ++++----- src/buildActions/index.ts | 24 +----- src/buildMiddleware/index.ts | 75 +++++++++-------- src/middleware.test.ts | 158 +++++++++++++++++++++++++++-------- src/types.ts | 8 +- src/updux.ts | 115 +++++++++++++++++++------ 6 files changed, 279 insertions(+), 139 deletions(-) diff --git a/src/actions.test.ts b/src/actions.test.ts index cebe253..1ce7077 100644 --- a/src/actions.test.ts +++ b/src/actions.test.ts @@ -1,38 +1,38 @@ -import Updux from '.'; -import u from 'updeep'; +import Updux from "."; +import u from "updeep"; const noopEffect = () => () => () => {}; -test('actions defined in effects and mutations, multi-level', () => { - const {actions} = new Updux({ +test.only("actions defined in effects and mutations, multi-level", () => { + const { actions } = new Updux({ effects: { - foo: noopEffect, + foo: noopEffect }, - mutations: {bar: () => () => null}, + mutations: { bar: () => () => null }, subduxes: { mysub: { - effects: {baz: noopEffect }, - mutations: {quux: () => () => null}, + effects: { baz: noopEffect }, + mutations: { quux: () => () => null }, actions: { - foo: (limit:number) => ({limit}), - }, + foo: (limit: number) => ({ limit }) + } }, myothersub: { effects: { - foo: noopEffect, - }, - }, - }, + foo: noopEffect + } + } + } }); const types = Object.keys(actions); types.sort(); - expect(types).toEqual(['bar', 'baz', 'foo', 'quux']); + expect(types).toEqual(["bar", "baz", "foo", "quux"]); - expect(actions.bar()).toEqual({type: 'bar'}); - expect(actions.bar('xxx')).toEqual({type: 'bar', payload: 'xxx'}); - expect(actions.bar(undefined, 'yyy')).toEqual({type: 'bar', meta: 'yyy'}); + expect(actions.bar()).toEqual({ type: "bar" }); + expect(actions.bar("xxx")).toEqual({ type: "bar", payload: "xxx" }); + expect(actions.bar(undefined, "yyy")).toEqual({ type: "bar", meta: "yyy" }); - expect(actions.foo(12)).toEqual({type: 'foo', payload: {limit: 12}}); + expect(actions.foo(12)).toEqual({ type: "foo", payload: { limit: 12 } }); }); diff --git a/src/buildActions/index.ts b/src/buildActions/index.ts index 8548b75..aaec164 100644 --- a/src/buildActions/index.ts +++ b/src/buildActions/index.ts @@ -44,32 +44,14 @@ export function actionFor(type: string): ActionCreator { type ActionPair = [string, ActionCreator]; -function buildActions( - generators: Dictionary = {}, - actionNames: string[] = [], - subActions: ActionPair[] = [] -): Dictionary { +function buildActions(actions: ActionPair[] = []): Dictionary { // priority => generics => generic subs => craft subs => creators const [crafted, generic] = fp.partition(([type, f]) => !f._genericAction)( - subActions + fp.compact(actions) ); - const actions: any = [ - ...actionNames.map(type => [type, actionFor(type)]), - ...generic, - ...crafted, - ...Object.entries( - generators - ).map(([type, payload]: [string, Function]): any => [ - type, - (payload as any).type - ? payload - : (...args: any) => ({ type, payload: payload(...args) }) - ]) - ]; - - return fp.fromPairs(actions); + return fp.fromPairs([...generic, ...crafted]); } export default buildActions; diff --git a/src/buildMiddleware/index.ts b/src/buildMiddleware/index.ts index 433b0b1..0b1d198 100644 --- a/src/buildMiddleware/index.ts +++ b/src/buildMiddleware/index.ts @@ -1,54 +1,57 @@ -import fp from 'lodash/fp'; +import fp from "lodash/fp"; -import { Middleware, MiddlewareAPI, Dispatch } from 'redux'; -import { Dictionary, ActionCreator, Action, UpduxDispatch, UpduxMiddleware, UpduxMiddlewareAPI } from '../types'; +import { Middleware, MiddlewareAPI, Dispatch } from "redux"; +import { + Dictionary, + ActionCreator, + Action, + UpduxDispatch, + UpduxMiddleware, + UpduxMiddlewareAPI, + EffectEntry +} from "../types"; -const MiddlewareFor = (type: any, mw: Middleware ): Middleware => api => next => action => { - if (type !== '*' && action.type !== type) return next(action); +const MiddlewareFor = ( + type: any, + mw: Middleware +): Middleware => api => next => action => { + if (!["*", "^", "$"].includes(type) && action.type !== type) + return next(action); - return mw(api)(next)(action); + return mw(api)(next)(action); }; type Next = (action: Action) => any; -function sliceMw( slice: string, mw: Middleware ): Middleware { - return (api) => { - const getSliceState = () => fp.get(slice, api.getState() ); - const getRootState = (api as any).getRootState || api.getState; - return mw({...api, getState: getSliceState, getRootState} as any ) - }; +function sliceMw(slice: string, mw: Middleware): Middleware { + return api => { + const getSliceState = + slice.length > 0 ? () => fp.get(slice, api.getState()) : api.getState; + const getRootState = (api as any).getRootState || api.getState; + return mw({ ...api, getState: getSliceState, getRootState } as any); + }; } -function buildMiddleware( - effects : Dictionary>= {}, - actions : Dictionary= {}, - subduxes :any = {}, -): UpduxMiddleware - { - - const subMiddlewares = fp.flow( - fp.mapValues( fp.get('middleware') ), - fp.toPairs, - fp.filter(x=>x[1]), - fp.map( ([ slice, mw ]: [ string, Middleware]) => sliceMw(slice,mw) ) - )( subduxes ); +function buildMiddleware( + middlewareEntries: any[] = [], + actions: Dictionary = {} +): UpduxMiddleware { + let mws = middlewareEntries + .map(([slice, actionType, mw, isGen]: any) => + isGen ? [slice, actionType, mw()] : [slice, actionType, mw] + ) + .map(([slice, actionType, mw]) => + MiddlewareFor(actionType, sliceMw(slice, mw)) + ); return (api: UpduxMiddlewareAPI) => { - for (let type in actions) { const ac = actions[type]; - api.dispatch[type] = (...args:any[]) => api.dispatch(ac(...args)); + api.dispatch[type] = (...args: any[]) => api.dispatch(ac(...args)); } - return (original_next: Next)=> { - return [ - ...fp.toPairs(effects).map(([type, effect]) => - MiddlewareFor(type,effect as Middleware) - ), - ...subMiddlewares - ] - .filter(x => x) - .reduceRight((next, mw) => mw(api)(next), original_next); + return (original_next: Next) => { + return mws.reduceRight((next, mw) => mw(api)(next), original_next); }; }; } diff --git a/src/middleware.test.ts b/src/middleware.test.ts index d3478ec..54d02a9 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,7 +1,7 @@ -import Updux from '.'; -import u from 'updeep'; +import Updux, { actionCreator } from "."; +import u from "updeep"; -test('simple effect', () => { +test("simple effect", () => { const tracer = jest.fn(); const store = new Updux({ @@ -9,13 +9,13 @@ test('simple effect', () => { foo: (api: any) => (next: any) => (action: any) => { tracer(); next(action); - }, - }, + } + } }).createStore(); expect(tracer).not.toHaveBeenCalled(); - store.dispatch({type: 'bar'}); + store.dispatch({ type: "bar" }); expect(tracer).not.toHaveBeenCalled(); @@ -24,11 +24,11 @@ test('simple effect', () => { expect(tracer).toHaveBeenCalled(); }); -test('effect and sub-effect', () => { +test("effect and sub-effect", () => { const tracer = jest.fn(); const tracerEffect = (signature: string) => (api: any) => (next: any) => ( - action: any, + action: any ) => { tracer(signature); next(action); @@ -36,27 +36,27 @@ test('effect and sub-effect', () => { const store = new Updux({ effects: { - foo: tracerEffect('root'), + foo: tracerEffect("root") }, subduxes: { zzz: { effects: { - foo: tracerEffect('child'), - }, - }, - }, + foo: tracerEffect("child") + } + } + } }).createStore(); expect(tracer).not.toHaveBeenCalled(); - store.dispatch({type: 'bar'}); + store.dispatch({ type: "bar" }); expect(tracer).not.toHaveBeenCalled(); store.dispatch.foo(); - expect(tracer).toHaveBeenNthCalledWith(1, 'root'); - expect(tracer).toHaveBeenNthCalledWith(2, 'child'); + expect(tracer).toHaveBeenNthCalledWith(1, "root"); + expect(tracer).toHaveBeenNthCalledWith(2, "child"); }); test('"*" effect', () => { @@ -64,21 +64,21 @@ test('"*" effect', () => { const store = new Updux({ effects: { - '*': api => next => action => { + "*": api => next => action => { tracer(); next(action); - }, - }, + } + } }).createStore(); expect(tracer).not.toHaveBeenCalled(); - store.dispatch({type: 'bar'}); + store.dispatch({ type: "bar" }); expect(tracer).toHaveBeenCalled(); }); -test('async effect', async () => { +test("async effect", async () => { function timeout(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -91,8 +91,8 @@ test('async effect', async () => { next(action); await timeout(1000); tracer(); - }, - }, + } + } }).createStore(); expect(tracer).not.toHaveBeenCalled(); @@ -106,37 +106,123 @@ test('async effect', async () => { expect(tracer).toHaveBeenCalled(); }); -test('getState is local', () => { +test("getState is local", () => { let childState; let rootState; let rootFromChild; const child = new Updux({ - initial: {alpha: 12}, + initial: { alpha: 12 }, effects: { - doIt: ({getState,getRootState}) => next => action => { + doIt: ({ getState, getRootState }) => next => action => { childState = getState(); rootFromChild = getRootState(); next(action); - }, - }, + } + } }); const root = new Updux({ - initial: {beta: 24}, - subduxes: {child}, + initial: { beta: 24 }, + subduxes: { child }, effects: { - doIt: ({getState}) => next => action => { + doIt: ({ getState }) => next => action => { rootState = getState(); next(action); - }, - }, + } + } }); const store = root.createStore(); store.dispatch.doIt(); - expect(rootState).toEqual({beta: 24, child: {alpha: 12}}); - expect(rootFromChild).toEqual({beta: 24, child: {alpha: 12}}); - expect(childState).toEqual({alpha: 12}); + expect(rootState).toEqual({ beta: 24, child: { alpha: 12 } }); + expect(rootFromChild).toEqual({ beta: 24, child: { alpha: 12 } }); + expect(childState).toEqual({ alpha: 12 }); +}); + +test("middleware as map", () => { + let childState; + let rootState; + let rootFromChild; + + const doIt = actionCreator("doIt"); + + const child = new Updux({ + initial: "", + effects: [ + [ + doIt, + () => next => action => { + next(u({ payload: (p: string) => p + "Child" }, action) as any); + } + ] + ] + }); + + const root = new Updux({ + initial: { message: "" }, + subduxes: { child }, + effects: [ + [ + "^", + () => next => action => { + next(u({ payload: (p: string) => p + "Pre" }, action) as any); + } + ], + [ + doIt, + () => next => action => { + next(u({ payload: (p: string) => p + "Root" }, action) as any); + } + ], + [ + "*", + () => next => action => { + next(u({ payload: (p: string) => p + "After" }, action) as any); + } + ], + [ + "$", + () => next => action => { + next(u({ payload: (p: string) => p + "End" }, action) as any); + } + ] + ], + mutations: [[doIt, (message: any) => () => ({ message })]] + }); + + const store = root.createStore(); + store.dispatch.doIt(""); + + expect(store.getState()).toEqual({ message: "PreRootAfterChildEnd" }); +}); + +test("generator", () => { + const updux = new Updux({ + initial: 0, + mutations: [["doIt", payload => () => payload]], + effects: [ + [ + "doIt", + () => { + let i = 0; + return () => (next: any) => (action: any) => + next({ ...action, payload: ++i }); + }, + true + ] + ] + }); + + const store1 = updux.createStore(); + store1.dispatch.doIt(); + expect(store1.getState()).toEqual(1); + store1.dispatch.doIt(); + expect(store1.getState()).toEqual(2); + updux.actions; + + const store2 = updux.createStore(); + store2.dispatch.doIt(); + expect(store2.getState()).toEqual(1); }); diff --git a/src/types.ts b/src/types.ts index 54c876c..ca0da78 100644 --- a/src/types.ts +++ b/src/types.ts @@ -183,9 +183,15 @@ export type UpduxConfig = { * ``` * */ - effects?: Dictionary>; + effects?: Dictionary> | EffectEntry[]; }; +export type EffectEntry = [ + ActionCreator | string, + UpduxMiddleware, + boolean? +]; + export type Upreducer = (action: Action) => (state: S) => S; export interface UpduxMiddlewareAPI { diff --git a/src/updux.ts b/src/updux.ts index 69a4431..c8b1fce 100644 --- a/src/updux.ts +++ b/src/updux.ts @@ -1,8 +1,7 @@ import fp from "lodash/fp"; import u from "updeep"; -import { observable, computed, toJS } from "mobx"; -import buildActions, { actionFor } from "./buildActions"; +import buildActions, { actionFor, actionCreator } from "./buildActions"; import buildInitial from "./buildInitial"; import buildMutations from "./buildMutations"; @@ -18,7 +17,8 @@ import { Upreducer, UpduxDispatch, UpduxMiddleware, - MutationEntry + MutationEntry, + EffectEntry } from "./types"; import { Middleware, Store } from "redux"; @@ -99,11 +99,11 @@ export class Updux { */ groomMutations: (mutation: Mutation) => Mutation; - @observable private localEffects: Dictionary>; + private localEffects: EffectEntry[] = []; - @observable private localActions: Dictionary; + private localActions: Dictionary = {}; - @observable private localMutations: Dictionary< + private localMutations: Dictionary< Mutation | [Mutation, boolean | undefined] > = {}; @@ -114,9 +114,18 @@ export class Updux { fp.isPlainObject(value) ? new Updux(value) : value )(fp.getOr({}, "subduxes", config)) as Dictionary; - this.localActions = fp.getOr({}, "actions", config); + const actions = fp.getOr({}, "actions", config); + Object.entries(actions).forEach(([type, payload]: [string, any]): any => + this.addAction( + (payload as any).type ? payload : actionCreator(type, payload as any) + ) + ); - this.localEffects = fp.getOr({}, "effects", config); + let effects = fp.getOr([], "effects", config); + if (!Array.isArray(effects)) { + effects = Object.entries(effects); + } + effects.forEach(effect => this.addEffect(...effect)); this.initial = buildInitial( config.initial, @@ -132,15 +141,15 @@ export class Updux { } /** - * A middleware aggregating all the effects defined in the + * Array of middlewares aggregating all the effects defined in the * updux and its subduxes. Effects of the updux itself are * done before the subduxes effects. * Note that `getState` will always return the state of the * local updux. The function `getRootState` is provided * alongside `getState` to get the root state. */ - @computed get middleware(): UpduxMiddleware { - return buildMiddleware(this.localEffects, this.actions, this.subduxes); + get middleware(): UpduxMiddleware { + return buildMiddleware(this._middlewareEntries, this.actions); } /** @@ -157,19 +166,19 @@ export class Updux { * actions generated from mutations/effects < non-custom subduxes actions < * custom subduxes actions < custom actions */ - @computed get actions(): Dictionary { - return buildActions( - this.localActions, - [...Object.keys(this.localMutations), ...Object.keys(this.localEffects)], - fp.flatten( + get actions(): Dictionary { + return buildActions([ + ...(Object.entries(this.localActions) as any), + ...(fp.flatten( Object.values(this.subduxes).map(({ actions }: Updux) => Object.entries(actions) ) - ) - ); + ) as any), + , + ]); } - @computed get upreducer(): Upreducer { + get upreducer(): Upreducer { return buildUpreducer(this.initial, this.mutations); } @@ -177,7 +186,7 @@ export class Updux { * A Redux reducer generated using the computed initial state and * mutations. */ - @computed get reducer(): (state: S | undefined, action: Action) => S { + get reducer(): (state: S | undefined, action: Action) => S { return (state, action) => this.upreducer(action)(state as S); } @@ -186,7 +195,7 @@ export class Updux { * mutations in both the main updux and its subduxes, the subduxes * mutations will be performed first. */ - @computed get mutations(): Dictionary> { + get mutations(): Dictionary> { return buildMutations(this.localMutations, this.subduxes); } @@ -207,7 +216,7 @@ export class Updux { * ); * ``` */ - @computed get subduxUpreducer() { + get subduxUpreducer() { return buildUpreducer(this.initial, buildMutations({}, this.subduxes)); } @@ -236,14 +245,14 @@ export class Updux { * // still work * store.dispatch( actions.addTodo(...) ); */ - @computed get createStore(): () => StoreWithDispatchActions { + get createStore(): () => StoreWithDispatchActions { const actions = this.actions; return buildCreateStore( this.reducer, this.initial, - this.middleware as Middleware, - this.actions + this.middleware as any, + actions ) as () => StoreWithDispatchActions; } @@ -285,13 +294,67 @@ export class Updux { ) { let c = fp.isFunction(creator) ? creator : actionFor(creator); - this.localActions[c.type] = c; + this.addAction(c); this.localMutations[c.type] = [ this.groomMutations(mutation as any) as Mutation, isSink ]; } + + addEffect( + creator: ActionCreator | string, + middleware: UpduxMiddleware, + isGenerator: boolean = false + ) { + let c = fp.isFunction(creator) ? creator : actionFor(creator); + + this.addAction(c); + this.localActions[c.type] = c; + this.localEffects.push([c.type, middleware, isGenerator]); + } + + addAction(action: string | ActionCreator) { + if (typeof action === "string") { + if (!this.localActions[action]) { + this.localActions[action] = actionFor(action); + } + } else { + this.localActions[action.type] = action; + } + } + + get _middlewareEntries() { + const groupByOrder = (mws: any) => + fp.groupBy( + ([_, actionType]: any) => + ["^", "$"].includes(actionType) ? actionType : "middle", + mws + ); + + let subs = fp.flow([ + fp.mapValues("_middlewareEntries"), + fp.toPairs, + fp.map(([slice, entries]) => + entries.map(([ps, ...args]: any) => [[slice, ...ps], ...args]) + ), + fp.flatten, + groupByOrder + ])(this.subduxes); + + let local = groupByOrder(this.localEffects.map(x => [[], ...x])); + + return fp.flatten( + [ + local["^"], + subs["^"], + local.middle, + subs.middle, + subs["$"], + local["$"] + ].filter(x => x) + ); + } } export default Updux;