diff --git a/src/Updux.ts b/src/Updux.ts index cd27cca..34000e2 100644 --- a/src/Updux.ts +++ b/src/Updux.ts @@ -7,6 +7,7 @@ import { MiddlewareAPI, AnyAction, Middleware, + Dispatch, } from 'redux'; import { configureStore, @@ -19,7 +20,7 @@ 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 { augmentGetState, buildEffectsMiddleware, EffectMiddleware } from './effects.js'; +import { augmentGetState, augmentMiddlewareApi, buildEffectsMiddleware, EffectMiddleware } from './effects.js'; import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore.js'; type MyActionCreator = { type: string } & ((...args: any) => any); @@ -54,6 +55,17 @@ type ResolveActions< : never; }; +type Reaction = + (api: M, state: S, previousState: S, unsubscribe: () => void) => void; + +type AugmentedMiddlewareAPI = + MiddlewareAPI, S> & { + dispatch: A, + getState: CurriedSelectors, + actions: A, + selectors: SELECTORS, + }; + export type Mutation = Action, S = any> = ( payload: A extends { payload: infer P; @@ -151,6 +163,19 @@ export default class Updux< ] } + get reactions(): any { + return [...this.#localReactions, + ...Object.entries(this.#subduxes).flatMap( + ([slice, { reactions }]) => reactions.map( + (r) => (api, unsub) => r({ + ...api, + getState: () => api.getState()[slice], + }, unsub) + ) + ) + ]; + } + createStore( options: Partial<{ initial: T_LocalState; @@ -166,7 +191,7 @@ export default class Updux< const store = configureStore({ - reducer: ((state) => state) as Reducer< + reducer: this.reducer as Reducer< AggregateState, AnyAction >, @@ -185,20 +210,27 @@ export default class Updux< store.getState = augmentGetState(store.getState, this.selectors); + for (const reaction of this.reactions) { + let unsub; + const r = reaction(store); + + unsub = store.subscribe(() => r(unsub)); + } + + return store as ToolkitStore< AggregateState - > & { - dispatch: AggregateActions< + > & AugmentedMiddlewareAPI< + AggregateState, + AggregateActions< ResolveActions, T_Subduxes - >; - } & { - getState: CurriedSelectors, AggregateSelectors< T_LocalSelectors, T_Subduxes, AggregateState - >> - }; + > + >; } get selectors(): AggregateSelectors< @@ -271,5 +303,63 @@ export default class Updux< ); } + + #localReactions: any[] = []; + addReaction(reaction: Reaction, + AugmentedMiddlewareAPI< + AggregateState, + AggregateActions< + ResolveActions, + T_Subduxes + >, AggregateSelectors< + T_LocalSelectors, + T_Subduxes, + AggregateState + > + > + >) { + + let previous: any; + + const memoized = (api: any) => { + api = augmentMiddlewareApi(api, + this.actions, + this.selectors + ); + return (unsub: () => void) => { + const state = api.getState(); + if (state === previous) return; + let p = previous; + previous = state; + reaction(api, state, p, unsub); + } + } + ; + + this.#localReactions.push(memoized); + } + + // internal method REMOVE + subscribeTo(store, subscription) { + const localStore = augmentMiddlewareApi({ + ...store, + subscribe: (subscriber) => this.subscribeTo(store, subscriber), // TODO not sure + }, this.actions, this.selectors); + + const subscriber = subscription(localStore); + + let previous; + let unsub; + + const memoSub = () => { + const state = store.getState(); + if (state === previous) return; + let p = previous; + previous = state; + subscriber(state, p, unsub); + }; + + return store.subscribe(memoSub); + } } diff --git a/src/effects.ts b/src/effects.ts index ad5076a..59cad76 100644 --- a/src/effects.ts +++ b/src/effects.ts @@ -32,7 +32,7 @@ const augmentDispatch = (originalDispatch, actions) => { return dispatch; }; -const augmentMiddlewareApi = (api, actions, selectors) => { +export const augmentMiddlewareApi = (api, actions, selectors) => { return { ...api, getState: augmentGetState(api.getState, selectors), diff --git a/src/reactions.test.todo b/src/reactions.test.todo index 8b17efb..8f006f8 100644 --- a/src/reactions.test.todo +++ b/src/reactions.test.todo @@ -2,26 +2,6 @@ import { test, expect, vi } from 'vitest'; import { Updux } from './Updux.js'; -test('basic reactions', async () => { - const spyA = vi.fn(); - const spyB = vi.fn(); - const foo = new Updux({ - initial: { i: 0 }, - reactions: [() => spyA], - actions: { inc: null }, - mutations: { - inc: () => (state) => ({ ...state, i: state.i + 1 }), - }, - }); - - foo.addReaction((api) => spyB); - - const store = foo.createStore(); - store.dispatch.inc(); - - expect(spyA).toHaveBeenCalledOnce(); - expect(spyB).toHaveBeenCalledOnce(); -}); test('subduxes reactions', async () => { const spyA = vi.fn(); diff --git a/src/reactions.test.ts b/src/reactions.test.ts new file mode 100644 index 0000000..a6cf647 --- /dev/null +++ b/src/reactions.test.ts @@ -0,0 +1,82 @@ +import { test, expect, vi } from 'vitest'; +import Updux from './index.js'; + +test('basic reactions', () => { + const foo = new Updux({ + initial: 0, + actions: { inc: 0, reset: 0 }, + }); + + // TODO immer that stuff + foo.addMutation(foo.actions.inc, () => (state) => state + 1); + foo.addMutation(foo.actions.reset, () => (state) => 0); + + foo.addReaction((api, state, _previous, unsubscribe) => { + if (state < 3) return; + unsubscribe(); + api.dispatch.reset(); + }); + + // TODO + //reaction: (api) => (state,previous,unsubscribe) + + const store = foo.createStore(); + + store.dispatch.inc(); + expect(store.getState()).toEqual(1); + store.dispatch.inc(); + store.dispatch.inc(); + + expect(store.getState()).toEqual(0); // we've been reset + + store.dispatch.inc(); + store.dispatch.inc(); + store.dispatch.inc(); + store.dispatch.inc(); + + expect(store.getState()).toEqual(4); // we've unsubscribed +}); + +test('subdux reactions', () => { + const bar = new Updux({ + initial: 0, + actions: { inc: 0, reset: 0 }, + selectors: { + getIt: (x) => x, + }, + }); + + const foo = new Updux({ actions: { notInBar: 0 }, subduxes: { bar } }); + + // TODO immer that stuff + bar.addMutation(foo.actions.inc, () => (state) => state + 1); + bar.addMutation(foo.actions.reset, () => (state) => 0); + + let seen = 0; + bar.addReaction((api, state, _previous, unsubscribe) => { + seen++; + expect(api.actions).not.toHaveProperty('notInBar'); + expect(state).toBeTypeOf('number'); + if (state < 3) return; + unsubscribe(); + api.dispatch.reset(); + }); + + const store = foo.createStore(); + + store.dispatch.inc(); + expect(seen).toEqual(1); + expect(store.getState()).toEqual({ bar: 1 }); + expect(store.getState.getIt()).toEqual(1); + store.dispatch.inc(); + store.dispatch.inc(); + + expect(store.getState.getIt()).toEqual(0); // we've been reset + + store.dispatch.inc(); + store.dispatch.inc(); + store.dispatch.inc(); + store.dispatch.inc(); + + expect(store.getState.getIt()).toEqual(4); // we've unsubscribed +}); diff --git a/src/types.ts b/src/types.ts index ee58fa3..54db08d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ export type Dux< action: ReturnType, ) => STATE; effects: Middleware[]; + reactions: ((...args: any[]) => void)[]; }>; type ActionsOf = DUX extends { actions: infer A } ? A : {};