diff --git a/src/Updux.original b/src/Updux.original new file mode 100644 index 0000000..586b803 --- /dev/null +++ b/src/Updux.original @@ -0,0 +1,457 @@ +/* TODO change * for leftovers to +, change subscriptions to reactions */ +import moize from 'moize'; +import u from 'updeep'; +import { createStore as reduxCreateStore, applyMiddleware } from 'redux'; +import { get, map, mapValues, merge, difference } from 'lodash-es'; + +import { buildInitial } from './buildInitial/index.js'; +import { buildActions } from './buildActions/index.js'; +import { buildSelectors } from './buildSelectors/index.js'; +import { action } from './actions.js'; +import { buildUpreducer } from './buildUpreducer.js'; +import { + buildMiddleware, + augmentMiddlewareApi, + effectToMiddleware, +} from './buildMiddleware/index.js'; + +import { + AggregateDuxActions, + AggregateDuxState, + Dict, + ItemsOf, + Reducer, + Upreducer, +} from './types.js'; + +type Mutation = (payload:TAction['payload'], action:TAction) => (state: TState) => TState; + +/** + * Configuration object typically passed to the constructor of the class Updux. + */ +export interface UpduxConfig< + TState = any, + TActions = {}, + TSelectors = {}, + TSubduxes = {} +> { + /** + * Local initial state. + * @default {} + */ + initial?: TState; + + /** + * Subduxes to be merged to this dux. + */ + subduxes?: TSubduxes; + + /** + * Local actions. + */ + actions?: TActions; + + /** + * Local selectors. + */ + selectors?: Record; + + /** + * Local mutations + */ + mutations?: Record; + + /** + * Selectors to apply to the mapped subduxes. Only + * applicable if the dux is a mapping dux. + */ + mappedSelectors?: Record; + + /** + * Local effects. + */ + effects?: Record; + + /** + * Local reactions. + */ + reactions?: Function[]; + + /** + * If true, enables mapped reactions. Additionally, it can be + * a reaction function, which will treated as a regular + * reaction for the mapped dux. + */ + mappedReaction?: Function | boolean; + + /** + * Wrapping function for the upreducer to provides full customization. + * @example + * // if an action has the 'dontDoIt' meta flag, don't do anything + * const dux = new Updux({ + * ..., + * upreducerWrapper: (upreducer) => action => { + * if( action?.meta?.dontDoIt ) return state => state; + * return upreducer(action); + * } + * }) + */ + upreducerWrapper?: ( + upreducer: Upreducer< + AggregateDuxState, + ItemsOf> + > + ) => Upreducer< + AggregateDuxState, + ItemsOf> + >; + + middlewareWrapper?: Function; +} + +export class Updux< + TState extends any = {}, + TActions extends object = {}, + TSelectors = {}, + TSubduxes extends object = {} +> { + /** @type { unknown } */ + #initial = {}; + #subduxes = {}; + + /** @type Record */ + #actions = {}; + #selectors = {}; + #mutations = {}; + #effects = []; + #reactions = []; + #mappedSelectors = undefined; + #mappedReaction = undefined; + #upreducerWrapper = undefined; + + #middlewareWrapper = undefined; + + constructor( + config: UpduxConfig + ) { + this.#initial = config.initial ?? {}; + this.#subduxes = config.subduxes ?? {}; + + if (config.subduxes) { + this.#subduxes = mapValues(config.subduxes, (sub) => + sub instanceof Updux ? sub : new Updux(sub) + ); + } + + if (config.actions) { + for (const [type, actionArg] of Object.entries(config.actions)) { + if (typeof actionArg === 'function' && actionArg.type) { + this.#actions[type] = actionArg; + } else { + const args = Array.isArray(actionArg) + ? actionArg + : [actionArg]; + this.#actions[type] = action(type, ...args); + } + } + } + + this.#selectors = config.selectors ?? {}; + this.#mappedSelectors = config.mappedSelectors; + + this.#mutations = config.mutations ?? {}; + + Object.keys(this.#mutations) + .filter((action) => action !== '+') + .filter((action) => !this.actions.hasOwnProperty(action)) + .forEach((action) => { + throw new Error(`action '${action}' is not defined`); + }); + + if (config.effects) { + this.#effects = Object.entries(config.effects); + } + + this.#reactions = config.reactions ?? []; + + this.#mappedReaction = config.mappedReaction; + + this.#upreducerWrapper = config.upreducerWrapper; + + this.#middlewareWrapper = config.middlewareWrapper; + } + + #memoInitial = moize(buildInitial); + #memoActions = moize(buildActions); + #memoSelectors = moize(buildSelectors); + #memoUpreducer = moize(buildUpreducer); + #memoMiddleware = moize(buildMiddleware); + + setMappedSelector(name, f) { + this.#mappedSelectors = { + ...this.#mappedSelectors, + [name]: f, + }; + } + + get middleware() { + return this.#memoMiddleware( + this.#effects, + this.actions, + this.selectors, + this.#subduxes, + this.#middlewareWrapper, + this + ); + } + + setMiddlewareWrapper(wrapper: Function) { + this.#middlewareWrapper = wrapper; + } + + /** @member { unknown } */ + get initial(): AggregateDuxState { + return this.#memoInitial(this.#initial, this.#subduxes); + } + + get actions(): AggregateDuxActions { + return this.#memoActions(this.#actions, this.#subduxes) as any; + } + + get selectors() { + return this.#memoSelectors( + this.#selectors, + this.#mappedSelectors, + this.#subduxes + ); + } + + get subduxes() { return this.#subduxes } + + get upreducer(): Upreducer< + AggregateDuxState, + ItemsOf> + > { + return this.#memoUpreducer( + this.initial, + this.#mutations, + this.#subduxes, + this.#upreducerWrapper + ); + } + + get reducer(): Reducer< + AggregateDuxState, + ItemsOf> + > { + return (state, action) => this.upreducer(action)(state); + } + + addSubscription(subscription) { + this.#reactions = [...this.#reactions, subscription]; + } + + addReaction(reaction) { + this.#reactions = [...this.#reactions, reaction]; + } + + + setAction(type, payloadFunc?: (...args: any) => any) { + const theAction = action(type, payloadFunc); + + this.#actions = { ...this.#actions, [type]: theAction }; + + return theAction; + } + + setSelector(name, func) { + // TODO selector already exists? Complain! + this.#selectors = { + ...this.#selectors, + [name]: func, + }; + return func; + } + + setMutation>(name: TAction, mutation: Mutation, + ReturnType[TAction]>>) { + if (typeof name === 'function') name = name.type; + + this.#mutations = { + ...this.#mutations, + [name]: mutation, + }; + + return mutation; + } + + addEffect(action: TType, effect: E): E { + this.#effects = [...this.#effects, [action, effect]]; + return effect; + } + + augmentMiddlewareApi(api) { + return augmentMiddlewareApi(api, this.actions, this.selectors); + } + + splatSubscriber(store, inner, splatReaction) { + const cache = {}; + + return () => (state, previous, unsub) => { + const cacheKeys = Object.keys(cache); + + const newKeys = difference(Object.keys(state), cacheKeys); + + for (const slice of newKeys) { + let localStore = { + ...store, + getState: () => store.getState()[slice], + }; + + cache[slice] = []; + + if (typeof splatReaction === 'function') { + localStore = { + ...localStore, + ...splatReaction(localStore, slice), + }; + } + + const { unsub, subscriber, subscriberRaw } = + inner.subscribeAll(localStore); + + cache[slice].push({ unsub, subscriber, subscriberRaw }); + subscriber(); + } + + const deletedKeys = difference(cacheKeys, Object.keys(state)); + + for (const deleted of deletedKeys) { + for (const inner of cache[deleted]) { + inner.subscriber(); + inner.unsub(); + } + delete cache[deleted]; + } + }; + } + + subscribeTo(store, subscription, setupArgs = []) { + const localStore = augmentMiddlewareApi( + { + ...store, + subscribe: (subscriber) => + this.subscribeTo(store, () => subscriber), + }, + this.actions, + this.selectors + ); + + const subscriber = subscription(localStore, ...setupArgs); + + let previous; + + const memoSub = () => { + const state = store.getState(); + if (state === previous) return; + let p = previous; + previous = state; + subscriber(state, p, unsub); + }; + + let ret = store.subscribe(memoSub); + const unsub = typeof ret === 'function' ? ret : ret.unsub; + return { + unsub, + subscriber: memoSub, + subscriberRaw: subscriber, + }; + } + + subscribeAll(store) { + let results = this.#reactions.map((sub) => + this.subscribeTo(store, sub) + ); + + for (const subdux in this.#subduxes) { + if (subdux !== '*') { + const localStore = { + ...store, + getState: () => get(store.getState(), subdux), + }; + results.push(this.#subduxes[subdux].subscribeAll(localStore)); + } + } + + if (this.#mappedReaction) { + results.push( + this.subscribeTo( + store, + this.splatSubscriber( + store, + this.#subduxes['*'], + this.#mappedReaction + ) + ) + ); + } + + return { + unsub: () => results.forEach(({ unsub }) => unsub()), + subscriber: () => + results.forEach(({ subscriber }) => subscriber()), + subscriberRaw: (...args) => + results.forEach(({ subscriberRaw }) => + subscriberRaw(...args) + ), + }; + } + + createStore(initial?: unknown, enhancerGenerator?: Function) { + const enhancer = (enhancerGenerator ?? applyMiddleware)( + this.middleware + ); + + const store: { + getState: Function & Record; + dispatch: Function & Record; + selectors: Record; + actions: AggregateDuxActions; + } = reduxCreateStore( + this.reducer as any, + initial ?? this.initial, + enhancer + ) as any; + + store.actions = this.actions; + + store.selectors = this.selectors; + + merge( + store.getState, + mapValues(this.selectors, (selector) => { + return (...args) => { + let result = selector(store.getState()); + + if (typeof result === 'function') return result(...args); + + return result; + }; + }) + ); + + for (const action in this.actions) { + store.dispatch[action] = (...args) => { + return store.dispatch(this.actions[action](...(args as any))); + }; + } + + this.subscribeAll(store); + + return store; + } + + effectToMiddleware(effect) { + return effectToMiddleware(effect, this.actions, this.selectors); + } +} diff --git a/src/Updux.ts b/src/Updux.ts index a776021..b021aad 100644 --- a/src/Updux.ts +++ b/src/Updux.ts @@ -53,6 +53,10 @@ export class Updux state => this.reducer(state,action); + } + get reducer() { return (state : DuxAggregateState, _action : any) => state; } diff --git a/src/reducer.test.ts b/src/reducer.test.ts index 838247e..c668a1e 100644 --- a/src/reducer.test.ts +++ b/src/reducer.test.ts @@ -9,3 +9,11 @@ test('basic reducer', () => { expect(dux.reducer({ a: 1 }, { type: 'foo' })).toMatchObject({ a: 1 }); // noop }); + +test('basic upreducer', () => { + const dux = new Updux({ initial: {a: 3} }); + + expect(dux.upreducer).toBeTypeOf('function'); + + expect(dux.upreducer({ type: 'foo' })({a:1})).toMatchObject({ a: 1 }); // noop +});