Skip to content

Commit ba86faa

Browse files
Fix tests and update onPersist and onRehydrate functions
1 parent b56e209 commit ba86faa

File tree

7 files changed

+439
-61
lines changed

7 files changed

+439
-61
lines changed

src/core/reducer.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,51 @@ import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils';
1818

1919
/**
2020
* Creates a persisted reducer that wraps Redux Toolkit's `createReducer`.
21-
* This function enhances a standard reducer with automatic state persistence,
22-
* saving its state to storage and rehydrating it on app startup.
2321
*
24-
* This function must be used with a store configured by `configurePersistedStore`.
22+
* This function enhances a standard reducer with automatic state persistence,
23+
* saving its state to storage and rehydrating it when the application starts.
24+
* It must be used with a store configured by `configurePersistedStore`.
2525
*
26-
* @param reducerName - A unique string name for the reducer. This name serves as the key in both the root state and the storage.
27-
* @param initialState - The initial state for the reducer.
28-
* @param mapOrBuilderCallback - A callback that receives a `builder` object to define case reducers, similar to the original `createReducer`.
29-
* @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.
3026
* @public
27+
* @param reducerName - A unique name for the reducer. This name is used as the key
28+
* in both the root state and the underlying storage.
29+
* @param initialState - The initial state for the reducer, same as in `createReducer`.
30+
* @param mapOrBuilderCallback - A callback that receives a `builder` object to define
31+
* case reducers. This builder is wrapped to automatically track state changes for persistence.
32+
* @param persistenceOptions - Optional configuration for customizing persistence behavior.
33+
* @returns A reducer function enhanced with persistence capabilities.
34+
*
35+
* @example
36+
* ```typescript
37+
* const counterReducer = createPersistedReducer(
38+
* 'counter',
39+
* { value: 0 },
40+
* (builder) => {
41+
* builder.addCase(increment, (state) => {
42+
* state.value++;
43+
* });
44+
* },
45+
* {
46+
* // Optional: Specify a different path in the root state.
47+
* nestedPath: 'nested.counter',
48+
* // Optional: Transform state before saving.
49+
* onPersist: (state) => ({ value: state.value.toString() }),
50+
* // Optional: Transform state after loading from storage.
51+
* onRehydrate: (persistedState) => ({ value: parseInt(persistedState.value, 10) }),
52+
* }
53+
* );
54+
* ```
3155
*/
3256
export const createPersistedReducer = <
3357
ReducerName extends string,
3458
S extends NotFunction<any>,
59+
SavedState,
3560
Nesting extends NestedPath<ReducerName> = ReducerName,
3661
>(
3762
reducerName: ReducerName,
3863
initialState: S | (() => S),
3964
mapOrBuilderCallback: (builder: ActionReducerMapBuilder<S>) => void,
40-
persistenceOptions?: ReducerPersistenceOptions<ReducerName, S, Nesting>,
65+
persistenceOptions?: ReducerPersistenceOptions<ReducerName, S, SavedState, Nesting>,
4166
): PersistedReducer<S, ReducerName, Nesting> => {
4267
// Register the reducer for persistence tracking.
4368
Settings.subscribeSlice(reducerName);
@@ -51,6 +76,7 @@ export const createPersistedReducer = <
5176
/**
5277
* The full dot-separated path to the reducer's state within the root state object.
5378
* Defaults to the reducer's name if `nestedPath` is not provided.
79+
* @internal
5480
*/
5581
const finalNestedPath = (persistenceOptions?.nestedPath ?? reducerName) as Nesting;
5682

@@ -64,10 +90,16 @@ export const createPersistedReducer = <
6490
if (debounceTimeout) clearTimeout(debounceTimeout);
6591
debounceTimeout = setTimeout(() => {
6692
const reducerState = deepGetByPath(state, finalNestedPath);
67-
if (persistenceOptions && 'onDump' in persistenceOptions) {
68-
writePersistedStorage(persistenceOptions.onDump(reducerState), reducerName);
93+
if (reducerState === null) {
94+
if (process.env.NODE_ENV !== 'production') {
95+
console.error(`DUMP: No state found for ${reducerName}, check if the nestedPath is corrected.`);
96+
}
6997
} else {
70-
writePersistedStorage(reducerState, reducerName);
98+
if (persistenceOptions && 'onPersist' in persistenceOptions) {
99+
writePersistedStorage(persistenceOptions.onPersist(reducerState), reducerName);
100+
} else {
101+
writePersistedStorage(reducerState, reducerName);
102+
}
71103
}
72104
}, 100);
73105
};
@@ -87,14 +119,14 @@ export const createPersistedReducer = <
87119
// Add a case to handle the rehydration of state from storage.
88120
builder.addCase(
89121
REHYDRATE.toString(),
90-
(_state, action: PayloadAction<RehydrateActionPayload<ReducerName, S>>):
122+
(_state, action: PayloadAction<RehydrateActionPayload<ReducerName, S | SavedState>>):
91123
| void
92124
| S => {
93125
if (action.payload?.[reducerName]) {
94126
if (persistenceOptions && 'onRehydrate' in persistenceOptions) {
95-
return persistenceOptions.onRehydrate(action.payload[reducerName]);
127+
return persistenceOptions.onRehydrate(action.payload[reducerName] as SavedState);
96128
} else {
97-
return action.payload[reducerName];
129+
return action.payload[reducerName] as S;
98130
}
99131
}
100132
},

src/core/slice.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
import { Builder } from './extraReducersBuilder';
99
import { listenerMiddleware } from './middleware';
1010
import Settings from './settings';
11-
import { NestedPath, PersistedSlice, RehydrateActionPayload, SlicePersistenceOptions } from './types';
11+
import {
12+
NestedPath,
13+
PersistedSlice,
14+
RehydrateActionPayload,
15+
SlicePersistenceOptions,
16+
} from './types';
1217
import UpdatedAtHelper from './updatedAtHelper';
1318
import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils';
1419

@@ -19,17 +24,48 @@ import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils';
1924
*
2025
* This function must be used with a store configured by `configurePersistedStore`.
2126
*
27+
* @public
2228
* @param sliceOptions - The standard `CreateSliceOptions` object from Redux Toolkit.
23-
* @param nestedPath - An optional dot-separated string path indicating where the
24-
* slice's state is located within the root state. If not provided, `reducerPath`
25-
* or `name` from `sliceOptions` is used.
29+
* @param persistenceOptions - Optional configuration for persistence behavior.
30+
* @param persistenceOptions.nestedPath - A dot-separated string path indicating where the
31+
* slice's state is located within the root state. If not provided, it defaults to
32+
* `reducerPath` or `name` from `sliceOptions`.
33+
* @param persistenceOptions.onPersist - A function that transforms the slice's state
34+
* *before* it is saved to storage. This is useful for saving a different version of the state
35+
* than what is used in the application.
36+
* @param persistenceOptions.onRehydrate - A function that transforms the state *after* it is
37+
* loaded from storage but *before* it is placed in the Redux store. This is useful for
38+
* migrating old state shapes or re-instantiating complex objects.
2639
* @returns A Redux slice object with persistence enabled, augmented with a
27-
* `nestedPath` property for state tracking.
40+
* `nestedPath` property for internal state tracking.
2841
*
29-
* @public
42+
* @example
43+
* // Basic usage
44+
* const counterSlice = createPersistedSlice({
45+
* name: 'counter',
46+
* initialState: { value: 0 },
47+
* reducers: {
48+
* increment: (state) => {
49+
* state.value += 1;
50+
* },
51+
* },
52+
* });
53+
*
54+
* // Usage with persistence options
55+
* const userSlice = createPersistedSlice({
56+
* name: 'user',
57+
* initialState: { data: null, loadedAt: null },
58+
* reducers: {
59+
* // ...
60+
* },
61+
* }, {
62+
* onRehydrate: (savedState) => ({ ...savedState, loadedAt: new Date() }),
63+
* onPersist: (state) => ({ data: state.data }), // Only persist the 'data' field
64+
* });
3065
*/
3166
export const createPersistedSlice = <
3267
SliceState,
68+
SavedState,
3369
Name extends string,
3470
PCR extends SliceCaseReducers<SliceState>,
3571
ReducerPath extends string = Name,
@@ -43,7 +79,13 @@ export const createPersistedSlice = <
4379
ReducerPath,
4480
PersistedSelectors
4581
>,
46-
persistenceOptions?: SlicePersistenceOptions<SliceState, Name, ReducerPath, Nesting>,
82+
persistenceOptions?: SlicePersistenceOptions<
83+
SliceState,
84+
SavedState,
85+
Name,
86+
ReducerPath,
87+
Nesting
88+
>,
4789
): PersistedSlice<
4890
SliceState,
4991
PCR,
@@ -63,6 +105,7 @@ export const createPersistedSlice = <
63105

64106
/**
65107
* The full dot-separated path to the slice's state within the root state object.
108+
* @internal
66109
*/
67110
const finalNestedPath = (persistenceOptions?.nestedPath ??
68111
sliceOptions.reducerPath ??
@@ -78,10 +121,19 @@ export const createPersistedSlice = <
78121
if (debounceTimeout) clearTimeout(debounceTimeout);
79122
debounceTimeout = setTimeout(() => {
80123
const sliceState = deepGetByPath(state, finalNestedPath);
81-
if (persistenceOptions && 'onDump' in persistenceOptions) {
82-
writePersistedStorage(persistenceOptions.onDump(sliceState), sliceOptions.name);
124+
if (sliceState === null) {
125+
if (process.env.NODE_ENV !== 'production') {
126+
console.error(`DUMP: No state found for ${sliceOptions.name}, check if the nestedPath is corrected.`);
127+
}
83128
} else {
84-
writePersistedStorage(sliceState, sliceOptions.name);
129+
if (persistenceOptions && 'onPersist' in persistenceOptions) {
130+
writePersistedStorage(
131+
persistenceOptions.onPersist(sliceState),
132+
sliceOptions.name,
133+
);
134+
} else {
135+
writePersistedStorage(sliceState, sliceOptions.name);
136+
}
85137
}
86138
}, 100);
87139
};
@@ -105,13 +157,17 @@ export const createPersistedSlice = <
105157
REHYDRATE.toString(),
106158
(
107159
_state,
108-
action: PayloadAction<RehydrateActionPayload<Name, SliceState>>,
160+
action: PayloadAction<
161+
RehydrateActionPayload<Name, SliceState | SavedState>
162+
>,
109163
): void | SliceState => {
110164
if (action.payload?.[sliceOptions.name]) {
111165
if (persistenceOptions && 'onRehydrate' in persistenceOptions) {
112-
return persistenceOptions.onRehydrate(action.payload[sliceOptions.name]);
166+
return persistenceOptions.onRehydrate(
167+
action.payload[sliceOptions.name] as SavedState,
168+
);
113169
} else {
114-
return action.payload[sliceOptions.name];
170+
return action.payload[sliceOptions.name] as SliceState;
115171
}
116172
}
117173
},

0 commit comments

Comments
 (0)