Merge branch 'reactions-ts'

This commit is contained in:
Yanick Champoux 2023-04-21 14:10:08 -04:00
commit 9fe6fc952e
59 changed files with 2314 additions and 2817 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ pnpm-debug.log
yarn-error.log
GPUCache/
updux-2.0.0.tgz
pnpm-lock.yaml

View File

@ -2,7 +2,27 @@
version: '3'
vars:
PARENT_BRANCH: main
tasks:
build: tsc
checks:
deps: [lint, test, build]
integrate:
deps: [checks]
cmds:
- git is-clean
# did we had tests?
- git diff-ls {{.PARENT_BRANCH}} | grep test
- git checkout {{.PARENT_BRANCH}}
- git weld -
test: vitest run src
test:dev: vitest src
lint:fix:delta:
vars:
FILES:

View File

@ -91,6 +91,3 @@ const updux = new Updux({
});
```

View File

@ -51,4 +51,3 @@ test( "tutorial", async () => {
]);
});

View File

@ -37,4 +37,3 @@ test( "basic tests", async () => {
expect(store.getState().nbrTodos).toEqual(2);
});

View File

@ -4,22 +4,23 @@ This tutorial walks you through the features of `Updux` using the
time-honored example of the implementation of Todo list store.
We'll be using
[updeep](https://www.npmjs.com/package/updeep) to
[@yanick/updeep-remeda](https://www.npmjs.com/package/@yanick/updeep-remeda) to
help with immutability and deep merging,
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][],
[remeda][],
[lodash][], or even
plain JavaScript.
## Definition of the state
To begin with, let's define that has nothing but an initial state.
```js
import { Updux } from 'updux';
import Updux from 'updux';
const todosDux = new Updux({
initial: {
next_id: 1,
nextId: 1,
todos: [],
}
});
@ -32,22 +33,26 @@ initial state will be automatically set:
```js
const store = todosDux.createStore();
console.log(store.getState()); // prints { next_id: 1, todos: [] }
console.log(store.getState()); // prints { nextId: 1, todos: [] }
```
## Add actions
This is all good, but a little static. Let's add actions!
```js
import { createAction } from 'updux';
const addTodo = createAction('addTodo');
const todoDone = createAction('todoDone');
const todosDux = new Updux({
initial: {
next_id: 1,
nextId: 1,
todos: [],
},
{
addTodo: null,
todoDone: null,
actions: {
addTodo,
todoDone,
}
});
```
@ -55,22 +60,22 @@ const todosDux = new Updux({
### Accessing actions
Once an action is defined, its creator is accessible via the `actions` accessor.
This is not yet terribly exciting, but it'll get better once we begin using
subduxes.
```js
console.log( todosDux.actions.addTodo('write tutorial') );
// prints { type: 'addTodo', payload: 'write tutorial' }
// => { type: 'addTodo', payload: 'write tutorial' }
```
### Adding a mutation
Mutations are the reducing functions associated to actions. They
are defined via the `setMutation` method:
are defined via the `mutation` method:
```js
todosDux.setMutation( 'addTodo', description => ({next_id: id, todos}) => ({
next_id: 1 + id,
todos: [...todos, { description, id, done: false }]
}));
```js
dux.mutation(addTodo, (state, description) => {
state.todos.unshift({ description, id: state.nextId, done: false });
state.nextId++;
});
```
## Effects
@ -404,4 +409,4 @@ const myDux = new Updux({
[immer]: https://www.npmjs.com/package/immer
[lodash]: https://www.npmjs.com/package/lodash
[ts-action]: https://www.npmjs.com/package/ts-action
[remeda]: remedajs.com/

View File

@ -1,6 +1,7 @@
{
"type": "module",
"dependencies": {
"@yanick/updeep-remeda": "^2.1.0",
"immer": "^9.0.15",
"json-schema-shorthand": "^2.0.0",
"redux": "^4.2.0",
@ -29,6 +30,7 @@
},
"homepage": "https://github.com/yanick/updux#readme",
"devDependencies": {
"@reduxjs/toolkit": "^1.9.3",
"@vitest/browser": "^0.23.1",
"@vitest/ui": "^0.23.1",
"eslint": "^8.22.0",
@ -36,6 +38,9 @@
"eslint-plugin-todo-plz": "^1.2.1",
"jsdoc-to-markdown": "^7.1.1",
"prettier": "^2.7.1",
"redux-toolkit": "^1.1.2",
"typescript": "^4.9.5",
"vite": "^4.2.1",
"vitest": "0.23.1"
}
}

File diff suppressed because it is too large Load Diff

457
src/Updux.original Normal file
View File

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

13
src/Updux.test.ts Normal file
View File

@ -0,0 +1,13 @@
import { test, expect } from 'vitest';
import Updux from './Updux.js';
test('subdux idempotency', () => {
const foo = new Updux({
subduxes: {
a: new Updux({ initialState: 2 }),
},
});
let fooState = foo.reducer(undefined, { type: 'noop' });
expect(foo.reducer(fooState, { type: 'noop' })).toBe(fooState);
});

View File

@ -35,7 +35,7 @@ export class Updux {
this.#middlewareWrapper = config.middlewareWrapper;
this.#localInitial = config.initial;
this.#localInitial = config.initialState;
this.#subduxes = config.subduxes ?? {};
this.#actions = R.mapValues(config.actions ?? {}, (arg, name) =>
@ -89,7 +89,7 @@ export class Updux {
return this.#actions;
}
get initial() {
get initialState() {
if (Object.keys(this.#subduxes).length === 0)
return this.#localInitial ?? {};
@ -101,7 +101,7 @@ export class Updux {
return Object.assign(
{},
this.#localInitial ?? {},
R.mapValues(this.#subduxes, ({ initial }) => initial),
R.mapValues(this.#subduxes, ({ initialState }) => initialState),
);
}
@ -167,14 +167,14 @@ export class Updux {
);
}
createStore(initial = undefined, enhancerGenerator = undefined) {
createStore(initialState = undefined, enhancerGenerator = undefined) {
const enhancer = (enhancerGenerator ?? applyMiddleware)(
this.middleware,
);
const store = reduxCreateStore(
this.reducer,
initial ?? this.initial,
initialState ?? this.initialState,
enhancer,
);
@ -247,13 +247,16 @@ export class Updux {
return (state, previousState, unsubscribe) => {
const gone = { ...cache };
// TODO assuming object here
for (const key in state) {
const mappedState = Array.isArray(state)? Object.fromEntries(
state.map( s => [ mapper(s), s ] )
) : state;
for (const key in mappedState) {
if (cache[key]) {
delete gone[key];
} else {
const dux = new Updux({
initial: null,
initialState: null,
actions: { update: null },
mutations: {
update: (payload) => () => payload,

383
src/Updux.ts Normal file
View File

@ -0,0 +1,383 @@
import * as R from 'remeda';
import {
createStore as reduxCreateStore,
applyMiddleware,
DeepPartial,
Action,
MiddlewareAPI,
AnyAction,
Middleware,
Dispatch,
} from 'redux';
import {
configureStore,
Reducer,
ActionCreator,
ActionCreatorWithoutPayload,
ActionCreatorWithPreparedPayload,
} from '@reduxjs/toolkit';
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,
augmentMiddlewareApi,
buildEffectsMiddleware,
EffectMiddleware,
} from './effects.js';
import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore.js';
import { produce } from 'immer';
type MyActionCreator = { type: string } & ((...args: any) => any);
type XSel<R> = R extends Function ? R : () => R;
type CurriedSelector<S> = S extends (...args: any) => infer R ? XSel<R> : never;
type CurriedSelectors<S> = {
[key in keyof S]: CurriedSelector<S[key]>;
};
type ResolveAction<
ActionType extends string,
ActionArg extends any,
> = ActionArg extends MyActionCreator
? ActionArg
: ActionArg extends (...args: any) => any
? ActionCreatorWithPreparedPayload<
Parameters<ActionArg>,
ReturnType<ActionArg>,
ActionType
>
: ActionCreatorWithoutPayload<ActionType>;
type ResolveActions<
A extends {
[key: string]: any;
},
> = {
[ActionType in keyof A]: ActionType extends string
? ResolveAction<ActionType, A[ActionType]>
: never;
};
type Reaction<S = any, M extends MiddlewareAPI = MiddlewareAPI> = (
api: M,
) => (state: S, previousState: S, unsubscribe: () => void) => any;
type AugmentedMiddlewareAPI<S, A, SELECTORS> = MiddlewareAPI<
Dispatch<AnyAction>,
S
> & {
dispatch: A;
getState: CurriedSelectors<SELECTORS>;
actions: A;
selectors: SELECTORS;
};
export type Mutation<A extends Action<any> = Action<any>, S = any> = (
payload: A extends {
payload: infer P;
}
? P
: undefined,
action: A,
) => (state: S) => S | void;
type SelectorForState<S> = (state: S) => unknown;
type SelectorsForState<S> = {
[key: string]: SelectorForState<S>;
};
export default class Updux<
T_LocalState = Record<any, any>,
T_LocalActions extends {
[actionType: string]: any;
} = {},
T_Subduxes extends Record<string, Dux> = {},
T_LocalSelectors extends SelectorsForState<
AggregateState<T_LocalState, T_Subduxes>
> = {},
> {
#localInitial: T_LocalState;
#localActions: T_LocalActions;
#localMutations: MutationCase[] = [];
#defaultMutation: Omit<MutationCase, 'matcher'>;
#subduxes: T_Subduxes;
#name: string;
#actions: AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>;
#initialState: AggregateState<T_LocalState, T_Subduxes>;
#localSelectors: Record<
string,
(state: AggregateState<T_LocalState, T_Subduxes>) => any
>;
#selectors: any;
#localEffects: Middleware[] = [];
constructor(
config: Partial<{
initialState: T_LocalState;
actions: T_LocalActions;
subduxes: T_Subduxes;
selectors: T_LocalSelectors;
}>,
) {
// TODO check that we can't alter the initialState after the fact
this.#localInitial = config.initialState ?? ({} as T_LocalState);
this.#localActions = config.actions ?? ({} as T_LocalActions);
this.#subduxes = config.subduxes ?? ({} as T_Subduxes);
this.#actions = buildActions(this.#localActions, this.#subduxes);
this.#initialState = buildInitial(this.#localInitial, this.#subduxes);
this.#localSelectors = config.selectors;
const basedSelectors = R.mergeAll(
Object.entries(this.#subduxes)
.filter(([slice, { selectors }]) => selectors)
.map(([slice, { selectors }]) =>
R.mapValues(selectors, (s) => (state = {}) => {
return s(state?.[slice]);
}),
),
);
this.#selectors = R.merge(basedSelectors, this.#localSelectors);
}
get actions() {
return this.#actions;
}
// TODO memoize?
get initialState() {
return this.#initialState;
}
get effects() {
return [
...this.#localEffects,
...Object.entries(this.#subduxes).flatMap(
([slice, { effects }]) => {
if (!effects) return [];
return effects.map(
(effect) => (api) =>
effect({
...api,
getState: () => api.getState()[slice],
}),
);
},
),
];
}
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<{
initialState: T_LocalState;
}> = {},
) {
const preloadedState: any = options.initialState ?? this.initialState;
const effects = buildEffectsMiddleware(
this.effects,
this.actions,
this.selectors,
);
const store = configureStore({
reducer: this.reducer as Reducer<
AggregateState<T_LocalState, T_Subduxes>,
AnyAction
>,
preloadedState,
middleware: [effects],
});
const dispatch: any = store.dispatch;
for (const a in this.actions) {
dispatch[a] = (...args) => {
const action = (this.actions as any)[a](...args);
dispatch(action);
return action;
};
}
store.getState = augmentGetState(store.getState, this.selectors);
for (const reaction of this.reactions) {
let unsub;
const r = reaction(store);
unsub = store.subscribe(() => r(unsub));
}
(store as any).actions = this.actions;
(store as any).selectors = this.selectors;
return store as ToolkitStore<AggregateState<T_LocalState, T_Subduxes>> &
AugmentedMiddlewareAPI<
AggregateState<T_LocalState, T_Subduxes>,
AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>,
AggregateSelectors<
T_LocalSelectors,
T_Subduxes,
AggregateState<T_LocalState, T_Subduxes>
>
>;
}
get selectors(): AggregateSelectors<
T_LocalSelectors,
T_Subduxes,
AggregateState<T_LocalState, T_Subduxes>
> {
return this.#selectors as any;
}
// TODO memoize this sucker
get reducer() {
return buildReducer(
this.initialState,
this.#localMutations,
this.#defaultMutation,
this.#subduxes,
) as any as (
state: undefined | typeof this.initialState,
action: Action,
) => typeof this.initialState;
}
// TODO be smarter with the guard?
addMutation<A extends Action<any>>(
matcher: (action: A) => boolean,
mutation: Mutation<A, AggregateState<T_LocalState, T_Subduxes>>,
terminal?: boolean,
);
addMutation<A extends ActionCreator<any>>(
actionCreator: A,
mutation: Mutation<
ReturnType<A>,
AggregateState<T_LocalState, T_Subduxes>
>,
terminal?: boolean,
);
addMutation(matcher, mutation, terminal = false) {
if (typeof matcher === 'function' && matcher.match) {
// matcher, matcher man...
matcher = matcher.match;
}
const immerMutation = (...args) => produce(mutation(...args));
this.#localMutations.push({
terminal,
matcher,
mutation: immerMutation,
});
}
addDefaultMutation(
mutation: Mutation<
Action<any>,
AggregateState<T_LocalState, T_Subduxes>
>,
terminal = false,
) {
this.#defaultMutation = { mutation, terminal };
}
addEffect(effect: EffectMiddleware) {
this.#localEffects.push(effect);
}
get effectsMiddleware() {
return buildEffectsMiddleware(
this.effects,
this.actions,
this.selectors,
);
}
#localReactions: any[] = [];
addReaction(
reaction: Reaction<
AggregateState<T_LocalState, T_Subduxes>,
AugmentedMiddlewareAPI<
AggregateState<T_LocalState, T_Subduxes>,
AggregateActions<ResolveActions<T_LocalActions>, T_Subduxes>,
AggregateSelectors<
T_LocalSelectors,
T_Subduxes,
AggregateState<T_LocalState, T_Subduxes>
>
>
>,
) {
let previous: any;
const memoized = (api: any) => {
api = augmentMiddlewareApi(api, this.actions, this.selectors);
const r = reaction(api);
return (unsub: () => void) => {
const state = api.getState();
if (state === previous) return;
let p = previous;
previous = state;
r(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);
}
}

View File

@ -1,26 +0,0 @@
export function isActionGen(action) {
return typeof action === 'function' && action.type;
}
export function action(type, payloadFunction, transformer) {
let generator = function (...payloadArg) {
const result = { type };
if (payloadFunction) {
result.payload = payloadFunction(...payloadArg);
} else {
if (payloadArg[0] !== undefined) result.payload = payloadArg[0];
}
return result;
};
if (transformer) {
const orig = generator;
generator = (...args) => transformer(orig(...args), args);
}
generator.type = type;
return generator;
}

View File

@ -1,86 +0,0 @@
import { test, expect } from 'vitest';
import { action } from './actions.js';
import { Updux } from './Updux.js';
test('basic action', () => {
const foo = action('foo', (thing) => ({ thing }));
expect(foo('bar')).toEqual({
type: 'foo',
payload: {
thing: 'bar',
},
});
});
test('Updux config accepts actions', () => {
const foo = new Updux({
actions: {
one: action('one', (x) => ({ x })),
two: action('two', (x) => x),
},
});
expect(Object.keys(foo.actions)).toHaveLength(2);
expect(foo.actions.one).toBeTypeOf('function');
expect(foo.actions.one('potato')).toEqual({
type: 'one',
payload: {
x: 'potato',
},
});
});
test('subduxes actions', () => {
const foo = new Updux({
actions: {
foo: null,
},
subduxes: {
beta: {
actions: {
bar: null,
},
},
},
});
expect(foo.actions).toHaveProperty('foo');
expect(foo.actions).toHaveProperty('bar');
});
test('throw if double action', () => {
expect(
() =>
new Updux({
actions: {
foo: action('foo'),
},
subduxes: {
beta: {
actions: {
foo: action('foo'),
},
},
},
}),
).toThrow(/action 'foo' already defined/);
});
test('action definition shortcut', () => {
const foo = new Updux({
actions: {
foo: null,
bar: (x) => ({ x }),
},
});
expect(foo.actions.foo('hello')).toEqual({ type: 'foo', payload: 'hello' });
expect(foo.actions.bar('hello')).toEqual({
type: 'bar',
payload: { x: 'hello' },
});
});

122
src/actions.test.ts Normal file
View File

@ -0,0 +1,122 @@
import Updux, { createAction, withPayload } from './index.js';
test('basic action', () => {
const foo = createAction(
'foo',
withPayload((thing: string) => ({ thing })),
);
expect(foo('bar')).toEqual({
type: 'foo',
payload: {
thing: 'bar',
},
});
});
test('subduxes actions', () => {
const bar = createAction<number>('bar');
const baz = createAction('baz');
const foo = new Updux({
actions: {
bar,
},
subduxes: {
beta: {
actions: {
baz,
},
},
// to check if we can deal with empty actions
gamma: {},
},
});
expect(foo.actions).toHaveProperty('bar');
expect(foo.actions).toHaveProperty('baz');
expect(foo.actions.bar(2)).toHaveProperty('type', 'bar');
expect(foo.actions.baz()).toHaveProperty('type', 'baz');
});
test('Updux config accepts actions', () => {
const foo = new Updux({
actions: {
one: createAction(
'one',
withPayload((x) => ({ x })),
),
two: createAction(
'two',
withPayload((x) => x),
),
},
});
expect(Object.keys(foo.actions)).toHaveLength(2);
expect(foo.actions.one).toBeTypeOf('function');
expect(foo.actions.one('potato')).toEqual({
type: 'one',
payload: {
x: 'potato',
},
});
});
test('throw if double action', () => {
expect(
() =>
new Updux({
actions: {
foo: createAction('foo'),
},
subduxes: {
beta: {
actions: {
foo: createAction('foo'),
},
},
},
}),
).toThrow(/action 'foo' defined both locally and in subdux 'beta'/);
expect(
() =>
new Updux({
subduxes: {
gamma: {
actions: {
foo: createAction('foo'),
},
},
beta: {
actions: {
foo: createAction('foo'),
},
},
},
}),
).toThrow(/action 'foo' defined both in subduxes 'gamma' and 'beta'/);
});
test('action definition shortcut', () => {
const foo = new Updux({
actions: {
foo: 0,
bar: (x: number) => ({ x }),
baz: createAction('baz', withPayload<boolean>()),
},
});
expect(foo.actions.foo()).toEqual({ type: 'foo', payload: undefined });
expect(foo.actions.baz(false)).toEqual({
type: 'baz',
payload: false,
});
expect(foo.actions.bar(2)).toEqual({
type: 'bar',
payload: { x: 2 },
});
});

29
src/actions.ts Normal file
View File

@ -0,0 +1,29 @@
import { createAction } from '@reduxjs/toolkit';
export { createAction } from '@reduxjs/toolkit';
interface WithPayload {
<P>(): (input: P) => { payload: P };
<P, A extends any[]>(prepare: (...args: A) => P): (...input: A) => {
payload: P;
};
}
export const withPayload: WithPayload = ((prepare) =>
(...input) => ({
payload: prepare ? prepare(...input) : input[0],
})) as any;
const id = (x) => x;
export const createPayloadAction = <
P extends any = any,
T extends string = string,
F extends (...args: any[]) => P = (input: P) => P,
>(
type: T,
prepare?: F,
) =>
createAction(
type,
withPayload<ReturnType<F>, Parameters<F>>(prepare ?? (id as any)),
);

45
src/buildActions.ts Normal file
View File

@ -0,0 +1,45 @@
import { createAction } from '@reduxjs/toolkit';
import * as R from 'remeda';
import { withPayload } from './actions.js';
function resolveActions(configActions) {
return R.mapValues(configActions, (prepare, type: string) => {
if (typeof prepare === 'function' && prepare.type) return prepare;
return createAction(type, withPayload(prepare));
});
}
export function buildActions(localActions, subduxes) {
localActions = resolveActions(localActions);
let actions: Record<string, string> = {};
for (const slice in subduxes) {
const subdux = subduxes[slice].actions;
if (!subdux) continue;
for (const a in subdux) {
if (actions[a] && subduxes[actions[a]].actions[a] !== subdux[a]) {
throw new Error(
`action '${a}' defined both in subduxes '${actions[a]}' and '${slice}'`,
);
}
actions[a] = slice;
}
}
for (const a in localActions) {
if (actions[a]) {
throw new Error(
`action '${a}' defined both locally and in subdux '${actions[a]}'`,
);
}
}
return R.mergeAll([
localActions,
...Object.values(subduxes).map(R.pathOr<any, any>(['actions'], {})),
]) as any;
}

21
src/buildInitial.test.ts Normal file
View File

@ -0,0 +1,21 @@
import { test, expect } from 'vitest';
import { buildInitial } from './initial.js';
test('basic', () => {
expect(
buildInitial(
{ a: 1 },
{ b: { initialState: { c: 2 } }, d: { initialState: 'e' } },
),
).toEqual({
a: 1,
b: { c: 2 },
d: 'e',
});
});
test('throw if subduxes and initialState is not an object', () => {
expect(() => {
buildInitial(3, { bar: 'foo' });
}).toThrow();
});

View File

@ -0,0 +1,70 @@
import { mapValues, map, get } from 'lodash-es';
const middlewareFor = (type, middleware) => (api) => (next) => (action) => {
if (type !== '*' && action.type !== type) return next(action);
return middleware(api)(next)(action);
};
const sliceMw = (slice, mw) => (api) => {
const getSliceState = () => get(api.getState(), slice);
return mw(
Object.assign(Object.assign({}, api), { getState: getSliceState }),
);
};
export const augmentGetState = (getState, selectors) =>
Object.assign(
getState,
mapValues(selectors, (selector) => {
return (...args) => {
let result = selector(api.getState());
if (typeof result === 'function') return result(...args);
return result;
};
}),
);
export function augmentMiddlewareApi(api, actions, selectors) {
const getState = augmentGetState(() => api.getState(), selectors);
const dispatch = (action) => api.dispatch(action);
Object.assign(
dispatch,
mapValues(actions, (action) => {
return (...args) => api.dispatch(action(...args));
}),
);
return Object.assign(Object.assign({}, api), {
getState,
dispatch,
actions,
selectors,
});
}
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));
};
const composeMw = (mws) => (api) => (original_next) =>
mws.reduceRight((next, mw) => mw(api)(next), original_next);
export function buildMiddleware(
effects = [],
actions = {},
selectors = {},
sub = {},
wrapper = undefined,
dux = undefined,
) {
let inner = map(sub, ({ middleware }, slice) =>
slice !== '*' && middleware ? sliceMw(slice, middleware) : undefined,
).filter((x) => x);
const local = effects.map((effect) =>
effectToMiddleware(effect, actions, selectors),
);
let mws = [...local, ...inner];
if (wrapper) mws = wrapper(mws, dux);
return composeMw(mws);
}

View File

@ -0,0 +1,85 @@
import u from 'updeep';
import { mapValues, map, get } from 'lodash-es';
const middlewareFor = (type, middleware) => (api) => (next) => (action) => {
if (type !== '*' && action.type !== type) return next(action);
return middleware(api)(next)(action);
};
const sliceMw = (slice, mw) => (api) => {
const getSliceState = () => get(api.getState(), slice);
return mw({ ...api, getState: getSliceState });
};
export function augmentMiddlewareApi(api, actions, selectors) {
const getState = () => api.getState();
const dispatch = (action) => api.dispatch(action);
Object.assign(
getState,
mapValues(selectors, (selector) => {
return (...args) => {
let result = selector(api.getState());
if (typeof result === 'function') return result(...args);
return result;
};
}),
);
Object.assign(
dispatch,
mapValues(actions, (action) => {
return (...args) => api.dispatch(action(...args));
}),
);
return {
...api,
getState,
dispatch,
actions,
selectors,
};
}
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));
};
const composeMw = (mws) => (api) => (original_next) =>
mws.reduceRight((next, mw) => mw(api)(next), original_next);
export function buildMiddleware(
effects = [],
actions = {},
selectors = {},
sub = {},
wrapper = undefined,
dux = undefined,
) {
let inner = map(sub, ({ middleware }, slice) =>
slice !== '*' && middleware ? sliceMw(slice, middleware) : undefined,
).filter((x) => x);
const local = effects.map((effect) =>
effectToMiddleware(effect, actions, selectors),
);
let mws = [...local, ...inner];
if (wrapper) mws = wrapper(mws, dux);
return composeMw(mws);
}

View File

@ -0,0 +1,29 @@
import { map, mapValues, merge } from 'lodash-es';
export function buildSelectors(
localSelectors,
splatSelector = {},
subduxes = {},
) {
const subSelectors = map(subduxes, ({ selectors }, slice) => {
if (!selectors) return {};
if (slice === '*') return {};
return mapValues(selectors, (func) => (state) => func(state[slice]));
});
let splat = {};
for (const name in splatSelector) {
splat[name] =
(state) =>
(...args) => {
const value = splatSelector[name](state)(...args);
const res = () => value;
return merge(
res,
mapValues(
subduxes['*'].selectors,
(selector) => () => selector(value),
),
);
};
}
return merge({}, ...subSelectors, localSelectors, splat);
}

View File

@ -0,0 +1,38 @@
import { map, mapValues, merge } from 'lodash-es';
export function buildSelectors(
localSelectors,
splatSelector = {},
subduxes = {},
) {
const subSelectors = map(subduxes, ({ selectors }, slice) => {
if (!selectors) return {};
if (slice === '*') return {};
return mapValues(
selectors,
(func: Function) => (state) => func(state[slice]),
);
});
let splat = {};
for (const name in splatSelector) {
splat[name] =
(state) =>
(...args) => {
const value = splatSelector[name](state)(...args);
const res = () => value;
return merge(
res,
mapValues(
subduxes['*'].selectors,
(selector) => () => selector(value),
),
);
};
}
return merge({}, ...subSelectors, localSelectors, splat);
}

View File

@ -1,45 +0,0 @@
import u from 'updeep';
import { mapValues } from 'lodash-es';
export function buildUpreducer(
initial,
mutations,
subduxes = {},
wrapper = undefined,
) {
const subReducers =
Object.keys(subduxes).length > 0
? mapValues(subduxes, ({ upreducer }) => upreducer)
: null;
const upreducer = (action) => (state) => {
if (!action?.type)
throw new Error('upreducer called with a bad action');
let newState = state ?? initial;
if (subReducers) {
if (subduxes['*']) {
newState = u.updateIn(
'*',
subduxes['*'].upreducer(action),
newState,
);
} else {
const update = mapValues(subReducers, (upReducer) =>
upReducer(action),
);
newState = u(update, newState);
}
}
const a = mutations[action.type] || mutations['+'];
if (!a) return newState;
return a(action.payload, action)(newState);
};
return wrapper ? wrapper(upreducer) : upreducer;
}

View File

@ -1,18 +0,0 @@
import { test, expect } from 'vitest';
import { Updux } from './Updux.js';
test('basic createStore', async () => {
const foo = new Updux({
initial: { a: 1 },
actions: {
a1: null,
},
});
const store = foo.createStore();
expect(store.getState).toBeTypeOf('function');
expect(store.getState()).toEqual({ a: 1 });
expect(store.actions.a1).toBeTypeOf('function');
});

View File

@ -6,7 +6,7 @@ import { matches } from './utils.js';
test('basic selectors', () => {
const foo = dux({
initial: {
initialState: {
x: 1,
},
selectors: {
@ -14,7 +14,7 @@ test('basic selectors', () => {
},
subduxes: {
bar: {
initial: { y: 2 },
initialState: { y: 2 },
selectors: {
getY: ({ y }) => y,
},
@ -27,7 +27,7 @@ test('basic selectors', () => {
test('splat selector', async () => {
const bar = new Updux({
initial: { id: 0, label: '' },
initialState: { id: 0, label: '' },
selectors: {
getLabel: R.prop('label'),
getLabelAppended: (state) => (suffix) => state.label + ' ' + suffix,
@ -35,7 +35,7 @@ test('splat selector', async () => {
});
const foo = new Updux({
initial: [],
initialState: [],
findSelectors: {
getBar: (state) => (id) => {
return state.find(matches({ id }));

110
src/effects.test.ts Normal file
View File

@ -0,0 +1,110 @@
import { buildEffectsMiddleware } from './effects.js';
import Updux, { createAction } from './index.js';
test('buildEffectsMiddleware', () => {
let seen = 0;
const mw = buildEffectsMiddleware(
[
(api) => (next) => (action) => {
seen++;
expect(api).toHaveProperty('getState');
expect(api.getState).toBeTypeOf('function');
expect(api.getState()).toEqual('the state');
expect(action).toHaveProperty('type');
expect(next).toBeTypeOf('function');
expect(api).toHaveProperty('actions');
expect(api.actions.action1()).toHaveProperty('type', 'action1');
api.dispatch.action1();
expect(api.selectors.getFoo(2)).toBe(2);
expect(api.getState.getFoo()).toBe('the state');
expect(api.getState.getBar(2)).toBe('the state2');
next(action);
},
],
{
action1: createAction('action1'),
},
{
getFoo: (state) => state,
getBar: (state) => (i) => state + i,
},
);
expect(seen).toEqual(0);
const dispatch = vi.fn();
mw({ getState: () => 'the state', dispatch })(() => {})({
type: 'noop',
});
expect(seen).toEqual(1);
expect(dispatch).toHaveBeenCalledWith({ type: 'action1' });
});
test('basic', () => {
const dux = new Updux({
initialState: {
loaded: true,
},
actions: {
foo: 0,
},
});
let seen = 0;
dux.addEffect((api) => (next) => (action) => {
seen++;
expect(api).toHaveProperty('getState');
expect(api.getState()).toHaveProperty('loaded');
expect(action).toHaveProperty('type');
expect(next).toBeTypeOf('function');
next(action);
});
const store = dux.createStore();
expect(seen).toEqual(0);
store.dispatch.foo();
expect(seen).toEqual(1);
});
test('subdux', () => {
const bar = new Updux({
initialState: 'bar state',
actions: { foo: 0 },
});
let seen = 0;
bar.addEffect((api) => (next) => (action) => {
seen++;
expect(api.getState()).toBe('bar state');
next(action);
});
const dux = new Updux({
initialState: {
loaded: true,
},
subduxes: {
bar,
},
});
const store = dux.createStore();
expect(seen).toEqual(0);
store.dispatch.foo();
expect(seen).toEqual(1);
});
// TODO subdux effects
// TODO allow to subscribe / unsubscribe effects?

59
src/effects.ts Normal file
View File

@ -0,0 +1,59 @@
import { AnyAction } from '@reduxjs/toolkit';
import { MiddlewareAPI } from '@reduxjs/toolkit';
import { Dispatch } from '@reduxjs/toolkit';
export interface EffectMiddleware<S = any, D extends Dispatch = Dispatch> {
(api: MiddlewareAPI<D, S>): (
next: Dispatch<AnyAction>,
) => (action: AnyAction) => any;
}
const composeMw = (mws) => (api) => (originalNext) =>
mws.reduceRight((next, mw) => mw(api)(next), originalNext);
export const augmentGetState = (originalGetState, selectors) => {
const getState = () => originalGetState();
for (const s in selectors) {
getState[s] = (...args) => {
let result = selectors[s](originalGetState());
if (typeof result === 'function') return result(...args);
return result;
};
}
return getState;
};
const augmentDispatch = (originalDispatch, actions) => {
const dispatch = (action) => originalDispatch(action);
for (const a in actions) {
dispatch[a] = (...args) => dispatch(actions[a](...args));
}
return dispatch;
};
export const augmentMiddlewareApi = (api, actions, selectors) => {
return {
...api,
getState: augmentGetState(api.getState, selectors),
dispatch: augmentDispatch(api.dispatch, actions),
actions,
selectors,
};
};
export function buildEffectsMiddleware(
effects = [],
actions = {},
selectors = {},
) {
return (api) => {
const newApi = augmentMiddlewareApi(api, actions, selectors);
let mws = effects.map((e) => e(newApi));
return (originalNext) => {
return mws.reduceRight((next, mw) => mw(next), originalNext);
};
};
}

5
src/index.ts Normal file
View File

@ -0,0 +1,5 @@
import Updux from './Updux.js';
export { withPayload, createAction, createPayloadAction } from './actions.js';
export default Updux;

View File

@ -1,46 +0,0 @@
import { test, expect } from 'vitest';
import { Updux } from './Updux.js';
const bar = new Updux({ initial: 123 });
const foo = new Updux({
initial: { root: 'abc' },
subduxes: {
bar,
},
});
test('single dux', () => {
const foo = new Updux({
initial: { a: 1 },
});
expect(foo.initial).toEqual({ a: 1 });
});
test('initial value', () => {
expect(foo.initial).toEqual({
root: 'abc',
bar: 123,
});
});
test('splat initial', async () => {
const bar = new Updux({
initial: { id: 0 },
});
const foo = new Updux({
subduxes: { '*': bar },
});
expect(foo.initial).toEqual([]);
expect(
new Updux({
initial: 'overriden',
subduxes: { '*': bar },
}).initial,
).toEqual('overriden');
});

100
src/initial.test.ts Normal file
View File

@ -0,0 +1,100 @@
import { expectType } from './tutorial.test.js';
import Updux from './Updux.js';
const bar = new Updux({ initialState: 123 });
const foo = new Updux({
initialState: { root: 'abc' },
subduxes: {
bar,
},
});
test('default', () => {
const { initialState } = new Updux({});
expect(initialState).toBeTypeOf('object');
expect(initialState).toEqual({});
});
test('number', () => {
const { initialState } = new Updux({ initialState: 3 });
expect(initialState).toBeTypeOf('number');
expect(initialState).toEqual(3);
});
test('initialState to createStore', () => {
const initialState = {
a: 1,
b: 2,
};
const dux = new Updux({
initialState,
});
expect(
dux.createStore({ initialState: { a: 3, b: 4 } }).getState(),
).toEqual({
a: 3,
b: 4,
});
});
test('single dux', () => {
const foo = new Updux({
initialState: { a: 1 },
});
expect(foo.initialState).toEqual({ a: 1 });
});
// TODO add 'check for no todo eslint rule'
test('initialState value', () => {
expect(foo.initialState).toEqual({
root: 'abc',
bar: 123,
});
expectType<{
root: string;
bar: number;
}>(foo.initialState);
});
test('no initialState', () => {
const dux = new Updux({});
expectType<{}>(dux.initialState);
expect(dux.initialState).toEqual({});
});
test('no initialState for subdux', () => {
const dux = new Updux({
subduxes: {
bar: new Updux({}),
baz: new Updux({ initialState: 'potato' }),
},
});
expectType<{ bar: {}; baz: string }>(dux.initialState);
expect(dux.initialState).toEqual({ bar: {}, baz: 'potato' });
});
test.todo('splat initialState', async () => {
const bar = new Updux({
initialState: { id: 0 },
});
const foo = new Updux({
subduxes: { '*': bar },
});
expect(foo.initialState).toEqual([]);
expect(
new Updux({
initialState: 'overriden',
subduxes: { '*': bar },
}).initialState,
).toEqual('overriden');
});

26
src/initial.ts Normal file
View File

@ -0,0 +1,26 @@
import u from '@yanick/updeep-remeda';
import * as R from 'remeda';
type SubduxState<S> = 'initialState' extends keyof S ? S['initialState'] : {};
export type AggregateState<LOCAL, SUBDUXES extends Record<any, any>> = LOCAL &
(keyof SUBDUXES extends never
? {}
: {
[Slice in keyof SUBDUXES]: Slice extends string
? SubduxState<SUBDUXES[Slice]>
: never;
});
export function buildInitial(localInitial, subduxes) {
if (Object.keys(subduxes).length > 0 && typeof localInitial !== 'object') {
throw new Error(
"can't have subduxes when the initialState value is not an object",
);
}
return u(
localInitial,
R.mapValues(subduxes, R.pathOr(['initialState'], {})),
);
}

View File

@ -1,48 +0,0 @@
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());
});

View File

@ -1,90 +0,0 @@
import { test, expect } from 'vitest';
import schema from 'json-schema-shorthand';
import u from 'updeep';
import { action } from './actions.js';
import { Updux, dux } from './Updux.js';
test('set a mutation', () => {
const dux = new Updux({
initial: {
x: 'potato',
},
actions: {
foo: action('foo', (x) => ({ x })),
bar: action('bar'),
},
});
dux.setMutation(dux.actions.foo, (payload, action) => {
expect(payload).toEqual({ x: 'hello ' });
expect(action).toEqual(dux.actions.foo('hello '));
return u({
x: payload.x + action.type,
});
});
const result = dux.reducer(undefined, dux.actions.foo('hello '));
expect(result).toEqual({
x: 'hello foo',
});
});
test('mutation of a subdux', async () => {
const bar = dux({
actions: {
baz: null,
},
});
bar.setMutation('baz', () => (state) => ({ ...state, x: 1 }));
const foo = dux({
subduxes: { bar },
});
const store = foo.createStore();
store.dispatch.baz();
expect(store.getState()).toMatchObject({ bar: { x: 1 } });
});
test('strings and generators', async () => {
const actionA = action('a');
const foo = dux({
actions: {
b: null,
a: actionA,
},
});
// as a string and defined
expect(() => foo.setMutation('a', () => {})).not.toThrow();
// as a generator and defined
expect(() => foo.setMutation(actionA, () => {})).not.toThrow();
// as a string, not defined
expect(() => foo.setMutation('c', () => {})).toThrow();
foo.setMutation(action('d'), () => {});
expect(foo.actions.d).toBeTypeOf('function');
});
test('splat mutation', () => {
const myDux = new Updux({
initial: [],
actions: { one: null, two: null },
mutations: {
'*': (payload) => (state) => payload ? [...state, payload] : state,
},
});
const store = myDux.createStore();
expect(store.getState()).toEqual([]);
store.dispatch.one(11);
store.dispatch.two(22);
expect(store.getState()).toEqual([11, 22]);
});

80
src/mutations.test.ts Normal file
View File

@ -0,0 +1,80 @@
import { test, expect } from 'vitest';
import Updux, { createAction } from './index.js';
test('set a mutation', () => {
const dux = new Updux({
initialState: 'potato',
actions: {
foo: (x) => ({ x }),
bar: 0,
},
});
let didIt = false;
dux.addMutation(dux.actions.foo, (payload, action) => () => {
didIt = true;
expect(payload).toEqual({ x: 'hello ' });
expect(action).toEqual(dux.actions.foo('hello '));
return payload.x + action.type;
});
const result = dux.reducer(undefined, dux.actions.foo('hello '));
expect(didIt).toBeTruthy();
expect(result).toEqual('hello foo');
});
test('catch-all mutation', () => {
const dux = new Updux({
initialState: '',
});
dux.addMutation(
() => true,
(payload, action) => () => 'got it',
);
expect(dux.reducer(undefined, { type: 'foo' })).toEqual('got it');
});
test('default mutation', () => {
const dux = new Updux({
initialState: '',
actions: {
foo: 0,
},
});
dux.addMutation(dux.actions.foo, () => () => 'got it');
dux.addDefaultMutation((_payload, action) => () => action.type);
expect(dux.reducer(undefined, { type: 'foo' })).toEqual('got it');
expect(dux.reducer(undefined, { type: 'bar' })).toEqual('bar');
});
test('mutation of a subdux', () => {
const baz = createAction('baz');
const noop = createAction('noop');
const stopit = createAction('stopit');
const bar = new Updux({
initialState: 0,
actions: {
baz,
stopit,
},
});
bar.addMutation(baz, () => () => 1);
bar.addMutation(stopit, () => () => 2);
const foo = new Updux({
subduxes: { bar },
});
foo.addMutation(stopit, () => (state) => state, true);
expect(foo.reducer(undefined, noop())).toHaveProperty('bar', 0);
expect(foo.reducer(undefined, baz())).toHaveProperty('bar', 1);
expect(foo.reducer(undefined, stopit())).toHaveProperty('bar', 0);
});

View File

@ -1,50 +0,0 @@
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();
const spyB = vi.fn();
const foo = new Updux({
subduxes: {
a: new Updux({
initial: 1,
reactions: [() => (state) => spyA(state)],
actions: { inc: null },
mutations: {
inc: () => (state) => state + 1,
},
}),
b: new Updux({ initial: 10, reactions: [() => spyB] }),
},
});
const store = foo.createStore();
store.dispatch.inc();
store.dispatch.inc();
expect(spyA).toHaveBeenCalledTimes(2);
expect(spyA).toHaveBeenCalledWith(3);
expect(spyB).toHaveBeenCalledOnce(); // the original inc initialized the state
});

82
src/reactions.test.ts Normal file
View File

@ -0,0 +1,82 @@
import { test, expect, vi } from 'vitest';
import Updux from './index.js';
test('basic reactions', () => {
const foo = new Updux({
initialState: 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({
initialState: 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
});

View File

@ -1,19 +0,0 @@
import { test, expect } from 'vitest';
import { Updux } from './Updux.js';
test('basic reducer', () => {
const dux = new Updux({ initial: { a: 3 } });
expect(dux.reducer).toBeTypeOf('function');
expect(dux.reducer({ a: 1 }, { type: 'foo' })).toMatchObject({ a: 1 }); // noop
});
test('basic upreducer', () => {
const dux = new Updux({ initial: { a: 3 } });
expect(dux.upreducer).toBeTypeOf('function');
expect(dux.upreducer({ type: 'foo' })({ a: 1 })).toMatchObject({ a: 1 }); // noop
});

31
src/reducer.test.ts Normal file
View File

@ -0,0 +1,31 @@
import { test, expect } from 'vitest';
import { buildReducer } from './reducer.js';
import Updux from './Updux.js';
test('buildReducer, initialState state', () => {
const reducer = buildReducer({ a: 1 });
expect(reducer(undefined, { type: 'foo' })).toEqual({ a: 1 });
});
test('buildReducer, mutation', () => {
const reducer = buildReducer(1, [
{
matcher: ({ type }) => type === 'inc',
mutation: () => (state) => state + 1,
terminal: false,
},
]);
expect(reducer(undefined, { type: 'foo' })).toEqual(1);
expect(reducer(undefined, { type: 'inc' })).toEqual(2);
});
test.todo('basic reducer', () => {
const dux = new Updux({ initialState: { a: 3 } });
expect(dux.reducer).toBeTypeOf('function');
expect(dux.reducer({ a: 1 }, { type: 'foo' })).toMatchObject({ a: 1 }); // noop
});

95
src/reducer.ts Normal file
View File

@ -0,0 +1,95 @@
import { Action, ActionCreator, createAction } from '@reduxjs/toolkit';
import { BaseActionCreator } from '@reduxjs/toolkit/dist/createAction.js';
import * as R from 'remeda';
import { Dux } from './types.js';
import { Mutation } from './Updux.js';
import u from '@yanick/updeep-remeda';
import { produce } from 'immer';
export type MutationCase = {
matcher: (action: Action) => boolean;
mutation: Mutation;
terminal: boolean;
};
export function buildReducer(
initialStateState: any,
mutations: MutationCase[] = [],
defaultMutation?: Omit<MutationCase, 'matcher'>,
subduxes: Record<string, Dux> = {},
) {
const subReducers = R.mapValues(subduxes, R.prop('reducer'));
// TODO matcherMutation
// TODO defaultMutation
//
const reducer = (state = initialStateState, action: Action) => {
const orig = state;
if (!action?.type)
throw new Error('upreducer called with a bad action');
let terminal = false;
let didSomething = false;
mutations
.filter(({ matcher }) => matcher(action))
.forEach(({ mutation, terminal: t }) => {
if (t) terminal = true;
didSomething = true;
state = produce(
state,
mutation((action as any).payload, action),
);
});
if (!didSomething && defaultMutation) {
if (defaultMutation.terminal) terminal = true;
state = defaultMutation.mutation(
(action as any).payload,
action,
)(state);
}
if (!terminal && Object.keys(subduxes).length > 0) {
// subduxes
state = u.update(
state,
R.mapValues(subReducers, (reducer, slice) =>
(reducer as any)(state[slice], action),
),
);
}
return state;
};
return reducer;
/*
if (subReducers) {
if (subduxes['*']) {
newState = u.updateIn(
'*',
subduxes['*'].upreducer(action),
newState,
);
} else {
const update = mapValues(subReducers, (upReducer) =>
upReducer(action),
);
newState = u(update, newState);
}
}
const a = mutations[action.type] || mutations['+'];
if (!a) return newState;
return a(action.payload, action)(newState);
};
return wrapper ? wrapper(upreducer) : upreducer;
*/
}

View File

@ -1,43 +0,0 @@
import R from 'remeda';
export function buildSelectors(
localSelectors,
findSelectors = {},
subduxes = {},
) {
const subSelectors = Object.entries(subduxes).map(
([slice, { selectors }]) => {
if (!selectors) return {};
if (slice === '*') return {};
return R.mapValues(
selectors,
(func) => (state) => func(state[slice]),
);
},
);
let splat = {};
for (const name in findSelectors) {
splat[name] =
(mainState) =>
(...args) => {
const state = findSelectors[name](mainState)(...args);
return R.merge(
{ state },
R.mapValues(
subduxes['*']?.selectors ?? {},
(selector) =>
(...args) => {
let value = selector(state);
if (typeof value !== 'function') return value;
return value(...args);
},
),
);
};
}
return R.mergeAll([...subSelectors, localSelectors, splat]);
}

42
src/selectors.test.ts Normal file
View File

@ -0,0 +1,42 @@
import { test, expect } from 'vitest';
import Updux, { createAction } from './index.js';
test('basic selectors', () => {
type State = { x: number };
const foo = new Updux({
initialState: {
x: 1,
},
selectors: {
getX: ({ x }: State) => x,
},
subduxes: {
bar: new Updux({
initialState: { y: 2 },
selectors: {
getY: ({ y }: { y: number }) => y,
getYPlus:
({ y }) =>
(incr: number) =>
(y + incr) as number,
},
}),
},
});
const sample = {
x: 4,
bar: { y: 3 },
};
expect(foo.selectors.getY(sample)).toBe(3);
expect(foo.selectors.getX(sample)).toBe(4);
expect(foo.selectors.getYPlus(sample)(3)).toBe(6);
const store = foo.createStore();
expect(store.getState.getY()).toBe(2);
expect(store.getState.getYPlus(3)).toBe(5);
});

View File

@ -9,7 +9,7 @@ const thingReactionSnitch = vi.fn();
const subThing = new Updux({
name: 'subThing',
initial: 0,
initialState: 0,
});
subThing.addReaction((api) => (state, previousState, unsubscribe) => {
@ -18,7 +18,7 @@ subThing.addReaction((api) => (state, previousState, unsubscribe) => {
const thing = new Updux({
name: 'thing',
initial: {},
initialState: {},
subduxes: {
'*': subThing,
},
@ -41,11 +41,11 @@ const things = new Updux({
subduxes: {
'*': thing,
},
initial: {},
initialState: {},
actions: { newThing: (id) => id },
splatReactionMapper: ({ id }) => id,
mutations: {
newThing: (id) => (state) => ({ ...state, [id]: thing.initial }),
newThing: (id) => (state) => ({ ...state, [id]: thing.initialState }),
},
});

61
src/tutorial.test.ts Normal file
View File

@ -0,0 +1,61 @@
import Updux, { createAction, withPayload } from './index.js';
import u from '@yanick/updeep-remeda';
export const expectType = <T>(value: T) => value;
test('initialState state', () => {
const initialState = {
next_id: 1,
todos: [],
};
const dux = new Updux({
initialState,
});
expectType<{
next_id: number;
todos: unknown[];
}>(dux.initialState);
expect(dux.initialState).toEqual(initialState);
const store = dux.createStore();
expect(store.getState()).toEqual(initialState);
});
test('actions', () => {
const addTodo = createAction('addTodo', withPayload<string>());
const todoDone = createAction('todoDone');
const todosDux = new Updux({
actions: {
addTodo,
todoDone,
},
});
expect(todosDux.actions.addTodo('write tutorial')).toEqual({
type: 'addTodo',
payload: 'write tutorial',
});
});
test('mutation', () => {
const addTodo = createAction('addTodo', withPayload<string>());
type Todo = {
description: string;
id: number;
done: boolean;
};
const dux = new Updux({
initialState: { nextId: 0, todos: [] as Todo[] },
});
dux.addMutation(addTodo, (description) => (state) => {
state.todos.unshift({ description, id: state.nextId, done: false });
state.nextId++;
});
});

46
src/types.ts Normal file
View File

@ -0,0 +1,46 @@
import { Action, ActionCreator, Middleware, Reducer } from 'redux';
export type Dux<
STATE = any,
ACTIONS extends Record<string, ActionCreator<string>> = {},
> = Partial<{
initialState: STATE;
actions: ACTIONS;
selectors: Record<string, (state: STATE) => any>;
reducer: (
state: STATE,
action: ReturnType<ACTIONS[keyof ACTIONS]>,
) => STATE;
effects: Middleware[];
reactions: ((...args: any[]) => void)[];
}>;
type ActionsOf<DUX> = DUX extends { actions: infer A } ? A : {};
type SelectorsOf<DUX> = DUX extends { selectors: infer A } ? A : {};
export type AggregateActions<A, S> = UnionToIntersection<
ActionsOf<S[keyof S]> | A
>;
type BaseSelector<F extends (...args: any) => any, STATE> = (
state: STATE,
) => ReturnType<F>;
type BaseSelectors<S extends Record<string, any>, STATE> = {
[key in keyof S]: BaseSelector<S[key], STATE>;
};
export type AggregateSelectors<
S extends Record<string, (...args: any) => any>,
SUBS extends Record<string, Dux>,
STATE = {},
> = BaseSelectors<
UnionToIntersection<SelectorsOf<SUBS[keyof SUBS]> | S>,
STATE
>;
export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;

View File

@ -1,6 +0,0 @@
export const matches = (conditions) => (target) =>
Object.entries(conditions).every(([key, value]) =>
typeof value === 'function'
? value(target[key])
: target[key] === value,
);

105
tsconfig.json Normal file
View File

@ -0,0 +1,105 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "nodenext" /* Specify what module code is generated. */,
"rootDir": "./src" /* Specify the root folder within your source files. */,
"moduleResolution": "nodenext" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": [
"vitest/globals"
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": false /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

8
vitest.config.js Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
isolate: false,
},
});