Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redux persistent middleware #680

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 80 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@reduxjs/toolkit": "^2.2.5",
"@testing-library/react": "^14.1.2",
"@types/jest": "^29.5.11",
"@types/lodash": "^4.17.7",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.12.0",
Expand All @@ -61,12 +62,14 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^14.0.1",
"lodash": "^4.17.21",
"mui-datatables": "^4.3.0",
"notistack": "^3.0.1",
"prettier": "^3.0.3",
"prettier-plugin-organize-imports": "^3.2.3",
"react-error-boundary": "^4.0.12",
"react-markdown": "^8.0.7",
"react-redux": "^8.1.3",
"rehype-raw": "^6.1.1",
"remark-gfm": "^3.0.1",
"ts-jest": "^29.1.1",
Expand Down
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export * from './base';
export * from './colors';
export * from './custom';
export * from './icons';
export * from './redux-persist';
export * from './schemas';
export * from './theme';
181 changes: 181 additions & 0 deletions src/redux-persist/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sudhanshutech dont skip ts checks

Copy link
Member

@leecalcote leecalcote Aug 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sudhanshutech does this work? Able to incorporate this feedback?


import _ from 'lodash';
import { FC, ReactNode, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Dispatch } from 'redux';

const INVALID_REDUCER_PATH = Symbol('INVALID_REDUCER_PATH');
const REHYDRATE_STATE_ACTION = 'REHYDRATE_STATE_ACTION';

// Define types for the actions
interface RehydrateStateAction {
type: typeof REHYDRATE_STATE_ACTION;
payload: {
reducerPath: string;
inflatedState: unknown;
};
}

type Action = RehydrateStateAction | { type: string; [key: string]: unknown };

// Define the shape of the actionsToPersist object
interface ActionsToPersist {
[actionType: string]: string[];
}

/**
* Creates an action to rehydrate state.
*
* @param {string} reducerPath - The path of the reducer to rehydrate.
* @param {unknown} inflatedState - The state to rehydrate with.
* @returns {RehydrateStateAction} An action object for rehydrating state.
*/
const rehydrateState = (reducerPath: string, inflatedState: unknown): RehydrateStateAction => {
return {
type: REHYDRATE_STATE_ACTION,
payload: {
reducerPath,
inflatedState
}
};
};

/**
* Reducer to handle state rehydration during Redux store updates.
* This reducer intercepts a specific action type to rehydrate the state of specified reducers.
*
* @param {unknown} state - The current state.
* @param {Action} action - The dispatched action.
* @returns {unknown} The new state after rehydration.
*/
const rehydrateStateReducer = (state: unknown, action: Action): unknown => {
if (action.type === REHYDRATE_STATE_ACTION) {
const appState = _.cloneDeep(state);
_.set(
appState as object,
(action.payload as RehydrateStateAction['payload']).reducerPath.split('/'),
(action.payload as RehydrateStateAction['payload']).inflatedState
);
return appState;
}
return state;
};

/**
* Initializes Redux persistence with given actions to persist on
*
* @param {ActionsToPersist} actionsToPersist - An object mapping action types to arrays of reducer paths.
* Each action type is associated with an array of reducer paths whose state should be persisted.
* @returns {Object} An object containing Redux persistence functions.
*/
export const initReduxPersist = (actionsToPersist: ActionsToPersist) => {
/**
* Creates a new reducer with enhanced state rehydration logic for Redux persistence.
* This function returns a new reducer that first rehydrates the state using the
* rehydrateStateReducer, and then applies the original reducer to the rehydrated state.
*
* @param {function} reducer - The original reducer function to enhance.
* @returns {function} A new enhanced reducer function with added state rehydration.
*/
const createPersistEnhancedReducer =
(reducer: (state: unknown, action: Action) => unknown) =>
(state: unknown, action: Action): unknown => {
const newState = rehydrateStateReducer(state, action);
return reducer(newState, action);
};

/**
* Redux middleware to persist state to local storage based on dispatched actions.
* This middleware listens for specific actions and saves the relevant state to local storage.
*
* @param {any} store - The Redux store.
* @returns {Function} A middleware function.
*/
const persistMiddleware =
(store: any) =>
(next: (action: Action) => unknown) =>
(action: Action): unknown => {
const result = next(action);

const reducersToPersist = actionsToPersist[action.type];

if (reducersToPersist) {
const appState = store.getState();
reducersToPersist.forEach((reducerPath) => {
const path = reducerPath.split('/');
const stateToPersist = _.get(appState, path, INVALID_REDUCER_PATH);

if (stateToPersist === INVALID_REDUCER_PATH) {
throw new Error(`Reducer Path to Persist Is Invalid: ${reducerPath}`);
}

localStorage.setItem(reducerPath, JSON.stringify(stateToPersist));
});
}
return result;
};

/**
* Action creator to load persisted state from local storage during Redux store initialization.
* This function retrieves previously saved state from local storage and dispatches rehydration actions.
*
* @returns {Function} A thunk function.
*/
const loadPersistedState = () => (dispatch: Dispatch) => {
Object.values(actionsToPersist).forEach((reducerPaths) => {
reducerPaths.forEach((path) => {
let inflatedState = localStorage.getItem(path);
try {
if (inflatedState) {
inflatedState = JSON.parse(inflatedState);
dispatch(rehydrateState(path, inflatedState));
}
} catch (e) {
console.error(`Error rehydrating state for reducer ${path}`, inflatedState);
}
});
});
};

return {
persistMiddleware,
createPersistEnhancedReducer,
loadPersistedState
};
};

// Define types for PersistedStateProvider props
interface PersistedStateProviderProps {
children: ReactNode;
loadPersistedState: () => (dispatch: Dispatch) => void;
}

export const PersistedStateProvider: FC<PersistedStateProviderProps> = ({
children,
loadPersistedState
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const dispatch = useDispatch();

useEffect(() => {
if (!loading) {
return;
}
try {
dispatch(loadPersistedState() as any);
} catch (e) {
setError(e as Error);
}
setLoading(false);
}, [loading, dispatch, loadPersistedState]);

error && console.error('Error Loading Persisted State', error);

if (loading) {
return null;
}

return <>{children}</>;
};
Loading