diff --git a/README.md b/README.md index 3b1bd39..51fa192 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
-# RTK Persist [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) ![GitHub package.json version](https://img.shields.io/github/package-json/v/FancyPixel/rtk-persist?color=%2332C553) +# RTK Persist **`rtk-persist`** is a lightweight, zero-dependency library that enhances Redux Toolkit's state management by adding seamless, persistent storage. It allows specified slices or reducers of your Redux state to be saved to a storage medium of your choice (like `localStorage` or `AsyncStorage`) and rehydrated on app startup. @@ -17,13 +17,23 @@ The library works by wrapping standard Redux Toolkit functions, adding persisten ## ✨ Features * **Effortless Persistence**: Persist any Redux Toolkit slice or reducer with minimal configuration. + * **Asynchronous Rehydration**: Store creation is now asynchronous, ensuring that your app only renders after the state has been fully rehydrated. + * **Seamless Integration**: Designed as a drop-in replacement for RTK functions. Adding or removing persistence is as simple as changing an import. + * **React Redux Integration**: Comes with a `` and a `usePersistedStore` hook for easy integration with React applications. + * **Flexible API**: Choose between a `createPersistedSlice` utility or a `createPersistedReducer` builder syntax. + * **Nested State Support**: Easily persist slices or reducers that are deeply nested within your root state using a simple `nestedPath` option. + +* **Custom Serialization**: Use `onPersist` and `onRehydrate` to transform your state before saving and after loading. + * **Storage Agnostic**: Works with any storage provider that implements a simple `getItem`, `setItem`, and `removeItem` interface. + * **TypeScript Support**: Fully typed to ensure a great developer experience with path validation. + * **Minimal Footprint**: Extremely lightweight with a production size under 15 KB.
@@ -83,6 +93,7 @@ export const counterSlice = createPersistedSlice({ export const { increment, decrement, incrementByAmount } = counterSlice.actions; export default counterSlice.reducer; + ``` ### Option 2: Using `createPersistedReducer` @@ -114,6 +125,7 @@ export const counterReducer = createPersistedReducer( }); } ); + ``` ### 2. Configure the Store @@ -147,6 +159,7 @@ export const store = configurePersistedStore( export type Store = Awaited; export type RootState = ReturnType; export type AppDispatch = Store['dispatch']; + ```
@@ -163,7 +176,7 @@ This component replaces the standard `Provider` from `react-redux`. It waits for In your application's entry point (e.g., `main.tsx` or `index.js`), wrap your `App` component with `PersistedProvider`. -```tsx +```typescript // main.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; @@ -178,62 +191,38 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
, ); + ``` The `PersistedProvider` accepts two props: + * `store`: The promise returned by `configurePersistedStore`. + * `loader` (optional): A React node to display while the store is rehydrating. ### `usePersistedStore` -A custom hook that provides access to the rehydrated store instance. This is useful for dispatching actions or accessing store methods like `flush`. +A custom hook that provides access to the rehydrated store instance. This is useful for dispatching actions or accessing store methods. #### Usage -```tsx +```typescript import React from 'react'; import { usePersistedStore } from 'rtk-persist'; const MyComponent = () => { const { store } = usePersistedStore(); - const handleSaveNow = () => { - // Manually forces the store to save its current state to storage. - store.flush(); + const handleClear = () => { + // Manually clears the persisted state from storage. + store.clearPersistedState(); }; - return ; + return ; }; -``` - -
- -## ↔️ Seamless Integration -A core design principle of `rtk-persist` is that it should be easy to add or remove. The API is intentionally designed to mirror Redux Toolkit's, so enabling or disabling persistence is as simple as changing an import. - -**From this:** - -```typescript -import { createSlice } from '@reduxjs/toolkit'; - -export const counterSlice = createSlice({ - /* ... */ -}); ``` -**To this:** - -```typescript -import { createPersistedSlice } from 'rtk-persist'; - -export const counterSlice = createPersistedSlice({ - /* ... */ -}); -``` - -No other code changes are needed in your slice file. -
## 🌳 Handling Nested State @@ -256,7 +245,9 @@ export const counterSlice = createPersistedSlice( /* ... */ }, }, - 'features.counter' // The nestedPath to the slice's state + { + nestedPath: 'features.counter' // The nestedPath to the slice's state + } ); // app/store.ts @@ -277,10 +268,74 @@ export const store = configurePersistedStore( 'my-app-id', localStorage ); + ```
+## 🔬 Advanced Usage: Custom Serialization + +Sometimes, you may need to transform a slice's state before it's saved to storage or after it's rehydrated. For example, you might want to store a `Date` object as an ISO string, or omit certain transient properties. + +`rtk-persist` supports this through the `onPersist` and `onRehydrate` options. + +### Example with `onPersist` and `onRehydrate` + +Here's how you can persist a slice that contains a non-serializable value like a `Date` object. + +```typescript +// features/session/sessionSlice.ts +import { createPersistedSlice } from 'rtk-persist'; + +interface SessionState { + lastLogin: Date | null; + token: string | null; +} + +const initialState: SessionState = { + lastLogin: null, + token: null, +}; + +export const sessionSlice = createPersistedSlice( + { + name: 'session', + initialState, + reducers: { + login: (state, action) => { + state.token = action.payload.token; + state.lastLogin = new Date(); + }, + logout: (state) => { + state.token = null; + state.lastLogin = null; + }, + }, + }, + { + // Transform state before saving + onPersist: (state) => ({ + ...state, + lastLogin: state.lastLogin ? state.lastLogin.toISOString() : null, + }), + // Transform state after rehydrating + onRehydrate: (state) => ({ + ...state, + lastLogin: state.lastLogin ? new Date(state.lastLogin) : null, + }), + } +); + +``` + +In this example: + +* `onPersist` converts the `lastLogin` `Date` object into an ISO string before it's written to `localStorage`. + +* `onRehydrate` parses the ISO string and converts it back into a `Date` object when the state is loaded from storage. + +
+ ## 🛠️ API ### `createPersistedSlice` @@ -290,13 +345,18 @@ A wrapper around RTK's `createSlice` that adds persistence. #### Takes * **`sliceOptions`**: The standard `CreateSliceOptions` object from Redux Toolkit. -* **`nestedPath`** (optional, `string`): A dot-notation string representing the path to the slice's state from the root. Required if the slice is not at the root level. -#### Returns +* **`persistenceOptions`** (optional, `object`): Configuration for persistence behavior. + + * `nestedPath` (optional, `string`): A dot-notation string for the slice's state if it's nested. + + * `onPersist` (optional, `function`): A function to transform state *before* it's saved. + + * `onRehydrate` (optional, `function`): A function to transform state *after* it's rehydrated. -* A standard `Slice` object, enhanced with a `nestedPath` property. +#### Returns ---- +* A `PersistedSlice` object, which is a standard `Slice` object enhanced with persistence properties. ### `createPersistedReducer` @@ -305,15 +365,22 @@ A wrapper around RTK's `createReducer` that adds persistence. #### Takes * **`name`**: A unique string to identify this reducer in storage. + * **`initialState`**: The initial state for the reducer. + * **`builderCallback`**: A callback that receives a `builder` object to define case reducers. -* **`nestedPath`** (optional, `string`): A dot-notation string representing the path to the reducer's state. An empty string (`''`) signifies that this reducer is the root state. -#### Returns +* **`persistenceOptions`** (optional, `object`): Configuration for persistence behavior. + + * `nestedPath` (optional, `string`): A dot-notation string for the reducer's state. An empty string (`''`) signifies the root state. -* A standard `Reducer` function, enhanced with `reducerName` and `nestedPath` properties. + * `onPersist` (optional, `function`): A function to transform state *before* it's saved. ---- + * `onRehydrate` (optional, `function`): A function to transform state *after* it's rehydrated. + +#### Returns + +* A `PersistedReducer` function, which is a standard `Reducer` enhanced with persistence properties. ### `configurePersistedStore` @@ -322,16 +389,22 @@ A wrapper around RTK's `configureStore`. #### Takes * **`storeOptions`**: The standard `ConfigureStoreOptions` object. + * **`applicationId`**: A unique string that identifies the application to namespace storage keys. + * **`storageHandler`**: A storage object that implements `getItem`, `setItem`, and `removeItem`. + * **`persistenceOptions`** (optional): An object to control the persistence behavior: - * `rehydrationTimeout` (optional, `number`): Max time in ms to wait for rehydration. Defaults to `5000`. + + * `rehydrationTimeout` (optional, `number`): Max time in ms to wait for rehydration. Defaults to `5000`. #### Returns * A `Promise` object, which resolves to a standard Redux store enhanced with the following methods: - * **`rehydrate()`**: A function to manually trigger rehydration from storage. - * **`clearPersistedState()`**: A function that clears all persisted data for the application from storage. + + * **`rehydrate()`**: A function to manually trigger rehydration from storage. + + * **`clearPersistedState()`**: A function that clears all persisted data for the application from storage.
@@ -346,5 +419,5 @@ This library was crafted from our daily experiences building modern web and mobi ## 📄 License This project is licensed under the MIT License. - + Library icon freely created from a [iconsax](https://iconsax.io/) icon and the [redux](https://redux.js.org/img/redux.svg) logo. diff --git a/src/core/reducer.ts b/src/core/reducer.ts index 81e3125..bffa42c 100644 --- a/src/core/reducer.ts +++ b/src/core/reducer.ts @@ -10,6 +10,7 @@ import { NestedPath, NotFunction, PersistedReducer, + ReducerPersistenceOptions, RehydrateActionPayload, } from './types'; import UpdatedAtHelper from './updatedAtHelper'; @@ -17,26 +18,51 @@ import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils'; /** * Creates a persisted reducer that wraps Redux Toolkit's `createReducer`. - * This function enhances a standard reducer with automatic state persistence, - * saving its state to storage and rehydrating it on app startup. * - * This function must be used with a store configured by `configurePersistedStore`. + * This function enhances a standard reducer with automatic state persistence, + * saving its state to storage and rehydrating it when the application starts. + * It must be used with a store configured by `configurePersistedStore`. * - * @param reducerName - A unique string name for the reducer. This name serves as the key in both the root state and the storage. - * @param initialState - The initial state for the reducer. - * @param mapOrBuilderCallback - A callback that receives a `builder` object to define case reducers, similar to the original `createReducer`. - * @param nestedPath - An optional dot-separated string path indicating where the reducer's state is located within the root state. If not provided, `reducerName` is used. * @public + * @param reducerName - A unique name for the reducer. This name is used as the key + * in both the root state and the underlying storage. + * @param initialState - The initial state for the reducer, same as in `createReducer`. + * @param mapOrBuilderCallback - A callback that receives a `builder` object to define + * case reducers. This builder is wrapped to automatically track state changes for persistence. + * @param persistenceOptions - Optional configuration for customizing persistence behavior. + * @returns A reducer function enhanced with persistence capabilities. + * + * @example + * ```typescript + * const counterReducer = createPersistedReducer( + * 'counter', + * { value: 0 }, + * (builder) => { + * builder.addCase(increment, (state) => { + * state.value++; + * }); + * }, + * { + * // Optional: Specify a different path in the root state. + * nestedPath: 'nested.counter', + * // Optional: Transform state before saving. + * onPersist: (state) => ({ value: state.value.toString() }), + * // Optional: Transform state after loading from storage. + * onRehydrate: (persistedState) => ({ value: parseInt(persistedState.value, 10) }), + * } + * ); + * ``` */ export const createPersistedReducer = < ReducerName extends string, S extends NotFunction, + SavedState, Nesting extends NestedPath = ReducerName, >( reducerName: ReducerName, initialState: S | (() => S), mapOrBuilderCallback: (builder: ActionReducerMapBuilder) => void, - nestedPath?: Nesting, + persistenceOptions?: ReducerPersistenceOptions, ): PersistedReducer => { // Register the reducer for persistence tracking. Settings.subscribeSlice(reducerName); @@ -50,8 +76,9 @@ export const createPersistedReducer = < /** * The full dot-separated path to the reducer's state within the root state object. * Defaults to the reducer's name if `nestedPath` is not provided. + * @internal */ - const finalNestedPath = (nestedPath ?? reducerName) as Nesting; + const finalNestedPath = (persistenceOptions?.nestedPath ?? reducerName) as Nesting; /** * Debounces the `writePersistedStorage` function to prevent excessive writes @@ -63,7 +90,17 @@ export const createPersistedReducer = < if (debounceTimeout) clearTimeout(debounceTimeout); debounceTimeout = setTimeout(() => { const reducerState = deepGetByPath(state, finalNestedPath); - writePersistedStorage(reducerState, reducerName); + if (reducerState === null) { + if (process.env.NODE_ENV !== 'production') { + console.error(`DUMP: No state found for ${reducerName}, check if the nestedPath is corrected.`); + } + } else { + if (persistenceOptions && 'onPersist' in persistenceOptions) { + writePersistedStorage(persistenceOptions.onPersist(reducerState), reducerName); + } else { + writePersistedStorage(reducerState, reducerName); + } + } }, 100); }; @@ -82,10 +119,16 @@ export const createPersistedReducer = < // Add a case to handle the rehydration of state from storage. builder.addCase( REHYDRATE.toString(), - (_state, action: PayloadAction>): + (_state, action: PayloadAction>): | void | S => { - if (action.payload?.[reducerName]) return action.payload[reducerName]; + if (action.payload?.[reducerName]) { + if (persistenceOptions && 'onRehydrate' in persistenceOptions) { + return persistenceOptions.onRehydrate(action.payload[reducerName] as SavedState); + } else { + return action.payload[reducerName] as S; + } + } }, ); // Wrap the builder to automatically track state changes for persistence. diff --git a/src/core/slice.ts b/src/core/slice.ts index 46f3136..853bf0f 100644 --- a/src/core/slice.ts +++ b/src/core/slice.ts @@ -8,7 +8,12 @@ import { import { Builder } from './extraReducersBuilder'; import { listenerMiddleware } from './middleware'; import Settings from './settings'; -import { NestedPath, PersistedSlice, RehydrateActionPayload } from './types'; +import { + NestedPath, + PersistedSlice, + RehydrateActionPayload, + SlicePersistenceOptions, +} from './types'; import UpdatedAtHelper from './updatedAtHelper'; import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils'; @@ -19,17 +24,48 @@ import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils'; * * This function must be used with a store configured by `configurePersistedStore`. * + * @public * @param sliceOptions - The standard `CreateSliceOptions` object from Redux Toolkit. - * @param nestedPath - An optional dot-separated string path indicating where the - * slice's state is located within the root state. If not provided, `reducerPath` - * or `name` from `sliceOptions` is used. + * @param persistenceOptions - Optional configuration for persistence behavior. + * @param persistenceOptions.nestedPath - A dot-separated string path indicating where the + * slice's state is located within the root state. If not provided, it defaults to + * `reducerPath` or `name` from `sliceOptions`. + * @param persistenceOptions.onPersist - A function that transforms the slice's state + * *before* it is saved to storage. This is useful for saving a different version of the state + * than what is used in the application. + * @param persistenceOptions.onRehydrate - A function that transforms the state *after* it is + * loaded from storage but *before* it is placed in the Redux store. This is useful for + * migrating old state shapes or re-instantiating complex objects. * @returns A Redux slice object with persistence enabled, augmented with a - * `nestedPath` property for state tracking. + * `nestedPath` property for internal state tracking. * - * @public + * @example + * // Basic usage + * const counterSlice = createPersistedSlice({ + * name: 'counter', + * initialState: { value: 0 }, + * reducers: { + * increment: (state) => { + * state.value += 1; + * }, + * }, + * }); + * + * // Usage with persistence options + * const userSlice = createPersistedSlice({ + * name: 'user', + * initialState: { data: null, loadedAt: null }, + * reducers: { + * // ... + * }, + * }, { + * onRehydrate: (savedState) => ({ ...savedState, loadedAt: new Date() }), + * onPersist: (state) => ({ data: state.data }), // Only persist the 'data' field + * }); */ export const createPersistedSlice = < SliceState, + SavedState, Name extends string, PCR extends SliceCaseReducers, ReducerPath extends string = Name, @@ -43,7 +79,13 @@ export const createPersistedSlice = < ReducerPath, PersistedSelectors >, - nestedPath?: Nesting, + persistenceOptions?: SlicePersistenceOptions< + SliceState, + SavedState, + Name, + ReducerPath, + Nesting + >, ): PersistedSlice< SliceState, PCR, @@ -63,8 +105,9 @@ export const createPersistedSlice = < /** * The full dot-separated path to the slice's state within the root state object. + * @internal */ - const finalNestedPath = (nestedPath ?? + const finalNestedPath = (persistenceOptions?.nestedPath ?? sliceOptions.reducerPath ?? sliceOptions.name) as Nesting; @@ -78,7 +121,20 @@ export const createPersistedSlice = < if (debounceTimeout) clearTimeout(debounceTimeout); debounceTimeout = setTimeout(() => { const sliceState = deepGetByPath(state, finalNestedPath); - writePersistedStorage(sliceState, sliceOptions.name); + if (sliceState === null) { + if (process.env.NODE_ENV !== 'production') { + console.error(`DUMP: No state found for ${sliceOptions.name}, check if the nestedPath is corrected.`); + } + } else { + if (persistenceOptions && 'onPersist' in persistenceOptions) { + writePersistedStorage( + persistenceOptions.onPersist(sliceState), + sliceOptions.name, + ); + } else { + writePersistedStorage(sliceState, sliceOptions.name); + } + } }, 100); }; @@ -101,10 +157,19 @@ export const createPersistedSlice = < REHYDRATE.toString(), ( _state, - action: PayloadAction>, + action: PayloadAction< + RehydrateActionPayload + >, ): void | SliceState => { - if (action.payload?.[sliceOptions.name]) - return action.payload[sliceOptions.name]; + if (action.payload?.[sliceOptions.name]) { + if (persistenceOptions && 'onRehydrate' in persistenceOptions) { + return persistenceOptions.onRehydrate( + action.payload[sliceOptions.name] as SavedState, + ); + } else { + return action.payload[sliceOptions.name] as SliceState; + } + } }, ); // Wrap the builder to automatically track state changes for persistence. diff --git a/src/core/store.ts b/src/core/store.ts index 46e4867..23c0a88 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -15,8 +15,8 @@ import { ExtractDispatchExtensions, Middlewares, PersistedStore, - PersistenceOptions, StorageHandler, + StorePersistenceOptions, ThunkMiddlewareFor, } from './types'; import { clearPersistedStorage, getStoredState, REHYDRATE } from './utils'; @@ -64,7 +64,7 @@ export const configurePersistedStore: < options: ConfigureStoreOptions>, E, P>, applicationId: string, storageHandler: StorageHandler, - persistenceOptions?: PersistenceOptions, + persistenceOptions?: StorePersistenceOptions, ) => Promise> = async < S extends Record = any, A extends Action = UnknownAction, @@ -82,7 +82,7 @@ export const configurePersistedStore: < options: ConfigureStoreOptions>, E, P>, applicationId: string, storageHandler: StorageHandler, - persistenceOptions: PersistenceOptions = { + persistenceOptions: StorePersistenceOptions = { rehydrationTimeout: 5000, }, ) => { diff --git a/src/core/types.ts b/src/core/types.ts index 92ebf07..26d97d7 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -16,9 +16,9 @@ import { } from '@reduxjs/toolkit'; /** - * Defines the interface for a storage handler, allowing `rtk-persist` to - * work with different storage mechanisms like web `localStorage` or React - * Native's `AsyncStorage`. + * Defines the interface for a storage handler, enabling `rtk-persist` to + * integrate with various storage mechanisms like web `localStorage` or + * React Native's `AsyncStorage`. * * @example * // For web environments: @@ -30,17 +30,25 @@ import { * @public */ export interface StorageHandler { - /** Saves a key-value pair to the storage. */ + /** * Saves a key-value pair to the storage. + * @param key - The key under which to store the value. + * @param value - The string value to store. + */ setItem: (key: string, value: string) => Promise | void; - /** Retrieves a value from storage by its key. */ + /** * Retrieves a value from storage by its key. + * @param key - The key of the item to retrieve. + * @returns A promise resolving to the value, or the value directly, or null if not found. + */ getItem: (key: string) => Promise | string | null; - /** Removes a key-value pair from storage. */ + /** * Removes a key-value pair from storage. + * @param key - The key of the item to remove. + */ removeItem: (key: string) => Promise | void; } /** * Defines the shape of the payload for the internal `REHYDRATE` action. - * It's a record where keys are slice names and values are their persisted states. + * This is a record where keys are slice names and values are their persisted states. * @internal */ export type RehydrateActionPayload< @@ -48,11 +56,6 @@ export type RehydrateActionPayload< State = unknown, > = Record | null; -// --- Internal RTK Types --- -// These types are re-exported or re-defined from Redux Toolkit to ensure -// proper type inference in `configurePersistedStore`. They are not intended -// for direct use. - /** @internal */ export type Enhancers = ReadonlyArray; /** @internal */ @@ -107,16 +110,16 @@ export type ExtractDispatchExtensions = M extends Tuple = T extends Function ? never : T; /** - * A utility type that defines a valid nesting path for a persisted slice or reducer. - * The path must be a dot-notation string that ends with the slice/reducer's name. - * An empty string is also valid for root-level items. + * Defines a valid nesting path for a persisted slice or reducer. + * The path is a dot-notation string that must end with the slice/reducer's name. + * An empty string is also valid for items at the root level of the state. * * @template Path - The name or path of the reducer, which must be the final segment. */ @@ -131,15 +134,15 @@ export type PersistedReducer< ReducerName extends string, Nesting extends NestedPath = ReducerName, > = Reducer & { - /** The unique name of the reducer, used as the storage key. */ + /** The unique name of the reducer, used as the key in storage. */ reducerName: ReducerName; - /** The full dot-separated path to the reducer's state in the root state object. */ + /** The full dot-separated path to the reducer's state within the root state object. */ nestedPath: Nesting; }; /** * An enhanced Redux slice that includes the `nestedPath` property for - * tracking its location within the root state. + * tracking its location within the root state, allowing for persistence of nested state. * @public */ export type PersistedSlice< @@ -150,7 +153,7 @@ export type PersistedSlice< PersistedSelectors extends SliceSelectors, Nesting extends NestedPath = ReducerPath, > = Slice & { - /** The full dot-separated path to the slice's state in the root state object. */ + /** The full dot-separated path to the slice's state within the root state object. */ nestedPath: Nesting; }; @@ -175,21 +178,69 @@ export type PersistedStore< > = EnhancedStore & { /** * Manually triggers the rehydration of the store's state from storage. + * This is useful for scenarios like re-authenticating a user. * @returns A promise that resolves when rehydration is complete. */ rehydrate: () => Promise; /** - * Clears all persisted state for the application from storage. + * Clears all persisted state for this store from the storage engine. * @returns A promise that resolves when the state has been cleared. */ clearPersistedState: () => Promise; }; /** - * Defines configuration options for the persistence and rehydration process. + * Defines global configuration options for the store's persistence and rehydration process. * @public */ -export interface PersistenceOptions { +export interface StorePersistenceOptions { /** The maximum time in milliseconds to wait for rehydration before timing out. Defaults to 5000. */ rehydrationTimeout?: number; } + +/** + * Defines persistence options for an individual slice, allowing for custom + * serialization and deserialization logic. + * @public + */ +export type SlicePersistenceOptions< + SliceState, + SavedState, + Name extends string, + ReducerPath extends string = Name, + Nesting extends NestedPath = ReducerPath, +> = { + /** + * Specifies the dot-notation path to the slice's state if it's nested. + * If not provided, it's assumed to be at the root. + */ + nestedPath?: Nesting; +} & ({ + /** A function to transform the slice's state before it's saved to storage. */ + onPersist: (sliceState: SliceState) => SavedState; + /** A function to transform the saved state back into the slice's state upon rehydration. */ + onRehydrate: (savedState: SavedState) => SliceState; +} | {}); + +/** + * Defines persistence options for an individual reducer, allowing for custom + * serialization and deserialization logic. + * @public + */ +export type ReducerPersistenceOptions< + ReducerName extends string, + S extends NotFunction, + SavedState, + Nesting extends NestedPath = ReducerName, +> = { + /** + * Specifies the dot-notation path to the reducer's state if it's nested. + * If not provided, it's assumed to be at the root. + */ + nestedPath?: Nesting; +} & ({ + /** A function to transform the reducer's state before it's saved to storage. */ + onPersist: (sliceState: S) => SavedState; + /** A function to transform the saved state back into the reducer's state upon rehydration. */ + onRehydrate: (savedState: SavedState) => S; +} | {}); diff --git a/src/core/utils.ts b/src/core/utils.ts index 64b3435..b7c87f1 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -101,7 +101,7 @@ export const deepGet = ( for (const key of keys) { // If at any point the path leads to a non-object (and is not the end of the path), // we cannot go deeper, so the path is considered invalid. - if (typeof current !== 'object' || current === null) { + if (typeof current !== 'object' || current === null || !(key in current)) { return null; } current = current[key]; diff --git a/test/core/reducer.test.ts b/test/core/reducer.test.ts index ffba3f5..164860e 100644 --- a/test/core/reducer.test.ts +++ b/test/core/reducer.test.ts @@ -6,15 +6,19 @@ */ import { createAction, PayloadAction } from '@reduxjs/toolkit'; +import { listenerMiddleware } from '../../src/core/middleware'; import { createPersistedReducer } from '../../src/core/reducer'; -import { RehydrateActionPayload } from '../../src/core/types'; +import { configurePersistedStore } from '../../src/core/store'; +import { RehydrateActionPayload, StorageHandler } from '../../src/core/types'; import { REHYDRATE } from '../../src/core/utils'; +import { flushTimersAndPromises, StorageMock } from './mocks'; describe('createPersistedReducer', () => { // --- Test Setup --- const reducerName = 'test'; const initialState = { value: 0 }; const increment = createAction('increment'); + let storage: StorageHandler; // A mock callback that defines the reducer's logic. const mapOrBuilderCallback = jest.fn((builder: any) => { @@ -27,7 +31,16 @@ describe('createPersistedReducer', () => { }); beforeEach(() => { + // Set up a fresh mock storage and use fake timers for debounce control. + storage = new StorageMock(); mapOrBuilderCallback.mockClear(); + listenerMiddleware.clearListeners(); + jest.useFakeTimers(); + }); + + afterEach(() => { + // Clean up timers after each test. + jest.useRealTimers(); }); // --- Core Reducer Functionality --- @@ -77,7 +90,9 @@ describe('createPersistedReducer', () => { reducerName, initialState, mapOrBuilderCallback, - nestedPath, + { + nestedPath, + }, ); expect(reducer.nestedPath).toBe(nestedPath); }); @@ -149,6 +164,133 @@ describe('createPersistedReducer', () => { }); }); + // --- Persistence Options --- + + describe('Persistence Options (onPersist/onRehydrate)', () => { + it('should use onPersist to transform state before persisting', async () => { + // Arrange: onPersist will omit the 'sensitive' field. + const update = createAction('update'); + const reducer = createPersistedReducer( + 'sensitive', + { sensitive: 'secret', safe: 'public' }, + (builder) => { + builder.addCase(update, (state) => { + state.sensitive = 'new-secret'; + state.safe = 'new-public'; + }); + }, + { + onPersist: (state) => ({ safe: state.safe }), // Only persist the 'safe' field + onRehydrate: (savedState) => ({ safe: savedState.safe, sensitive: 'secret' }) + }, + ); + const store = await configurePersistedStore( + { reducer: { sensitive: reducer } }, + 'testApp1', + storage, + ); + await flushTimersAndPromises(); + + // Act + store.dispatch(update()); + await flushTimersAndPromises(); + + // Assert: The stored data should be transformed. + const persistedState = await storage.getItem(`persist:testApp1-sensitive`); + const parsed = JSON.parse(persistedState!); + expect(parsed.safe).toBe('new-public'); + expect(parsed.sensitive).toBeUndefined(); + }); + + it('should use onRehydrate to transform state on startup', async () => { + // Arrange: Pre-seed storage with a legacy data format. + await storage.setItem(`persist:testApp2-legacy`, '{"v1_data": 42}'); + const reducer = createPersistedReducer( + 'legacy', + { version: 2, data: 0 }, + () => {}, + { + onPersist: (state) => ({ + v1_data: state.data, + }), + onRehydrate: (savedState) => ({ + version: 2, // Add new field + data: savedState.v1_data, // Map old field to new field + }), + }, + ); + + // Act: Configure the store to trigger rehydration. + const store1 = await configurePersistedStore( + { reducer: { legacy: reducer } }, + 'testApp2', + storage, + ); + await flushTimersAndPromises(); + + // Assert: The state should be correctly transformed. + expect(store1.getState().legacy).toEqual({ version: 2, data: 42 }); + }); + + it('should correctly perform a round-trip with onPersist and onRehydrate', async () => { + // Arrange: Use onPersist to minify state and onRehydrate to expand it. + type SliceState = { user: string; lastLogin: number }; + type SavedState = { u: string; l: number }; + + const login = createAction('login'); + + const reducer = createPersistedReducer( + 'minified', + { user: '', lastLogin: 0 } as SliceState, + (builder) => { + builder.addCase(login, (state, action: PayloadAction) => { + state.user = action.payload.user; + state.lastLogin = action.payload.lastLogin; + }); + }, + { + onPersist: (state: SliceState): SavedState => ({ + u: state.user, + l: state.lastLogin, + }), + onRehydrate: (saved: SavedState): SliceState => ({ + user: saved.u, + lastLogin: saved.l, + }), + }, + ); + + // --- Part 1: Persist the data --- + const store1 = await configurePersistedStore( + { reducer: { minified: reducer } }, + 'testApp3', + storage, + ); + await flushTimersAndPromises(); + + store1.dispatch(login({ user: 'test', lastLogin: 123 })); + await flushTimersAndPromises(); + + // Assert that the minified version was stored. + const persisted = await storage.getItem(`persist:testApp3-minified`); + expect(JSON.parse(persisted!)).toEqual({ u: 'test', l: 123 }); + + // --- Part 2: Rehydrate the data --- + const store2 = await configurePersistedStore( + { reducer: { minified: reducer } }, + 'testApp3', + storage, + ); + await flushTimersAndPromises(); + + // Assert that the state was correctly expanded upon rehydration. + expect(store2.getState().minified).toEqual({ + user: 'test', + lastLogin: 123, + }); + }); + }); + // --- Advanced Builder Cases --- describe('Advanced Builder Cases', () => { diff --git a/test/core/slice.test.ts b/test/core/slice.test.ts index bc95587..2f13552 100644 --- a/test/core/slice.test.ts +++ b/test/core/slice.test.ts @@ -5,6 +5,7 @@ */ import { combineReducers, createAction, PayloadAction } from '@reduxjs/toolkit'; +import { listenerMiddleware } from '../../src/core/middleware'; import { TestSettings } from '../../src/core/settings'; import { createPersistedSlice } from '../../src/core/slice'; import { configurePersistedStore } from '../../src/core/store'; @@ -17,6 +18,7 @@ describe('createPersistedSlice', () => { beforeEach(() => { // Set up a fresh mock storage and use fake timers for debounce control. storage = new StorageMock(); + listenerMiddleware.clearListeners(); TestSettings.restoreDefaults(); jest.useFakeTimers(); }); @@ -201,7 +203,9 @@ describe('createPersistedSlice', () => { }, }, }, - 'nested.sliceB', + { + nestedPath: 'nested.sliceB', + }, ); const nestedReducer = combineReducers({ [sliceB.name]: sliceB.reducer }); const store = await configurePersistedStore( @@ -323,4 +327,132 @@ describe('createPersistedSlice', () => { }); }); }); + + describe('Persistence Options (onPersist/onRehydrate)', () => { + it('should use onPersist to transform state before persisting', async () => { + // Arrange: onPersist will omit the 'sensitive' field. + const slice = createPersistedSlice( + { + name: 'secure', + initialState: { sensitive: 'secret', safe: 'public' }, + reducers: { + update: (state) => { + state.sensitive = 'new-secret'; + state.safe = 'new-public'; + }, + }, + }, + { + onPersist: (state) => ({ safe: state.safe }), // Only persist the 'safe' field + onRehydrate: (savedState) => ({ safe: savedState.safe, sensitive: 'secret' }) + }, + ); + const store = await configurePersistedStore( + { reducer: { [slice.name]: slice.reducer } }, + 'testApp', + storage, + ); + await flushTimersAndPromises(); + + // Act + store.dispatch(slice.actions.update()); + await flushTimersAndPromises(); + + // Assert: The stored data should be transformed. + const persistedState = await storage.getItem('persist:testApp-secure'); + const parsed = JSON.parse(persistedState!); + expect(parsed.safe).toBe('new-public'); + expect(parsed.sensitive).toBeUndefined(); + }); + + it('should use onRehydrate to transform state on startup', async () => { + // Arrange: Pre-seed storage with a legacy data format. + await storage.setItem('persist:testApp-legacy', '{"v1_data": 42}'); + const slice = createPersistedSlice( + { + name: 'legacy', + initialState: { version: 2, data: 0 }, + reducers: {}, + }, + { + onPersist: (state) => ({ + v1_data: state.data, + }), + onRehydrate: (savedState) => ({ + version: 2, // Add new field + data: savedState.v1_data, // Map old field to new field + }), + }, + ); + + // Act: Configure the store to trigger rehydration. + const store = await configurePersistedStore( + { reducer: { [slice.name]: slice.reducer } }, + 'testApp', + storage, + ); + await flushTimersAndPromises(); + + // Assert: The state should be correctly transformed. + expect(store.getState().legacy).toEqual({ version: 2, data: 42 }); + }); + + it('should correctly perform a round-trip with onPersist and onRehydrate', async () => { + // Arrange: Use onPersist to minify state and onRehydrate to expand it. + type SliceState = { user: string; lastLogin: number }; + type SavedState = { u: string; l: number }; + + const slice = createPersistedSlice( + { + name: 'minified', + initialState: { user: '', lastLogin: 0 } as SliceState, + reducers: { + login: (state, action: PayloadAction) => { + state.user = action.payload.user; + state.lastLogin = action.payload.lastLogin; + }, + }, + }, + { + onPersist: (state: SliceState): SavedState => ({ + u: state.user, + l: state.lastLogin, + }), + onRehydrate: (saved: SavedState): SliceState => ({ + user: saved.u, + lastLogin: saved.l, + }), + }, + ); + + // --- Part 1: Persist the data --- + const store1 = await configurePersistedStore( + { reducer: { [slice.name]: slice.reducer } }, + 'testApp', + storage, + ); + store1.dispatch(slice.actions.login({ user: 'test', lastLogin: 123 })); + await flushTimersAndPromises(); + + // Assert that the minified version was stored. + const persisted = await storage.getItem('persist:testApp-minified'); + expect(JSON.parse(persisted!)).toEqual({ u: 'test', l: 123 }); + + // --- Part 2: Rehydrate the data --- + TestSettings.restoreDefaults(); // Reset settings for a clean startup + TestSettings.subscribeSlice(slice.name); + const store2 = await configurePersistedStore( + { reducer: { [slice.name]: slice.reducer } }, + 'testApp', + storage, + ); + await flushTimersAndPromises(); + + // Assert that the state was correctly expanded upon rehydration. + expect(store2.getState().minified).toEqual({ + user: 'test', + lastLogin: 123, + }); + }); + }); }); diff --git a/test/core/store.test.ts b/test/core/store.test.ts index 408008a..6ee1125 100644 --- a/test/core/store.test.ts +++ b/test/core/store.test.ts @@ -5,6 +5,7 @@ */ import { createAction } from '@reduxjs/toolkit'; +import { listenerMiddleware } from '../../src/core/middleware'; import { createPersistedReducer } from '../../src/core/reducer'; import { TestSettings } from '../../src/core/settings'; import { createPersistedSlice } from '../../src/core/slice'; @@ -19,6 +20,7 @@ describe('configurePersistedStore', () => { beforeEach(() => { // Set up a fresh mock storage for each test and use fake timers. storage = new StorageMock(); + listenerMiddleware.clearListeners(); TestSettings.restoreDefaults(); jest.useFakeTimers(); }); @@ -116,7 +118,9 @@ describe('configurePersistedStore', () => { state.value += 1; }); }, - '', // Empty string signifies a root reducer. + { + nestedPath: '', // Empty string signifies a root reducer. + }, ); // Act @@ -212,7 +216,7 @@ describe('configurePersistedStore', () => { const sliceA = createPersistedSlice( { name: 'sliceA', initialState: { value: 'a' }, reducers: {} }, - 'level1.level2.sliceA', + { nestedPath: 'level1.level2.sliceA' }, ); // This should compile without errors.