effectsMiddleware

This commit is contained in:
Yanick Champoux 2023-03-11 11:06:38 -05:00
parent 7a5733425e
commit 95768706fa
4 changed files with 171 additions and 45 deletions

View File

@ -4,6 +4,8 @@ import {
applyMiddleware, applyMiddleware,
DeepPartial, DeepPartial,
Action, Action,
MiddlewareAPI,
AnyAction,
} from 'redux'; } from 'redux';
import { import {
configureStore, configureStore,
@ -16,6 +18,8 @@ import { AggregateActions, AggregateSelectors, Dux } from './types.js';
import { buildActions } from './buildActions.js'; import { buildActions } from './buildActions.js';
import { buildInitial, AggregateState } from './initial.js'; import { buildInitial, AggregateState } from './initial.js';
import { buildReducer, MutationCase } from './reducer.js'; import { buildReducer, MutationCase } from './reducer.js';
import { buildEffectsMiddleware, EffectMiddleware } from './effects.js';
import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore.js';
type MyActionCreator = { type: string } & ((...args: any) => any); type MyActionCreator = { type: string } & ((...args: any) => any);
@ -84,6 +88,8 @@ export default class Updux<
>; >;
#selectors: any; #selectors: any;
#effects: any[] = [];
constructor( constructor(
config: Partial<{ config: Partial<{
initial: T_LocalState; initial: T_LocalState;
@ -129,11 +135,31 @@ export default class Updux<
) { ) {
const preloadedState: any = options.initial ?? this.initial; const preloadedState: any = options.initial ?? this.initial;
const store = configureStore({ const store = configureStore({
reducer: ((state) => state) as Reducer<T_LocalState, any>, reducer: ((state) => state) as Reducer<
AggregateState<T_LocalState, T_Subduxes>,
AnyAction
>,
preloadedState, preloadedState,
middleware: [this.effectsMiddleware],
}); });
return store; const dispatch: any = store.dispatch;
for (const a in this.actions) {
dispatch[a] = (...args) => {
const action = (this.actions as any)[a](...args);
dispatch(action);
return action;
}
}
return store as ToolkitStore<
AggregateState<T_LocalState, T_Subduxes>
> & {
dispatch: AggregateActions<
ResolveActions<T_LocalActions>,
T_Subduxes
>;
};
} }
get selectors(): AggregateSelectors< get selectors(): AggregateSelectors<
@ -193,4 +219,21 @@ export default class Updux<
) { ) {
this.#defaultMutation = { mutation, terminal }; this.#defaultMutation = { mutation, terminal };
} }
addEffect(effect: EffectMiddleware) {
this.#effects.push(effect);
}
get effects() {
return this.#effects;
}
get effectsMiddleware() {
return buildEffectsMiddleware(
this.effects,
this.actions,
this.selectors,
);
}
} }

79
src/effects.test.ts Normal file
View File

@ -0,0 +1,79 @@
import { buildEffectsMiddleware } from './effects.js';
import Updux, { createAction } from './index.js';
test('buildEffectsMiddleware', () => {
let seen = 0;
const mw = buildEffectsMiddleware(
[
(api) => (next) => (action) => {
seen++;
expect(api).toHaveProperty('getState');
expect(api.getState).toBeTypeOf('function');
expect(api.getState()).toEqual('the state');
expect(action).toHaveProperty('type');
expect(next).toBeTypeOf('function');
expect(api).toHaveProperty('actions');
expect(api.actions.action1()).toHaveProperty('type', 'action1');
api.dispatch.action1();
expect(api.selectors.getFoo(2)).toBe(2);
expect(api.getState.getFoo()).toBe('the state');
expect(api.getState.getBar(2)).toBe('the state2');
next(action);
},
],
{
action1: createAction('action1'),
},
{
getFoo: (state) => state,
getBar: (state) => (i) => state + i,
},
);
expect(seen).toEqual(0);
const dispatch = vi.fn();
mw({ getState: () => 'the state', dispatch })(() => { })({
type: 'noop',
});
expect(seen).toEqual(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'action1' });
});
test('basic', () => {
const dux = new Updux({
initial: {
loaded: true,
},
actions: {
foo: 0,
},
});
let seen = 0;
dux.addEffect((api) => (next) => (action) => {
seen++;
expect(api).toHaveProperty('getState');
expect(api.getState()).toHaveProperty('loaded');
expect(action).toHaveProperty('type');
expect(next).toBeTypeOf('function');
next(action);
});
const store = dux.createStore();
expect(seen).toEqual(0);
store.dispatch.foo();
expect(seen).toEqual(1);
});
// TODO subdux effects
// TODO allow to subscribe / unsubscribe effects?

47
src/effects.ts Normal file
View File

@ -0,0 +1,47 @@
import { AnyAction } from '@reduxjs/toolkit';
import { MiddlewareAPI } from '@reduxjs/toolkit';
import { Dispatch } from '@reduxjs/toolkit';
export interface EffectMiddleware<S = any, D extends Dispatch = Dispatch> {
(api: MiddlewareAPI<D, S>): (
next: Dispatch<AnyAction>,
) => (action: AnyAction) => any;
}
const composeMw = (mws) => (api) => (originalNext) =>
mws.reduceRight((next, mw) => mw(api)(next), originalNext);
export function buildEffectsMiddleware(
effects = [],
actions = {},
selectors = {},
) {
return ({
getState: originalGetState,
dispatch: originalDispatch,
...rest
}) => {
const dispatch = (action) => originalDispatch(action);
for (const a in actions) {
dispatch[a] = (...args) => dispatch(actions[a](...args));
}
const getState = () => originalGetState();
for (const s in selectors) {
getState[s] = (...args) => {
let result = selectors[s](originalGetState());
if (typeof result === 'function') return result(...args);
return result;
};
}
let mws = effects.map((e) =>
e({ getState, dispatch, actions, selectors, ...rest }),
);
return (originalNext) => {
return mws.reduceRight((next, mw) => mw(next), originalNext);
};
};
}

View File

@ -1,43 +0,0 @@
import R from 'remeda';
export function buildSelectors(
localSelectors,
findSelectors = {},
subduxes = {},
) {
const subSelectors = Object.entries(subduxes).map(
([slice, { selectors }]) => {
if (!selectors) return {};
if (slice === '*') return {};
return R.mapValues(
selectors,
(func) => (state) => func(state[slice]),
);
},
);
let splat = {};
for (const name in findSelectors) {
splat[name] =
(mainState) =>
(...args) => {
const state = findSelectors[name](mainState)(...args);
return R.merge(
{ state },
R.mapValues(
subduxes['*']?.selectors ?? {},
(selector) =>
(...args) => {
let value = selector(state);
if (typeof value !== 'function') return value;
return value(...args);
},
),
);
};
}
return R.mergeAll([...subSelectors, localSelectors, splat]);
}