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,12 +1,12 @@
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', () => {
test.only("actions defined in effects and mutations, multi-level", () => {
const { actions } = new Updux({
effects: {
foo: noopEffect,
foo: noopEffect
},
mutations: { bar: () => () => null },
subduxes: {
@ -14,25 +14,25 @@ test('actions defined in effects and mutations, multi-level', () => {
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 } });
});

View File

@ -44,32 +44,14 @@ export function actionFor(type: string): ActionCreator {
type ActionPair = [string, ActionCreator];
function buildActions(
generators: Dictionary<ActionPayloadGenerator> = {},
actionNames: string[] = [],
subActions: ActionPair[] = []
): Dictionary<ActionCreator> {
function buildActions(actions: ActionPair[] = []): Dictionary<ActionCreator> {
// 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;

View File

@ -1,10 +1,22 @@
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);
};
@ -12,43 +24,34 @@ const MiddlewareFor = (type: any, mw: Middleware ): Middleware => api => next =>
type Next = (action: Action) => any;
function sliceMw(slice: string, mw: Middleware): Middleware {
return (api) => {
const getSliceState = () => fp.get(slice, api.getState() );
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 )
return mw({ ...api, getState: getSliceState, getRootState } as any);
};
}
function buildMiddleware<S = any>(
effects : Dictionary<UpduxMiddleware<S>>= {},
actions : Dictionary<ActionCreator>= {},
subduxes :any = {},
): UpduxMiddleware<S>
{
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 );
middlewareEntries: any[] = [],
actions: Dictionary<ActionCreator> = {}
): UpduxMiddleware<S> {
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<S>) => {
for (let type in actions) {
const ac = actions[type];
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 mws.reduceRight((next, mw) => mw(api)(next), original_next);
};
};
}

View File

@ -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,7 +106,7 @@ test('async effect', async () => {
expect(tracer).toHaveBeenCalled();
});
test('getState is local', () => {
test("getState is local", () => {
let childState;
let rootState;
let rootFromChild;
@ -118,8 +118,8 @@ test('getState is local', () => {
childState = getState();
rootFromChild = getRootState();
next(action);
},
},
}
}
});
const root = new Updux({
@ -129,8 +129,8 @@ test('getState is local', () => {
doIt: ({ getState }) => next => action => {
rootState = getState();
next(action);
},
},
}
}
});
const store = root.createStore();
@ -140,3 +140,89 @@ test('getState is local', () => {
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);
});

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 interface UpduxMiddlewareAPI<S> {

View File

@ -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<S = any> {
*/
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]
> = {};
@ -114,9 +114,18 @@ export class Updux<S = any> {
fp.isPlainObject(value) ? new Updux(value) : value
)(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>(
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
* 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<S> {
return buildMiddleware(this.localEffects, this.actions, this.subduxes);
get middleware(): UpduxMiddleware<S> {
return buildMiddleware(this._middlewareEntries, this.actions);
}
/**
@ -157,19 +166,19 @@ export class Updux<S = any> {
* actions generated from mutations/effects < non-custom subduxes actions <
* custom subduxes actions < custom actions
*/
@computed get actions(): Dictionary<ActionCreator> {
return buildActions(
this.localActions,
[...Object.keys(this.localMutations), ...Object.keys(this.localEffects)],
fp.flatten(
get actions(): Dictionary<ActionCreator> {
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<S> {
get upreducer(): Upreducer<S> {
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
* 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<S = any> {
* mutations in both the main updux and its subduxes, the subduxes
* mutations will be performed first.
*/
@computed get mutations(): Dictionary<Mutation<S>> {
get mutations(): Dictionary<Mutation<S>> {
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));
}
@ -236,14 +245,14 @@ export class Updux<S = any> {
* // still work
* store.dispatch( actions.addTodo(...) );
*/
@computed get createStore(): () => StoreWithDispatchActions<S> {
get createStore(): () => StoreWithDispatchActions<S> {
const actions = this.actions;
return buildCreateStore<S>(
this.reducer,
this.initial,
this.middleware as Middleware,
this.actions
this.middleware as any,
actions
) as () => StoreWithDispatchActions<S, typeof actions>;
}
@ -285,13 +294,67 @@ export class Updux<S = any> {
) {
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<S>,
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;