@equinor/fusion-observable
Version:
291 lines (260 loc) • 12.5 kB
text/typescript
import { produce as createNextState, isDraft, isDraftable } from 'immer';
import type { Draft } from 'immer';
import type { TypeGuard } from './types/ts-helpers';
import type { Action, ActionType, AnyAction, ExtractAction } from './types/actions';
import type { ReducerWithInitialState } from './types/reducers';
function freezeDraftable<T>(val: T) {
// biome-ignore lint/suspicious/noEmptyBlockStatements: This is a valid use case for an empty block statement
return isDraftable(val) ? createNextState(val, () => {}) : val;
}
// biome-ignore lint/complexity/noBannedTypes: This is a valid use case for an empty object type
type NotFunction<T = unknown> = T extends Function ? never : T;
function isStateFunction<S>(x: unknown): x is () => S {
return typeof x === 'function';
}
export type ActionMatcherDescription<S, A extends AnyAction> = {
matcher: TypeGuard<A> | ((action: A) => boolean);
reducer: CaseReducer<S, NoInfer<A>>;
};
// biome-ignore lint/suspicious/noExplicitAny: This is a valid use case for any
type ActionMatcherDescriptionCollection<S> = Array<ActionMatcherDescription<S, any>>;
interface TypedActionCreator<Type extends string> {
// biome-ignore lint/suspicious/noExplicitAny: This is a valid use case for any
(...args: any[]): Action<Type>;
type: Type;
}
type CaseReducer<S = unknown, A extends Action = AnyAction> = (
state: Draft<S>,
action: A,
// biome-ignore lint/suspicious/noConfusingVoidType: This is a valid use case for void
) => S | void | Draft<S>;
type CaseReducers<S, AS extends Record<string, Action>> = {
// biome-ignore lint/suspicious/noConfusingVoidType: This is a valid use case for void
[T in keyof AS]: AS[T] extends Action ? CaseReducer<S, AS[T]> : void;
};
/**
* A utility function that allows defining a reducer as a mapping from action
* type to *case reducer* functions that handle these action types. The
* reducer's initial state is passed as the first argument.
*
* @remarks
* The body of every case reducer is implicitly wrapped with a call to
* `produce()` from the [immer](https://github.com/mweststrate/immer) library.
* This means that rather than returning a new state object, you can also
* mutate the passed-in state object directly; these mutations will then be
* automatically and efficiently translated into copies, giving you both
* convenience and immutability.
*
* @overloadSummary
* This overload accepts a callback function that receives a `builder` object as its argument.
* That builder provides `addCase`, `addMatcher` and `addDefaultCase` functions that may be
* called to define what actions this reducer will handle.
*
* @param initialState - `State | (() => State)`: The initial state that should be used when the reducer is called the first time. This may also be a "lazy initializer" function, which should return an initial state value when called. This will be used whenever the reducer is called with `undefined` as its state value, and is primarily useful for cases like reading initial state from `localStorage`.
* @param builderCallback - `(builder: Builder) => void` A callback that receives a *builder* object to define
* case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`.
*
* @public
*/
export function createReducer<S extends NotFunction, A extends Action = AnyAction>(
initialState: S | (() => S),
builderCallback: (builder: ActionReducerMapBuilder<S, A>) => void,
): ReducerWithInitialState<S, A>;
export function createReducer<S extends NotFunction, A extends Action = AnyAction>(
initialState: S | (() => S),
mapOrBuilderCallback: (builder: ActionReducerMapBuilder<S, A>) => void,
): ReducerWithInitialState<S, A> {
const [actionsMap, finalActionMatchers, finalDefaultCaseReducer] =
executeReducerBuilderCallback(mapOrBuilderCallback);
// Ensure the initial state gets frozen either way (if draftable)
let getInitialState: () => S;
if (isStateFunction(initialState)) {
getInitialState = () => freezeDraftable(initialState());
} else {
const frozenInitialState = freezeDraftable(initialState);
getInitialState = () => frozenInitialState;
}
function reducer(state: S, action: A): S {
let caseReducers = [
actionsMap[action.type],
...finalActionMatchers.filter(({ matcher }) => matcher(action)).map(({ reducer }) => reducer),
];
if (finalDefaultCaseReducer && caseReducers.filter((cr) => !!cr).length === 0) {
caseReducers = [finalDefaultCaseReducer];
}
return caseReducers.reduce((previousState, caseReducer): S => {
if (caseReducer) {
if (isDraft(previousState)) {
// If it's already a draft, we must already be inside a `createNextState` call,
// likely because this is being wrapped in `createReducer`, `createSlice`, or nested
// inside an existing draft. It's safe to just pass the draft to the mutator.
const draft = previousState as Draft<S>; // We can assume this is already a draft
const result = caseReducer(draft, action);
if (result === undefined) {
return previousState;
}
return result as S;
} else if (!isDraftable(previousState)) {
// If state is not draftable (ex: a primitive, such as 0), we want to directly
// return the caseReducer func and not wrap it with produce.
const result = caseReducer(previousState as unknown as Draft<S>, action);
if (result === undefined) {
if (previousState === null) {
return previousState;
}
throw Error('A case reducer on a non-draftable value must not return undefined');
}
return result as S;
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// createNextState() produces an Immutable<Draft<S>> rather
// than an Immutable<S>, and TypeScript cannot find out how to reconcile
// these two types.
return createNextState(previousState, (draft: Draft<S>) => {
return caseReducer(draft, action);
});
}
}
return previousState;
}, state ?? getInitialState());
}
reducer.getInitialState = getInitialState;
return reducer as ReducerWithInitialState<S, A>;
}
/**
* A builder for an action <-> reducer map.
*
* @public
*/
export interface ActionReducerMapBuilder<State, Actions extends AnyAction = AnyAction> {
/**
* Adds a case reducer to handle a single exact action type.
* @remarks
* All calls to `builder.addCase` must come before any calls to `builder.addMatcher` or `builder.addDefaultCase`.
* @param actionCreator - Either a plain action type string, or an action creator generated by [`createAction`](./createAction) that can be used to determine the action type.
* @param reducer - The actual case reducer function.
*/
addCase<ActionCreator extends TypedActionCreator<ActionType<Actions>>>(
actionCreator: ActionCreator,
reducer: CaseReducer<State, ReturnType<ActionCreator>>,
): ActionReducerMapBuilder<State, Actions>;
/**
* Adds a case reducer to handle a single exact action type.
* @remarks
* All calls to `builder.addCase` must come before any calls to `builder.addMatcher` or `builder.addDefaultCase`.
* @param actionCreator - Either a plain action type string, or an action creator generated by [`createAction`](./createAction) that can be used to determine the action type.
* @param reducer - The actual case reducer function.
*/
addCase<Type extends Actions['type']>(
type: Type,
reducer: CaseReducer<State, ExtractAction<Actions, Type>>,
): ActionReducerMapBuilder<State, Actions>;
// /**
// * Adds a case reducer to handle a single exact action type.
// * @remarks
// * All calls to `builder.addCase` must come before any calls to `builder.addMatcher` or `builder.addDefaultCase`.
// * @param actionCreator - Either a plain action type string, or an action creator generated by [`createAction`](./createAction) that can be used to determine the action type.
// * @param reducer - The actual case reducer function.
// */
// addCase(
// type: string,
// reducer: CaseReducer<State, Actions>,
// ): ActionReducerMapBuilder<State, Actions>;
/**
* Allows you to match your incoming actions against your own filter function instead of only the `action.type` property.
* @remarks
* If multiple matcher reducers match, all of them will be executed in the order
* they were defined in - even if a case reducer already matched.
* All calls to `builder.addMatcher` must come after any calls to `builder.addCase` and before any calls to `builder.addDefaultCase`.
* @param matcher - A matcher function. In TypeScript, this should be a [type predicate](https://www.typescriptlang.org/docs/handbook/advanced-types.html#using-type-predicates)
* function
* @param reducer - The actual case reducer function.
*
*/
addMatcher<TAction extends Actions = Actions>(
matcher: (action: Actions) => action is TAction,
reducer: CaseReducer<State, TAction extends AnyAction ? TAction : TAction & AnyAction>,
): Omit<ActionReducerMapBuilder<State, Actions>, 'addCase'>;
addMatcher<TAction extends Actions = Actions>(
matcher: (action: Actions) => boolean,
reducer: CaseReducer<State, TAction extends AnyAction ? TAction : TAction & AnyAction>,
): Omit<ActionReducerMapBuilder<State, Actions>, 'addCase'>;
/**
* Adds a "default case" reducer that is executed if no case reducer and no matcher
* reducer was executed for this action.
* @param reducer - The fallback "default case" reducer function.
*/
addDefaultCase(reducer: CaseReducer<State, AnyAction>): void;
}
export function executeReducerBuilderCallback<TState, TAction extends AnyAction>(
builderCallback: (builder: ActionReducerMapBuilder<TState, TAction>) => void,
): [
CaseReducers<TState, Record<string, TAction>>,
ActionMatcherDescriptionCollection<TState>,
CaseReducer<TState, TAction> | undefined,
] {
const actionsMap: Record<string, CaseReducer<TState, TAction>> = {};
const actionMatchers: ActionMatcherDescriptionCollection<TState> = [];
let defaultCaseReducer: CaseReducer<TState, TAction> | undefined;
const builder: ActionReducerMapBuilder<TState> = {
addCase(
typeOrActionCreator: string | TypedActionCreator<string>,
reducer: CaseReducer<TState, Action>,
) {
if (process.env.NODE_ENV !== 'production') {
/*
* to keep the definition by the user in line with actual behavior,
* we enforce `addCase` to always be called before calling `addMatcher`
* as matching cases take precedence over matchers
*/
if (actionMatchers.length > 0) {
throw new Error(
'`builder.addCase` should only be called before calling `builder.addMatcher`',
);
}
if (defaultCaseReducer) {
throw new Error(
'`builder.addCase` should only be called before calling `builder.addDefaultCase`',
);
}
}
const type =
typeof typeOrActionCreator === 'string' ? typeOrActionCreator : typeOrActionCreator.type;
if (type in actionsMap) {
throw new Error('addCase cannot be called with two reducers for the same action type');
}
actionsMap[type] = reducer as CaseReducer<TState, TAction>;
return builder;
},
addMatcher<A>(
matcher: TypeGuard<A>,
reducer: CaseReducer<TState, A extends AnyAction ? A : TAction>,
) {
if (process.env.NODE_ENV !== 'production') {
if (defaultCaseReducer) {
throw new Error(
'`builder.addMatcher` should only be called before calling `builder.addDefaultCase`',
);
}
}
actionMatchers.push({ matcher, reducer });
return builder;
},
addDefaultCase(reducer: CaseReducer<TState, AnyAction>) {
if (process.env.NODE_ENV !== 'production') {
if (defaultCaseReducer) {
throw new Error('`builder.addDefaultCase` can only be called once');
}
}
defaultCaseReducer = reducer;
return builder;
},
};
builderCallback(builder as ActionReducerMapBuilder<TState, TAction>);
return [
actionsMap as CaseReducers<TState, Record<string, TAction>>,
actionMatchers,
defaultCaseReducer,
];
}