Merge branch 'effects-ts' into typescript
This commit is contained in:
commit
347d90267e
105
src/Updux.ts
105
src/Updux.ts
@ -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,
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
110
src/effects.test.ts
Normal 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
47
src/effects.ts
Normal 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
32
src/selectors.test.ts
Normal 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);
|
||||||
|
});
|
@ -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]);
|
|
||||||
}
|
|
22
src/types.ts
22
src/types.ts
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user