From c660b7be1dbcba09aa28999ed60fd22c98a979ee Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Fri, 10 Mar 2023 10:31:30 -0500 Subject: [PATCH 1/6] beginning on selectors --- src/Updux.ts | 35 +++++++++++++++++++++++++++++------ src/selectors.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 src/selectors.test.ts diff --git a/src/Updux.ts b/src/Updux.ts index d35b81c..b54ab15 100644 --- a/src/Updux.ts +++ b/src/Updux.ts @@ -26,10 +26,10 @@ type ResolveAction< ? ActionArg : ActionArg extends (...args: any) => any ? ActionCreatorWithPreparedPayload< - Parameters, - ReturnType, - ActionType - > + Parameters, + ReturnType, + ActionType + > : ActionCreatorWithoutPayload; type ResolveActions< @@ -37,10 +37,10 @@ type ResolveActions< [key: string]: any; }, > = { - [ActionType in keyof A]: ActionType extends string + [ActionType in keyof A]: ActionType extends string ? ResolveAction : never; -}; + }; export type Mutation = Action, S = any> = ( payload: A extends { @@ -51,12 +51,22 @@ export type Mutation = Action, S = any> = ( action: A, ) => (state: S) => S | void; +export type AggregateSelectors = S; + +type SelectorForState = (state: S) => unknown; +type SelectorsForState = { + [key: string]: SelectorForState; +}; + export default class Updux< T_LocalState = Record, T_LocalActions extends { [actionType: string]: any; } = {}, T_Subduxes extends Record = {}, + T_LocalSelectors extends SelectorsForState< + AggregateState + > = {}, > { #localInitial: T_LocalState; #localActions: T_LocalActions; @@ -70,11 +80,19 @@ export default class Updux< #initial: AggregateState; + #localSelectors: Record< + string, + (state: AggregateState) => any + >; + + #selectors: AggregateSelectors; + constructor( config: Partial<{ initial: T_LocalState; actions: T_LocalActions; subduxes: T_Subduxes; + selectors: T_LocalSelectors; }>, ) { // TODO check that we can't alter the initial after the fact @@ -85,6 +103,7 @@ export default class Updux< this.#actions = buildActions(this.#localActions, this.#subduxes); this.#initial = buildInitial(this.#localInitial, this.#subduxes); + this.#localSelectors = config.selectors; } get actions() { @@ -110,6 +129,10 @@ export default class Updux< return store; } + get selectors(): T_LocalSelectors { + return this.#localSelectors as any; + } + // TODO memoize this sucker get reducer() { return buildReducer( diff --git a/src/selectors.test.ts b/src/selectors.test.ts new file mode 100644 index 0000000..86430c3 --- /dev/null +++ b/src/selectors.test.ts @@ -0,0 +1,27 @@ +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, + }, + }), + }, + }); + + expect(foo.selectors.getY({ bar: { y: 3 } } as typeof foo.initial)).toBe(3); + expect(foo.selectors.getX({ x: 4 } as typeof foo.initial)).toBe(4); +}); From 7a5733425e1a950894197573940087c3e70d72d1 Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Fri, 10 Mar 2023 12:04:19 -0500 Subject: [PATCH 2/6] selector test is passing --- src/Updux.ts | 25 ++++++++++++++++++------- src/selectors.test.ts | 9 +++++++-- src/types.ts | 15 +++++++++++++++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/Updux.ts b/src/Updux.ts index b54ab15..d6d7790 100644 --- a/src/Updux.ts +++ b/src/Updux.ts @@ -12,7 +12,7 @@ import { ActionCreatorWithoutPayload, ActionCreatorWithPreparedPayload, } from '@reduxjs/toolkit'; -import { AggregateActions, Dux } from './types.js'; +import { AggregateActions, AggregateSelectors, Dux } from './types.js'; import { buildActions } from './buildActions.js'; import { buildInitial, AggregateState } from './initial.js'; import { buildReducer, MutationCase } from './reducer.js'; @@ -51,8 +51,6 @@ export type Mutation = Action, S = any> = ( action: A, ) => (state: S) => S | void; -export type AggregateSelectors = S; - type SelectorForState = (state: S) => unknown; type SelectorsForState = { [key: string]: SelectorForState; @@ -84,8 +82,7 @@ export default class Updux< string, (state: AggregateState) => any >; - - #selectors: AggregateSelectors; + #selectors: any; constructor( config: Partial<{ @@ -104,6 +101,16 @@ export default class Updux< 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() { @@ -129,8 +136,12 @@ export default class Updux< return store; } - get selectors(): T_LocalSelectors { - return this.#localSelectors as any; + get selectors(): AggregateSelectors< + T_LocalSelectors, + T_Subduxes, + AggregateState + > { + return this.#selectors as any; } // TODO memoize this sucker diff --git a/src/selectors.test.ts b/src/selectors.test.ts index 86430c3..11fe428 100644 --- a/src/selectors.test.ts +++ b/src/selectors.test.ts @@ -22,6 +22,11 @@ test('basic selectors', () => { }, }); - expect(foo.selectors.getY({ bar: { y: 3 } } as typeof foo.initial)).toBe(3); - expect(foo.selectors.getX({ x: 4 } as typeof foo.initial)).toBe(4); + const sample = { + x: 4, + bar: { y: 3 }, + }; + + expect(foo.selectors.getY(sample)).toBe(3); + expect(foo.selectors.getX(sample)).toBe(4); }); diff --git a/src/types.ts b/src/types.ts index fb4a0a6..cca0034 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ export type Dux< > = Partial<{ initial: STATE; actions: ACTIONS; + selectors: Record any>; reducer: ( state: STATE, action: ReturnType, @@ -13,11 +14,25 @@ export type Dux< }>; type ActionsOf = DUX extends { actions: infer A } ? A : {}; +type SelectorsOf = DUX extends { selectors: infer A } ? A : {}; export type AggregateActions = UnionToIntersection< ActionsOf | A >; +type BaseSelector any, STATE> = ( + state: STATE, +) => ReturnType; + +type BaseSelectors = { + [key in keyof S]: BaseSelector; +}; + +export type AggregateSelectors = BaseSelectors< + UnionToIntersection | S>, + STATE +>; + export type UnionToIntersection = ( U extends any ? (k: U) => void : never ) extends (k: infer I) => void From 95768706fa031aa5b672ea10dee6651ac589ee82 Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Sat, 11 Mar 2023 11:06:38 -0500 Subject: [PATCH 3/6] effectsMiddleware --- src/Updux.ts | 47 +++++++++++++++++++++++++-- src/effects.test.ts | 79 +++++++++++++++++++++++++++++++++++++++++++++ src/effects.ts | 47 +++++++++++++++++++++++++++ src/selectors.todo | 43 ------------------------ 4 files changed, 171 insertions(+), 45 deletions(-) create mode 100644 src/effects.test.ts create mode 100644 src/effects.ts delete mode 100644 src/selectors.todo diff --git a/src/Updux.ts b/src/Updux.ts index d6d7790..6063217 100644 --- a/src/Updux.ts +++ b/src/Updux.ts @@ -4,6 +4,8 @@ import { applyMiddleware, DeepPartial, Action, + MiddlewareAPI, + AnyAction, } from 'redux'; import { configureStore, @@ -16,6 +18,8 @@ import { AggregateActions, AggregateSelectors, Dux } from './types.js'; import { buildActions } from './buildActions.js'; import { buildInitial, AggregateState } from './initial.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); @@ -84,6 +88,8 @@ export default class Updux< >; #selectors: any; + #effects: any[] = []; + constructor( config: Partial<{ initial: T_LocalState; @@ -129,11 +135,31 @@ export default class Updux< ) { const preloadedState: any = options.initial ?? this.initial; const store = configureStore({ - reducer: ((state) => state) as Reducer, + reducer: ((state) => state) as Reducer< + AggregateState, + AnyAction + >, 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 + > & { + dispatch: AggregateActions< + ResolveActions, + T_Subduxes + >; + }; } get selectors(): AggregateSelectors< @@ -193,4 +219,21 @@ export default class Updux< ) { 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, + ); + + } } diff --git a/src/effects.test.ts b/src/effects.test.ts new file mode 100644 index 0000000..906d83b --- /dev/null +++ b/src/effects.test.ts @@ -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? diff --git a/src/effects.ts b/src/effects.ts new file mode 100644 index 0000000..489e715 --- /dev/null +++ b/src/effects.ts @@ -0,0 +1,47 @@ +import { AnyAction } from '@reduxjs/toolkit'; +import { MiddlewareAPI } from '@reduxjs/toolkit'; +import { Dispatch } from '@reduxjs/toolkit'; + +export interface EffectMiddleware { + (api: MiddlewareAPI): ( + next: Dispatch, + ) => (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); + }; + }; +} diff --git a/src/selectors.todo b/src/selectors.todo deleted file mode 100644 index e90b4e9..0000000 --- a/src/selectors.todo +++ /dev/null @@ -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]); -} From daa8421251e6f12783fb9b4c012c68bfa1b4c843 Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Sat, 11 Mar 2023 13:37:33 -0500 Subject: [PATCH 4/6] subdux effects --- src/Updux.ts | 34 +++++++++++++++++++++++++++------- src/effects.test.ts | 31 +++++++++++++++++++++++++++++++ src/types.ts | 3 ++- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/Updux.ts b/src/Updux.ts index 6063217..8045e73 100644 --- a/src/Updux.ts +++ b/src/Updux.ts @@ -6,6 +6,7 @@ import { Action, MiddlewareAPI, AnyAction, + Middleware, } from 'redux'; import { configureStore, @@ -88,7 +89,7 @@ export default class Updux< >; #selectors: any; - #effects: any[] = []; + #localEffects: Middleware[] = []; constructor( config: Partial<{ @@ -128,19 +129,42 @@ export default class Updux< 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( options: Partial<{ initial: T_LocalState; }> = {}, ) { const preloadedState: any = options.initial ?? this.initial; + + const effects = buildEffectsMiddleware( + this.effects, + this.actions, + this.selectors, + ); + + const store = configureStore({ reducer: ((state) => state) as Reducer< AggregateState, AnyAction >, preloadedState, - middleware: [this.effectsMiddleware], + middleware: [effects], }); const dispatch: any = store.dispatch; @@ -221,11 +245,7 @@ export default class Updux< } addEffect(effect: EffectMiddleware) { - this.#effects.push(effect); - } - - get effects() { - return this.#effects; + this.#localEffects.push(effect); } get effectsMiddleware() { diff --git a/src/effects.test.ts b/src/effects.test.ts index 906d83b..f500cae 100644 --- a/src/effects.test.ts +++ b/src/effects.test.ts @@ -75,5 +75,36 @@ test('basic', () => { 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? diff --git a/src/types.ts b/src/types.ts index cca0034..2420635 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { Action, ActionCreator, Reducer } from 'redux'; +import { Action, ActionCreator, Middleware, Reducer } from 'redux'; export type Dux< STATE = any, @@ -11,6 +11,7 @@ export type Dux< state: STATE, action: ReturnType, ) => STATE; + effects: Middleware[]; }>; type ActionsOf = DUX extends { actions: infer A } ? A : {}; From 5edbc688bebf85cb5d1b170f6671d4d3c6483177 Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Sat, 11 Mar 2023 13:51:23 -0500 Subject: [PATCH 5/6] make tsc happy --- src/types.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/types.ts b/src/types.ts index 2420635..ee58fa3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,11 +25,15 @@ type BaseSelector any, STATE> = ( state: STATE, ) => ReturnType; -type BaseSelectors = { +type BaseSelectors, STATE> = { [key in keyof S]: BaseSelector; }; -export type AggregateSelectors = BaseSelectors< +export type AggregateSelectors< + S extends Record any>, + SUBS extends Record, + STATE = {}, +> = BaseSelectors< UnionToIntersection | S>, STATE >; From 17cd3bec463ce7628fe1325feda52229efa93bd1 Mon Sep 17 00:00:00 2001 From: Yanick Champoux Date: Sat, 11 Mar 2023 16:47:26 -0500 Subject: [PATCH 6/6] remove actions.todo --- src/actions.todo | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 src/actions.todo diff --git a/src/actions.todo b/src/actions.todo deleted file mode 100644 index 51ad508..0000000 --- a/src/actions.todo +++ /dev/null @@ -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; -}