Merge branch 'effects' into rewrite
This commit is contained in:
commit
c5ca566c12
69
docs/tutorial-effects.test.js
Normal file
69
docs/tutorial-effects.test.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
|
||||||
|
import u from 'updeep';
|
||||||
|
import { action, Updux, dux } from '../src/index.js';
|
||||||
|
|
||||||
|
const addTodoWithId = action('addTodoWithId');
|
||||||
|
const incNextId = action('incNextId');
|
||||||
|
const addTodo = action('addTodo');
|
||||||
|
|
||||||
|
const addTodoEffect = ({ getState, dispatch }) => next => action => {
|
||||||
|
const id = getState.nextId();
|
||||||
|
|
||||||
|
dispatch.incNextId();
|
||||||
|
|
||||||
|
next(action);
|
||||||
|
|
||||||
|
dispatch.addTodoWithId({ description: action.payload, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const todosDux = new Updux({
|
||||||
|
initial: { nextId: 1, todos: [] },
|
||||||
|
actions: { addTodo, incNextId, addTodoWithId },
|
||||||
|
selectors: {
|
||||||
|
nextId: ({nextId}) => nextId,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
addTodoWithId: (todo) => u({ todos: (todos) => [...todos, todo] }),
|
||||||
|
incNextId: () => u({ nextId: id => id+1 }),
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
'addTodo': addTodoEffect
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = todosDux.createStore();
|
||||||
|
|
||||||
|
test( "tutorial example", async () => {
|
||||||
|
store.dispatch.addTodo('Do the thing');
|
||||||
|
|
||||||
|
expect( store.getState() ).toMatchObject({
|
||||||
|
nextId:2, todos: [ { description: 'Do the thing', id: 1 } ]
|
||||||
|
})
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
test( "catch-all effect", () => {
|
||||||
|
|
||||||
|
let seen = [];
|
||||||
|
|
||||||
|
const foo = new Updux({
|
||||||
|
actions: {
|
||||||
|
one: null,
|
||||||
|
two: null,
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
'*': (api) => next => action => {
|
||||||
|
seen.push(action.type);
|
||||||
|
next(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
const store = foo.createStore();
|
||||||
|
|
||||||
|
store.dispatch.one();
|
||||||
|
store.dispatch.two();
|
||||||
|
|
||||||
|
expect(seen).toEqual([ 'one', 'two' ]);
|
||||||
|
} )
|
@ -8,7 +8,7 @@ We'll be using
|
|||||||
help with immutability and deep merging,
|
help with immutability and deep merging,
|
||||||
but that's totally optional. If `updeep` is not your bag,
|
but that's totally optional. If `updeep` is not your bag,
|
||||||
it can easily be substitued with, say, [immer][], [lodash][], or even
|
it can easily be substitued with, say, [immer][], [lodash][], or even
|
||||||
plain JavaScript.
|
plain JavaScript.
|
||||||
|
|
||||||
## Definition of the state
|
## Definition of the state
|
||||||
|
|
||||||
@ -44,7 +44,7 @@ const todosDux = new Updux({
|
|||||||
initial: {
|
initial: {
|
||||||
next_id: 1,
|
next_id: 1,
|
||||||
todos: [],
|
todos: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
addTodo: null,
|
addTodo: null,
|
||||||
todoDone: null,
|
todoDone: null,
|
||||||
@ -57,7 +57,7 @@ const todosDux = new Updux({
|
|||||||
Once an action is defined, its creator is accessible via the `actions` accessor.
|
Once an action is defined, its creator is accessible via the `actions` accessor.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
console.log( todosDux.actions.addTodo('write tutorial') );
|
console.log( todosDux.actions.addTodo('write tutorial') );
|
||||||
// prints { type: 'addTodo', payload: 'write tutorial' }
|
// prints { type: 'addTodo', payload: 'write tutorial' }
|
||||||
```
|
```
|
||||||
### Adding a mutation
|
### Adding a mutation
|
||||||
@ -72,3 +72,62 @@ todosDux.setMutation( 'addTodo', description => ({next_id: id, todos}) => ({
|
|||||||
todos: [...todos, { description, id, done: false }]
|
todos: [...todos, { description, id, done: false }]
|
||||||
}));
|
}));
|
||||||
```
|
```
|
||||||
|
## Effects
|
||||||
|
|
||||||
|
In addition to mutations, Updux also provides action-specific middleware, here
|
||||||
|
called effects.
|
||||||
|
|
||||||
|
Effects use the usual Redux middleware signature, plus a few goodies.
|
||||||
|
The `getState` and `dispatch` functions are augmented with the dux selectors,
|
||||||
|
and actions, respectively. The selectors and actions are also available
|
||||||
|
from the api object.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import u from 'updeep';
|
||||||
|
import { action, Updux } from 'updux';
|
||||||
|
|
||||||
|
// we want to decouple the increment of next_id and the creation of
|
||||||
|
// a new todo. So let's use a new version of the action 'addTodo'.
|
||||||
|
|
||||||
|
const addTodoWithId = action('addTodoWithId');
|
||||||
|
const incNextId = action('incNextId');
|
||||||
|
const addTodo = action('addTodo');
|
||||||
|
|
||||||
|
const addTodoEffect = ({ getState, dispatch }) => next => action => {
|
||||||
|
const id = getState.nextId();
|
||||||
|
|
||||||
|
dispatch.incNextId();
|
||||||
|
|
||||||
|
next(action);
|
||||||
|
|
||||||
|
dispatch.addTodoWithId({ description: action.payload, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const todosDux = new Updux({
|
||||||
|
initial: { nextId: 1, todos: [] },
|
||||||
|
actions: { addTodo, incNextId, addTodoWithId },
|
||||||
|
selectors: {
|
||||||
|
nextId: ({nextId}) => nextId,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
addTodoWithId: (todo) => u({ todos: (todos) => [...todos, todo] }),
|
||||||
|
incNextId: () => u({ nextId: id => id+1 }),
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
'addTodo': addTodoEffect
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = todosDux.createStore();
|
||||||
|
|
||||||
|
store.dispatch.addTodo('Do the thing');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catch-all effect
|
||||||
|
|
||||||
|
It is possible to have an effect match all actions via the special `*` token.
|
||||||
|
```
|
||||||
|
todosUpdux.addEffect('*', () => next => action => {
|
||||||
|
console.log( 'seeing action fly by:', action );
|
||||||
|
next(action);
|
||||||
|
});
|
||||||
|
41
src/Updux.js
41
src/Updux.js
@ -1,14 +1,11 @@
|
|||||||
import R from 'remeda';
|
import R from 'remeda';
|
||||||
import u from 'updeep';
|
import u from 'updeep';
|
||||||
import { createStore as reduxCreateStore } from 'redux';
|
import { createStore as reduxCreateStore, applyMiddleware } from 'redux';
|
||||||
|
|
||||||
import { buildSelectors } from './selectors.js';
|
import { buildSelectors } from './selectors.js';
|
||||||
import { buildUpreducer } from './upreducer.js';
|
import { buildUpreducer } from './upreducer.js';
|
||||||
import { action } from './actions.js';
|
import { buildMiddleware } from './middleware.js';
|
||||||
|
import { action, isActionGen } from './actions.js';
|
||||||
function isActionGen(action) {
|
|
||||||
return typeof action === 'function' && action.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updux configuration object
|
* Updux configuration object
|
||||||
@ -23,6 +20,7 @@ export class Updux {
|
|||||||
#mutations = {};
|
#mutations = {};
|
||||||
#config = {};
|
#config = {};
|
||||||
#selectors = {};
|
#selectors = {};
|
||||||
|
#effects = [];
|
||||||
|
|
||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
this.#config = config;
|
this.#config = config;
|
||||||
@ -36,11 +34,21 @@ export class Updux {
|
|||||||
this.#addSubduxActions(slice, sub),
|
this.#addSubduxActions(slice, sub),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Object.entries(config.mutations ?? {}).forEach((args) =>
|
||||||
|
this.setMutation(...args),
|
||||||
|
);
|
||||||
|
|
||||||
this.#selectors = buildSelectors(
|
this.#selectors = buildSelectors(
|
||||||
config.selectors,
|
config.selectors,
|
||||||
config.splatSelectors,
|
config.splatSelectors,
|
||||||
this.#subduxes,
|
this.#subduxes,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(config.effects)) {
|
||||||
|
this.#effects = config.effects;
|
||||||
|
} else if (R.isObject(config.effects)) {
|
||||||
|
this.#effects = Object.entries(config.effects);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#addSubduxActions(_slice, subdux) {
|
#addSubduxActions(_slice, subdux) {
|
||||||
@ -118,15 +126,24 @@ export class Updux {
|
|||||||
return this.#mutations;
|
return this.#mutations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get middleware() {
|
||||||
|
return buildMiddleware(
|
||||||
|
this.#effects,
|
||||||
|
this.actions,
|
||||||
|
this.selectors,
|
||||||
|
this.subduxes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
createStore(initial = undefined, enhancerGenerator = undefined) {
|
createStore(initial = undefined, enhancerGenerator = undefined) {
|
||||||
// const enhancer = (enhancerGenerator ?? applyMiddleware)(
|
const enhancer = (enhancerGenerator ?? applyMiddleware)(
|
||||||
// this.middleware
|
this.middleware,
|
||||||
// );
|
);
|
||||||
|
|
||||||
const store = reduxCreateStore(
|
const store = reduxCreateStore(
|
||||||
this.reducer,
|
this.reducer,
|
||||||
initial ?? this.initial,
|
initial ?? this.initial,
|
||||||
//enhancer
|
enhancer,
|
||||||
);
|
);
|
||||||
|
|
||||||
store.actions = this.actions;
|
store.actions = this.actions;
|
||||||
@ -156,6 +173,10 @@ export class Updux {
|
|||||||
|
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addEffect(action, effect) {
|
||||||
|
this.#effects = [...this.#effects, [action, effect]];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dux = (config) => new Updux(config);
|
export const dux = (config) => new Updux(config);
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
export function isActionGen(action) {
|
||||||
|
return typeof action === 'function' && action.type;
|
||||||
|
}
|
||||||
|
|
||||||
export function action(type, payloadFunction, transformer) {
|
export function action(type, payloadFunction, transformer) {
|
||||||
let generator = function (...payloadArg) {
|
let generator = function (...payloadArg) {
|
||||||
const result = { type };
|
const result = { type };
|
||||||
|
@ -1 +1,2 @@
|
|||||||
export { Updux, dux } from './Updux.js';
|
export { Updux, dux } from './Updux.js';
|
||||||
|
export { action } from './actions.js';
|
||||||
|
82
src/middleware.js
Normal file
82
src/middleware.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import R from 'remeda';
|
||||||
|
|
||||||
|
const composeMw = (mws) => (api) => (original_next) =>
|
||||||
|
mws.reduceRight((next, mw) => mw(api)(next), original_next);
|
||||||
|
|
||||||
|
export function augmentMiddlewareApi(api, actions, selectors) {
|
||||||
|
const getState = () => api.getState();
|
||||||
|
const dispatch = (action) => api.dispatch(action);
|
||||||
|
|
||||||
|
Object.assign(
|
||||||
|
getState,
|
||||||
|
R.mapValues(selectors, (selector) => {
|
||||||
|
return (...args) => {
|
||||||
|
let result = selector(api.getState());
|
||||||
|
|
||||||
|
if (typeof result === 'function') return result(...args);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.assign(
|
||||||
|
dispatch,
|
||||||
|
R.mapValues(actions, (action) => {
|
||||||
|
return (...args) => api.dispatch(action(...args));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...api,
|
||||||
|
getState,
|
||||||
|
dispatch,
|
||||||
|
actions,
|
||||||
|
selectors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliceMw = (slice, mw) => (api) => {
|
||||||
|
const getSliceState = () => get(api.getState(), slice);
|
||||||
|
return mw({ ...api, getState: getSliceState });
|
||||||
|
};
|
||||||
|
|
||||||
|
const middlewareFor = (type, middleware) => (api) => (next) => (action) => {
|
||||||
|
if (type !== '*' && action.type !== type) return next(action);
|
||||||
|
|
||||||
|
return middleware(api)(next)(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const effectToMiddleware = (effect, actions, selectors) => {
|
||||||
|
let mw = effect;
|
||||||
|
let action = '*';
|
||||||
|
|
||||||
|
if (Array.isArray(effect)) {
|
||||||
|
action = effect[0];
|
||||||
|
mw = effect[1];
|
||||||
|
mw = middlewareFor(action, mw);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (api) => mw(augmentMiddlewareApi(api, actions, selectors));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildMiddleware(
|
||||||
|
effects = [],
|
||||||
|
actions = {},
|
||||||
|
selectors = {},
|
||||||
|
subduxes = {},
|
||||||
|
) {
|
||||||
|
let inner = R.compact(
|
||||||
|
Object.entries(subduxes).map((slice, [{ middleware }]) =>
|
||||||
|
slice !== '*' && middleware ? sliceMw(slice, middleware) : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const local = effects.map((effect) =>
|
||||||
|
effectToMiddleware(effect, actions, selectors),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mws = [...local, ...inner];
|
||||||
|
|
||||||
|
return composeMw(mws);
|
||||||
|
}
|
48
src/middleware.test.js
Normal file
48
src/middleware.test.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { test, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { buildMiddleware } from './middleware.js';
|
||||||
|
import { action } from './actions.js';
|
||||||
|
|
||||||
|
test('buildMiddleware, effects', async () => {
|
||||||
|
const effectMock = vi.fn();
|
||||||
|
|
||||||
|
const mw = buildMiddleware([
|
||||||
|
['*', (api) => (next) => (action) => effectMock()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
mw({})(() => {})({});
|
||||||
|
|
||||||
|
expect(effectMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildMiddleware, augmented api', async () => {
|
||||||
|
const myAction = action('myAction');
|
||||||
|
|
||||||
|
const mw = buildMiddleware(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'*',
|
||||||
|
(api) => (next) => (action) => {
|
||||||
|
expect(api.getState.mySelector()).toEqual(13);
|
||||||
|
api.dispatch(myAction());
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
{
|
||||||
|
myAction,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mySelector: (state) => state?.selected,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = vi.fn();
|
||||||
|
const getState = vi.fn(() => ({ selected: 13 }));
|
||||||
|
const next = vi.fn();
|
||||||
|
|
||||||
|
mw({ dispatch, getState })(next)(myAction());
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(dispatch).toHaveBeenCalledWith(myAction());
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user