feat: add support for subscriptions

typescript
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,69 +37,84 @@ 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.
@ -333,17 +348,17 @@ export type Dux<
* ``` * ```
*/ */
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 */

View File

@ -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
@ -81,6 +114,8 @@ export class Updux<
Mutation<S> | [Mutation<S>, boolean | undefined] Mutation<S> | [Mutation<S>, boolean | undefined]
> = {}; > = {};
private localSubscriptions: Function[] = [];
get initial(): AggDuxState<S, C> { get initial(): AggDuxState<S, C> {
return buildInitial( return buildInitial(
this.localInitial, this.localInitial,
@ -99,30 +134,33 @@ export class Updux<
this.coduxes = config.coduxes ?? []; this.coduxes = config.coduxes ?? [];
this.subduxes = config.subduxes ?? {}; this.subduxes = config.subduxes ?? {};
Object.entries(config.actions ?? {}).forEach((args) => Object.entries(config.actions ?? {}).forEach(args =>
(this.addAction as any)(...args) (this.addAction as any)(...args)
); );
this.coduxes.forEach((c: any) => this.coduxes.forEach((c: any) =>
Object.entries(c.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.values(this.subduxes).forEach((c: any) => {
Object.entries(c.actions).forEach((args) => { Object.entries(c.actions).forEach(args => {
(this.addAction as any)(...args); (this.addAction as any)(...args);
}); });
}); });
this.groomMutations = if (config.subscriptions) {
config.groomMutations ?? ((x: Mutation<S>) => x); config.subscriptions.forEach(sub => this.addSubscription(sub));
}
this.groomMutations = config.groomMutations ?? ((x: Mutation<S>) => x);
let effects = config.effects ?? []; let effects = config.effects ?? [];
if (!Array.isArray(effects)) { if (!Array.isArray(effects)) {
effects = (Object.entries(effects) as unknown) as Effect[]; effects = (Object.entries(effects) as unknown) as Effect[];
} }
effects.forEach((effect) => (this.addEffect as any)(...effect)); effects.forEach(effect => (this.addEffect as any)(...effect));
let mutations = config.mutations ?? []; let mutations = config.mutations ?? [];
@ -130,7 +168,7 @@ export class Updux<
mutations = fp.toPairs(mutations); mutations = fp.toPairs(mutations);
} }
mutations.forEach((args) => (this.addMutation as any)(...args)); mutations.forEach(args => (this.addMutation as any)(...args));
/* /*
@ -172,7 +210,7 @@ export class Updux<
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,
@ -204,10 +242,7 @@ export class Updux<
} }
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;
} }
/** /**
@ -254,10 +289,7 @@ export class Updux<
* *
*/ */
get subduxUpreducer() { get subduxUpreducer() {
return buildUpreducer( return buildUpreducer(this.initial, buildMutations({}, this.subduxes));
this.initial,
buildMutations({}, this.subduxes)
);
} }
/** /**
@ -278,12 +310,35 @@ export class Updux<
* *
* *
*/ */
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);
return store;
}
/**
* Returns an array of all subscription functions registered for the dux.
* 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);
return subscriptions.map(sub => wrap_subscription(sub));
} }
/** /**
@ -305,6 +360,7 @@ export class Updux<
* mutations, * mutations,
* initial, * initial,
* selectors, * selectors,
* subscriptions,
* } = myUpdux.asDux; * } = myUpdux.asDux;
* ``` * ```
* *
@ -324,6 +380,7 @@ export class Updux<
mutations: this.mutations, mutations: this.mutations,
initial: this.initial, initial: this.initial,
selectors: this.selectors, selectors: this.selectors,
subscriptions: this.subscriptions,
}; };
} }
@ -358,11 +415,7 @@ export class Updux<
mutation: Mutation<S, any>, mutation: Mutation<S, any>,
isSink?: boolean isSink?: boolean
); );
addMutation<A extends ActionCreator = any>( addMutation<A extends ActionCreator = any>(creator, mutation, isSink) {
creator,
mutation,
isSink
) {
const c = this.addAction(creator); const c = this.addAction(creator);
this.localMutations[c.type] = [ this.localMutations[c.type] = [
@ -420,10 +473,7 @@ export class Updux<
* baz(); // => { type: 'baz', payload: undefined } * baz(); // => { type: 'baz', payload: undefined }
* ``` * ```
*/ */
addAction( addAction(theaction: string, transform?: any): ActionCreator<string, any>;
theaction: string,
transform?: any
): ActionCreator<string, any>;
addAction( addAction(
theaction: string | ActionCreator<any>, theaction: string | ActionCreator<any>,
transform?: never transform?: never
@ -442,8 +492,7 @@ export class Updux<
payload: transform(...args), payload: transform(...args),
})); }));
} else { } else {
creator = creator = this.localActions[name] ?? action(name, payload());
this.localActions[name] ?? action(name, payload());
} }
} else { } else {
name = actionIn.type; name = actionIn.type;
@ -466,9 +515,7 @@ export class Updux<
const groupByOrder = (mws: any) => const groupByOrder = (mws: any) =>
fp.groupBy( fp.groupBy(
([, , actionType]: any) => ([, , actionType]: any) =>
['^', '$'].includes(actionType) ['^', '$'].includes(actionType) ? actionType : 'middle',
? actionType
: 'middle',
mws mws
); );
@ -486,7 +533,7 @@ export class Updux<
])(this.subduxes); ])(this.subduxes);
const local = groupByOrder( const local = groupByOrder(
this.localEffects.map((x) => [this, [], ...x]) this.localEffects.map(x => [this, [], ...x])
); );
return fp.flatten( return fp.flatten(
@ -497,7 +544,7 @@ export class Updux<
subs.middle, subs.middle,
subs['$'], subs['$'],
local['$'], local['$'],
].filter((x) => x) ].filter(x => x)
); );
} }
@ -517,6 +564,13 @@ sub-state already taken care of you).
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;