├── logo.png ├── .gitignore ├── src ├── action-types.ts ├── __tests__ │ ├── action-types.test.ts │ ├── errors.test.ts │ ├── utils.test.ts │ ├── init.test.ts │ ├── index.test.ts │ ├── rehydrate.test.ts │ ├── is-deep-equal.test.ts │ └── persist.test.ts ├── types.ts ├── errors.ts ├── init.ts ├── persist.ts ├── utils.ts ├── rehydrate.ts ├── is-deep-equal.ts └── index.ts ├── .editorconfig ├── demo-web ├── src │ ├── RehydrateGate.tsx │ ├── store │ │ ├── slices │ │ │ ├── forgotten-slice.ts │ │ │ ├── persisted-slice.ts │ │ │ ├── index.ts │ │ │ └── redux-remember-slice.ts │ │ └── index.ts │ ├── index.tsx │ └── App.tsx ├── tsconfig.json ├── package.json ├── index.html └── rollup.config.ts ├── tsconfig.json ├── .github └── workflows │ ├── test.yml │ └── build.yml ├── LICENSE ├── eslint.config.mts ├── babel.config.mts ├── LEGACY-USAGE.md ├── package.json └── README.md /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zewish/redux-remember/HEAD/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | .DS_Store 5 | demo-web/bundle.js 6 | demo-web/bundle.js.map 7 | -------------------------------------------------------------------------------- /src/action-types.ts: -------------------------------------------------------------------------------- 1 | export const REMEMBER_REHYDRATED = '@@REMEMBER_REHYDRATED'; 2 | export const REMEMBER_PERSISTED = '@@REMEMBER_PERSISTED'; 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | 7 | indent_style = space 8 | indent_size = 2 9 | 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /src/__tests__/action-types.test.ts: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../action-types'; 2 | 3 | describe('action-types.ts', () => { 4 | it('exports proper items', () => { 5 | expect(actionTypes).toEqual({ 6 | REMEMBER_REHYDRATED: '@@REMEMBER_REHYDRATED', 7 | REMEMBER_PERSISTED: '@@REMEMBER_PERSISTED' 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /demo-web/src/RehydrateGate.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from 'react'; 2 | import { useAppSelector } from './store'; 3 | 4 | const RehydrateGate: FC = ({ children }) => { 5 | const isRehydrated = useAppSelector((state) => state.reduxRemember.isRehyrdated); 6 | return isRehydrated 7 | ? children 8 | :
Rehydrating, please wait...
; 9 | }; 10 | 11 | export default RehydrateGate; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2024", 4 | "module": "es2022", 5 | "moduleResolution": "bundler", 6 | "checkJs": true, 7 | "lib": ["dom"], 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "forceConsistentCasingInFileNames": true 14 | }, 15 | "include": ["src"] 16 | } 17 | -------------------------------------------------------------------------------- /demo-web/src/store/slices/forgotten-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | const forgottenTextSlice = createSlice({ 4 | name: 'forgotten-text', 5 | initialState: { 6 | text: '' 7 | }, 8 | reducers: { 9 | setForgottenText(state, action: PayloadAction) { 10 | state.text = action.payload; 11 | } 12 | } 13 | }); 14 | 15 | export default forgottenTextSlice; 16 | -------------------------------------------------------------------------------- /demo-web/src/store/slices/persisted-slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | const persistedTextSlice = createSlice({ 4 | name: 'persisted-text', 5 | initialState: { 6 | text: '' 7 | }, 8 | reducers: { 9 | setPersistedText(state, action: PayloadAction) { 10 | state.text = action.payload; 11 | } 12 | } 13 | }); 14 | 15 | export default persistedTextSlice; 16 | -------------------------------------------------------------------------------- /demo-web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import { Provider } from 'react-redux'; 3 | import store from './store'; 4 | import App from './App'; 5 | import RehydrateGate from './RehydrateGate'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root')! 9 | ); 10 | 11 | root.render( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /demo-web/src/store/slices/index.ts: -------------------------------------------------------------------------------- 1 | import persistedSlice from './persisted-slice'; 2 | import forgottenSlice from './forgotten-slice'; 3 | import reduxRememberSlice from './redux-remember-slice'; 4 | 5 | export const reducers = { 6 | persisted: persistedSlice.reducer, 7 | forgotten: forgottenSlice.reducer, 8 | reduxRemember: reduxRememberSlice.reducer 9 | }; 10 | 11 | export const actions = { 12 | ...persistedSlice.actions, 13 | ...forgottenSlice.actions, 14 | ...reduxRememberSlice.actions, 15 | }; 16 | -------------------------------------------------------------------------------- /demo-web/src/store/slices/redux-remember-slice.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createSlice } from '@reduxjs/toolkit'; 2 | import { REMEMBER_REHYDRATED } from 'redux-remember'; 3 | 4 | const reduxRememberSlice = createSlice({ 5 | name: 'redux-remember', 6 | initialState: { 7 | isRehyrdated: false 8 | }, 9 | reducers: {}, 10 | extraReducers: (builder) => builder 11 | .addCase(createAction(REMEMBER_REHYDRATED), (state) => { 12 | state.isRehyrdated = true; 13 | }) 14 | }); 15 | 16 | export default reduxRememberSlice; 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [pull_request] 3 | concurrency: 4 | group: "test" 5 | cancel-in-progress: false 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Setup node 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: lts/* 16 | check-latest: true 17 | - name: Build and test 18 | run: | 19 | npm ci 20 | npm run prepare 21 | - name: Check coverage 22 | uses: coverallsapp/github-action@master 23 | with: 24 | github-token: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /demo-web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "moduleResolution": "bundler", 6 | "lib": ["es2022", "dom", "dom.iterable"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": false, 13 | "noFallthroughCasesInSwitch": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": ".", 19 | "noImplicitAny": true, 20 | "downlevelIteration": true 21 | }, 22 | "include": ["src", "rollup.config.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { PersistError, RehydrateError } from './errors'; 2 | 3 | export type SerializeFunction = (data: any, key: string) => any; 4 | export type UnserializeFunction = (data: any, key: string) => any; 5 | 6 | export type Driver = { 7 | getItem: (key: string) => any; 8 | setItem: (key: string, value: any) => any; 9 | }; 10 | 11 | export type Options = { 12 | prefix: string, 13 | serialize: SerializeFunction, 14 | unserialize: UnserializeFunction, 15 | persistThrottle: number, 16 | persistDebounce?: number, 17 | persistWholeStore: boolean, 18 | errorHandler: (error: PersistError | RehydrateError) => void; 19 | initActionType?: string 20 | }; 21 | 22 | export type ExtendedOptions = Options & { 23 | driver: Driver 24 | }; 25 | -------------------------------------------------------------------------------- /demo-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-web", 3 | "version": "5.3.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "rollup -c ./rollup.config.ts --configPlugin typescript" 8 | }, 9 | "private": true, 10 | "author": "Iskren Slavov ", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "@reduxjs/toolkit": "^2.9.0", 14 | "@rollup/plugin-commonjs": "^28.0.6", 15 | "@rollup/plugin-node-resolve": "^16.0.1", 16 | "@rollup/plugin-replace": "^6.0.2", 17 | "@rollup/plugin-terser": "^0.4.4", 18 | "@rollup/plugin-typescript": "^12.1.4", 19 | "@types/react-dom": "^19.1.9", 20 | "react": "^19.1.1", 21 | "react-dom": "^19.1.1", 22 | "react-redux": "^9.2.0", 23 | "redux": "^5.0.1", 24 | "redux-remember": "../", 25 | "rollup": "^4.50.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | class CustomError extends Error { 2 | originalError?: Error; 3 | 4 | constructor(originalError: unknown) { 5 | const isOrigErrorValid = originalError instanceof Error; 6 | const prevStackLines = isOrigErrorValid 7 | ? originalError.stack?.split('\n') 8 | : []; 9 | 10 | super(isOrigErrorValid 11 | ? `${originalError.name}: ${originalError.message}` 12 | : ''); 13 | 14 | this.name = this.constructor.name; 15 | if (isOrigErrorValid) { 16 | this.originalError = originalError; 17 | } 18 | 19 | if (prevStackLines?.length && this.stack) { 20 | this.stack = this.stack 21 | .split('\n') 22 | .slice(0, 2) 23 | .concat( 24 | prevStackLines.slice(1, prevStackLines.length) 25 | ) 26 | .join('\n'); 27 | } 28 | } 29 | } 30 | 31 | export class PersistError extends CustomError {} 32 | export class RehydrateError extends CustomError {} 33 | -------------------------------------------------------------------------------- /demo-web/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import { reducers, actions } from './slices'; 3 | import { rememberReducer, rememberEnhancer } from 'redux-remember'; 4 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; 5 | 6 | const rememberedKeys: Array = [ 'persisted' ]; 7 | 8 | const store = configureStore({ 9 | reducer: rememberReducer(reducers), 10 | enhancers: (getDefaultEnhancers) => getDefaultEnhancers().concat( 11 | rememberEnhancer( 12 | window.localStorage, 13 | rememberedKeys, 14 | { 15 | persistWholeStore: true 16 | } 17 | ) 18 | ) 19 | }); 20 | 21 | export type RootState = ReturnType; 22 | export type AppDispatch = typeof store.dispatch; 23 | 24 | export const useAppDispatch: () => AppDispatch = useDispatch; 25 | export const useAppSelector: TypedUseSelectorHook = useSelector; 26 | 27 | export { actions }; 28 | export default store; 29 | -------------------------------------------------------------------------------- /demo-web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Redux Remember demo (web) 6 | 47 | 48 | 49 |
50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present, Iskren Slavov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /eslint.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'eslint/config'; 2 | import eslint from '@eslint/js'; 3 | import tseslint from 'typescript-eslint'; 4 | 5 | export default defineConfig([ 6 | { 7 | extends: [ 8 | eslint.configs.recommended, 9 | tseslint.configs.recommendedTypeChecked 10 | ], 11 | plugins: { 12 | '@typescript-eslint': tseslint.plugin, 13 | }, 14 | languageOptions: { 15 | parser: tseslint.parser, 16 | parserOptions: { 17 | projectService: true, 18 | tsconfigRootDir: import.meta.dirname, 19 | }, 20 | }, 21 | ignores: ['**/dist/**'], 22 | files: ['**/*.{js,ts,tsx}'], 23 | rules: { 24 | eqeqeq: 'error', 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | 'no-prototype-builtins': 'off', 27 | '@typescript-eslint/no-empty-object-type': 'off', 28 | '@typescript-eslint/no-unsafe-return': 'off', 29 | '@typescript-eslint/no-unsafe-assignment': 'off', 30 | '@typescript-eslint/no-unsafe-argument': 'off', 31 | '@typescript-eslint/no-unsafe-call': 'off', 32 | '@typescript-eslint/no-misused-promises': 'off', 33 | '@typescript-eslint/no-unsafe-member-access': 'off', 34 | '@typescript-eslint/no-for-in-array': 'off', 35 | }, 36 | } 37 | ]); 38 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | concurrency: 11 | group: "build" 12 | cancel-in-progress: true 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | - name: Setup node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: lts/* 23 | check-latest: true 24 | - name: Build and test 25 | run: | 26 | npm ci 27 | npm run prepare 28 | - name: Check coverage 29 | uses: coverallsapp/github-action@master 30 | with: 31 | github-token: ${{ secrets.GITHUB_TOKEN }} 32 | - name: Setup pages 33 | uses: actions/configure-pages@v5 34 | - name: Build pages 35 | run: | 36 | cd ./demo-web/ 37 | npm ci 38 | npm run build 39 | cd .. 40 | mkdir -p ./pages/demo-web/ 41 | cp -f ./demo-web/index.html ./demo-web/bundle.js ./pages/demo-web/ 42 | - name: Upload page artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: './pages/' 46 | - name: Deploy demo to pages 47 | id: deployment 48 | uses: actions/deploy-pages@v4 49 | -------------------------------------------------------------------------------- /demo-web/rollup.config.ts: -------------------------------------------------------------------------------- 1 | import ts from '@rollup/plugin-typescript'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import replace from '@rollup/plugin-replace'; 5 | import terser from '@rollup/plugin-terser'; 6 | import process from 'node:process'; 7 | import { RollupOptions } from 'rollup'; 8 | 9 | process.env.NODE_ENV = 'development'; 10 | 11 | const config: RollupOptions = { 12 | input: './src/index.tsx', 13 | context: 'window', 14 | output: { 15 | file: `./bundle.js`, 16 | sourcemap: true, 17 | format: 'iife' 18 | }, 19 | plugins: [ 20 | resolve({ 21 | mainFields: [ 22 | 'module', 23 | 'jsnext:main', 24 | 'main' 25 | ] 26 | }), 27 | commonjs(), 28 | ts({ 29 | tsconfig: './tsconfig.json', 30 | sourceMap: true 31 | }), 32 | replace({ 33 | preventAssignment: true, 34 | 'process.env.NODE_ENV': `'${process.env.NODE_ENV}'` 35 | }), 36 | terser({ 37 | parse: { 38 | ecma: 2020 39 | }, 40 | compress: { 41 | ecma: 5, 42 | comparisons: false, 43 | inline: 2 44 | }, 45 | mangle: { 46 | safari10: true 47 | }, 48 | keep_classnames: false, 49 | keep_fnames: false, 50 | ie8: false, 51 | output: { 52 | ecma: 5, 53 | comments: false, 54 | ascii_only: true 55 | } 56 | }) 57 | ] 58 | }; 59 | 60 | export default config; 61 | -------------------------------------------------------------------------------- /babel.config.mts: -------------------------------------------------------------------------------- 1 | import { type Options } from '@babel/preset-env'; 2 | import { type TransformOptions } from '@babel/core'; 3 | import { createRequire } from 'node:module'; 4 | 5 | const require = createRequire(import.meta.filename); 6 | 7 | const browsers = [ 8 | '>1%', 9 | 'last 4 versions', 10 | 'Firefox ESR' 11 | ]; 12 | 13 | const getPresets = ({ 14 | modules, 15 | targets = { browsers } 16 | }: Partial = {}) => [ 17 | require.resolve('@babel/preset-typescript'), 18 | [require.resolve('@babel/preset-env'), { 19 | modules, 20 | targets, 21 | loose: true, 22 | exclude: ['@babel/plugin-transform-regenerator'] 23 | }] 24 | ]; 25 | 26 | const config: TransformOptions = { 27 | plugins: [ 28 | [require.resolve('@babel/plugin-proposal-object-rest-spread'), { 29 | useBuiltIns: true 30 | }] 31 | ], 32 | presets: getPresets(), 33 | env: { 34 | cjs: { 35 | presets: getPresets({ modules: 'commonjs' }), 36 | plugins: [ 37 | [require.resolve('babel-plugin-module-extension-resolver'), { 38 | dstExtension: '.cjs' 39 | }] 40 | ] 41 | }, 42 | mjs: { 43 | presets: getPresets({ modules: false }), 44 | plugins: [ 45 | [require.resolve('babel-plugin-module-extension-resolver'), { 46 | dstExtension: '.mjs' 47 | }] 48 | ] 49 | }, 50 | test: { 51 | presets: getPresets({ 52 | targets: { node: 'current' } 53 | }) 54 | } 55 | } 56 | }; 57 | 58 | export default config; 59 | -------------------------------------------------------------------------------- /src/init.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'redux'; 2 | import { ExtendedOptions } from './types'; 3 | import { rehydrate } from './rehydrate'; 4 | import { persist } from './persist'; 5 | import { REMEMBER_PERSISTED } from './action-types'; 6 | import { pick, throttle, debounce } from './utils'; 7 | import isDeepEqual from './is-deep-equal'; 8 | 9 | const init = async ( 10 | store: Store, 11 | rememberedKeys: string[], 12 | { 13 | prefix, 14 | driver, 15 | serialize, 16 | unserialize, 17 | persistThrottle, 18 | persistDebounce, 19 | persistWholeStore, 20 | errorHandler 21 | }: ExtendedOptions 22 | ) => { 23 | await rehydrate( 24 | store, 25 | rememberedKeys, 26 | { prefix, driver, unserialize, persistWholeStore, errorHandler } 27 | ); 28 | 29 | let oldState = {}; 30 | 31 | const persistFunc = async () => { 32 | const state = pick( 33 | store.getState(), 34 | rememberedKeys 35 | ); 36 | 37 | await persist( 38 | state, 39 | oldState, 40 | { prefix, driver, serialize, persistWholeStore, errorHandler } 41 | ); 42 | 43 | if (!isDeepEqual(state, oldState)) { 44 | store.dispatch({ 45 | type: REMEMBER_PERSISTED, 46 | payload: state 47 | }); 48 | } 49 | 50 | oldState = state; 51 | }; 52 | 53 | if (persistDebounce && persistDebounce > 0) { 54 | store.subscribe(debounce(persistFunc, persistDebounce)); 55 | } else { 56 | store.subscribe(throttle(persistFunc, persistThrottle)); 57 | } 58 | }; 59 | 60 | export default init; 61 | -------------------------------------------------------------------------------- /src/persist.ts: -------------------------------------------------------------------------------- 1 | import { PersistError } from './errors'; 2 | import isDeepEqual from './is-deep-equal'; 3 | import { ExtendedOptions } from './types'; 4 | 5 | type PersistOptions = Pick< 6 | ExtendedOptions, 7 | 'prefix' | 'driver' | 'serialize' | 'persistWholeStore' | 'errorHandler' 8 | > 9 | 10 | type SaveAllOptions = Pick< 11 | ExtendedOptions, 12 | 'prefix' | 'driver' | 'serialize' 13 | >; 14 | 15 | export const saveAll = ( 16 | state: any, 17 | oldState: any, 18 | { prefix, driver, serialize }: SaveAllOptions 19 | ) => { 20 | if (!isDeepEqual(state, oldState)) { 21 | return driver.setItem( 22 | `${prefix}rootState`, 23 | serialize(state, 'rootState') 24 | ); 25 | } 26 | }; 27 | 28 | export const saveAllKeyed = ( 29 | state: any, 30 | oldState: any, 31 | { prefix, driver, serialize }: SaveAllOptions 32 | ) => Promise.all( 33 | Object.keys(state).map((key) => { 34 | if (isDeepEqual(state[key], oldState[key])) { 35 | return Promise.resolve(); 36 | } 37 | 38 | return driver.setItem( 39 | `${prefix}${key}`, 40 | serialize(state[key], key) 41 | ); 42 | }) 43 | ); 44 | 45 | export const persist = async ( 46 | state = {}, 47 | oldState = {}, 48 | { 49 | prefix, 50 | driver, 51 | persistWholeStore, 52 | serialize, 53 | errorHandler 54 | }: PersistOptions 55 | ) => { 56 | try { 57 | const save = persistWholeStore 58 | ? saveAll 59 | : saveAllKeyed; 60 | 61 | await save( 62 | state, 63 | oldState, 64 | { prefix, driver, serialize } 65 | ); 66 | } catch (err) { 67 | errorHandler(new PersistError(err)); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /demo-web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { actions, useAppDispatch, useAppSelector } from './store'; 2 | 3 | const App = () => { 4 | const textToBePersisted = useAppSelector((store) => store.persisted.text); 5 | const textToBeForgotten = useAppSelector((store) => store.forgotten.text); 6 | const dispatch = useAppDispatch(); 7 | 8 | return ( 9 |
10 |
11 | redux-remember logo 15 |
16 | 17 |
18 |

redux-remember demo

19 |

Type something into the inputs and reload the page

20 |
21 | 22 |
23 |
24 | 25 |
26 | 27 |
28 | dispatch(actions.setPersistedText(ev.target.value))} 32 | /> 33 |
34 | 35 |
36 | 37 |
38 | 39 |
40 | dispatch(actions.setForgottenText(ev.target.value))} 44 | /> 45 |
46 |
47 | 48 |

49 | 54 | [ See demo source ] 55 | 56 |

57 |
58 | ); 59 | }; 60 | 61 | export default App; 62 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | type TimeoutHandle = ReturnType; 2 | 3 | export const pick = , K extends keyof T>( 4 | src: T | null | undefined, 5 | keys: K[] 6 | ): Partial => { 7 | if (src === null || typeof src !== 'object') { 8 | return {}; 9 | } 10 | 11 | let index = -1; 12 | const dest = {} as T; 13 | 14 | while (++index < keys.length) { 15 | const key = keys[index]; 16 | 17 | if (src.hasOwnProperty(key)) { 18 | dest[key] = src[key]; 19 | } 20 | } 21 | 22 | return dest; 23 | }; 24 | 25 | export const throttle = void>( 26 | callback: T, 27 | msecs: number 28 | ): T => { 29 | let nextCallNow = 0; 30 | let nextCallTimeout: TimeoutHandle | null; 31 | 32 | return ((...args: any): void => { 33 | const now = +new Date(); 34 | const timeLeft = (nextCallNow || now) - now; 35 | 36 | if (timeLeft <= 0 && !nextCallTimeout) { 37 | nextCallNow = now + msecs; 38 | callback(...args); 39 | return; 40 | } 41 | 42 | if (nextCallTimeout) { 43 | clearTimeout(nextCallTimeout); 44 | nextCallTimeout = null; 45 | } 46 | 47 | nextCallTimeout = setTimeout(() => { 48 | nextCallNow = +new Date() + msecs; 49 | callback(...args); 50 | 51 | clearTimeout(nextCallTimeout as TimeoutHandle); 52 | nextCallTimeout = null; 53 | }, timeLeft); 54 | }) as T; 55 | }; 56 | 57 | export const debounce = void>( 58 | callback: T, 59 | msecs: number 60 | ): T => { 61 | let nextCallTimeout: TimeoutHandle | null; 62 | 63 | return ((...args: any): void => { 64 | clearTimeout(nextCallTimeout as TimeoutHandle); 65 | 66 | nextCallTimeout = setTimeout(() => { 67 | callback(...args); 68 | nextCallTimeout = null; 69 | }, msecs); 70 | }) as T; 71 | }; 72 | -------------------------------------------------------------------------------- /src/rehydrate.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'redux'; 2 | import { REMEMBER_REHYDRATED } from './action-types'; 3 | import { ExtendedOptions } from './types'; 4 | import { pick } from './utils'; 5 | import { RehydrateError } from './errors'; 6 | 7 | type RehydrateOptions = Pick< 8 | ExtendedOptions, 9 | 'driver' | 'prefix' | 'unserialize' | 'persistWholeStore' | 'errorHandler' 10 | > 11 | 12 | type LoadAllOptions = Pick< 13 | ExtendedOptions, 14 | 'driver' | 'prefix' | 'unserialize' 15 | > & { 16 | rememberedKeys: string[] 17 | }; 18 | 19 | export const loadAll = async ({ 20 | rememberedKeys, 21 | driver, 22 | prefix, 23 | unserialize 24 | }: LoadAllOptions): Promise> => { 25 | const key = 'rootState'; 26 | const data = await driver.getItem(`${prefix}${key}`); 27 | 28 | if (data === null || data === undefined) { 29 | return {}; 30 | } 31 | 32 | return pick( 33 | unserialize(data, key), 34 | rememberedKeys 35 | ); 36 | }; 37 | 38 | export const loadAllKeyed = async ({ 39 | rememberedKeys, 40 | driver, 41 | prefix, 42 | unserialize 43 | }: LoadAllOptions): Promise> => { 44 | const items = await Promise.all( 45 | rememberedKeys.map((key) => driver.getItem( 46 | `${prefix}${key}` 47 | )) 48 | ); 49 | 50 | return rememberedKeys.reduce((obj: Record, key, i) => { 51 | if (items[i] !== null && items[i] !== undefined) { 52 | obj[key] = unserialize(items[i], key); 53 | } 54 | 55 | return obj; 56 | }, {}); 57 | }; 58 | 59 | export const rehydrate = async ( 60 | store: Store, 61 | rememberedKeys: string[], 62 | { 63 | prefix, 64 | driver, 65 | persistWholeStore, 66 | unserialize, 67 | errorHandler 68 | }: RehydrateOptions 69 | ) => { 70 | let state = store.getState(); 71 | 72 | try { 73 | const load = persistWholeStore 74 | ? loadAll 75 | : loadAllKeyed; 76 | 77 | state = { 78 | ...state, 79 | ...await load({ 80 | rememberedKeys, 81 | driver, 82 | prefix, 83 | unserialize 84 | }) 85 | }; 86 | } catch (err) { 87 | errorHandler(new RehydrateError(err)); 88 | } 89 | 90 | store.dispatch({ 91 | type: REMEMBER_REHYDRATED, 92 | payload: state 93 | }); 94 | }; 95 | -------------------------------------------------------------------------------- /src/__tests__/errors.test.ts: -------------------------------------------------------------------------------- 1 | import { PersistError, RehydrateError } from '../errors'; 2 | 3 | describe('errors.ts', () => { 4 | describe('PersistError', () => { 5 | it('extends Error', () => { 6 | const error = new Error('ERROR 1-0'); 7 | const instance = new PersistError(error); 8 | 9 | expect(instance).toBeInstanceOf(Error); 10 | expect(instance).toBeInstanceOf(PersistError); 11 | expect(instance.originalError).toEqual(error); 12 | expect(instance.message).toEqual(`${error.name}: ${error.message}`); 13 | }); 14 | 15 | it('copies the stack trace of wrapped Error', () => { 16 | const error = new Error('ERROR 1-1'); 17 | const errorStackLines = error.stack!.split('\n'); 18 | const errorStackOnly = errorStackLines 19 | .slice(1, errorStackLines.length) 20 | .join('\n'); 21 | 22 | const instance = new PersistError(error); 23 | const instanceStackLine1 = instance.stack!.split('\n')[1]; 24 | 25 | expect(instance.stack).toEqual( 26 | `PersistError: Error: ${error.message}\n` 27 | + `${instanceStackLine1}\n` 28 | + `${errorStackOnly}` 29 | ); 30 | }); 31 | 32 | it('does not break when an invalid error is wrapped', () => { 33 | expect(new PersistError({ invalid: 'error1' })).toBeInstanceOf(PersistError); 34 | }); 35 | }); 36 | 37 | describe('RehydrateError', () => { 38 | it('extends Error', () => { 39 | const error = new Error('ERROR 2-0'); 40 | const instance = new RehydrateError(error); 41 | 42 | expect(instance).toBeInstanceOf(Error); 43 | expect(instance).toBeInstanceOf(RehydrateError); 44 | expect(instance.originalError).toEqual(error); 45 | expect(instance.message).toEqual(`${error.name}: ${error.message}`); 46 | }); 47 | 48 | it('copies the stack trace of wrapped Error', () => { 49 | const error = new Error('ERROR 1-1'); 50 | const errorStackLines = error.stack!.split('\n'); 51 | const errorStackOnly = errorStackLines 52 | .slice(1, errorStackLines.length) 53 | .join('\n'); 54 | 55 | const instance = new RehydrateError(error); 56 | const instanceStackLine1 = instance.stack!.split('\n')[1]; 57 | 58 | expect(instance.stack).toEqual( 59 | `RehydrateError: Error: ${error.message}\n` 60 | + `${instanceStackLine1}\n` 61 | + `${errorStackOnly}` 62 | ); 63 | }); 64 | 65 | it('does not break when an invalid error is wrapped', () => { 66 | expect(new PersistError({ invalid: 'error2' })).toBeInstanceOf(PersistError); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/is-deep-equal.ts: -------------------------------------------------------------------------------- 1 | const getObjectType = (o: object): string | undefined => Object.prototype.toString.call(o).match( 2 | /\[object (.*)\]/ 3 | )?.[1]; 4 | 5 | const isTypedArray = (o: object): boolean => ( 6 | /^\[object (?:Float(?:32|64)|(?:Int|Uint)(?:8|16|32)|Uint8Clamped)Array\]$/ 7 | .test(Object.prototype.toString.call(o)) 8 | ); 9 | 10 | const isDeepEqual = (a: any, b: any): boolean => { 11 | if (a === b) { 12 | return true; 13 | } 14 | 15 | if (typeof a !== 'object' || typeof b !== 'object' 16 | || a === null || a === undefined || b === null || b === undefined 17 | ) { 18 | return a !== a && b !== b; 19 | } 20 | 21 | if (a.constructor !== b.constructor) { 22 | return false; 23 | } 24 | 25 | if (a.constructor === RegExp) { 26 | return a.source === b.source && a.flags === b.flags; 27 | } 28 | 29 | if (Array.isArray(a)) { 30 | if (a.length !== b.length) { 31 | return false; 32 | } 33 | 34 | for (let i = a.length; i-- !== 0;) { 35 | if (!isDeepEqual(a[i], b[i])) { 36 | return false; 37 | } 38 | } 39 | 40 | return true; 41 | } 42 | 43 | if (isTypedArray(a) && isTypedArray(b)) { 44 | if (a.byteLength !== b.byteLength) { 45 | return false; 46 | } 47 | 48 | for (let i = a.byteLength; i-- !== 0;) { 49 | if (!isDeepEqual(a[i], b[i])) { 50 | return false; 51 | } 52 | } 53 | 54 | return true; 55 | } 56 | 57 | const aType = getObjectType(a); 58 | const bType = getObjectType(b); 59 | 60 | if (aType === 'DataView' && bType === 'DataView') { 61 | if (a.byteLength !== b.byteLength || a.byteOffset !== b.byteOffset) { 62 | return false; 63 | } 64 | 65 | return isDeepEqual(a.buffer, b.buffer); 66 | } 67 | 68 | if (aType === 'ArrayBuffer' && bType === 'ArrayBuffer') { 69 | if (a.byteLength !== b.byteLength) { 70 | return false; 71 | } 72 | 73 | return isDeepEqual(new Uint8Array(a), new Uint8Array(b)); 74 | } 75 | 76 | if (aType === 'Map' && bType === 'Map') { 77 | if (a.size !== b.size) { 78 | return false; 79 | } 80 | 81 | for (const [key] of a.entries()) { 82 | if (!b.has(key)) { 83 | return false; 84 | } 85 | } 86 | 87 | for (const [key, value] of a.entries()) { 88 | if (!isDeepEqual(value, b.get(key))) { 89 | return false; 90 | } 91 | } 92 | 93 | return true; 94 | } 95 | 96 | if (aType === 'Set' && bType === 'Set') { 97 | if (a.size !== b.size) { 98 | return false; 99 | } 100 | 101 | for (const [key] of a.entries()) { 102 | if (!b.has(key)) { 103 | return false; 104 | } 105 | } 106 | 107 | return true; 108 | } 109 | 110 | if (aType === 'Date' && bType === 'Date') { 111 | return +a === +b; 112 | } 113 | 114 | const aKeys = Object.keys(a); 115 | if (aKeys.length !== Object.keys(b).length) { 116 | return false; 117 | } 118 | 119 | for (let i = aKeys.length; i-- !== 0;) { 120 | if (!Object.prototype.hasOwnProperty.call(b, aKeys[i])) { 121 | return false; 122 | } 123 | } 124 | 125 | for (let i = aKeys.length; i-- !== 0;) { 126 | const key = aKeys[i]; 127 | 128 | if (!isDeepEqual(a[key], b[key])) { 129 | return false; 130 | } 131 | } 132 | 133 | if (aType !== 'Object' && bType !== 'Object') { 134 | return a === b; 135 | } 136 | 137 | return true; 138 | }; 139 | 140 | export default isDeepEqual; 141 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '../utils'; 2 | 3 | describe('utils.ts', () => { 4 | describe('pick()', () => { 5 | it('does not break for non-object values', () => { 6 | expect(utils.pick(null, [])).toEqual({}); 7 | expect(utils.pick(undefined, [])).toEqual({}); 8 | expect(utils.pick([] as any, [])).toEqual({}); 9 | expect(utils.pick(10 as any, [])).toEqual({}); 10 | expect(utils.pick('string' as any, [])).toEqual({}); 11 | }); 12 | 13 | it('works properly for objects', () => { 14 | const src = { 15 | first: 'get me', 16 | skipped: 'I will be skipped :(', 17 | third: 'get me too' 18 | }; 19 | 20 | expect(utils.pick(src, ['first', 'third'])).toEqual({ 21 | first: 'get me', 22 | third: 'get me too' 23 | }); 24 | }); 25 | 26 | it('does not copy non-existent properties', () => { 27 | const src = { 28 | first: 'get the first only', 29 | skipped: 'I will be skipped :(', 30 | }; 31 | 32 | expect(utils.pick(src, ['first', 'third'] as any)).toEqual({ 33 | first: 'get the first only' 34 | }); 35 | }); 36 | }); 37 | 38 | describe('throttle()', () => { 39 | beforeEach(() => { 40 | jest.useFakeTimers(); 41 | }); 42 | 43 | afterEach(() => { 44 | jest.clearAllTimers(); 45 | jest.useRealTimers(); 46 | }); 47 | 48 | it('calls immediately on first call', () => { 49 | const spy = jest.fn(); 50 | utils.throttle(spy, 1000)(); 51 | expect(spy).toHaveBeenCalledTimes(1); 52 | }); 53 | 54 | it('throttles, and always calls with the latest call arguments', () => { 55 | const spy = jest.fn((value: string) => {}); // eslint-disable-line @typescript-eslint/no-unused-vars 56 | const fn = utils.throttle(spy, 1000); 57 | 58 | fn('first'); 59 | fn('second-skipped'); 60 | fn('third-skipped'); 61 | fn('fourth'); 62 | 63 | expect(spy).toHaveBeenCalledTimes(1); 64 | expect(spy).toHaveBeenLastCalledWith('first'); 65 | 66 | jest.advanceTimersByTime(1000); 67 | expect(spy).toHaveBeenCalledTimes(2); 68 | expect(spy).toHaveBeenLastCalledWith('fourth'); 69 | }); 70 | }); 71 | 72 | describe('debounce()', () => { 73 | beforeEach(() => { 74 | jest.useFakeTimers(); 75 | }); 76 | 77 | afterEach(() => { 78 | jest.clearAllTimers(); 79 | jest.useRealTimers(); 80 | }); 81 | 82 | it('only calls after the debounce interval', () => { 83 | const spy = jest.fn(); 84 | const debouncedSpy = utils.debounce(spy, 1000); 85 | debouncedSpy(); 86 | 87 | expect(spy).not.toHaveBeenCalled(); 88 | 89 | jest.advanceTimersByTime(500); 90 | expect(spy).not.toHaveBeenCalled(); 91 | 92 | jest.advanceTimersByTime(500); 93 | expect(spy).toHaveBeenCalledTimes(1); 94 | }); 95 | 96 | it('debounces, and only calls with the latest call arguments', () => { 97 | const spy = jest.fn((value: number) => {}); // eslint-disable-line @typescript-eslint/no-unused-vars 98 | const debouncedSpy = utils.debounce(spy, 1000); 99 | 100 | for (let i = 0; i < 100; i++) { 101 | debouncedSpy(i); 102 | } 103 | 104 | expect(spy).not.toHaveBeenCalled(); 105 | 106 | jest.runAllTimers(); 107 | expect(spy).toHaveBeenCalledTimes(1); 108 | expect(spy).toHaveBeenLastCalledWith(99); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import init from './init'; 2 | import { REMEMBER_REHYDRATED, REMEMBER_PERSISTED } from './action-types'; 3 | import { Driver, Options } from './types'; 4 | import { 5 | Action, 6 | StoreEnhancer, 7 | Reducer, 8 | Store, 9 | StoreCreator, 10 | ReducersMapObject, 11 | combineReducers, 12 | UnknownAction 13 | } from 'redux'; 14 | 15 | export * from './errors'; 16 | export * from './types'; 17 | 18 | const rememberReducer = ( 19 | reducer: Reducer | ReducersMapObject 20 | ): Reducer => { 21 | const data: any = { 22 | state: {} 23 | }; 24 | 25 | return (state: any = data.state, action: any) => { 26 | if (action.type && ( 27 | action?.type === '@@INIT' 28 | || action?.type?.startsWith('@@redux/INIT') 29 | )) { 30 | data.state = { ...state }; 31 | } 32 | 33 | const rootReducer = typeof reducer === 'function' 34 | ? reducer 35 | : combineReducers(reducer); 36 | 37 | switch (action.type) { 38 | case REMEMBER_REHYDRATED: { 39 | const rehydratedState = { 40 | ...data.state, 41 | ...(action?.payload || {}) 42 | }; 43 | 44 | data.state = rootReducer( 45 | rehydratedState, 46 | { 47 | type: REMEMBER_REHYDRATED, 48 | payload: rehydratedState 49 | } as any 50 | ); 51 | 52 | return data.state; 53 | } 54 | default: 55 | return rootReducer( 56 | state, 57 | action 58 | ); 59 | } 60 | }; 61 | }; 62 | 63 | const rememberEnhancer = ( 64 | driver: Driver, 65 | rememberedKeys: string[], 66 | { 67 | prefix = '@@remember-', 68 | serialize = (data) => JSON.stringify(data), 69 | unserialize = (data) => JSON.parse(data), 70 | persistThrottle = 100, 71 | persistDebounce, 72 | persistWholeStore = false, 73 | initActionType, 74 | errorHandler = console.warn 75 | }: Partial = {} 76 | ): StoreEnhancer => { 77 | const storeCreator = (createStore: StoreCreator): StoreCreator => ( 78 | rootReducer: Reducer, 79 | preloadedState?: any, 80 | enhancer?: StoreEnhancer 81 | ): Store => { 82 | let isInitialized = false; 83 | const initialize = (store: Store) => init( 84 | store, 85 | rememberedKeys, 86 | { 87 | driver, 88 | prefix, 89 | serialize, 90 | unserialize, 91 | persistThrottle, 92 | persistDebounce, 93 | persistWholeStore, 94 | errorHandler 95 | } 96 | ); 97 | 98 | const store = createStore( 99 | (state, action) => { 100 | if (!isInitialized 101 | && initActionType 102 | && action.type === initActionType 103 | ) { 104 | isInitialized = true; 105 | setTimeout(() => initialize(store), 0); 106 | } 107 | 108 | return rootReducer(state, action); 109 | }, 110 | preloadedState, 111 | enhancer 112 | ); 113 | 114 | if (!initActionType) { 115 | isInitialized = true; 116 | void initialize(store); 117 | } 118 | 119 | return store; 120 | }; 121 | 122 | return storeCreator; 123 | }; 124 | 125 | export { 126 | rememberReducer, 127 | rememberEnhancer, 128 | REMEMBER_REHYDRATED, 129 | REMEMBER_PERSISTED 130 | }; 131 | -------------------------------------------------------------------------------- /LEGACY-USAGE.md: -------------------------------------------------------------------------------- 1 | # Legacy Usage (apps without redux toolkit) 2 | 3 | Legacy usage - web 4 | ------------------ 5 | 6 | ```js 7 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; 8 | import { rememberReducer, rememberEnhancer } from 'redux-remember'; 9 | 10 | const myStateIsRemembered = (state = '', action) => { 11 | switch (action.type) { 12 | case 'SET_TEXT1': 13 | return action.payload; 14 | 15 | default: 16 | return state; 17 | } 18 | }; 19 | 20 | const myStateIsForgotten = (state = '', action) => { 21 | switch (action.type) { 22 | case 'SET_TEXT2': 23 | return action.payload; 24 | 25 | default: 26 | return state; 27 | } 28 | } 29 | 30 | const reducers = { 31 | myStateIsRemembered, 32 | myStateIsForgotten 33 | }; 34 | 35 | const rememberedKeys = [ 'myStateIsRemembered' ]; // 'myStateIsForgotten' will be forgotten, as it's not in this list 36 | 37 | const reducer = rememberReducer( 38 | combineReducers(reducers) 39 | ); 40 | 41 | const store = createStore( 42 | reducer, 43 | compose( 44 | applyMiddleware( 45 | // ... 46 | ), 47 | rememberEnhancer( 48 | window.localStorage, // or window.sessionStorage, or your own custom storage driver 49 | rememberedKeys 50 | ) 51 | ) 52 | ); 53 | 54 | // Continue using the redux store as usual... 55 | ``` 56 | 57 | Legacy usage - react native 58 | --------------------------- 59 | 60 | ```js 61 | import AsyncStorage from '@react-native-community/async-storage'; 62 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; 63 | import { rememberReducer, rememberEnhancer } from 'redux-remember'; 64 | 65 | const myStateIsRemembered = (state = '', action) => { 66 | switch (action.type) { 67 | case 'SET_TEXT1': 68 | return action.payload; 69 | 70 | default: 71 | return state; 72 | } 73 | }; 74 | 75 | const myStateIsForgotten = (state = '', action) => { 76 | switch (action.type) { 77 | case 'SET_TEXT2': 78 | return action.payload; 79 | 80 | default: 81 | return state; 82 | } 83 | }; 84 | 85 | const reducers = { 86 | myStateIsRemembered, 87 | myStateIsForgotten 88 | }; 89 | 90 | const rememberedKeys = [ 'myStateIsRemembered' ]; // 'myStateIsForgotten' will be forgotten, as it's not in this list 91 | 92 | const reducer = rememberReducer( 93 | combineReducers(reducers) 94 | ); 95 | 96 | const store = createStore( 97 | reducer, 98 | compose( 99 | applyMiddleware( 100 | // ... 101 | ), 102 | rememberEnhancer( 103 | AsyncStorage, // or your own custom storage driver 104 | rememberedKeys 105 | ) 106 | ) 107 | ); 108 | 109 | // Continue using the redux store as usual... 110 | ``` 111 | 112 | Legacy usage - inside a reducer 113 | ------------------------------- 114 | 115 | ```js 116 | import { REMEMBER_REHYDRATED, REMEMBER_PERSISTED } from 'redux-remember'; 117 | 118 | const defaultState = { 119 | isRehydrated: false, 120 | isPersisted: false 121 | }; 122 | 123 | const reduxRemember = (state = defaultState, action) => { 124 | switch (action.type) { 125 | case REMEMBER_REHYDRATED: 126 | // "action.payload" is the Rehydrated Root State 127 | return { 128 | isRehydrated: true 129 | }; 130 | 131 | case REMEMBER_PERSISTED: 132 | return { 133 | ...state, 134 | isPersisted: true 135 | }; 136 | 137 | default: 138 | return state; 139 | } 140 | } 141 | 142 | export default reduxRemember; 143 | ``` 144 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-remember", 3 | "version": "5.3.0", 4 | "description": "Saves and loads your redux state from a key-value store of your choice", 5 | "author": "Iskren Slavov ", 6 | "license": "MIT", 7 | "types": "dist/types/index.d.ts", 8 | "main": "dist/cjs/index.cjs", 9 | "module": "dist/mjs/index.mjs", 10 | "react-native": "src/index.ts", 11 | "source": "src/index.ts", 12 | "exports": { 13 | ".": { 14 | "types": "./dist/types/index.d.ts", 15 | "require": "./dist/cjs/index.cjs", 16 | "import": "./dist/mjs/index.mjs", 17 | "default": "./dist/cjs/index.cjs" 18 | }, 19 | "./package.json": "./package.json" 20 | }, 21 | "sideEffects": false, 22 | "scripts": { 23 | "clean": "rimraf -V dist", 24 | "typecheck": "tsc --noEmit", 25 | "build:cjs": "BABEL_ENV=cjs babel --extensions '.ts' src --source-maps --out-file-extension '.cjs' --out-dir dist/cjs", 26 | "build:mjs": "BABEL_ENV=mjs babel --extensions '.ts' src --source-maps --out-file-extension '.mjs' --out-dir dist/mjs", 27 | "build:types": "tsc --pretty --declaration --declarationMap --emitDeclarationOnly --outDir dist/types && rimraf -V dist/types/__tests__", 28 | "build": "npm run build:cjs && npm run build:mjs && npm run build:types", 29 | "prepare": "npm run clean && npm run typecheck && npm run test && npm run build", 30 | "lint": "eslint ./src/*.ts", 31 | "test": "npm run lint && jest ./src/__tests__/*.test.ts" 32 | }, 33 | "jest": { 34 | "collectCoverage": true, 35 | "moduleNameMapper": { 36 | "^(\\.{1,2}/.*)\\.js$": "$1" 37 | }, 38 | "transform": { 39 | "^.+\\.ts$": [ 40 | "ts-jest", 41 | { 42 | "useESM": true, 43 | "extensionsToTreatAsEsm": [ 44 | ".js", 45 | ".ts" 46 | ] 47 | } 48 | ] 49 | } 50 | }, 51 | "files": [ 52 | "src", 53 | "dist", 54 | "LICENSE", 55 | "README.md", 56 | "!**/__tests__" 57 | ], 58 | "repository": { 59 | "type": "git", 60 | "url": "https://github.com/zewish/redux-remember.git" 61 | }, 62 | "bugs": { 63 | "url": "https://github.com/zewish/redux-remember/issues" 64 | }, 65 | "homepage": "https://github.com/zewish/redux-remember/", 66 | "keywords": [ 67 | "redux", 68 | "remember", 69 | "storage", 70 | "persist", 71 | "persistance", 72 | "rehydrate", 73 | "rehydration", 74 | "localstorage", 75 | "sessionstorage", 76 | "asyncstorage", 77 | "react", 78 | "react-native" 79 | ], 80 | "devDependencies": { 81 | "@babel/cli": "^7.28.3", 82 | "@babel/core": "^7.28.4", 83 | "@babel/plugin-proposal-object-rest-spread": "^7.20.7", 84 | "@babel/plugin-transform-runtime": "^7.28.3", 85 | "@babel/preset-env": "^7.28.3", 86 | "@babel/preset-typescript": "^7.27.1", 87 | "@types/babel__core": "^7.20.5", 88 | "@types/babel__preset-env": "^7.10.0", 89 | "@types/jest": "^30.0.0", 90 | "@types/react": "^19.1.12", 91 | "@typescript-eslint/eslint-plugin": "^8.42.0", 92 | "@typescript-eslint/parser": "^8.42.0", 93 | "babel-plugin-module-extension-resolver": "^1.0.0", 94 | "eslint": "^9.35.0", 95 | "fs-extra": "^11.3.1", 96 | "glob": "^11.0.3", 97 | "jest": "^30.1.3", 98 | "jiti": "^2.5.1", 99 | "redux": ">=5.0.1", 100 | "rimraf": "^6.0.1", 101 | "ts-jest": "^29.4.1", 102 | "typescript": "^5.9.2", 103 | "typescript-eslint": "^8.42.0" 104 | }, 105 | "peerDependencies": { 106 | "redux": ">=5.0.0" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/__tests__/init.test.ts: -------------------------------------------------------------------------------- 1 | import * as rehydrateModule from '../rehydrate'; 2 | import * as persistModule from '../persist'; 3 | import { REMEMBER_PERSISTED } from '../action-types'; 4 | import { ExtendedOptions } from '../types'; 5 | import { Store } from 'redux'; 6 | 7 | describe('init.ts', () => { 8 | let mockState: any; 9 | let mockStore: Partial & { 10 | [key: string]: any 11 | }; 12 | 13 | let mockRehydrate: Partial; 14 | let mockPersist: Partial; 15 | let mockPick: jest.MockedFn; 16 | let mockThrottle: jest.MockedFn; 17 | let mockDebounce: jest.MockedFn; 18 | 19 | let init: (...args: any[]) => any; 20 | let args: any[]; 21 | let options: ExtendedOptions; 22 | 23 | beforeEach(async () => { 24 | mockState = 'dummy'; 25 | 26 | mockStore = { 27 | dispatch: jest.fn(), 28 | subscribe: jest.fn((fn: () => any) => fn()), 29 | getState: jest.fn(() => mockState) 30 | }; 31 | 32 | mockRehydrate = { 33 | rehydrate: jest.fn() 34 | }; 35 | 36 | mockPersist = { 37 | persist: jest.fn() 38 | }; 39 | 40 | mockPick = jest.fn((state: any) => state); 41 | mockThrottle = jest.fn((fn: any) => fn); 42 | mockDebounce = jest.fn((fn: any) => fn); 43 | 44 | jest.mock( 45 | '../rehydrate', 46 | () => mockRehydrate 47 | ); 48 | 49 | jest.mock( 50 | '../persist', 51 | () => mockPersist 52 | ); 53 | 54 | jest.mock( 55 | '../utils', 56 | () => ({ 57 | pick: mockPick, 58 | throttle: mockThrottle, 59 | debounce: mockDebounce 60 | }) 61 | ); 62 | 63 | jest.mock( 64 | '../is-deep-equal', 65 | () => ({ 66 | __esModule: true, 67 | default: () => false 68 | }) 69 | ); 70 | 71 | init = (await import('../init')).default; 72 | 73 | options = { 74 | prefix: 'yay', 75 | driver: { 76 | getItem: () => {}, 77 | setItem: () => {} 78 | }, 79 | serialize() {}, 80 | unserialize() {}, 81 | errorHandler() {}, 82 | persistThrottle: 100, 83 | persistWholeStore: true 84 | }; 85 | 86 | args = [ 87 | mockStore, 88 | [1, 2, 3], 89 | options 90 | ]; 91 | }); 92 | 93 | afterEach(() => { 94 | jest.clearAllMocks(); 95 | jest.resetModules(); 96 | }); 97 | 98 | it('calls rehydrate()', async () => { 99 | await init(...args); 100 | 101 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 102 | const { serialize, persistThrottle, ...rehydrateOpts } = args[2]; 103 | 104 | expect(mockRehydrate.rehydrate).toHaveBeenCalledWith( 105 | args[0], args[1], rehydrateOpts 106 | ); 107 | }); 108 | 109 | it('calls store.subscribe()', async () => { 110 | await init(...args); 111 | 112 | expect(mockStore.subscribe).toHaveBeenCalledWith( 113 | expect.any(Function) 114 | ); 115 | }); 116 | 117 | it('calls pick()', async () => { 118 | await init(...args); 119 | 120 | expect(mockPick).toHaveBeenCalledWith( 121 | mockState, 122 | args[1] 123 | ); 124 | }); 125 | 126 | it('calls throttle()', async () => { 127 | await init(...args); 128 | expect(mockThrottle).toHaveBeenCalledTimes(1); 129 | }); 130 | 131 | it('calls debounce()', async () => { 132 | options.persistDebounce = 100; 133 | 134 | await init(...args); 135 | expect(mockDebounce).toHaveBeenCalledTimes(1); 136 | }); 137 | 138 | it('calls persist()', async () => { 139 | await init(...args); 140 | 141 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 142 | const { unserialize, persistThrottle, ...persistOpts } = args[2]; 143 | 144 | expect(mockPersist.persist).toHaveBeenCalledWith( 145 | mockState, 146 | {}, 147 | persistOpts 148 | ); 149 | }); 150 | 151 | it('calls store.dispatch()', async () => { 152 | await init(...args); 153 | 154 | expect(mockStore.dispatch).toHaveBeenCalledWith({ 155 | type: REMEMBER_PERSISTED, 156 | payload: mockState 157 | }); 158 | }); 159 | 160 | it('does not call store.dispatch()', async () => { 161 | jest.resetModules(); 162 | jest.mock('../is-deep-equal', () => ({ 163 | __esModule: true, 164 | default: () => true 165 | })); 166 | 167 | init = (await import('../init')).default; 168 | await init(...args); 169 | 170 | expect(mockStore.dispatch).toHaveBeenCalledTimes(0); 171 | }); 172 | 173 | it('remembers old state between store.subscribe() calls', async () => { 174 | mockStore.getState = () => 'state1'; 175 | mockStore.subscribe = (fn: jest.MockedFn): any => { 176 | mockStore.__call_subscribe_func__ = fn; 177 | }; 178 | 179 | const opts = { 180 | prefix: '1', 181 | driver: '2', 182 | serialize() {} 183 | }; 184 | 185 | await init( 186 | mockStore, 187 | [], 188 | opts 189 | ); 190 | 191 | await mockStore.__call_subscribe_func__(); 192 | 193 | expect(mockPersist.persist).toHaveBeenNthCalledWith( 194 | 1, 195 | 'state1', 196 | {}, 197 | opts 198 | ); 199 | 200 | mockStore.getState = () => 'state2'; 201 | await mockStore.__call_subscribe_func__(); 202 | 203 | expect(mockPersist.persist).toHaveBeenNthCalledWith( 204 | 2, 205 | 'state2', 206 | 'state1', 207 | opts 208 | ); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as indexModule from '../index'; 2 | import * as actionTypes from '../action-types'; 3 | import { Reducer, ReducersMapObject, StoreCreator } from 'redux'; 4 | import { Options } from '../types'; 5 | 6 | describe('index.ts', () => { 7 | const mockRehydrate = { 8 | rehydrateReducer: jest.fn(() => 'REHYDRATE_REDUCER') 9 | }; 10 | 11 | let mockInit: jest.Mock; 12 | let mockCombineReducers: jest.Mock; 13 | let index: typeof indexModule; 14 | 15 | beforeEach(async () => { 16 | mockRehydrate.rehydrateReducer = jest.fn(() => 'REHYDRATE_REDUCER'); 17 | mockInit = jest.fn(() => {}); 18 | mockCombineReducers = jest.fn(() => {}); 19 | 20 | jest.mock('../rehydrate', () => mockRehydrate); 21 | jest.mock('../init', () => mockInit); 22 | jest.mock('redux', () => ({ 23 | ...jest.requireActual('redux'), 24 | combineReducers: mockCombineReducers 25 | })); 26 | 27 | index = await import('../index'); 28 | }); 29 | 30 | afterEach(() => { 31 | jest.clearAllMocks(); 32 | jest.resetModules(); 33 | }); 34 | 35 | it('exports proper items', () => { 36 | expect(index.REMEMBER_REHYDRATED).toEqual( 37 | actionTypes.REMEMBER_REHYDRATED 38 | ); 39 | 40 | expect(index.REMEMBER_PERSISTED).toEqual( 41 | actionTypes.REMEMBER_PERSISTED 42 | ); 43 | 44 | expect(typeof index.rememberReducer).toBe( 45 | 'function' 46 | ); 47 | 48 | expect(typeof index.rememberEnhancer).toBe( 49 | 'function' 50 | ); 51 | }); 52 | 53 | describe('rememberReducer()', () => { 54 | let mockReducer: Reducer; 55 | 56 | const exec = (state: any, action: any) => ( 57 | index.rememberReducer(mockReducer)(state, action) 58 | ); 59 | 60 | beforeEach(() => { 61 | mockReducer = jest.fn((state: any) => state); 62 | }); 63 | 64 | it('call combineReducers()', () => { 65 | const reducersObj: ReducersMapObject = { 66 | dummy: () => 'test123' 67 | }; 68 | 69 | const mockState = { dummy: 'test' }; 70 | mockCombineReducers.mockReturnValue(() => mockState); 71 | 72 | expect(index.rememberReducer(reducersObj)(undefined, { type: 'TEST' })).toEqual( 73 | mockState 74 | ); 75 | 76 | expect(mockCombineReducers).toHaveBeenCalledWith(reducersObj); 77 | }); 78 | 79 | it('does not break when state and action are empty', () => { 80 | expect(exec(undefined, {})).toEqual( 81 | {} 82 | ); 83 | }); 84 | 85 | it('returns preloaded state', () => { 86 | const state = { cool: 'state' }; 87 | 88 | expect(exec(state, { type: '@@INIT' })).toEqual( 89 | state 90 | ); 91 | 92 | expect(exec(state, { type: '@@redux/INIT.12345' })).toEqual( 93 | state 94 | ); 95 | }); 96 | 97 | it('returns rehydrated state', () => { 98 | const payload = { 99 | wow: 'beep', 100 | nah: 'lol' 101 | }; 102 | 103 | expect(exec( 104 | null, 105 | { 106 | type: actionTypes.REMEMBER_REHYDRATED, 107 | payload 108 | } 109 | )).toEqual(payload); 110 | }); 111 | 112 | it('does not fail if there is missing payload', () => { 113 | expect(exec( 114 | null, 115 | { type: actionTypes.REMEMBER_REHYDRATED } 116 | )).toEqual({}); 117 | }); 118 | }); 119 | 120 | describe('rememberEnhancer()', () => { 121 | const mockDriver = { 122 | getItem() {}, 123 | setItem() {} 124 | }; 125 | 126 | let mockCreateStore: StoreCreator; 127 | const mockStore = 'my-mocked-store'; 128 | const rememberedKeys = ['zz', 'bb', 'kk']; 129 | const rootReducer = (state = {}) => state; 130 | let rootReducerWrapper: Reducer; 131 | const initialState = { myReducer: 'bla' }; 132 | const enhancer: any = 'dummy enhancer'; 133 | 134 | beforeEach(() => { 135 | mockCreateStore = jest.fn((wrapper) => { 136 | rootReducerWrapper = wrapper; 137 | return mockStore; 138 | }) as StoreCreator; 139 | }); 140 | 141 | it('calls createStore function and returns its result', () => { 142 | const enhancerInstance = index.rememberEnhancer( 143 | mockDriver, 144 | [] 145 | ); 146 | 147 | const storeMaker: StoreCreator = enhancerInstance(mockCreateStore); 148 | const res = storeMaker( 149 | rootReducer, initialState, enhancer 150 | ); 151 | 152 | expect(mockCreateStore).toHaveBeenCalledWith( 153 | expect.any(Function), initialState, enhancer 154 | ); 155 | 156 | expect(res).toEqual(mockStore); 157 | }); 158 | 159 | it('calls init()', () => { 160 | const opts: Options = { 161 | prefix: '@@yay!', 162 | persistThrottle: 432, 163 | persistWholeStore: true, 164 | serialize: (o) => o, 165 | unserialize: (o) => o, 166 | errorHandler() {} 167 | }; 168 | 169 | const storeMaker: StoreCreator = index.rememberEnhancer( 170 | mockDriver, rememberedKeys, opts 171 | )((() => mockStore) as StoreCreator); 172 | 173 | storeMaker( 174 | rootReducer, initialState, enhancer 175 | ); 176 | 177 | expect(mockInit).toHaveBeenCalledWith( 178 | mockStore, 179 | rememberedKeys, 180 | { driver: mockDriver, ...opts } 181 | ); 182 | }); 183 | 184 | it('calls init() with default options', () => { 185 | let optionDefaults: any; 186 | mockInit.mockImplementationOnce((_store, _rememberedKeys, opts) => { 187 | optionDefaults = opts; 188 | }); 189 | 190 | const storeMaker: StoreCreator = index.rememberEnhancer( 191 | mockDriver, rememberedKeys 192 | )((() => mockStore) as StoreCreator); 193 | 194 | storeMaker( 195 | rootReducer, initialState, enhancer 196 | ); 197 | 198 | expect(mockInit).toHaveBeenCalledWith( 199 | mockStore, 200 | rememberedKeys, 201 | { driver: mockDriver, ...optionDefaults } 202 | ); 203 | 204 | const stringifySpy = jest.spyOn(JSON, 'stringify'); 205 | const parseSpy = jest.spyOn(JSON, 'parse'); 206 | 207 | expect(optionDefaults).toMatchObject({ 208 | prefix: '@@remember-', 209 | persistThrottle: 100, 210 | persistWholeStore: false 211 | }); 212 | expect(optionDefaults.serialize('hello', 'auth')).toEqual('"hello"'); 213 | expect(stringifySpy).toHaveBeenCalledWith('hello'); 214 | expect(optionDefaults.unserialize('"bye"', 'auth')).toEqual('bye'); 215 | expect(parseSpy).toHaveBeenCalledWith('"bye"'); 216 | }); 217 | 218 | it('calls init() only once after the init action is dispatched', () => { 219 | jest.useFakeTimers(); 220 | 221 | const initActionType = 'WAIT_FOR_ME_BEFORE_INIT'; 222 | const opts: Options = { 223 | prefix: '@@very-cool-prefix!', 224 | persistThrottle: 42, 225 | persistWholeStore: true, 226 | serialize: (o) => o, 227 | unserialize: (o) => o, 228 | errorHandler() {} 229 | }; 230 | 231 | const storeMaker: StoreCreator = index.rememberEnhancer( 232 | mockDriver, rememberedKeys, { ...opts, initActionType } 233 | )(mockCreateStore); 234 | 235 | storeMaker( 236 | rootReducer, initialState, enhancer 237 | ); 238 | 239 | expect(mockInit).not.toHaveBeenCalled(); 240 | rootReducerWrapper({}, { type: initActionType }); 241 | jest.advanceTimersByTime(1); 242 | 243 | expect(mockInit).toHaveBeenCalledWith( 244 | mockStore, 245 | rememberedKeys, 246 | { driver: mockDriver, ...opts } 247 | ); 248 | 249 | rootReducerWrapper({}, { type: initActionType }); 250 | expect(mockInit).toHaveBeenCalledTimes(1); 251 | 252 | jest.clearAllTimers(); 253 | jest.useRealTimers(); 254 | }); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /src/__tests__/rehydrate.test.ts: -------------------------------------------------------------------------------- 1 | import * as rehydrateModule from '../rehydrate'; 2 | import { REMEMBER_REHYDRATED } from '../action-types'; 3 | import { Driver } from '../types'; 4 | 5 | describe('rehydrate.ts', () => { 6 | let mod: typeof rehydrateModule; 7 | 8 | beforeEach(async () => { 9 | mod = await import('../rehydrate'); 10 | }); 11 | 12 | describe('loadAll()', () => { 13 | let mockPrefix: string; 14 | let mockDriver: Driver; 15 | 16 | const exec = (opts = {}) => mod.loadAll({ 17 | rememberedKeys: [], 18 | prefix: mockPrefix, 19 | driver: mockDriver, 20 | unserialize: (o: any) => o, 21 | ...opts 22 | }); 23 | 24 | beforeEach(() => { 25 | mockDriver = { 26 | getItem: jest.fn((key) => `"${key}"`), 27 | setItem() {} 28 | }; 29 | 30 | mockPrefix = 'pref0.'; 31 | }); 32 | 33 | it('should call driver.getItem()', async () => { 34 | await exec(); 35 | 36 | expect(mockDriver.getItem).toHaveBeenCalledWith( 37 | `${mockPrefix}rootState` 38 | ); 39 | }); 40 | 41 | it('returns an empty object', async () => { 42 | const res1 = await exec({ 43 | driver: { 44 | getItem: () => Promise.resolve(null) 45 | } 46 | }); 47 | 48 | expect(res1).toEqual({}); 49 | 50 | const res2 = await exec({ 51 | driver: { 52 | getItem: () => Promise.resolve(undefined) 53 | } 54 | }); 55 | 56 | expect(res2).toEqual({}); 57 | }); 58 | 59 | it('returns unserialized data', async () => { 60 | const data = { 61 | keyA: 'bla', 62 | keyB: 'yay', 63 | skipMe: 'byebye' 64 | }; 65 | 66 | const res = await exec({ 67 | rememberedKeys: ['keyA', 'keyB'], 68 | unserialize: () => data 69 | }); 70 | 71 | expect(res).toEqual({ 72 | keyA: 'bla', 73 | keyB: 'yay' 74 | }); 75 | }); 76 | 77 | it('returns filtered data', async () => { 78 | const data = { 79 | keyZ: 'wow', 80 | skipMe: 'byebye', 81 | keyY: 'cool', 82 | }; 83 | 84 | const res = await exec({ 85 | rememberedKeys: ['keyZ', 'keyY'], 86 | driver: { 87 | getItem: () => Promise.resolve(data) 88 | }, 89 | unserialize: (o: any) => o 90 | }); 91 | 92 | expect(res).toEqual({ 93 | keyZ: 'wow', 94 | keyY: 'cool' 95 | }); 96 | }); 97 | }); 98 | 99 | describe('loadAllKeyed()', () => { 100 | let mockPrefix: string; 101 | let mockDriver: Driver; 102 | 103 | const exec = (opts = {}) => mod.loadAllKeyed({ 104 | rememberedKeys: [], 105 | prefix: mockPrefix, 106 | driver: mockDriver, 107 | unserialize: (o: any) => o, 108 | ...opts 109 | }); 110 | 111 | beforeEach(() => { 112 | mockDriver = { 113 | getItem: jest.fn((key) => `valueFor:${key.replace(mockPrefix, '')}`), 114 | setItem() {} 115 | }; 116 | 117 | mockPrefix = 'prefZ.'; 118 | }); 119 | 120 | it('should call driver.getItem()', async () => { 121 | await exec({ 122 | rememberedKeys: ['say', 'what'] 123 | }); 124 | 125 | expect(mockDriver.getItem).toHaveBeenNthCalledWith( 126 | 1, 127 | `${mockPrefix}say` 128 | ); 129 | 130 | expect(mockDriver.getItem).toHaveBeenNthCalledWith( 131 | 2, 132 | `${mockPrefix}what` 133 | ); 134 | }); 135 | 136 | it('returns unserialized state', async () => { 137 | const mockUnserialize = jest.fn() 138 | .mockImplementation((str) => str.toUpperCase()); 139 | 140 | const res = await exec({ 141 | rememberedKeys: ['yay', 'k'], 142 | unserialize: mockUnserialize 143 | }); 144 | 145 | expect(res).toEqual({ 146 | yay: 'VALUEFOR:YAY', 147 | k: 'VALUEFOR:K' 148 | }); 149 | 150 | expect(mockUnserialize) 151 | .toHaveBeenNthCalledWith(1, 'valueFor:yay', 'yay'); 152 | expect(mockUnserialize) 153 | .toHaveBeenNthCalledWith(2, 'valueFor:k', 'k'); 154 | }); 155 | 156 | it('returns state filtering null and undefined', async () => { 157 | const res = await exec({ 158 | rememberedKeys: ['so', 'iAmNull', 'great', 'iAmUndefined'], 159 | driver: { 160 | getItem: ( 161 | jest.fn() 162 | .mockReturnValueOnce('val7') 163 | .mockReturnValueOnce(null) 164 | .mockReturnValueOnce('val8') 165 | .mockReturnValueOnce(undefined) 166 | ) 167 | } 168 | }); 169 | 170 | expect(res).toEqual({ 171 | so: 'val7', 172 | great: 'val8' 173 | }); 174 | }); 175 | }); 176 | 177 | describe('rehydrate()', () => { 178 | let mockState = {}; 179 | const mockStore = { 180 | dispatch: jest.fn(), 181 | getState: jest.fn(() => mockState) 182 | }; 183 | 184 | let rememberedKeys: string[]; 185 | let mockPrefix: string; 186 | let mockDriver: Driver; 187 | 188 | type Opts = Partial[2]>>; 189 | const exec = (opts: Opts = {}) => mod.rehydrate( 190 | mockStore as any, 191 | rememberedKeys, 192 | { 193 | prefix: mockPrefix, 194 | driver: mockDriver, 195 | errorHandler() {}, 196 | unserialize: (data) => JSON.parse(data), 197 | persistWholeStore: false, 198 | ...opts 199 | } 200 | ); 201 | 202 | beforeEach(() => { 203 | rememberedKeys = ['3', '2', '1']; 204 | 205 | mockState = {}; 206 | mockStore.dispatch = jest.fn(); 207 | mockStore.getState = jest.fn(() => mockState); 208 | 209 | mockDriver = { 210 | getItem: jest.fn((key) => `"${key}"`), 211 | setItem() {} 212 | }; 213 | 214 | mockPrefix = 'pref1.'; 215 | }); 216 | 217 | it('propery passes rehydrate errors to errorHandler()', async () => { 218 | const error1 = 'UH OH ONE!'; 219 | const error2 = 'UH OH TWO!'; 220 | 221 | const rehydrateErrorMock = jest.fn((error) => ({ 222 | message: `REHYDRATE ERROR: ${error}` 223 | })); 224 | 225 | const errorHandlerMock = jest.fn(); 226 | jest.mock('../errors', () => ({ 227 | __esModule: true, 228 | RehydrateError: rehydrateErrorMock 229 | })); 230 | 231 | mockDriver.getItem = jest.fn() 232 | .mockRejectedValueOnce(error1) 233 | .mockRejectedValueOnce(error2); 234 | 235 | jest.resetModules(); 236 | mod = await import('../rehydrate'); 237 | 238 | await exec({ 239 | errorHandler: errorHandlerMock, 240 | persistWholeStore: true 241 | }); 242 | await exec({ errorHandler: errorHandlerMock }); 243 | 244 | expect(rehydrateErrorMock).toHaveBeenNthCalledWith( 245 | 1, 246 | error1 247 | ); 248 | 249 | expect(rehydrateErrorMock).toHaveBeenNthCalledWith( 250 | 2, 251 | error2 252 | ); 253 | 254 | expect(errorHandlerMock).toHaveBeenNthCalledWith( 255 | 1, 256 | { message: `REHYDRATE ERROR: ${error1}` } 257 | ); 258 | 259 | expect(errorHandlerMock).toHaveBeenNthCalledWith( 260 | 2, 261 | { message: `REHYDRATE ERROR: ${error2}` } 262 | ); 263 | }); 264 | 265 | it('calls store.getState()', async () => { 266 | await exec({ 267 | driver: { 268 | setItem: () => { throw new Error('not implemented'); }, 269 | getItem: () => Promise.resolve({}) 270 | }, 271 | unserialize: (o: any) => o, 272 | persistWholeStore: true 273 | }); 274 | 275 | expect(mockStore.getState).toHaveBeenCalledTimes(1); 276 | }); 277 | 278 | it('calls store.dispatch()', async () => { 279 | await exec({ 280 | unserialize: (o: any) => JSON.parse(o) 281 | }); 282 | 283 | await exec({ 284 | driver: { 285 | setItem: () => { throw new Error('not implemented'); }, 286 | getItem: () => Promise.resolve({ 287 | 3: 'zaz', 288 | 2: 'lol', 289 | 100: 'nope' 290 | }) 291 | }, 292 | unserialize: (o: any) => o, 293 | persistWholeStore: true 294 | }); 295 | 296 | expect(mockStore.dispatch).toHaveBeenNthCalledWith(1, { 297 | type: REMEMBER_REHYDRATED, 298 | payload: { 299 | 3: 'pref1.3', 300 | 2: 'pref1.2', 301 | 1: 'pref1.1' 302 | } 303 | }); 304 | 305 | expect(mockStore.dispatch).toHaveBeenNthCalledWith(2, { 306 | type: REMEMBER_REHYDRATED, 307 | payload: { 308 | 2: 'lol', 309 | 3: 'zaz' 310 | } 311 | }); 312 | }); 313 | 314 | it('merges with existing state', async () => { 315 | mockState = { 316 | 1: 'prev-state-1', 317 | 2: 'prev-state-2' 318 | }; 319 | 320 | await exec({ 321 | driver: { 322 | setItem: () => { throw new Error('not implemented'); }, 323 | getItem: () => Promise.resolve({ 324 | 3: 'number-3', 325 | 2: 'number-2', 326 | 100: 'skip-me' 327 | }) 328 | }, 329 | unserialize: (o: any) => o, 330 | persistWholeStore: true 331 | }); 332 | 333 | expect(mockStore.dispatch).toHaveBeenNthCalledWith(1, { 334 | type: REMEMBER_REHYDRATED, 335 | payload: { 336 | 3: 'number-3', 337 | 2: 'number-2', 338 | 1: 'prev-state-1' 339 | } 340 | }); 341 | }); 342 | }); 343 | }); 344 | -------------------------------------------------------------------------------- /src/__tests__/is-deep-equal.test.ts: -------------------------------------------------------------------------------- 1 | import isDeepEqual from '../is-deep-equal'; 2 | 3 | const stringObj = new String('c'); 4 | const intObj = new Number(2161); 5 | const floatObj = new Number(-273.15); 6 | const boolObj = new Boolean(true); 7 | const bigIntObjPos = BigInt(8675309); 8 | const bigIntObjNeg = BigInt(-8675309); 9 | const arrayObj = ['a', 42, 3.14, null]; 10 | const objectObj = { a: 1, b: 2 }; 11 | const dateObj = new Date('2000-01-01T00:00:00.000Z'); 12 | const errorObj = new Error('error object'); 13 | const functionObj = () => 42; 14 | export const generatorObj = (function* generatorFn() { 15 | yield 1; 16 | yield 'a'; 17 | yield true; 18 | })(); 19 | const mapObj = new Map([ 20 | ['a', 1], 21 | ['b', 2], 22 | ]); 23 | const promiseObj = new Promise(() => {}); 24 | const regexObj = /[a-fd]/gi; 25 | const setObj = new Set(['a', 'b']); 26 | const symbolObj = Symbol('a symbol'); 27 | 28 | export const weakMapObj = new WeakMap(); 29 | export const weakMapObj2 = new WeakMap(); 30 | const wmObj1 = {}; 31 | const wmObj2 = () => {}; 32 | weakMapObj.set(wmObj1, 37); 33 | weakMapObj.set(wmObj2, 'yay'); 34 | weakMapObj2.set(wmObj1, 37); 35 | weakMapObj2.set(wmObj2, 'yay'); 36 | 37 | export const weakSetObj = new WeakSet(); 38 | export const weakSetObj2 = new WeakSet(); 39 | const wsObj1 = {}; 40 | const wsObj2 = {}; 41 | weakSetObj.add(wsObj1); 42 | weakSetObj.add(wsObj2); 43 | weakSetObj2.add(wsObj1); 44 | weakSetObj2.add(wsObj2); 45 | 46 | const arrayBuffer1 = new ArrayBuffer(16); 47 | const arrayBuffer2 = new ArrayBuffer(32); 48 | const dataView1 = new DataView(arrayBuffer1); 49 | const dataView2 = new DataView(arrayBuffer2); 50 | dataView1.setInt8(0, 66); 51 | dataView2.setInt8(0, 67); 52 | 53 | const int16ArrayObj1 = new Int16Array(21); 54 | const int16ArrayObj2 = new Int16Array(31); 55 | int16ArrayObj1.set([67, 68], 0); 56 | int16ArrayObj2.set([68, 69, 70], 0); 57 | 58 | const sameTypesData: any[] = [ 59 | // strings 60 | [true, 'String', stringObj, stringObj], 61 | [false, 'String', new String('b'), stringObj], 62 | [true, 'String', 'c', 'c'], 63 | [false, 'String', 'd', 'e'], 64 | [true, 'String', '', ''], 65 | 66 | // integers 67 | [true, 'Integer', intObj, intObj], 68 | [true, 'Integer', 42, 42], 69 | [false, 'Integer', 43, 42], 70 | [true, 'Integer', -18, -18], 71 | [false, 'Integer', -19, -18], 72 | [true, 'Integer', 0, 0], 73 | [true, 'Integer', -0, 0], 74 | 75 | // floats 76 | [true, 'Float', floatObj, floatObj], 77 | [true, 'Float', 98.6, 98.6], 78 | [false, 'Float', 3.141592653589793, 3.141592653589794], 79 | 80 | // booleans 81 | [true, 'Boolean', boolObj, boolObj], 82 | [true, 'Boolean', false, false], 83 | 84 | // bigints 85 | [true, 'BigInt', bigIntObjPos, bigIntObjPos], 86 | [true, 'BigInt', 10n, 10n], 87 | [true, 'BigInt', bigIntObjNeg, bigIntObjNeg], 88 | [true, 'BigInt', -20n, -20n], 89 | 90 | // number-like 91 | [true, 'Number', NaN, NaN], 92 | [true, 'Number', Number.MAX_VALUE, Number.MAX_VALUE], 93 | [true, 'Number', Number.MIN_VALUE, Number.MIN_VALUE], 94 | [true, 'Number', Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], 95 | [true, 'Number', Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], 96 | 97 | // null 98 | [true, 'null', null, null], 99 | 100 | // arrays 101 | [true, 'Array', arrayObj, arrayObj], 102 | [true, 'Array', [], []], 103 | [false, 'Array', [1, 2, 3], [1, 2, 3, 4]], 104 | [false, 'Array', [1, 2, 3, 4, 5], [1, 2, 3, 4]], 105 | [false, 'Array', ['b', 42, 3.14, null], ['a', 42, 3.14, null]], 106 | [false, 'Array', [['c', ['d', ['e', ['f']]]]], [['d', [['g'], 'e']], 'c']], 107 | 108 | // objects 109 | [true, 'Object', objectObj, objectObj], 110 | [true, 'Object', {}, {}], 111 | [false, 'Object', { c: 3, d: 4 }, { c: 3, d: 4, e: 5 }], 112 | [false, 'Object', { c: 3, d: 4, e: 5, f: 6 }, { c: 3, d: 4, e: 5 }], 113 | [false, 'Object', { e: 5, f: 6 }, { e: 5, f: 7 }], 114 | [ 115 | true, 116 | 'Object', 117 | { g: 5, h: { i: [1, 2, 3], j: ['a', 'b', 'c'] } }, 118 | { h: { j: ['a', 'b', 'c'], i: [1, 2, 3] }, g: 5 }, 119 | ], 120 | [ 121 | false, 122 | 'Object', 123 | { k: { l: { m: [1, 2, { o: 'p' }] } } }, 124 | { k: { l: { m: [1, 2, { o: 'q' }] } } }, 125 | ], 126 | 127 | // dates 128 | [true, 'Date', dateObj, dateObj], 129 | [true, 'Date', new Date(), new Date()], 130 | 131 | // errors 132 | [true, 'Error', errorObj, errorObj], 133 | [false, 'Error', new Error('error 2'), new Error('error 2')], 134 | [false, 'Error', new TypeError('error'), new RangeError('error')], 135 | 136 | // functions 137 | [true, 'Function', functionObj, functionObj], 138 | [true, 'Function', generatorObj, generatorObj], 139 | [false, 'Function', () => {}, () => {}], 140 | [false, 'Function', () => 'a', () => 'b'], 141 | 142 | // maps 143 | [true, 'Map', mapObj, mapObj], 144 | [true, 'Map', new Map([]), new Map()], 145 | [false, 'Map', new Map([['d', 4]]), new Map([['e', 5]])], 146 | 147 | // promises 148 | [true, 'Promise', promiseObj, promiseObj], 149 | [false, 'Promise', Promise.resolve(true), Promise.resolve(true)], 150 | 151 | // regular expressions 152 | [true, 'RegularExpression', regexObj, regexObj], 153 | [true, 'RegularExpression', /\d+/g, /\d+/g], 154 | [false, 'RegularExpression', /[0-9]+/g, /[\d]+/g], 155 | 156 | // sets 157 | [true, 'Set', setObj, setObj], 158 | [true, 'Set', new Set(), new Set()], 159 | [true, 'Set', new Set([1, 2, 3]), new Set([1, 2, 3])], 160 | 161 | // undefined 162 | [true, 'undefined', undefined, undefined], 163 | 164 | // Array Buffers 165 | [true, 'ArrayBuffer', arrayBuffer1, arrayBuffer1], 166 | [false, 'ArrayBuffer', arrayBuffer2, arrayBuffer1], 167 | [true, 'ArrayBuffer', new ArrayBuffer(12), new ArrayBuffer(12)], 168 | [false, 'ArrayBuffer', new ArrayBuffer(32), new ArrayBuffer(16)], 169 | 170 | // Data Views 171 | [true, 'DavaView', dataView1, dataView1], 172 | [false, 'DavaView', dataView2, dataView1], 173 | [true, 'DavaView', new DataView(new ArrayBuffer(8)), new DataView(new ArrayBuffer(8))], 174 | [false, 'DavaView', new DataView(new ArrayBuffer(16)), new DataView(new ArrayBuffer(32))], 175 | 176 | // TypedArray 177 | [false, 'TypedArray', int16ArrayObj1, int16ArrayObj2], 178 | [true, 'TypedArray', Int8Array.from([0]), Int8Array.from([0])], 179 | [false, 'TypedArray', Int8Array.from([1]), Int8Array.from([2])], 180 | [true, 'TypedArray', Uint8Array.from([2]), Uint8Array.from([2])], 181 | [false, 'TypedArray', Uint8Array.from([3]), Uint8Array.from([4])], 182 | [true, 'TypedArray', Uint8ClampedArray.from([4]), Uint8ClampedArray.from([4])], 183 | [false, 'TypedArray', Uint8ClampedArray.from([5]), Uint8ClampedArray.from([6])], 184 | [true, 'TypedArray', Int16Array.from([6]), Int16Array.from([6])], 185 | [false, 'TypedArray', Int16Array.from([7]), Int16Array.from([8])], 186 | [true, 'TypedArray', Uint16Array.from([8]), Uint16Array.from([8])], 187 | [false, 'TypedArray', Uint16Array.from([9]), Uint16Array.from([10])], 188 | [true, 'TypedArray', Int32Array.from([10]), Int32Array.from([10])], 189 | [false, 'TypedArray', Int32Array.from([11]), Int32Array.from([12])], 190 | [true, 'TypedArray', Uint32Array.from([12]), Uint32Array.from([12])], 191 | [false, 'TypedArray', Uint32Array.from([13]), Uint32Array.from([14])], 192 | [true, 'TypedArray', Float32Array.from([14]), Float32Array.from([14])], 193 | [false, 'TypedArray', Float32Array.from([15]), Float32Array.from([16])], 194 | [true, 'TypedArray', Float64Array.from([16]), Float64Array.from([16])], 195 | [false, 'TypedArray', Float64Array.from([17]), Float64Array.from([18])], 196 | ]; 197 | 198 | const diffTypesData: any[] = []; 199 | for (const ndxOuter in sameTypesData) { 200 | for (const ndxInner in sameTypesData) { 201 | if (ndxOuter !== ndxInner) { 202 | const type1 = sameTypesData[ndxOuter][1]; 203 | const type2 = sameTypesData[ndxInner][1]; 204 | const type = `${type1} vs ${type2}`; 205 | const a = sameTypesData[ndxOuter][2]; 206 | const b = sameTypesData[ndxInner][2]; 207 | 208 | diffTypesData.push([ 209 | type1 === 'Integer' && type2 === 'Integer' && a === b, 210 | type, 211 | a, 212 | b 213 | ]); 214 | } 215 | } 216 | } 217 | sameTypesData.push(...diffTypesData); 218 | 219 | describe('is-deep-equal.ts', () => { 220 | test.each(sameTypesData)('Should return %s for "%s" %p and %p', ( 221 | expected: boolean, 222 | type: string, 223 | a: any, 224 | b: any 225 | ) => { 226 | expect(isDeepEqual(a, b)).toEqual(expected); 227 | }); 228 | 229 | it('Returns false on first wrong key of Set', () => { 230 | expect(isDeepEqual( 231 | new Set(['one', 'two']), 232 | new Set(['one', 'wrong!']) 233 | )).toEqual(false); 234 | }); 235 | 236 | it('Returns false on first wrong key of Map', () => { 237 | expect(isDeepEqual( 238 | new Map([['one', 1], ['two', 2]]), 239 | new Map([['one', 1], ['two', -1]]) 240 | )).toEqual(false); 241 | }); 242 | 243 | it('Symbol refs are the same', () => { 244 | expect(isDeepEqual(symbolObj, symbolObj)).toEqual(true); 245 | }); 246 | 247 | it('Two Symbols are different', () => { 248 | expect(isDeepEqual(Symbol('1'), Symbol('1'))).toEqual(false); 249 | }); 250 | 251 | it('WeakMap refs are the same', () => { 252 | expect(isDeepEqual(weakMapObj, weakMapObj)).toEqual(true); 253 | }); 254 | 255 | it('Two WeakMaps are different', () => { 256 | expect(isDeepEqual(weakMapObj, weakMapObj2)).toEqual(false); 257 | }); 258 | 259 | it('WeakSet refs are the same', () => { 260 | expect(isDeepEqual(weakSetObj, weakSetObj)).toEqual(true); 261 | }); 262 | 263 | it('Two WekSets are different', () => { 264 | expect(isDeepEqual(weakSetObj, weakSetObj2)).toEqual(false); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /src/__tests__/persist.test.ts: -------------------------------------------------------------------------------- 1 | import * as persistModule from '../persist'; 2 | import { Driver } from '../types'; 3 | 4 | describe('persist.ts', () => { 5 | let mockDriver: Driver; 6 | let mockIsDeepEqual: (a: any, b: any) => boolean; 7 | let mod: typeof persistModule; 8 | 9 | beforeEach(async () => { 10 | mockDriver = { 11 | getItem: jest.fn(), 12 | setItem: jest.fn() 13 | }; 14 | 15 | mockIsDeepEqual = jest.fn((a, b) => a === b); 16 | 17 | jest.mock('../is-deep-equal', () => ({ 18 | __esModule: true, 19 | default: mockIsDeepEqual 20 | })); 21 | 22 | mod = await import('../persist'); 23 | }); 24 | 25 | afterEach(() => { 26 | jest.clearAllMocks(); 27 | jest.resetModules(); 28 | }); 29 | 30 | describe('saveAllKeyed()', () => { 31 | it('calls isDeepEqual()', async () => { 32 | await mod.saveAllKeyed( 33 | { 34 | key1: 'val1', 35 | key2: 'val2' 36 | }, 37 | { 38 | key1: 'val11', 39 | key2: 'val22' 40 | }, 41 | { 42 | prefix: '', 43 | driver: mockDriver, 44 | serialize() {} 45 | } 46 | ); 47 | 48 | expect(mockIsDeepEqual).toHaveBeenNthCalledWith( 49 | 1, 'val1', 'val11' 50 | ); 51 | 52 | expect(mockIsDeepEqual).toHaveBeenNthCalledWith( 53 | 2, 'val2', 'val22' 54 | ); 55 | }); 56 | 57 | it('calls serialize()', async () => { 58 | const mockSerialize = jest.fn(); 59 | 60 | await mod.saveAllKeyed( 61 | { 62 | key1: 'yay', 63 | key2: 'i_am_not_changed', 64 | key3: 'wow' 65 | }, 66 | { 67 | key1: 'cool', 68 | key2: 'i_am_not_changed', 69 | key3: 'super' 70 | }, 71 | { 72 | prefix: 'yada.', 73 | driver: mockDriver, 74 | serialize: mockSerialize 75 | } 76 | ); 77 | 78 | expect(mockSerialize).toHaveBeenNthCalledWith( 79 | 1, 'yay', 'key1' 80 | ); 81 | 82 | expect(mockSerialize).toHaveBeenNthCalledWith( 83 | 2, 'wow', 'key3' 84 | ); 85 | }); 86 | 87 | it('calls driver.setItem()', async () => { 88 | await mod.saveAllKeyed( 89 | { 90 | key1: 'yay', 91 | key2: 'i_am_not_changed', 92 | key3: 'wow' 93 | }, 94 | { 95 | key1: 'cool', 96 | key2: 'i_am_not_changed', 97 | key3: 'super' 98 | }, 99 | { 100 | prefix: 'yada.', 101 | driver: mockDriver, 102 | serialize: (string) => JSON.stringify(string) 103 | } 104 | ); 105 | 106 | expect(mockDriver.setItem).toHaveBeenNthCalledWith( 107 | 1, 'yada.key1', '"yay"' 108 | ); 109 | 110 | expect(mockDriver.setItem).toHaveBeenNthCalledWith( 111 | 2, 'yada.key3', '"wow"' 112 | ); 113 | }); 114 | 115 | it('does not call driver.setItem()', async () => { 116 | jest.mock('../is-deep-equal', () => ({ 117 | __esModule: true, 118 | default: () => true 119 | })); 120 | jest.resetModules(); 121 | 122 | mod = await import('../persist'); 123 | 124 | await mod.saveAllKeyed( 125 | { 126 | key1: 'yay', 127 | key2: 'super', 128 | }, 129 | { 130 | key1: 'cool', 131 | key2: 'lol' 132 | }, 133 | { 134 | prefix: '', 135 | driver: mockDriver, 136 | serialize() {} 137 | } 138 | ); 139 | 140 | expect(mockDriver.setItem).toHaveBeenCalledTimes(0); 141 | }); 142 | }); 143 | 144 | describe('saveAll()', () => { 145 | it('calls isDeepEqual()', async () => { 146 | const state = { 147 | key1: 'val1', 148 | key2: 'val2' 149 | }; 150 | 151 | const oldState = { 152 | key1: 'val11', 153 | key2: 'val22' 154 | }; 155 | 156 | await mod.saveAll( 157 | state, 158 | oldState, 159 | { 160 | prefix: 'bla', 161 | driver: mockDriver, 162 | serialize() {} 163 | } 164 | ); 165 | 166 | expect(mockIsDeepEqual).toHaveBeenCalledWith( 167 | state, oldState 168 | ); 169 | }); 170 | 171 | it('calls serialize()', async () => { 172 | const mockSerialize = jest.fn(); 173 | 174 | const state = { 175 | key1: 'not_changed', 176 | key2: 'i_am_not_changed', 177 | key3: 'new-changed-value' 178 | }; 179 | 180 | const oldState = { 181 | key1: 'not_changed', 182 | key2: 'i_am_not_changed', 183 | key3: 'old-value' 184 | }; 185 | 186 | await mod.saveAll( 187 | state, 188 | oldState, 189 | { 190 | prefix: '@@yada-', 191 | driver: mockDriver, 192 | serialize: mockSerialize 193 | } 194 | ); 195 | 196 | expect(mockSerialize).toHaveBeenCalledWith(state, 'rootState'); 197 | }); 198 | 199 | it('calls driver.setItem()', async () => { 200 | const state = { 201 | key1: 'not_changed', 202 | key2: 'i_am_not_changed', 203 | key3: 'new-changed-value' 204 | }; 205 | 206 | const oldState = { 207 | key1: 'not_changed', 208 | key2: 'i_am_not_changed', 209 | key3: 'old-value' 210 | }; 211 | 212 | await mod.saveAll( 213 | state, 214 | oldState, 215 | { 216 | prefix: '@@yada-', 217 | driver: mockDriver, 218 | serialize: (o: any) => o 219 | } 220 | ); 221 | 222 | expect(mockDriver.setItem).toHaveBeenCalledWith( 223 | '@@yada-rootState', state 224 | ); 225 | }); 226 | 227 | it('does not call driver.setItem()', async () => { 228 | jest.mock('../is-deep-equal', () => ({ 229 | __esModule: true, 230 | default: () => true 231 | })); 232 | jest.resetModules(); 233 | 234 | mod = await import('../persist'); 235 | 236 | await mod.saveAll( 237 | { key1: 'changed' }, 238 | { key1: 'not_changed' }, 239 | { 240 | prefix: '@@yada-', 241 | driver: mockDriver, 242 | serialize() {} 243 | } 244 | ); 245 | 246 | expect(mockDriver.setItem).toHaveBeenCalledTimes(0); 247 | }); 248 | }); 249 | 250 | describe('persist()', () => { 251 | it('works with state objects provided', async () => { 252 | await mod.persist( 253 | { key1: 'new' }, 254 | { key1: 'old' }, 255 | { 256 | driver: mockDriver, 257 | prefix: 'one', 258 | persistWholeStore: false, 259 | serialize: (o: any) => o, 260 | errorHandler() {} 261 | } 262 | ); 263 | 264 | await mod.persist( 265 | { key1: 'new' }, 266 | { key1: 'old' }, 267 | { 268 | driver: mockDriver, 269 | prefix: 'two', 270 | persistWholeStore: true, 271 | serialize: (o: any) => o, 272 | errorHandler() {} 273 | } 274 | ); 275 | }); 276 | 277 | it('works without state objects provided', async () => { 278 | await mod.persist( 279 | undefined, 280 | undefined, 281 | { 282 | driver: mockDriver, 283 | prefix: 'three', 284 | persistWholeStore: false, 285 | serialize: (o: any) => o, 286 | errorHandler() {} 287 | } 288 | ); 289 | 290 | await mod.persist( 291 | undefined, 292 | undefined, 293 | { 294 | driver: mockDriver, 295 | prefix: 'four', 296 | persistWholeStore: true, 297 | serialize: (o: any) => o, 298 | errorHandler() {} 299 | } 300 | ); 301 | }); 302 | 303 | it('propery passes persist errors to errorHandler()', async () => { 304 | const error1 = 'DUMMY ERROR 1!!!'; 305 | const error2 = 'DUMMY ERROR 2!!!'; 306 | 307 | const persistErrorMock = jest.fn((error) => ({ 308 | message: `PERSIST ERROR: ${error}` 309 | })); 310 | 311 | const errorHandlerMock = jest.fn(); 312 | 313 | jest.mock('../errors', () => ({ 314 | __esModule: true, 315 | PersistError: persistErrorMock 316 | })); 317 | 318 | jest.mock('../is-deep-equal', () => ({ 319 | __esModule: true, 320 | default: () => false 321 | })); 322 | 323 | jest.resetModules(); 324 | mod = await import('../persist'); 325 | 326 | mockDriver = { 327 | getItem() {}, 328 | setItem: ( 329 | jest.fn() 330 | .mockRejectedValueOnce(error1) 331 | .mockRejectedValueOnce(error2) 332 | ) 333 | }; 334 | 335 | await mod.persist( 336 | { key1: 'yay' }, 337 | { key1: 'cool' }, 338 | { 339 | prefix: 'beep', 340 | persistWholeStore: false, 341 | driver: mockDriver, 342 | serialize: (o: any) => o, 343 | errorHandler: errorHandlerMock 344 | } 345 | ); 346 | 347 | await mod.persist( 348 | { key1: 'yay' }, 349 | { key1: 'cool' }, 350 | { 351 | prefix: 'boop', 352 | driver: mockDriver, 353 | persistWholeStore: true, 354 | serialize: (o: any) => o, 355 | errorHandler: errorHandlerMock 356 | } 357 | ); 358 | 359 | expect(persistErrorMock).toHaveBeenNthCalledWith( 360 | 1, 361 | error1 362 | ); 363 | 364 | expect(persistErrorMock).toHaveBeenNthCalledWith( 365 | 2, 366 | error2 367 | ); 368 | 369 | expect(errorHandlerMock).toHaveBeenNthCalledWith( 370 | 1, 371 | { message: `PERSIST ERROR: ${error1}` } 372 | ); 373 | 374 | expect(errorHandlerMock).toHaveBeenNthCalledWith( 375 | 2, 376 | { message: `PERSIST ERROR: ${error2}` } 377 | ); 378 | }); 379 | }); 380 | }); 381 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![NPM Version](https://img.shields.io/npm/v/redux-remember.svg?style=flat-square)](https://www.npmjs.com/package/redux-remember) 2 | [![Build Status](https://github.com/zewish/redux-remember/workflows/build/badge.svg)](https://github.com/zewish/redux-remember/actions?query=workflow%3Abuild) 3 | [![Coverage Status](https://coveralls.io/repos/github/zewish/redux-remember/badge.svg?branch=master)](https://coveralls.io/github/zewish/redux-remember?branch=master) 4 | [![NPM Downloads](https://img.shields.io/npm/dm/redux-remember.svg?style=flat-square)](https://www.npmjs.com/package/redux-remember) 5 | 6 | ![Logo](https://raw.githubusercontent.com/zewish/redux-remember/master/logo.png) 7 | 8 | Redux Remember saves and loads your redux state from a key-value store of your choice. 9 | 10 | __Important__ 11 | 12 | The current version of Redux Remember is tested working with redux@5.0.0+ and redux-toolkit@2.0.1+.
13 | In case you want to use this library with an older versions of Redux or Redux Toolkit you might need to switch back to version 4.2.2 of Redux Remember. 14 | 15 | __Key features:__ 16 | - Saves (persists) and loads (rehydrates) only allowed keys and does not touch anything else. 17 | - Completely unit and battle tested. 18 | - Works on both web (any redux compatible app) and native (react-native). 19 | 20 | __Works with any of the following:__ 21 | - AsyncStorage (react-native) 22 | - LocalStorage (web) 23 | - SessionStorage (web) 24 | - Your own custom storage driver that implements `setItem(key, value)` and `getItem(key)` 25 | 26 | ### [__See demo!__](https://zewish.github.io/redux-remember/demo-web/) 27 | 28 | Installation 29 | ------------ 30 | ```bash 31 | $ npm install --save redux-remember 32 | # or 33 | $ yarn add redux-remember 34 | ``` 35 | 36 | Usage - web 37 | ----------- 38 | 39 | ```ts 40 | import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit'; 41 | import { rememberReducer, rememberEnhancer } from 'redux-remember'; 42 | 43 | const myStateIsRemembered = createSlice({ 44 | name: 'persisted-slice', 45 | initialState: { 46 | text: '' 47 | }, 48 | reducers: { 49 | setPersistedText(state, action: PayloadAction) { 50 | state.text = action.payload; 51 | } 52 | } 53 | }); 54 | 55 | const myStateIsForgotten = createSlice({ 56 | name: 'forgotten-slice', 57 | initialState: { 58 | text: '' 59 | }, 60 | reducers: { 61 | setForgottenText(state, action: PayloadAction) { 62 | state.text = action.payload; 63 | } 64 | } 65 | }); 66 | 67 | const reducers = { 68 | myStateIsRemembered: myStateIsRemembered.reducer, 69 | myStateIsForgotten: myStateIsForgotten.reducer, 70 | someExtraData: (state = 'bla') => state 71 | }; 72 | 73 | export const actions = { 74 | ...myStateIsRemembered.actions, 75 | ...myStateIsForgotten.actions 76 | }; 77 | 78 | const rememberedKeys = [ 'myStateIsRemembered' ]; // 'myStateIsForgotten' will be forgotten, as it's not in this list 79 | 80 | const reducer = rememberReducer(reducers); 81 | const store = configureStore({ 82 | reducer, 83 | enhancers: (getDefaultEnhancers) => getDefaultEnhancers().concat( 84 | rememberEnhancer( 85 | window.localStorage, // or window.sessionStorage, or your own custom storage driver 86 | rememberedKeys 87 | ) 88 | ) 89 | }); 90 | 91 | // Continue using the redux store as usual... 92 | ``` 93 | 94 | Usage - react-native 95 | -------------------- 96 | 97 | ```ts 98 | import AsyncStorage from '@react-native-async-storage/async-storage'; 99 | import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit'; 100 | import { rememberReducer, rememberEnhancer } from 'redux-remember'; 101 | 102 | const myStateIsRemembered = createSlice({ 103 | name: 'persisted-slice', 104 | initialState: { 105 | text: '' 106 | }, 107 | reducers: { 108 | setPersistedText(state, action: PayloadAction) { 109 | state.text = action.payload; 110 | } 111 | } 112 | }); 113 | 114 | const myStateIsForgotten = createSlice({ 115 | name: 'forgotten-slice', 116 | initialState: { 117 | text: '' 118 | }, 119 | reducers: { 120 | setForgottenText(state, action: PayloadAction) { 121 | state.text = action.payload; 122 | } 123 | } 124 | }); 125 | 126 | const reducers = { 127 | myStateIsRemembered: myStateIsRemembered.reducer, 128 | myStateIsForgotten: myStateIsForgotten.reducer, 129 | someExtraData: (state = 'bla') => state 130 | }; 131 | 132 | export const actions = { 133 | ...myStateIsRemembered.actions, 134 | ...myStateIsForgotten.actions 135 | }; 136 | 137 | const rememberedKeys = [ 'myStateIsRemembered' ]; // 'myStateIsForgotten' will be forgotten, as it's not in this list 138 | 139 | const reducer = rememberReducer(reducers); 140 | const store = configureStore({ 141 | reducer, 142 | enhancers: (getDefaultEnhancers) => getDefaultEnhancers().concat( 143 | rememberEnhancer( 144 | AsyncStorage, // or your own custom storage driver 145 | rememberedKeys 146 | ) 147 | ) 148 | }); 149 | 150 | // Continue using the redux store as usual... 151 | ``` 152 | 153 | Usage - react-native with multiple storage types 154 | ------------------------------------------------ 155 | 156 | **Custom Driver that uses both expo-secure-store and AsyncStorage** 157 | 158 | ```ts 159 | import { configureStore } from '@reduxjs/toolkit'; 160 | import { Driver, rememberReducer, rememberEnhancer } from 'redux-remember'; 161 | import * as SecureStore from 'expo-secure-store'; 162 | import AsyncStorage from '@react-native-async-storage/async-storage'; 163 | import reducers from './reducers'; 164 | 165 | const secureKeys = ['secureKey1', 'secureKey2']; 166 | const rememberedKeys = [ 167 | 'insecureKey1', 'insecureKey2', 168 | ...secureKeys 169 | ]; 170 | 171 | export const customDriver: Driver = { 172 | setItem(key: string, value: any) { 173 | if (secureKeys.includes(originalKey)) { // If using prefix use: secureKeys.includes(key.slice(prefix.length)) 174 | return SecureStore.setItemAsync(key, value); 175 | } 176 | 177 | return AsyncStorage.setItem(key, value); 178 | }, 179 | getItem(key: string) { 180 | if (secureKeys.includes(key)) { // If using prefix use: secureKeys.includes(key.slice(prefix.length)) 181 | return SecureStore.getItemAsync(key); 182 | } 183 | 184 | return AsyncStorage.getItem(key); 185 | } 186 | }; 187 | 188 | const store = configureStore({ 189 | reducer: rememberReducer(reducers), 190 | enhancers: (getDefaultEnhancers) => getDefaultEnhancers().concat( 191 | rememberEnhancer( 192 | customDriver, 193 | rememberedKeys, 194 | { prefix: '' } 195 | ) 196 | ) 197 | }); 198 | 199 | // Continue using the redux store as usual... 200 | ``` 201 | 202 | Usage - inside a reducer 203 | ------------------------ 204 | 205 | ```ts 206 | import { createSlice, createAction, PayloadAction } from '@reduxjs/toolkit'; 207 | import { REMEMBER_REHYDRATED, REMEMBER_PERSISTED } from 'redux-remember'; 208 | 209 | const initialState = { 210 | isRehydrated: false, 211 | isPersisted: false 212 | }; 213 | 214 | const reduxRemember = createSlice({ 215 | name: 'redux-remember', 216 | initialState, 217 | reducers: {}, 218 | extraReducers: (builder) => builder 219 | .addCase(createAction(REMEMBER_REHYDRATED), (state, action) => { 220 | // "action.payload" is the Rehydrated Root State 221 | state.isRehydrated = true; 222 | }) 223 | .addCase(createAction(REMEMBER_PERSISTED), (state, action) => { 224 | state.isPersisted = true; 225 | }) 226 | }); 227 | 228 | const reducers = { 229 | reduxRemember: reduxRemember.reducer, 230 | // ... 231 | }; 232 | 233 | const rememberedKeys = [ 'myStateIsRemembered' ]; 234 | const reducer = rememberReducer(reducers); 235 | const store = configureStore({ 236 | reducer, 237 | enhancers: (getDefaultEnhancers) => getDefaultEnhancers().concat( 238 | rememberEnhancer( 239 | window.localStorage, // or window.sessionStorage, or AsyncStorage, or your own custom storage driver 240 | rememberedKeys 241 | ) 242 | ) 243 | }); 244 | 245 | export type RootState = ReturnType; 246 | export default store; 247 | 248 | // Continue using the redux store as usual... 249 | ``` 250 | 251 | Usage - React rehydration gate 252 | ------------------------------ 253 | 254 | **Prerequisite: to be used with: [Usage - inside a reducer](#usage---inside-a-reducer) or similar** 255 | 256 | ```tsx 257 | import { FC, PropsWithChildren } from 'react'; 258 | import { useSelector } from 'react-redux'; 259 | import ReactDOM from 'react-dom/client'; 260 | import store, { RootState } from './store'; 261 | 262 | const RehydrateGate: FC = ({ children }) => { 263 | const isRehydrated = useSelector((state) => state.reduxRemember.isRehyrdated); 264 | return isRehydrated 265 | ? children 266 | :
Rehydrating, please wait...
; 267 | }; 268 | 269 | const root = ReactDOM.createRoot( 270 | document.getElementById('root')! 271 | ); 272 | 273 | root.render( 274 | 275 | 276 | 277 | 278 | 279 | ); 280 | ``` 281 | 282 | Usage - legacy apps (without redux toolkit) 283 | ------------------------------------------- 284 | 285 | Examples here are using redux toolkit.
286 | If your application still isn't migrated to redux toolkit, [check the legacy usage documentation](./LEGACY-USAGE.md). 287 | 288 | 289 | API reference 290 | ------------- 291 | - **rememberReducer(reducers: Reducer | ReducersMapObject)** 292 | - Arguments: 293 | 1. **reducers** *(required)* - takes the result of `combineReducers()` function or list of non-combined reducers to combine internally (same as redux toolkit); 294 | - Returns - a new root reducer to use as first argument for the `configureStore()` (redux toolkit) or the `createStore()` (plain redux) function; 295 | 296 | 297 | - **rememberEnhancer(driver: Driver, rememberedKeys: string[], options?: Options)** 298 | - Arguments: 299 | 1. **driver** *(required)* - storage driver instance, that implements the `setItem(key, value)` and `getItem(key)` functions; 300 | 2. **rememberedKeys** *(required)* - an array of persistable keys - if an empty array is provided nothing will get persisted; 301 | 3. **options** *(optional)* - plain object of extra options: 302 | - **prefix**: storage key prefix *(default: `'@@remember-'`)*; 303 | - **serialize** - a plain function that takes unserialized store state and its key (`serialize(state, stateKey)`) and returns serialized state to be persisted *(default: `JSON.stringify`)*; 304 | - **unserialize** - a plain function that takes serialized persisted state and its key (`serialize(state, stateKey)`) and returns unserialized to be set in the store *(default: `JSON.parse`)*; 305 | - **persistThrottle** - how much time should the persistence be throttled in milliseconds *(default: `100`)* 306 | - **persistDebounce** *(optional)* - how much time should the persistence be debounced by in milliseconds. If provided, persistence will not be throttled, and the `persistThrottle` option will be ignored. The debounce is a simple trailing-edge-only debounce. 307 | - **persistWholeStore** - a boolean which specifies if the whole store should be persisted at once. Generally only use this if you're using your own storage driver which has gigabytes of storage limits. Don't use this when using window.localStorage, window.sessionStorage or AsyncStorage as their limits are quite small. When using this option, key won't be passed to `serialize` nor `unserialize` functions - *(default: `false`)*; 308 | - **errorHandler** - an error handler hook function which is gets a first argument of type `PersistError` or `RehydrateError` - these include a full error stack trace pointing to the source of the error. If this option isn't specified the default behaviour is to log the error using console.warn() - *(default: `console.warn`)*; 309 | - **initActionType** (optional) - a string which allows you to postpone the initialization of `Redux Remember` until an action with this type is dispatched to the store. This is used in special cases whenever you want to do something before state gets rehydrated and persisted automatically (e.g. preload your state from SSR). **NOTE: With this option enabled Redux Remember will be completely disabled until `dispatch({ type: YOUR_INIT_ACTION_TYPE_STRING })` is called**; 310 | - Returns - an enhancer to be used with Redux 311 | --------------------------------------------------------------------------------