feat!: middleware support refined

This commit is contained in:
Yanick Champoux 2020-01-02 20:04:41 -05:00
parent 73c2776826
commit d90d72148c
6 changed files with 279 additions and 139 deletions

View File

@ -1,38 +1,38 @@
import Updux from '.'; import Updux from ".";
import u from 'updeep'; import u from "updeep";
const noopEffect = () => () => () => {}; const noopEffect = () => () => () => {};
test('actions defined in effects and mutations, multi-level', () => { test.only("actions defined in effects and mutations, multi-level", () => {
const {actions} = new Updux({ const { actions } = new Updux({
effects: { effects: {
foo: noopEffect, foo: noopEffect
}, },
mutations: {bar: () => () => null}, mutations: { bar: () => () => null },
subduxes: { subduxes: {
mysub: { mysub: {
effects: {baz: noopEffect }, effects: { baz: noopEffect },
mutations: {quux: () => () => null}, mutations: { quux: () => () => null },
actions: { actions: {
foo: (limit:number) => ({limit}), foo: (limit: number) => ({ limit })
}, }
}, },
myothersub: { myothersub: {
effects: { effects: {
foo: noopEffect, foo: noopEffect
}, }
}, }
}, }
}); });
const types = Object.keys(actions); const types = Object.keys(actions);
types.sort(); 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()).toEqual({ type: "bar" });
expect(actions.bar('xxx')).toEqual({type: 'bar', payload: 'xxx'}); expect(actions.bar("xxx")).toEqual({ type: "bar", payload: "xxx" });
expect(actions.bar(undefined, 'yyy')).toEqual({type: 'bar', meta: 'yyy'}); 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 } });
}); });

View File

@ -44,32 +44,14 @@ export function actionFor(type: string): ActionCreator {
type ActionPair = [string, ActionCreator]; type ActionPair = [string, ActionCreator];
function buildActions( function buildActions(actions: ActionPair[] = []): Dictionary<ActionCreator> {
generators: Dictionary<ActionPayloadGenerator> = {},
actionNames: string[] = [],
subActions: ActionPair[] = []
): Dictionary<ActionCreator> {
// priority => generics => generic subs => craft subs => creators // priority => generics => generic subs => craft subs => creators
const [crafted, generic] = fp.partition(([type, f]) => !f._genericAction)( const [crafted, generic] = fp.partition(([type, f]) => !f._genericAction)(
subActions fp.compact(actions)
); );
const actions: any = [ return fp.fromPairs([...generic, ...crafted]);
...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);
} }
export default buildActions; export default buildActions;

View File

@ -1,54 +1,57 @@
import fp from 'lodash/fp'; import fp from "lodash/fp";
import { Middleware, MiddlewareAPI, Dispatch } from 'redux'; import { Middleware, MiddlewareAPI, Dispatch } from "redux";
import { Dictionary, ActionCreator, Action, UpduxDispatch, UpduxMiddleware, UpduxMiddlewareAPI } from '../types'; import {
Dictionary,
ActionCreator,
Action,
UpduxDispatch,
UpduxMiddleware,
UpduxMiddlewareAPI,
EffectEntry
} from "../types";
const MiddlewareFor = (type: any, mw: Middleware ): Middleware => api => next => action => { const MiddlewareFor = (
if (type !== '*' && action.type !== type) return next(action); 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; type Next = (action: Action) => any;
function sliceMw( slice: string, mw: Middleware ): Middleware { function sliceMw(slice: string, mw: Middleware): Middleware {
return (api) => { return api => {
const getSliceState = () => fp.get(slice, api.getState() ); const getSliceState =
slice.length > 0 ? () => fp.get(slice, api.getState()) : api.getState;
const getRootState = (api as any).getRootState || api.getState; const getRootState = (api as any).getRootState || api.getState;
return mw({...api, getState: getSliceState, getRootState} as any ) return mw({ ...api, getState: getSliceState, getRootState } as any);
}; };
} }
function buildMiddleware<S=any>( function buildMiddleware<S = any>(
effects : Dictionary<UpduxMiddleware<S>>= {}, middlewareEntries: any[] = [],
actions : Dictionary<ActionCreator>= {}, actions: Dictionary<ActionCreator> = {}
subduxes :any = {}, ): UpduxMiddleware<S> {
): UpduxMiddleware<S> let mws = middlewareEntries
{ .map(([slice, actionType, mw, isGen]: any) =>
isGen ? [slice, actionType, mw()] : [slice, actionType, mw]
const subMiddlewares = fp.flow( )
fp.mapValues( fp.get('middleware') ), .map(([slice, actionType, mw]) =>
fp.toPairs, MiddlewareFor(actionType, sliceMw(slice, mw))
fp.filter(x=>x[1]), );
fp.map( ([ slice, mw ]: [ string, Middleware]) => sliceMw(slice,mw) )
)( subduxes );
return (api: UpduxMiddlewareAPI<S>) => { return (api: UpduxMiddlewareAPI<S>) => {
for (let type in actions) { for (let type in actions) {
const ac = actions[type]; 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 (original_next: Next) => {
return [ return mws.reduceRight((next, mw) => mw(api)(next), original_next);
...fp.toPairs(effects).map(([type, effect]) =>
MiddlewareFor(type,effect as Middleware)
),
...subMiddlewares
]
.filter(x => x)
.reduceRight((next, mw) => mw(api)(next), original_next);
}; };
}; };
} }

View File

@ -1,7 +1,7 @@
import Updux from '.'; import Updux, { actionCreator } from ".";
import u from 'updeep'; import u from "updeep";
test('simple effect', () => { test("simple effect", () => {
const tracer = jest.fn(); const tracer = jest.fn();
const store = new Updux({ const store = new Updux({
@ -9,13 +9,13 @@ test('simple effect', () => {
foo: (api: any) => (next: any) => (action: any) => { foo: (api: any) => (next: any) => (action: any) => {
tracer(); tracer();
next(action); next(action);
}, }
}, }
}).createStore(); }).createStore();
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
store.dispatch({type: 'bar'}); store.dispatch({ type: "bar" });
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
@ -24,11 +24,11 @@ test('simple effect', () => {
expect(tracer).toHaveBeenCalled(); expect(tracer).toHaveBeenCalled();
}); });
test('effect and sub-effect', () => { test("effect and sub-effect", () => {
const tracer = jest.fn(); const tracer = jest.fn();
const tracerEffect = (signature: string) => (api: any) => (next: any) => ( const tracerEffect = (signature: string) => (api: any) => (next: any) => (
action: any, action: any
) => { ) => {
tracer(signature); tracer(signature);
next(action); next(action);
@ -36,27 +36,27 @@ test('effect and sub-effect', () => {
const store = new Updux({ const store = new Updux({
effects: { effects: {
foo: tracerEffect('root'), foo: tracerEffect("root")
}, },
subduxes: { subduxes: {
zzz: { zzz: {
effects: { effects: {
foo: tracerEffect('child'), foo: tracerEffect("child")
}, }
}, }
}, }
}).createStore(); }).createStore();
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
store.dispatch({type: 'bar'}); store.dispatch({ type: "bar" });
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
store.dispatch.foo(); store.dispatch.foo();
expect(tracer).toHaveBeenNthCalledWith(1, 'root'); expect(tracer).toHaveBeenNthCalledWith(1, "root");
expect(tracer).toHaveBeenNthCalledWith(2, 'child'); expect(tracer).toHaveBeenNthCalledWith(2, "child");
}); });
test('"*" effect', () => { test('"*" effect', () => {
@ -64,21 +64,21 @@ test('"*" effect', () => {
const store = new Updux({ const store = new Updux({
effects: { effects: {
'*': api => next => action => { "*": api => next => action => {
tracer(); tracer();
next(action); next(action);
}, }
}, }
}).createStore(); }).createStore();
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
store.dispatch({type: 'bar'}); store.dispatch({ type: "bar" });
expect(tracer).toHaveBeenCalled(); expect(tracer).toHaveBeenCalled();
}); });
test('async effect', async () => { test("async effect", async () => {
function timeout(ms: number) { function timeout(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
@ -91,8 +91,8 @@ test('async effect', async () => {
next(action); next(action);
await timeout(1000); await timeout(1000);
tracer(); tracer();
}, }
}, }
}).createStore(); }).createStore();
expect(tracer).not.toHaveBeenCalled(); expect(tracer).not.toHaveBeenCalled();
@ -106,37 +106,123 @@ test('async effect', async () => {
expect(tracer).toHaveBeenCalled(); expect(tracer).toHaveBeenCalled();
}); });
test('getState is local', () => { test("getState is local", () => {
let childState; let childState;
let rootState; let rootState;
let rootFromChild; let rootFromChild;
const child = new Updux({ const child = new Updux({
initial: {alpha: 12}, initial: { alpha: 12 },
effects: { effects: {
doIt: ({getState,getRootState}) => next => action => { doIt: ({ getState, getRootState }) => next => action => {
childState = getState(); childState = getState();
rootFromChild = getRootState(); rootFromChild = getRootState();
next(action); next(action);
}, }
}, }
}); });
const root = new Updux({ const root = new Updux({
initial: {beta: 24}, initial: { beta: 24 },
subduxes: {child}, subduxes: { child },
effects: { effects: {
doIt: ({getState}) => next => action => { doIt: ({ getState }) => next => action => {
rootState = getState(); rootState = getState();
next(action); next(action);
}, }
}, }
}); });
const store = root.createStore(); const store = root.createStore();
store.dispatch.doIt(); store.dispatch.doIt();
expect(rootState).toEqual({beta: 24, child: {alpha: 12}}); expect(rootState).toEqual({ beta: 24, child: { alpha: 12 } });
expect(rootFromChild).toEqual({beta: 24, child: {alpha: 12}}); expect(rootFromChild).toEqual({ beta: 24, child: { alpha: 12 } });
expect(childState).toEqual({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);
}); });

View File

@ -183,9 +183,15 @@ export type UpduxConfig<S = any> = {
* ``` * ```
* *
*/ */
effects?: Dictionary<UpduxMiddleware<S>>; effects?: Dictionary<UpduxMiddleware<S>> | EffectEntry<S>[];
}; };
export type EffectEntry<S> = [
ActionCreator | string,
UpduxMiddleware<S>,
boolean?
];
export type Upreducer<S = any> = (action: Action) => (state: S) => S; export type Upreducer<S = any> = (action: Action) => (state: S) => S;
export interface UpduxMiddlewareAPI<S> { export interface UpduxMiddlewareAPI<S> {

View File

@ -1,8 +1,7 @@
import fp from "lodash/fp"; import fp from "lodash/fp";
import u from "updeep"; 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 buildInitial from "./buildInitial";
import buildMutations from "./buildMutations"; import buildMutations from "./buildMutations";
@ -18,7 +17,8 @@ import {
Upreducer, Upreducer,
UpduxDispatch, UpduxDispatch,
UpduxMiddleware, UpduxMiddleware,
MutationEntry MutationEntry,
EffectEntry
} from "./types"; } from "./types";
import { Middleware, Store } from "redux"; import { Middleware, Store } from "redux";
@ -99,11 +99,11 @@ export class Updux<S = any> {
*/ */
groomMutations: (mutation: Mutation<S>) => Mutation<S>; groomMutations: (mutation: Mutation<S>) => Mutation<S>;
@observable private localEffects: Dictionary<UpduxMiddleware<S>>; private localEffects: EffectEntry<S>[] = [];
@observable private localActions: Dictionary<ActionCreator>; private localActions: Dictionary<ActionCreator> = {};
@observable private localMutations: Dictionary< private localMutations: Dictionary<
Mutation<S> | [Mutation<S>, boolean | undefined] Mutation<S> | [Mutation<S>, boolean | undefined]
> = {}; > = {};
@ -114,9 +114,18 @@ export class Updux<S = any> {
fp.isPlainObject(value) ? new Updux(value) : value fp.isPlainObject(value) ? new Updux(value) : value
)(fp.getOr({}, "subduxes", config)) as Dictionary<Updux>; )(fp.getOr({}, "subduxes", config)) as Dictionary<Updux>;
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<any>( this.initial = buildInitial<any>(
config.initial, config.initial,
@ -132,15 +141,15 @@ export class Updux<S = any> {
} }
/** /**
* 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 * updux and its subduxes. Effects of the updux itself are
* done before the subduxes effects. * done before the subduxes effects.
* Note that `getState` will always return the state of the * Note that `getState` will always return the state of the
* local updux. The function `getRootState` is provided * local updux. The function `getRootState` is provided
* alongside `getState` to get the root state. * alongside `getState` to get the root state.
*/ */
@computed get middleware(): UpduxMiddleware<S> { get middleware(): UpduxMiddleware<S> {
return buildMiddleware(this.localEffects, this.actions, this.subduxes); return buildMiddleware(this._middlewareEntries, this.actions);
} }
/** /**
@ -157,19 +166,19 @@ export class Updux<S = any> {
* actions generated from mutations/effects < non-custom subduxes actions < * actions generated from mutations/effects < non-custom subduxes actions <
* custom subduxes actions < custom actions * custom subduxes actions < custom actions
*/ */
@computed get actions(): Dictionary<ActionCreator> { get actions(): Dictionary<ActionCreator> {
return buildActions( return buildActions([
this.localActions, ...(Object.entries(this.localActions) as any),
[...Object.keys(this.localMutations), ...Object.keys(this.localEffects)], ...(fp.flatten(
fp.flatten(
Object.values(this.subduxes).map(({ actions }: Updux) => Object.values(this.subduxes).map(({ actions }: Updux) =>
Object.entries(actions) Object.entries(actions)
) )
) ) as any),
); ,
]);
} }
@computed get upreducer(): Upreducer<S> { get upreducer(): Upreducer<S> {
return buildUpreducer(this.initial, this.mutations); return buildUpreducer(this.initial, this.mutations);
} }
@ -177,7 +186,7 @@ export class Updux<S = any> {
* A Redux reducer generated using the computed initial state and * A Redux reducer generated using the computed initial state and
* mutations. * 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); return (state, action) => this.upreducer(action)(state as S);
} }
@ -186,7 +195,7 @@ export class Updux<S = any> {
* mutations in both the main updux and its subduxes, the subduxes * mutations in both the main updux and its subduxes, the subduxes
* mutations will be performed first. * mutations will be performed first.
*/ */
@computed get mutations(): Dictionary<Mutation<S>> { get mutations(): Dictionary<Mutation<S>> {
return buildMutations(this.localMutations, this.subduxes); return buildMutations(this.localMutations, this.subduxes);
} }
@ -207,7 +216,7 @@ export class Updux<S = any> {
* ); * );
* ``` * ```
*/ */
@computed get subduxUpreducer() { get subduxUpreducer() {
return buildUpreducer(this.initial, buildMutations({}, this.subduxes)); return buildUpreducer(this.initial, buildMutations({}, this.subduxes));
} }
@ -236,14 +245,14 @@ export class Updux<S = any> {
* // still work * // still work
* store.dispatch( actions.addTodo(...) ); * store.dispatch( actions.addTodo(...) );
*/ */
@computed get createStore(): () => StoreWithDispatchActions<S> { get createStore(): () => StoreWithDispatchActions<S> {
const actions = this.actions; const actions = this.actions;
return buildCreateStore<S>( return buildCreateStore<S>(
this.reducer, this.reducer,
this.initial, this.initial,
this.middleware as Middleware, this.middleware as any,
this.actions actions
) as () => StoreWithDispatchActions<S, typeof actions>; ) as () => StoreWithDispatchActions<S, typeof actions>;
} }
@ -285,13 +294,67 @@ export class Updux<S = any> {
) { ) {
let c = fp.isFunction(creator) ? creator : actionFor(creator); let c = fp.isFunction(creator) ? creator : actionFor(creator);
this.localActions[c.type] = c; this.addAction(c);
this.localMutations[c.type] = [ this.localMutations[c.type] = [
this.groomMutations(mutation as any) as Mutation<S>, this.groomMutations(mutation as any) as Mutation<S>,
isSink isSink
]; ];
} }
addEffect(
creator: ActionCreator | string,
middleware: UpduxMiddleware<S>,
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<any>) {
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; export default Updux;