feat: add support for subscriptions

This commit is contained in:
Yanick Champoux 2020-06-19 19:29:12 -04:00
parent 86dd272603
commit 9c45ee7efc
6 changed files with 964 additions and 752 deletions

View File

@ -1,4 +1,3 @@
# What's Updux? # What's Updux?
So, I'm a fan of [Redux](https://redux.js.org). Two days ago I discovered So, I'm a fan of [Redux](https://redux.js.org). Two days ago I discovered
@ -16,17 +15,16 @@ is the fun in that? And I'm also having a strong love for
All that to say, say hello to `Updux`. Heavily inspired by `rematch`, but twisted All that to say, say hello to `Updux`. Heavily inspired by `rematch`, but twisted
to work with `updeep` and to fit my peculiar needs. It offers features such as to work with `updeep` and to fit my peculiar needs. It offers features such as
* Mimic the way VueX has mutations (reducer reactions to specific actions) and - Mimic the way VueX has mutations (reducer reactions to specific actions) and
effects (middleware reacting to actions that can be asynchronous and/or effects (middleware reacting to actions that can be asynchronous and/or
have side-effects), so everything pertaining to a store are all defined have side-effects), so everything pertaining to a store are all defined
in the space place. in the space place.
* Automatically gather all actions used by the updux's effects and mutations, - Automatically gather all actions used by the updux's effects and mutations,
and makes then accessible as attributes to the `dispatch` object of the and makes then accessible as attributes to the `dispatch` object of the
store. store.
* Mutations have a signature that is friendly to Updux and Immer. - Mutations have a signature that is friendly to Updux and Immer.
* Also, the mutation signature auto-unwrap the payload of the actions for you. - Also, the mutation signature auto-unwrap the payload of the actions for you.
* TypeScript types. - TypeScript types.
Fair warning: this package is still very new, probably very buggy, Fair warning: this package is still very new, probably very buggy,
definitively very badly documented, and very subject to changes. Caveat definitively very badly documented, and very subject to changes. Caveat
@ -78,6 +76,8 @@ store.dispatch.inc(3);
# Description # Description
Full documentation can be [found here](https://yanick.github.io/updux/). Full documentation can be [found here](https://yanick.github.io/updux/).
Right now the best way to understand the whole thing is to go
through the [tutorial](https://yanick.github.io/updux/#/tutorial)
## Exporting upduxes ## Exporting upduxes
@ -94,7 +94,6 @@ const updux = new Updux({ ... });
export default updux; export default updux;
``` ```
Then you can use them as subduxes like this: Then you can use them as subduxes like this:
``` ```
@ -196,7 +195,6 @@ const updux = new Updux({
Converting it to Immer would look like: Converting it to Immer would look like:
``` ```
import Updux from 'updux'; import Updux from 'updux';
import { produce } from 'Immer'; import { produce } from 'Immer';
@ -213,7 +211,6 @@ const updux = new Updux({
But since typing `produce` over and over is no fun, `groomMutations` But since typing `produce` over and over is no fun, `groomMutations`
can be used to wrap all mutations with it: can be used to wrap all mutations with it:
``` ```
import Updux from 'updux'; import Updux from 'updux';
import { produce } from 'Immer'; import { produce } from 'Immer';
@ -227,6 +224,3 @@ const updux = new Updux({
}); });
``` ```

View File

@ -101,7 +101,6 @@ For TypeScript projects I recommend declaring the actions as part of the
configuration passed to the constructors, as it makes them accessible to the class configuration passed to the constructors, as it makes them accessible to the class
at compile-time, and allows Updux to auto-add them to its aggregated `actions` type. at compile-time, and allows Updux to auto-add them to its aggregated `actions` type.
``` ```
const todosUpdux = new Updux({ const todosUpdux = new Updux({
actions: { actions: {
@ -210,7 +209,6 @@ todosUpdux.addMutation( '*', (payload,action) => state => {
}); });
``` ```
## Effects ## Effects
In addition to mutations, Updux also provides action-specific middleware, here In addition to mutations, Updux also provides action-specific middleware, here
@ -498,7 +496,7 @@ todosUpdux.addMutation( add_todo_with_id, payload =>
export default updux.asDux; export default updux.asDux;
``` ```
Note the special '*' subdux key used here. This Note the special '\*' subdux key used here. This
allows the updux to map every item present in its allows the updux to map every item present in its
state to a `todo` updux. See [this recipe](/recipes?id=mapping-a-mutation-to-all-values-of-a-state) for details. state to a `todo` updux. See [this recipe](/recipes?id=mapping-a-mutation-to-all-values-of-a-state) for details.
We could also have written the updux as: We could also have written the updux as:
@ -519,11 +517,9 @@ const updux = new Updux({
``` ```
Note how we are using the `upreducer` accessor in the first case (which yields Note how we are using the `upreducer` accessor in the first case (which yields
a reducer for the dux using the signature `(payload,action) => state => a reducer for the dux using the signature `(payload,action) => state => new_state`) and `reducer` in the second case (which yield an equivalent
new_state`) and `reducer` in the second case (which yield an equivalent
reducer using the classic signature `(state,action) => new_state`). reducer using the classic signature `(state,action) => new_state`).
### Main store ### Main store
``` ```
@ -573,6 +569,50 @@ at the main level is actually defined as:
const getNextId = state => next_id.selectors.getNextId(state.next_id); const getNextId = state => next_id.selectors.getNextId(state.next_id);
``` ```
## Subscriptions
Subscriptions can be added by default to a updux store via the initial config
or the method `addSubscription`. The signature of a subscription is:
```
(store) => (state,unsubscribe) => {
...
}
```
Subscriptions registered for an updux and its subduxes are automatically
subscribed to the store when calling `createStore`.
The `state` passed to the subscriptions of the subduxes is the local state.
Also, all subscriptions are wrapped such that they are called only if the
local `state` changed since their last invocation.
Example:
```
const set_nbr_todos = action('set_nbr_todos', payload() );
const todos = dux({
initial: [],
subscriptions: [
({dispatch}) => todos => dispatch(set_nbr_todos(todos.length))
],
});
const myDux = dux({
initial: {
nbr_todos: 0
},
subduxes: {
todos,
},
mutations: [
[ set_nbr_todos, nbr_todos => u({nbr_todos}) ]
]
})
```
## Exporting upduxes ## Exporting upduxes
As a general rule, don't directly export your upduxes, but rather use the accessor `asDux`. As a general rule, don't directly export your upduxes, but rather use the accessor `asDux`.

View File

@ -43,7 +43,9 @@
"scripts": { "scripts": {
"docsify:serve": "docsify serve docs", "docsify:serve": "docsify serve docs",
"build": "tsc", "build": "tsc",
"test": "tap src/**test.ts" "test": "tap src/**test.ts",
"lint": "prettier -c --",
"lint:fix": "prettier --write --"
}, },
"version": "2.0.0", "version": "2.0.0",
"repository": { "repository": {

107
src/subscriptions.test.ts Normal file
View File

@ -0,0 +1,107 @@
import tap from 'tap';
import Updux from '.';
import { action, payload } from 'ts-action';
import u from 'updeep';
const inc = action('inc');
const set_double = action('set_double', payload<number>());
const dux = new Updux({
initial: {
x: 0,
double: 0,
},
actions: {
inc,
},
mutations: [
[inc, payload => u({ x: x => x + 1 })],
[set_double, double => u({ double })],
],
});
dux.addSubscription(store => (state, unsubscribe) => {
if (state.x > 2) return unsubscribe();
store.dispatch(set_double(state.x * 2));
});
const store = dux.createStore();
store.dispatch(inc());
tap.same(store.getState(), { x: 1, double: 2 });
store.dispatch(inc());
store.dispatch(inc());
tap.same(store.getState(), { x: 3, double: 4 }, 'we unsubscribed');
tap.test('subduxes subscriptions', async t => {
const inc_top = action('inc_top');
const inc_bar = action('inc_bar');
const transform_bar = action('transform_bar', payload());
const bar = new Updux({
initial: 'a',
mutations: [
[inc_bar, () => state => state + 'a'],
[transform_bar, outcome => () => outcome],
],
subscriptions: [
store => (state, unsubscribe) => {
console.log({ state });
if (state.length <= 2) return;
unsubscribe();
store.dispatch(transform_bar('look at ' + state));
},
],
});
const dux = new Updux({
initial: {
count: 0,
},
subduxes: {
bar: bar.asDux,
},
mutations: [[inc_top, () => u({ count: count => count + 1 })]],
effects: [
[
'*',
() => next => action => {
console.log('before ', action.type);
next(action);
console.log({ action });
},
],
],
subscriptions: [
store => {
let previous: any;
return ({ count }) => {
if (count !== previous) {
previous = count;
store.dispatch(inc_bar());
}
};
},
],
});
const store = dux.createStore();
store.dispatch(inc_top());
store.dispatch(inc_top());
t.same(store.getState(), {
count: 2,
bar: 'look at look at aaa',
});
store.dispatch(inc_top());
t.same(store.getState(), {
count: 3,
bar: 'look at look at aaaa',
});
});

View File

@ -37,324 +37,339 @@ export type UnionToIntersection<U> = (U extends any
export type StateOf<D> = D extends { initial: infer I } ? I : unknown; export type StateOf<D> = D extends { initial: infer I } ? I : unknown;
export type DuxStateCoduxes<C> = C extends Array<infer U> ? UnionToIntersection<StateOf<U>>: unknown export type DuxStateCoduxes<C> = C extends Array<infer U>
export type DuxStateSubduxes<C> = ? UnionToIntersection<StateOf<U>>
C extends { '*': infer I } ? { : unknown;
[ key: string ]: StateOf<I>, export type DuxStateSubduxes<C> = C extends { '*': infer I }
[ index: number ]: StateOf<I>, ? {
} : [key: string]: StateOf<I>;
C extends object ? { [ K in keyof C]: StateOf<C[K]>}: unknown; [index: number]: StateOf<I>;
}
: C extends object
? { [K in keyof C]: StateOf<C[K]> }
: unknown;
type DuxStateGlobSub<S> = S extends { '*': infer I } ? StateOf<I> : unknown; type DuxStateGlobSub<S> = S extends { '*': infer I } ? StateOf<I> : unknown;
type LocalDuxState<S> = S extends never[] ? unknown[] : S; type LocalDuxState<S> = S extends never[] ? unknown[] : S;
/** @ignore */ /** @ignore */
type AggDuxState2<L,S,C> = ( type AggDuxState2<L, S, C> = (L extends never[]
L extends never[] ? Array<DuxStateGlobSub<S>> : L & DuxStateSubduxes<S> ) & DuxStateCoduxes<C>; ? Array<DuxStateGlobSub<S>>
: L & DuxStateSubduxes<S>) &
DuxStateCoduxes<C>;
/** @ignore */ /** @ignore */
export type AggDuxState<O,S extends UpduxConfig> = unknown extends O ? export type AggDuxState<O, S extends UpduxConfig> = unknown extends O
AggDuxState2<S['initial'],S['subduxes'],S['coduxes']> : O ? AggDuxState2<S['initial'], S['subduxes'], S['coduxes']>
: O;
type SelectorsOf<C> = C extends { selectors: infer S } ? S : unknown; type SelectorsOf<C> = C extends { selectors: infer S } ? S : unknown;
/** @ignore */ /** @ignore */
export type DuxSelectorsSubduxes<C> = C extends object ? UnionToIntersection<SelectorsOf<C[keyof C]>> : unknown; export type DuxSelectorsSubduxes<C> = C extends object
? UnionToIntersection<SelectorsOf<C[keyof C]>>
: unknown;
/** @ignore */ /** @ignore */
export type DuxSelectorsCoduxes<C> = C extends Array<infer U> ? UnionToIntersection<SelectorsOf<U>> : unknown; export type DuxSelectorsCoduxes<C> = C extends Array<infer U>
? UnionToIntersection<SelectorsOf<U>>
: unknown;
type MaybeReturnType<X> = X extends (...args: any) => any ? ReturnType<X> : unknown; type MaybeReturnType<X> = X extends (...args: any) => any
? ReturnType<X>
: unknown;
type RebaseSelector<S,X> = { type RebaseSelector<S, X> = {
[ K in keyof X]: (state: S) => MaybeReturnType< X[K] > [K in keyof X]: (state: S) => MaybeReturnType<X[K]>;
} };
type ActionsOf<C> = C extends { actions: infer A } ? A : {}; type ActionsOf<C> = C extends { actions: infer A } ? A : {};
type DuxActionsSubduxes<C> = C extends object ? ActionsOf<C[keyof C]> : unknown; type DuxActionsSubduxes<C> = C extends object ? ActionsOf<C[keyof C]> : unknown;
export type DuxActionsCoduxes<C> = C extends Array<infer I> ? UnionToIntersection<ActionsOf<I>> : {}; export type DuxActionsCoduxes<C> = C extends Array<infer I>
? UnionToIntersection<ActionsOf<I>>
: {};
type ItemsOf<C> = C extends object? C[keyof C] : unknown type ItemsOf<C> = C extends object ? C[keyof C] : unknown;
export type DuxActions<A,C extends UpduxConfig> = A extends object ? A: ( export type DuxActions<A, C extends UpduxConfig> = A extends object
UnionToIntersection<ActionsOf<C|ItemsOf<C['subduxes']>|ItemsOf<C['coduxes']>>> ? A
); : UnionToIntersection<
ActionsOf<C | ItemsOf<C['subduxes']> | ItemsOf<C['coduxes']>>
>;
export type DuxSelectors<S,X,C extends UpduxConfig> = unknown extends X ? ( export type DuxSelectors<S, X, C extends UpduxConfig> = unknown extends X
RebaseSelector<S, ? RebaseSelector<
C['selectors'] & DuxSelectorsCoduxes<C['coduxes']> & S,
DuxSelectorsSubduxes<C['subduxes']> > C['selectors'] &
): X DuxSelectorsCoduxes<C['coduxes']> &
DuxSelectorsSubduxes<C['subduxes']>
>
: X;
export type Dux< export type Dux<S = unknown, A = unknown, X = unknown, C = unknown> = {
S = unknown, subduxes: Dictionary<Dux>;
A = unknown, coduxes: Dux[];
X = unknown, initial: AggDuxState<S, C>;
C = unknown, actions: A;
> = { subscriptions: Function[];
subduxes: Dictionary<Dux>, };
coduxes: Dux[],
initial: AggDuxState<S,C>,
actions: A,
}
/** /**
* Configuration object given to Updux's constructor. * Configuration object given to Updux's constructor.
* *
* #### arguments * #### arguments
* *
* ##### initial * ##### initial
* *
* Default initial state of the reducer. If applicable, is merged with * Default initial state of the reducer. If applicable, is merged with
* the subduxes initial states, with the parent having precedence. * the subduxes initial states, with the parent having precedence.
* *
* If not provided, defaults to an empty object. * If not provided, defaults to an empty object.
* *
* ##### actions * ##### actions
* *
* [Actions](/concepts/Actions) used by the updux. * [Actions](/concepts/Actions) used by the updux.
* *
* ```js * ```js
* import { dux } from 'updux'; * import { dux } from 'updux';
* import { action, payload } from 'ts-action'; * import { action, payload } from 'ts-action';
* *
* const bar = action('BAR', payload<int>()); * const bar = action('BAR', payload<int>());
* const foo = action('FOO'); * const foo = action('FOO');
* *
* const myDux = dux({ * const myDux = dux({
* actions: { * actions: {
* bar * bar
* }, * },
* mutations: [ * mutations: [
* [ foo, () => state => state ] * [ foo, () => state => state ]
* ] * ]
* }); * });
* *
* myDux.actions.foo({ x: 1, y: 2 }); // => { type: foo, x:1, y:2 } * myDux.actions.foo({ x: 1, y: 2 }); // => { type: foo, x:1, y:2 }
* myDux.actions.bar(2); // => { type: bar, payload: 2 } * myDux.actions.bar(2); // => { type: bar, payload: 2 }
* ``` * ```
* *
* New actions used directly in mutations and effects will be added to the * New actions used directly in mutations and effects will be added to the
* dux actions -- that is, they will be accessible via `dux.actions` -- but will * dux actions -- that is, they will be accessible via `dux.actions` -- but will
* not appear as part of its Typescript type. * not appear as part of its Typescript type.
* *
* ##### selectors * ##### selectors
* *
* Dictionary of selectors for the current updux. The updux also * Dictionary of selectors for the current updux. The updux also
* inherit its subduxes' selectors. * inherit its subduxes' selectors.
* *
* The selectors are available via the class' getter. * The selectors are available via the class' getter.
* *
* ##### mutations * ##### mutations
* *
* mutations: [ * mutations: [
* [ action, mutation, isSink ], * [ action, mutation, isSink ],
* ... * ...
* ] * ]
* *
* or * or
* *
* mutations: { * mutations: {
* action: mutation, * action: mutation,
* ... * ...
* } * }
* *
* List of mutations for assign to the dux. If you want Typescript goodness, you * List of mutations for assign to the dux. If you want Typescript goodness, you
* probably want to use `addMutation()` instead. * probably want to use `addMutation()` instead.
* *
* In its generic array-of-array form, * In its generic array-of-array form,
* each mutation tuple contains: the action, the mutation, * each mutation tuple contains: the action, the mutation,
* and boolean indicating if this is a sink mutation. * and boolean indicating if this is a sink mutation.
* *
* The action can be an action creator function or a string. If it's a string, it's considered to be the * The action can be an action creator function or a string. If it's a string, it's considered to be the
* action type and a generic `action( actionName, payload() )` creator will be * action type and a generic `action( actionName, payload() )` creator will be
* generated for it. If an action is not already defined in the `actions` * generated for it. If an action is not already defined in the `actions`
* parameter, it'll be automatically added. * parameter, it'll be automatically added.
* *
* The pseudo-action type `*` can be used to match any action not explicitly matched by other mutations. * The pseudo-action type `*` can be used to match any action not explicitly matched by other mutations.
* *
* ```js * ```js
* const todosUpdux = updux({ * const todosUpdux = updux({
* mutations: { * mutations: {
* add: todo => state => [ ...state, todo ], * add: todo => state => [ ...state, todo ],
* done: done_id => u.map( u.if( ({id} => id === done_id), {done: true} ) ), * done: done_id => u.map( u.if( ({id} => id === done_id), {done: true} ) ),
* '*' (payload,action) => state => { * '*' (payload,action) => state => {
* console.warn( "unexpected action ", action.type ); * console.warn( "unexpected action ", action.type );
* return state; * return state;
* }, * },
* } * }
* }); * });
* ``` * ```
* *
* The signature of the mutations is `(payload,action) => state => newState`. * The signature of the mutations is `(payload,action) => state => newState`.
* It is designed to play well with `Updeep` (and [Immer](https://immerjs.github.io/immer/docs/introduction)). This way, instead of doing * It is designed to play well with `Updeep` (and [Immer](https://immerjs.github.io/immer/docs/introduction)). This way, instead of doing
* *
* ```js * ```js
* mutation: { * mutation: {
* renameTodo: newName => state => { ...state, name: newName } * renameTodo: newName => state => { ...state, name: newName }
* } * }
* ``` * ```
* *
* we can do * we can do
* *
* ```js * ```js
* mutation: { * mutation: {
* renameTodo: newName => u({ name: newName }) * renameTodo: newName => u({ name: newName })
* } * }
* ``` * ```
* *
* The final argument is the optional boolean `isSink`. If it is true, it'll * The final argument is the optional boolean `isSink`. If it is true, it'll
* prevent subduxes' mutations on the same action. It defaults to `false`. * prevent subduxes' mutations on the same action. It defaults to `false`.
* *
* The object version of the argument can be used as a shortcut when all actions * The object version of the argument can be used as a shortcut when all actions
* are strings. In that case, `isSink` is `false` for all mutations. * are strings. In that case, `isSink` is `false` for all mutations.
* *
* ##### groomMutations * ##### groomMutations
* *
* Function that can be provided to alter all local mutations of the updux * Function that can be provided to alter all local mutations of the updux
* (the mutations of subduxes are left untouched). * (the mutations of subduxes are left untouched).
* *
* Can be used, for example, for Immer integration: * Can be used, for example, for Immer integration:
* *
* ```js * ```js
* import Updux from 'updux'; * import Updux from 'updux';
* import { produce } from 'Immer'; * import { produce } from 'Immer';
* *
* const updux = new Updux({ * const updux = new Updux({
* initial: { counter: 0 }, * initial: { counter: 0 },
* groomMutations: mutation => (...args) => produce( mutation(...args) ), * groomMutations: mutation => (...args) => produce( mutation(...args) ),
* mutations: { * mutations: {
* add: (inc=1) => draft => draft.counter += inc * add: (inc=1) => draft => draft.counter += inc
* } * }
* }); * });
* ``` * ```
* *
* Or perhaps for debugging: * Or perhaps for debugging:
* *
* ```js * ```js
* import Updux from 'updux'; * import Updux from 'updux';
* *
* const updux = new Updux({ * const updux = new Updux({
* initial: { counter: 0 }, * initial: { counter: 0 },
* groomMutations: mutation => (...args) => state => { * groomMutations: mutation => (...args) => state => {
* console.log( "got action ", args[1] ); * console.log( "got action ", args[1] );
* return mutation(...args)(state); * return mutation(...args)(state);
* } * }
* }); * });
* ``` * ```
* ##### subduxes * ##### subduxes
* *
* Object mapping slices of the state to sub-upduxes. In addition to creating * Object mapping slices of the state to sub-upduxes. In addition to creating
* sub-reducers for those slices, it'll make the parend updux inherit all the * sub-reducers for those slices, it'll make the parend updux inherit all the
* actions and middleware from its subduxes. * actions and middleware from its subduxes.
* *
* For example, if in plain Redux you would do * For example, if in plain Redux you would do
* *
* ```js * ```js
* import { combineReducers } from 'redux'; * import { combineReducers } from 'redux';
* import todosReducer from './todos'; * import todosReducer from './todos';
* import statisticsReducer from './statistics'; * import statisticsReducer from './statistics';
* *
* const rootReducer = combineReducers({ * const rootReducer = combineReducers({
* todos: todosReducer, * todos: todosReducer,
* stats: statisticsReducer, * stats: statisticsReducer,
* }); * });
* ``` * ```
* *
* then with Updux you'd do * then with Updux you'd do
* *
* ```js * ```js
* import { updux } from 'updux'; * import { updux } from 'updux';
* import todos from './todos'; * import todos from './todos';
* import statistics from './statistics'; * import statistics from './statistics';
* *
* const rootUpdux = updux({ * const rootUpdux = updux({
* subduxes: { * subduxes: {
* todos, * todos,
* statistics * statistics
* } * }
* }); * });
* ``` * ```
* *
* ##### effects * ##### effects
* *
* Array of arrays or plain object defining asynchronous actions and side-effects triggered by actions. * Array of arrays or plain object defining asynchronous actions and side-effects triggered by actions.
* The effects themselves are Redux middleware, with the `dispatch` * The effects themselves are Redux middleware, with the `dispatch`
* property of the first argument augmented with all the available actions. * property of the first argument augmented with all the available actions.
* *
* ``` * ```
* updux({ * updux({
* effects: { * effects: {
* fetch: ({dispatch}) => next => async (action) => { * fetch: ({dispatch}) => next => async (action) => {
* next(action); * next(action);
* *
* let result = await fetch(action.payload.url).then( result => result.json() ); * let result = await fetch(action.payload.url).then( result => result.json() );
* dispatch.fetchSuccess(result); * dispatch.fetchSuccess(result);
* } * }
* } * }
* }); * });
* ``` * ```
* *
* @example * @example
* *
* ``` * ```
* import Updux from 'updux'; * import Updux from 'updux';
* import { actions, payload } from 'ts-action'; * import { actions, payload } from 'ts-action';
* import u from 'updeep'; * import u from 'updeep';
* *
* const todoUpdux = new Updux({ * const todoUpdux = new Updux({
* initial: { * initial: {
* done: false, * done: false,
* note: "", * note: "",
* }, * },
* actions: { * actions: {
* finish: action('FINISH', payload()), * finish: action('FINISH', payload()),
* edit: action('EDIT', payload()), * edit: action('EDIT', payload()),
* }, * },
* mutations: [ * mutations: [
* [ edit, note => u({note}) ] * [ edit, note => u({note}) ]
* ], * ],
* selectors: { * selectors: {
* getNote: state => state.note * getNote: state => state.note
* }, * },
* groomMutations: mutation => transform(mutation), * groomMutations: mutation => transform(mutation),
* subduxes: { * subduxes: {
* foo * foo
* }, * },
* effects: { * effects: {
* finish: () => next => action => { * finish: () => next => action => {
* console.log( "Woo! one more bites the dust" ); * console.log( "Woo! one more bites the dust" );
* } * }
* } * }
* }) * })
* ``` * ```
*/ */
export type UpduxConfig = Partial<{ export type UpduxConfig = Partial<{
initial: unknown, /** foo */ initial: unknown /** foo */;
subduxes: Dictionary<Dux>, subduxes: Dictionary<Dux>;
coduxes: Dux[], coduxes: Dux[];
actions: Dictionary<ActionCreator>, actions: Dictionary<ActionCreator>;
selectors: Dictionary<Selector>, selectors: Dictionary<Selector>;
mutations: any, mutations: any;
groomMutations: (m: Mutation) => Mutation, groomMutations: (m: Mutation) => Mutation;
effects: any, effects: any;
subscriptions: Function[];
}>; }>;
export type Upreducer<S = any> = (action: Action) => (state: S) => S; export type Upreducer<S = any> = (action: Action) => (state: S) => S;
/** @ignore */ /** @ignore */
export interface UpduxMiddlewareAPI<S=any,X = Dictionary<Selector>> { export interface UpduxMiddlewareAPI<S = any, X = Dictionary<Selector>> {
dispatch: Function; dispatch: Function;
getState(): S; getState(): S;
selectors: X; selectors: X;
actions: Dictionary<ActionCreator>; actions: Dictionary<ActionCreator>;
} }
export type UpduxMiddleware<S=any,X = Dictionary<Selector>,A = Action> = ( export type UpduxMiddleware<S = any, X = Dictionary<Selector>, A = Action> = (
api: UpduxMiddlewareAPI<S,X> api: UpduxMiddlewareAPI<S, X>
) => (next: Function) => (action: A) => any; ) => (next: Function) => (action: A) => any;
export type Selector<S = any> = (state: S) => unknown; export type Selector<S = any> = (state: S) => unknown;

View File

@ -1,6 +1,6 @@
import fp from 'lodash/fp'; import fp from 'lodash/fp';
import { action, payload, ActionCreator, ActionType } from 'ts-action'; import { action, payload, ActionCreator, ActionType } from 'ts-action';
import {AnyAction} from 'redux'; import { AnyAction } from 'redux';
import buildInitial from './buildInitial'; import buildInitial from './buildInitial';
import buildMutations from './buildMutations'; import buildMutations from './buildMutations';
@ -51,6 +51,39 @@ type StoreWithDispatchActions<
dispatch: { [type in keyof Actions]: (...args: any) => void }; dispatch: { [type in keyof Actions]: (...args: any) => void };
}; };
function wrap_subscription(sub) {
return store => {
const sub_curried = sub(store);
let previous: unknown;
return (state, unsubscribe) => {
if (state === previous) return;
previous = state;
return sub_curried(state, unsubscribe);
};
};
}
function _subscribeToStore(store: any, subscriptions: Function[] = []) {
subscriptions.forEach(sub => {
const subscriber = sub(store);
let unsub = store.subscribe(() => {
const state = store.getState();
return subscriber(state, unsub);
});
});
}
function sliced_subscription(slice, sub) {
return store => {
const sub_curried = sub(store);
return (state, unsubscribe) =>
sub_curried(fp.get(slice, state), unsubscribe);
};
}
/** /**
* @public * @public
* `Updux` is a way to minimize and simplify the boilerplate associated with the * `Updux` is a way to minimize and simplify the boilerplate associated with the
@ -59,80 +92,85 @@ type StoreWithDispatchActions<
* In true `Redux`-like fashion, upduxes can be made of sub-upduxes (`subduxes` for short) for different slices of the root state. * In true `Redux`-like fashion, upduxes can be made of sub-upduxes (`subduxes` for short) for different slices of the root state.
*/ */
export class Updux< export class Updux<
S = unknown, S = unknown,
A = null, A = null,
X = unknown, X = unknown,
C extends UpduxConfig = {} C extends UpduxConfig = {}
> { > {
subduxes: Dictionary<Dux>; subduxes: Dictionary<Dux>;
coduxes: Dux[]; coduxes: Dux[];
private localSelectors: Dictionary<Selector> = {}; private localSelectors: Dictionary<Selector> = {};
private localInitial: unknown; private localInitial: unknown;
groomMutations: (mutation: Mutation<S>) => Mutation<S>; groomMutations: (mutation: Mutation<S>) => Mutation<S>;
private localEffects: Effect[] = []; private localEffects: Effect[] = [];
private localActions: Dictionary<ActionCreator> = {}; private localActions: Dictionary<ActionCreator> = {};
private localMutations: Dictionary< private localMutations: Dictionary<
Mutation<S> | [Mutation<S>, boolean | undefined] Mutation<S> | [Mutation<S>, boolean | undefined]
> = {}; > = {};
get initial(): AggDuxState<S, C> { private localSubscriptions: Function[] = [];
return buildInitial(
this.localInitial,
this.coduxes.map(({ initial }) => initial),
fp.mapValues('initial', this.subduxes)
) as any;
}
/** get initial(): AggDuxState<S, C> {
* @param config an [[UpduxConfig]] plain object return buildInitial(
* this.localInitial,
*/ this.coduxes.map(({ initial }) => initial),
constructor(config: C = {} as C) { fp.mapValues('initial', this.subduxes)
this.localInitial = config.initial ?? {}; ) as any;
this.localSelectors = config.selectors ?? {}; }
this.coduxes = config.coduxes ?? [];
this.subduxes = config.subduxes ?? {};
Object.entries(config.actions ?? {}).forEach((args) => /**
(this.addAction as any)(...args) * @param config an [[UpduxConfig]] plain object
); *
*/
constructor(config: C = {} as C) {
this.localInitial = config.initial ?? {};
this.localSelectors = config.selectors ?? {};
this.coduxes = config.coduxes ?? [];
this.subduxes = config.subduxes ?? {};
this.coduxes.forEach((c: any) => Object.entries(config.actions ?? {}).forEach(args =>
Object.entries(c.actions).forEach((args) => (this.addAction as any)(...args)
(this.addAction as any)(...args) );
)
);
Object.values(this.subduxes).forEach((c: any) => {
Object.entries(c.actions).forEach((args) => {
(this.addAction as any)(...args);
});
});
this.groomMutations = this.coduxes.forEach((c: any) =>
config.groomMutations ?? ((x: Mutation<S>) => x); Object.entries(c.actions).forEach(args =>
(this.addAction as any)(...args)
)
);
Object.values(this.subduxes).forEach((c: any) => {
Object.entries(c.actions).forEach(args => {
(this.addAction as any)(...args);
});
});
let effects = config.effects ?? []; if (config.subscriptions) {
config.subscriptions.forEach(sub => this.addSubscription(sub));
}
if (!Array.isArray(effects)) { this.groomMutations = config.groomMutations ?? ((x: Mutation<S>) => x);
effects = (Object.entries(effects) as unknown) as Effect[];
}
effects.forEach((effect) => (this.addEffect as any)(...effect));
let mutations = config.mutations ?? []; let effects = config.effects ?? [];
if (!Array.isArray(mutations)) { if (!Array.isArray(effects)) {
mutations = fp.toPairs(mutations); effects = (Object.entries(effects) as unknown) as Effect[];
} }
effects.forEach(effect => (this.addEffect as any)(...effect));
mutations.forEach((args) => (this.addMutation as any)(...args)); let mutations = config.mutations ?? [];
/* if (!Array.isArray(mutations)) {
mutations = fp.toPairs(mutations);
}
mutations.forEach(args => (this.addMutation as any)(...args));
/*
Object.entries(selectors).forEach(([name, sel]: [string, Function]) => Object.entries(selectors).forEach(([name, sel]: [string, Function]) =>
this.addSelector(name, sel as Selector) this.addSelector(name, sel as Selector)
@ -150,373 +188,389 @@ export class Updux<
); );
*/ */
} }
/** /**
* Array of middlewares aggregating all the effects defined in the * Array of middlewares aggregating all the effects defined in the
* updux and its subduxes. Effects of the updux itself are * updux and its subduxes. Effects of the updux itself are
* done before the subduxes effects. * done before the subduxes effects.
* Note that `getState` will always return the state of the * Note that `getState` will always return the state of the
* local updux. * local updux.
* *
* @example * @example
* *
* ``` * ```
* const middleware = updux.middleware; * const middleware = updux.middleware;
* ``` * ```
*/ */
get middleware(): UpduxMiddleware< get middleware(): UpduxMiddleware<
AggDuxState<S, C>, AggDuxState<S, C>,
DuxSelectors<AggDuxState<S, C>, X, C> DuxSelectors<AggDuxState<S, C>, X, C>
> { > {
const selectors = this.selectors; const selectors = this.selectors;
const actions = this.actions; const actions = this.actions;
return buildMiddleware( return buildMiddleware(
this.localEffects.map((effect) => this.localEffects.map(effect =>
effectToMw(effect, actions as any, selectors as any) effectToMw(effect, actions as any, selectors as any)
), ),
(this.coduxes as any).map(fp.get('middleware')) as any, (this.coduxes as any).map(fp.get('middleware')) as any,
fp.mapValues('middleware', this.subduxes) fp.mapValues('middleware', this.subduxes)
) as any; ) as any;
} }
/** /**
* Action creators for all actions defined or used in the actions, mutations, effects and subduxes * Action creators for all actions defined or used in the actions, mutations, effects and subduxes
* of the updux config. * of the updux config.
* *
* Non-custom action creators defined in `actions` have the signature `(payload={},meta={}) => ({type, * Non-custom action creators defined in `actions` have the signature `(payload={},meta={}) => ({type,
* payload,meta})` (with the extra sugar that if `meta` or `payload` are not * payload,meta})` (with the extra sugar that if `meta` or `payload` are not
* specified, that key won't be present in the produced action). * specified, that key won't be present in the produced action).
* *
* The same action creator can be included * The same action creator can be included
* in multiple subduxes. However, if two different creators * in multiple subduxes. However, if two different creators
* are included for the same action, an error will be thrown. * are included for the same action, an error will be thrown.
* *
* @example * @example
* *
* ``` * ```
* const actions = updux.actions; * const actions = updux.actions;
* ``` * ```
*/ */
get actions(): DuxActions<A, C> { get actions(): DuxActions<A, C> {
// UpduxActions<Updux<S,A,SUB,CO>> { // UpduxActions<Updux<S,A,SUB,CO>> {
return this.localActions as any; return this.localActions as any;
} }
get upreducer(): Upreducer<S> { get upreducer(): Upreducer<S> {
return buildUpreducer( return buildUpreducer(this.initial, this.mutations as any) as any;
this.initial, }
this.mutations as any
) as any;
}
/** /**
* A Redux reducer generated using the computed initial state and * A Redux reducer generated using the computed initial state and
* mutations. * mutations.
*/ */
get reducer(): (state: S | undefined, action: Action) => S { get reducer(): (state: S | undefined, action: Action) => S {
return (state, action) => this.upreducer(action)(state as S); return (state, action) => this.upreducer(action)(state as S);
} }
/** /**
* Merge of the updux and subduxes mutations. If an action triggers * Merge of the updux and subduxes mutations. If an action triggers
* mutations in both the main updux and its subduxes, the subduxes * mutations in both the main updux and its subduxes, the subduxes
* mutations will be performed first. * mutations will be performed first.
*/ */
get mutations(): Dictionary<Mutation<S>> { get mutations(): Dictionary<Mutation<S>> {
return buildMutations( return buildMutations(
this.localMutations, this.localMutations,
fp.mapValues('upreducer', this.subduxes as any), fp.mapValues('upreducer', this.subduxes as any),
fp.map('upreducer', this.coduxes as any) fp.map('upreducer', this.coduxes as any)
); );
} }
/** /**
* Returns the upreducer made of the merge of all sudbuxes reducers, without * Returns the upreducer made of the merge of all sudbuxes reducers, without
* the local mutations. Useful, for example, for sink mutations. * the local mutations. Useful, for example, for sink mutations.
* *
* @example * @example
* *
* ``` * ```
* import todo from './todo'; // updux for a single todo * import todo from './todo'; // updux for a single todo
* import Updux from 'updux'; * import Updux from 'updux';
* import u from 'updeep'; * import u from 'updeep';
* *
* const todos = new Updux({ initial: [], subduxes: { '*': todo } }); * const todos = new Updux({ initial: [], subduxes: { '*': todo } });
* todos.addMutation( * todos.addMutation(
* todo.actions.done, * todo.actions.done,
* ({todo_id},action) => u.map( u.if( u.is('id',todo_id) ), todos.subduxUpreducer(action) ) * ({todo_id},action) => u.map( u.if( u.is('id',todo_id) ), todos.subduxUpreducer(action) )
* true * true
* ); * );
* ``` * ```
* *
* *
* *
*/ */
get subduxUpreducer() { get subduxUpreducer() {
return buildUpreducer( return buildUpreducer(this.initial, buildMutations({}, this.subduxes));
this.initial, }
buildMutations({}, this.subduxes)
);
}
/** /**
* Returns a `createStore` function that takes two argument: * Returns a `createStore` function that takes two argument:
* `initial` and `injectEnhancer`. `initial` is a custom * `initial` and `injectEnhancer`. `initial` is a custom
* initial state for the store, and `injectEnhancer` is a function * initial state for the store, and `injectEnhancer` is a function
* taking in the middleware built by the updux object and allowing * taking in the middleware built by the updux object and allowing
* you to wrap it in any enhancer you want. * you to wrap it in any enhancer you want.
* *
* @example * @example
* *
* ``` * ```
* const createStore = updux.createStore; * const createStore = updux.createStore;
* *
* const store = createStore(initial); * const store = createStore(initial);
* ``` * ```
* *
* *
* *
*/ */
get createStore() { createStore(...args: any) {
return buildCreateStore<AggDuxState<S, C>, DuxActions<A, C>>( const store = buildCreateStore<AggDuxState<S, C>, DuxActions<A, C>>(
this.reducer as any, this.reducer as any,
this.middleware as any, this.middleware as any,
this.actions this.actions
); )(...args);
}
/** _subscribeToStore(store, this.subscriptions);
* Returns a <a href="https://github.com/erikras/ducks-modular-redux">ducks</a>-like
* plain object holding the reducer from the Updux object and all
* its trimmings.
*
* @example
*
* ```
* const {
* createStore,
* upreducer,
* subduxes,
* coduxes,
* middleware,
* actions,
* reducer,
* mutations,
* initial,
* selectors,
* } = myUpdux.asDux;
* ```
*
*
*
*
*/
get asDux() {
return {
createStore: this.createStore,
upreducer: this.upreducer,
subduxes: this.subduxes,
coduxes: this.coduxes,
middleware: this.middleware,
actions: this.actions,
reducer: this.reducer,
mutations: this.mutations,
initial: this.initial,
selectors: this.selectors,
};
}
/** return store;
* Adds a mutation and its associated action to the updux. }
*
* @param isSink - If `true`, disables the subduxes mutations for this action. To
* conditionally run the subduxes mutations, check out [[subduxUpreducer]]. Defaults to `false`.
*
* @remarks
*
* If a local mutation was already associated to the action,
* it will be replaced by the new one.
*
*
* @example
*
* ```js
* updux.addMutation(
* action('ADD', payload<int>() ),
* inc => state => state + in
* );
* ```
*/
addMutation<A extends ActionCreator>(
creator: A,
mutation: Mutation<S, ActionType<A>>,
isSink?: boolean
);
addMutation<A extends ActionCreator = any>(
creator: string,
mutation: Mutation<S, any>,
isSink?: boolean
);
addMutation<A extends ActionCreator = any>(
creator,
mutation,
isSink
) {
const c = this.addAction(creator);
this.localMutations[c.type] = [ /**
this.groomMutations(mutation as any) as Mutation<S>, * Returns an array of all subscription functions registered for the dux.
isSink, * Subdux subscriptions are wrapped such that they are getting their
]; * local state. Also all subscriptions are further wrapped such that
} * they are only called when the local state changed
*/
get subscriptions() {
let subscriptions = ([
this.localSubscriptions,
Object.entries(this.subduxes).map(([slice, subdux]) => {
return subdux.subscriptions.map(sub =>
sliced_subscription(slice, sub)
);
}),
] as any).flat(Infinity);
addEffect<AC extends ActionCreator>( return subscriptions.map(sub => wrap_subscription(sub));
creator: AC, }
middleware: UpduxMiddleware<
AggDuxState<S, C>,
DuxSelectors<AggDuxState<S, C>, X, C>,
ReturnType<AC>
>,
isGenerator?: boolean
);
addEffect(
creator: string,
middleware: UpduxMiddleware<
AggDuxState<S, C>,
DuxSelectors<AggDuxState<S, C>, X, C>
>,
isGenerator?: boolean
);
addEffect(creator, middleware, isGenerator = false) {
const c = this.addAction(creator);
this.localEffects.push([c.type, middleware, isGenerator] as any);
}
// can be /**
//addAction( actionCreator ) * Returns a <a href="https://github.com/erikras/ducks-modular-redux">ducks</a>-like
// addAction( 'foo', transform ) * plain object holding the reducer from the Updux object and all
/** * its trimmings.
* Adds an action to the updux. It can take an already defined action *
* creator, or any arguments that can be passed to `actionCreator`. * @example
* @example *
* ``` * ```
* const action = updux.addAction( name, ...creatorArgs ); * const {
* const action = updux.addAction( otherActionCreator ); * createStore,
* ``` * upreducer,
* @example * subduxes,
* ``` * coduxes,
* import {actionCreator, Updux} from 'updux'; * middleware,
* * actions,
* const updux = new Updux(); * reducer,
* * mutations,
* const foo = updux.addAction('foo'); * initial,
* const bar = updux.addAction( 'bar', (x) => ({stuff: x+1}) ); * selectors,
* * subscriptions,
* const baz = actionCreator( 'baz' ); * } = myUpdux.asDux;
* * ```
* foo({ a: 1}); // => { type: 'foo', payload: { a: 1 } } *
* bar(2); // => { type: 'bar', payload: { stuff: 3 } } *
* baz(); // => { type: 'baz', payload: undefined } *
* ``` *
*/ */
addAction( get asDux() {
theaction: string, return {
transform?: any createStore: this.createStore,
): ActionCreator<string, any>; upreducer: this.upreducer,
addAction( subduxes: this.subduxes,
theaction: string | ActionCreator<any>, coduxes: this.coduxes,
transform?: never middleware: this.middleware,
): ActionCreator<string, any>; actions: this.actions,
addAction(actionIn: any, transform: any) { reducer: this.reducer,
let name: string; mutations: this.mutations,
let creator: ActionCreator; initial: this.initial,
selectors: this.selectors,
subscriptions: this.subscriptions,
};
}
if (typeof actionIn === 'string') { /**
name = actionIn; * Adds a mutation and its associated action to the updux.
*
* @param isSink - If `true`, disables the subduxes mutations for this action. To
* conditionally run the subduxes mutations, check out [[subduxUpreducer]]. Defaults to `false`.
*
* @remarks
*
* If a local mutation was already associated to the action,
* it will be replaced by the new one.
*
*
* @example
*
* ```js
* updux.addMutation(
* action('ADD', payload<int>() ),
* inc => state => state + in
* );
* ```
*/
addMutation<A extends ActionCreator>(
creator: A,
mutation: Mutation<S, ActionType<A>>,
isSink?: boolean
);
addMutation<A extends ActionCreator = any>(
creator: string,
mutation: Mutation<S, any>,
isSink?: boolean
);
addMutation<A extends ActionCreator = any>(creator, mutation, isSink) {
const c = this.addAction(creator);
if (transform) { this.localMutations[c.type] = [
creator = transform.type this.groomMutations(mutation as any) as Mutation<S>,
? transform isSink,
: action(name, (...args: any) => ({ ];
payload: transform(...args), }
}));
} else {
creator =
this.localActions[name] ?? action(name, payload());
}
} else {
name = actionIn.type;
creator = actionIn;
}
const already = this.localActions[name]; addEffect<AC extends ActionCreator>(
creator: AC,
middleware: UpduxMiddleware<
AggDuxState<S, C>,
DuxSelectors<AggDuxState<S, C>, X, C>,
ReturnType<AC>
>,
isGenerator?: boolean
);
addEffect(
creator: string,
middleware: UpduxMiddleware<
AggDuxState<S, C>,
DuxSelectors<AggDuxState<S, C>, X, C>
>,
isGenerator?: boolean
);
addEffect(creator, middleware, isGenerator = false) {
const c = this.addAction(creator);
this.localEffects.push([c.type, middleware, isGenerator] as any);
}
if (!already) // can be
return ((this.localActions as any)[name] = creator) as any; //addAction( actionCreator )
// addAction( 'foo', transform )
/**
* Adds an action to the updux. It can take an already defined action
* creator, or any arguments that can be passed to `actionCreator`.
* @example
* ```
* const action = updux.addAction( name, ...creatorArgs );
* const action = updux.addAction( otherActionCreator );
* ```
* @example
* ```
* import {actionCreator, Updux} from 'updux';
*
* const updux = new Updux();
*
* const foo = updux.addAction('foo');
* const bar = updux.addAction( 'bar', (x) => ({stuff: x+1}) );
*
* const baz = actionCreator( 'baz' );
*
* foo({ a: 1}); // => { type: 'foo', payload: { a: 1 } }
* bar(2); // => { type: 'bar', payload: { stuff: 3 } }
* baz(); // => { type: 'baz', payload: undefined }
* ```
*/
addAction(theaction: string, transform?: any): ActionCreator<string, any>;
addAction(
theaction: string | ActionCreator<any>,
transform?: never
): ActionCreator<string, any>;
addAction(actionIn: any, transform: any) {
let name: string;
let creator: ActionCreator;
if (already !== creator && already.type !== '*') { if (typeof actionIn === 'string') {
throw new Error(`action ${name} already exists`); name = actionIn;
}
return already; if (transform) {
} creator = transform.type
? transform
: action(name, (...args: any) => ({
payload: transform(...args),
}));
} else {
creator = this.localActions[name] ?? action(name, payload());
}
} else {
name = actionIn.type;
creator = actionIn;
}
get _middlewareEntries() { const already = this.localActions[name];
const groupByOrder = (mws: any) =>
fp.groupBy(
([, , actionType]: any) =>
['^', '$'].includes(actionType)
? actionType
: 'middle',
mws
);
const subs = fp.flow([ if (!already)
fp.toPairs, return ((this.localActions as any)[name] = creator) as any;
fp.map(([slice, updux]) =>
updux._middlewareEntries.map(([u, ps, ...args]: any) => [
u,
[slice, ...ps],
...args,
])
),
fp.flatten,
groupByOrder,
])(this.subduxes);
const local = groupByOrder( if (already !== creator && already.type !== '*') {
this.localEffects.map((x) => [this, [], ...x]) throw new Error(`action ${name} already exists`);
); }
return fp.flatten( return already;
[ }
local['^'],
subs['^'],
local.middle,
subs.middle,
subs['$'],
local['$'],
].filter((x) => x)
);
}
addSelector(name: string, selector: Selector) { get _middlewareEntries() {
this.localSelectors[name] = selector; const groupByOrder = (mws: any) =>
} fp.groupBy(
([, , actionType]: any) =>
['^', '$'].includes(actionType) ? actionType : 'middle',
mws
);
/** const subs = fp.flow([
fp.toPairs,
fp.map(([slice, updux]) =>
updux._middlewareEntries.map(([u, ps, ...args]: any) => [
u,
[slice, ...ps],
...args,
])
),
fp.flatten,
groupByOrder,
])(this.subduxes);
const local = groupByOrder(
this.localEffects.map(x => [this, [], ...x])
);
return fp.flatten(
[
local['^'],
subs['^'],
local.middle,
subs.middle,
subs['$'],
local['$'],
].filter(x => x)
);
}
addSelector(name: string, selector: Selector) {
this.localSelectors[name] = selector;
}
/**
A dictionary of the updux's selectors. Subduxes' A dictionary of the updux's selectors. Subduxes'
selectors are included as well (with the mapping to the selectors are included as well (with the mapping to the
sub-state already taken care of you). sub-state already taken care of you).
*/ */
get selectors(): DuxSelectors<AggDuxState<S, C>, X, C> { get selectors(): DuxSelectors<AggDuxState<S, C>, X, C> {
return buildSelectors( return buildSelectors(
this.localSelectors, this.localSelectors,
fp.map('selectors', this.coduxes), fp.map('selectors', this.coduxes),
fp.mapValues('selectors', this.subduxes) fp.mapValues('selectors', this.subduxes)
) as any; ) as any;
} }
}
/**
* Add a subscription to the dux.
*/
addSubscription(subscription: Function) {
this.localSubscriptions = [...this.localSubscriptions, subscription];
}
}
export default Updux; export default Updux;