Merge branch 'effects-ts' into typescript

This commit is contained in:
Yanick Champoux 2023-03-11 16:47:52 -05:00
commit 347d90267e
7 changed files with 316 additions and 79 deletions

View File

@ -4,6 +4,9 @@ import {
applyMiddleware, applyMiddleware,
DeepPartial, DeepPartial,
Action, Action,
MiddlewareAPI,
AnyAction,
Middleware,
} from 'redux'; } from 'redux';
import { import {
configureStore, configureStore,
@ -12,10 +15,12 @@ import {
ActionCreatorWithoutPayload, ActionCreatorWithoutPayload,
ActionCreatorWithPreparedPayload, ActionCreatorWithPreparedPayload,
} from '@reduxjs/toolkit'; } from '@reduxjs/toolkit';
import { AggregateActions, Dux } from './types.js'; 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);
@ -40,7 +45,7 @@ type ResolveActions<
[ActionType in keyof A]: ActionType extends string [ActionType in keyof A]: ActionType extends string
? ResolveAction<ActionType, A[ActionType]> ? ResolveAction<ActionType, A[ActionType]>
: never; : never;
}; };
export type Mutation<A extends Action<any> = Action<any>, S = any> = ( export type Mutation<A extends Action<any> = Action<any>, S = any> = (
payload: A extends { payload: A extends {
@ -51,12 +56,20 @@ export type Mutation<A extends Action<any> = Action<any>, S = any> = (
action: A, action: A,
) => (state: S) => S | void; ) => (state: S) => S | void;
type SelectorForState<S> = (state: S) => unknown;
type SelectorsForState<S> = {
[key: string]: SelectorForState<S>;
};
export default class Updux< export default class Updux<
T_LocalState = Record<any, any>, T_LocalState = Record<any, any>,
T_LocalActions extends { T_LocalActions extends {
[actionType: string]: any; [actionType: string]: any;
} = {}, } = {},
T_Subduxes extends Record<string, Dux> = {}, T_Subduxes extends Record<string, Dux> = {},
T_LocalSelectors extends SelectorsForState<
AggregateState<T_LocalState, T_Subduxes>
> = {},
> { > {
#localInitial: T_LocalState; #localInitial: T_LocalState;
#localActions: T_LocalActions; #localActions: T_LocalActions;
@ -70,11 +83,20 @@ export default class Updux<
#initial: AggregateState<T_LocalState, T_Subduxes>; #initial: AggregateState<T_LocalState, T_Subduxes>;
#localSelectors: Record<
string,
(state: AggregateState<T_LocalState, T_Subduxes>) => any
>;
#selectors: any;
#localEffects: Middleware[] = [];
constructor( constructor(
config: Partial<{ config: Partial<{
initial: T_LocalState; initial: T_LocalState;
actions: T_LocalActions; actions: T_LocalActions;
subduxes: T_Subduxes; subduxes: T_Subduxes;
selectors: T_LocalSelectors;
}>, }>,
) { ) {
// TODO check that we can't alter the initial after the fact // TODO check that we can't alter the initial after the fact
@ -85,6 +107,17 @@ export default class Updux<
this.#actions = buildActions(this.#localActions, this.#subduxes); this.#actions = buildActions(this.#localActions, this.#subduxes);
this.#initial = buildInitial(this.#localInitial, this.#subduxes); this.#initial = buildInitial(this.#localInitial, this.#subduxes);
this.#localSelectors = config.selectors;
const basedSelectors = R.mergeAll(
Object.entries(this.#subduxes)
.filter(([slice, { selectors }]) => selectors)
.map(([slice, { selectors }]) =>
R.mapValues(selectors, (s) => (state) => s(state[slice])),
),
);
this.#selectors = R.merge(basedSelectors, this.#localSelectors);
} }
get actions() { get actions() {
@ -96,18 +129,69 @@ export default class Updux<
return this.#initial; return this.#initial;
} }
get effects() {
return [
...this.#localEffects,
...Object.entries(this.#subduxes).flatMap(
([slice, { effects }]) => {
if (!effects) return [];
return effects.map(effect => (api) => effect({
...api,
getState: () => api.getState()[slice],
}))
}
)
]
}
createStore( createStore(
options: Partial<{ options: Partial<{
initial: T_LocalState; initial: T_LocalState;
}> = {}, }> = {},
) { ) {
const preloadedState: any = options.initial ?? this.initial; const preloadedState: any = options.initial ?? this.initial;
const effects = buildEffectsMiddleware(
this.effects,
this.actions,
this.selectors,
);
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: [effects],
}); });
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<
T_LocalSelectors,
T_Subduxes,
AggregateState<T_LocalState, T_Subduxes>
> {
return this.#selectors as any;
} }
// TODO memoize this sucker // TODO memoize this sucker
@ -159,4 +243,17 @@ export default class Updux<
) { ) {
this.#defaultMutation = { mutation, terminal }; this.#defaultMutation = { mutation, terminal };
} }
addEffect(effect: EffectMiddleware) {
this.#localEffects.push(effect);
}
get effectsMiddleware() {
return buildEffectsMiddleware(
this.effects,
this.actions,
this.selectors,
);
}
} }

View File

@ -1,26 +0,0 @@
export function isActionGen(action) {
return typeof action === 'function' && action.type;
}
export function action(type, payloadFunction, transformer) {
let generator = function (...payloadArg) {
const result = { type };
if (payloadFunction) {
result.payload = payloadFunction(...payloadArg);
} else {
if (payloadArg[0] !== undefined) result.payload = payloadArg[0];
}
return result;
};
if (transformer) {
const orig = generator;
generator = (...args) => transformer(orig(...args), args);
}
generator.type = type;
return generator;
}

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

@ -0,0 +1,110 @@
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);
});
test('subdux', () => {
const bar = new Updux({
initial: 'bar state',
actions: { foo: 0 },
});
let seen = 0;
bar.addEffect((api) => next => action => {
seen++;
expect(api.getState()).toBe('bar state');
next(action);
});
const dux = new Updux({
initial: {
loaded: true,
},
subduxes: {
bar
},
});
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);
};
};
}

32
src/selectors.test.ts Normal file
View File

@ -0,0 +1,32 @@
import { test, expect } from 'vitest';
import Updux, { createAction } from './index.js';
test('basic selectors', () => {
type State = { x: number };
const foo = new Updux({
initial: {
x: 1,
},
selectors: {
getX: ({ x }: State) => x,
},
subduxes: {
bar: new Updux({
initial: { y: 2 },
selectors: {
getY: ({ y }: { y: number }) => y,
},
}),
},
});
const sample = {
x: 4,
bar: { y: 3 },
};
expect(foo.selectors.getY(sample)).toBe(3);
expect(foo.selectors.getX(sample)).toBe(4);
});

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]);
}

View File

@ -1,4 +1,4 @@
import { Action, ActionCreator, Reducer } from 'redux'; import { Action, ActionCreator, Middleware, Reducer } from 'redux';
export type Dux< export type Dux<
STATE = any, STATE = any,
@ -6,18 +6,38 @@ export type Dux<
> = Partial<{ > = Partial<{
initial: STATE; initial: STATE;
actions: ACTIONS; actions: ACTIONS;
selectors: Record<string, (state: STATE) => any>;
reducer: ( reducer: (
state: STATE, state: STATE,
action: ReturnType<ACTIONS[keyof ACTIONS]>, action: ReturnType<ACTIONS[keyof ACTIONS]>,
) => STATE; ) => STATE;
effects: Middleware[];
}>; }>;
type ActionsOf<DUX> = DUX extends { actions: infer A } ? A : {}; type ActionsOf<DUX> = DUX extends { actions: infer A } ? A : {};
type SelectorsOf<DUX> = DUX extends { selectors: infer A } ? A : {};
export type AggregateActions<A, S> = UnionToIntersection< export type AggregateActions<A, S> = UnionToIntersection<
ActionsOf<S[keyof S]> | A ActionsOf<S[keyof S]> | A
>; >;
type BaseSelector<F extends (...args: any) => any, STATE> = (
state: STATE,
) => ReturnType<F>;
type BaseSelectors<S extends Record<string, any>, STATE> = {
[key in keyof S]: BaseSelector<S[key], STATE>;
};
export type AggregateSelectors<
S extends Record<string, (...args: any) => any>,
SUBS extends Record<string, Dux>,
STATE = {},
> = BaseSelectors<
UnionToIntersection<SelectorsOf<SUBS[keyof SUBS]> | S>,
STATE
>;
export type UnionToIntersection<U> = ( export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never U extends any ? (k: U) => void : never
) extends (k: infer I) => void ) extends (k: infer I) => void