Skip to content

Commit 698ecd7

Browse files
Update tests and example
1 parent 2640cfa commit 698ecd7

File tree

8 files changed

+275
-206
lines changed

8 files changed

+275
-206
lines changed

example/src/main.tsx

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,17 @@ import { createRoot } from 'react-dom/client'
33
import { Provider } from 'react-redux'
44
import App from './App.tsx'
55
import './index.css'
6-
import { store as storePromise } from './state/store.ts'
6+
import { store } from './state/store.ts'
77

88
// Get the root element from the DOM.
99
const rootElement = document.getElementById('root')!;
1010
const root = createRoot(rootElement);
1111

12-
// Create an async function to initialize and render the app.
13-
const startApp = async () => {
14-
// Await the store promise to ensure the store is created and rehydrated.
15-
const store = await storePromise;
16-
17-
// Once the store is ready, render the application.
18-
root.render(
19-
<StrictMode>
20-
<Provider store={store}>
21-
<App />
22-
</Provider>
23-
</StrictMode>
24-
);
25-
};
26-
27-
// Call the async function to start the application.
28-
startApp();
12+
// Render the application.
13+
root.render(
14+
<StrictMode>
15+
<Provider store={store}>
16+
<App />
17+
</Provider>
18+
</StrictMode>
19+
);

example/src/state/store.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,5 @@ export const store = configurePersistedStore({
1111
},
1212
}, 'countersApp', localStorage);
1313

14-
export type Store = Awaited<typeof store>;
15-
export type RootState = ReturnType<Store['getState']>;
16-
export type AppDispatch = Store['dispatch'];
14+
export type RootState = ReturnType<typeof store.getState>;
15+
export type AppDispatch = typeof store.dispatch;

example/yarn.lock

Lines changed: 125 additions & 125 deletions
Large diffs are not rendered by default.

src/store.ts

Lines changed: 42 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import { Action, configureStore, ConfigureStoreOptions, createDynamicMiddleware, createListenerMiddleware, StoreEnhancer, Tuple, UnknownAction } from "@reduxjs/toolkit";
22
import { listenerMiddleware } from "./middleware";
33
import Settings from "./settings";
4-
import { Enhancers, ExtractDispatchExtensions, Middlewares, PersistedStore, StorageHandler, ThunkMiddlewareFor } from "./types";
4+
import { Enhancers, ExtractDispatchExtensions, Middlewares, PersistedStore, PersistenceOptions, StorageHandler, ThunkMiddlewareFor } from "./types";
55
import { clearPersistedStorage, getStoredState, REHYDRATE } from "./utils";
66

77
/**
8-
* A friendly encapsulation of the standard RTK `configureStore()` function
9-
* to add the option to persist slices.
8+
* Encapsulates the standard RTK `configureStore()` function to add state persistence.
109
*
11-
* @param options The store configuration.
12-
* @param applicationId The unique ID that identifies the application.
13-
* @param storageHandler The storage handler to use to persist the data.
14-
* @returns A promise that resolves to a configured Redux store, enhanced with persistence capabilities.
10+
* This function creates a Redux store that automatically saves and reloads specified
11+
* slices of the state from a given storage medium. The initial rehydration is
12+
* handled asynchronously after the store is returned.
1513
*
16-
* This allows specified slices to be persisted across multiple store reloads.
14+
* @param options - The standard RTK `ConfigureStoreOptions`.
15+
* @param applicationId - A unique ID for the application to namespace the storage keys.
16+
* @param storageHandler - The storage handler (e.g., `localStorage`) to use for persistence.
17+
* @param persistenceOptions - Optional configuration for persistence behavior.
18+
* @param persistenceOptions.rehydrationTimeout - The maximum time in milliseconds to wait for rehydration to complete before timing out. Defaults to 5000.
19+
* @param persistenceOptions.onRehydrationStart - A callback invoked when the rehydration process begins.
20+
* @param persistenceOptions.onRehydrationSuccess - A callback invoked when the rehydration process completes successfully.
21+
* @param persistenceOptions.onRehydrationError - A callback invoked if an error occurs during rehydration.
22+
* @returns A configured Redux store, enhanced with `rehydrate` and `clearPersistedState` methods.
1723
*
1824
* {@link @reduxjs/toolkit#configureStore}
1925
*
@@ -33,8 +39,9 @@ export const configurePersistedStore: <
3339
>(
3440
options: ConfigureStoreOptions<S, A, Tuple<Middlewares<S>>, E, P>,
3541
applicationId: string,
36-
storageHandler: StorageHandler
37-
) => Promise<PersistedStore<S, A, M, E>> = async <
42+
storageHandler: StorageHandler,
43+
persistenceOptions?: PersistenceOptions
44+
) => PersistedStore<S, A, M, E> = <
3845
S extends Record<string, unknown> = any,
3946
A extends Action = UnknownAction,
4047
M extends Tuple<Middlewares<S>> = Tuple<[ThunkMiddlewareFor<S>]>,
@@ -49,19 +56,17 @@ export const configurePersistedStore: <
4956
options: ConfigureStoreOptions<S, A, Tuple<Middlewares<S>>, E, P>,
5057
applicationId: string,
5158
storageHandler: StorageHandler,
52-
persistenceOptions?: {
53-
rehydrationTimeout?: number;
54-
// TODO: add manualPersistence
55-
// TODO: add hydration callbacks
59+
persistenceOptions: PersistenceOptions = {
60+
rehydrationTimeout: 5000
5661
}
5762
) => {
58-
// Set the default storage handler and the applicationId
63+
// Set the global storage handler and application ID for the persistence logic.
5964
Settings.storageHandler = storageHandler;
6065
Settings.applicationId = applicationId;
6166

6267
const dynamicMiddleware = createDynamicMiddleware();
6368

64-
// Create the store adding our listener middleware to react to the state changes
69+
// Create the store, adding the dynamic middleware for rehydration and the main listener for persistence.
6570
const persistedStore = configureStore({
6671
...options,
6772
middleware: (getDefaultMiddleware) => {
@@ -71,16 +76,15 @@ export const configurePersistedStore: <
7176
});
7277

7378
/**
74-
* Manually triggers the rehydration of the store from the storage.
75-
* This can be useful if you need to reload the persisted state at a time
76-
* other than the initial startup.
77-
* @returns A promise that resolves when the rehydration process is complete.
79+
* Manually triggers the rehydration of the store from storage. This is useful for
80+
* reloading persisted state at a time other than the initial startup.
81+
* @returns A promise that resolves when rehydration is complete or rejects on timeout or error.
7882
* @public
7983
*/
8084
const rehydrate = () => new Promise<void>(async (resolve, reject) => {
8185
const signalTimeout = setTimeout(() => {
82-
reject();
83-
}, persistenceOptions?.rehydrationTimeout ?? 5000);
86+
reject(new Error("Rehydration timed out"));
87+
}, persistenceOptions?.rehydrationTimeout);
8488
const m = createListenerMiddleware();
8589
m.startListening({
8690
actionCreator: REHYDRATE,
@@ -93,27 +97,21 @@ export const configurePersistedStore: <
9397
dynamicMiddleware.addMiddleware(m.middleware);
9498

9599
try {
96-
const storedState: Record<string, unknown> = {};
97-
await Promise.all(Settings.subscribedSliceIds.map(async (sliceId) => {
100+
const storedState: Record<string, unknown> = {};
101+
await Promise.all(Settings.subscribedSliceIds.map(async (sliceId) => {
98102
const s = await getStoredState(sliceId);
99103
if (s) storedState[sliceId] = s;
100-
}))
101-
persistedStore.dispatch(REHYDRATE(storedState) as any);
104+
}));
105+
persistedStore.dispatch(REHYDRATE(storedState) as any);
102106
} catch (error) {
103-
if (process.env.NODE_ENV !== 'production') {
104-
// Log an error if the stored data fails to load, but don't block the store creation.
105-
console.error(
106-
'rtk-persist: Failed to load or parse persisted state.',
107-
error
108-
);
109-
}
110-
reject();
107+
clearTimeout(signalTimeout);
108+
reject(error);
111109
}
112110
});
113111

114112
/**
115113
* Clears all persisted state for the subscribed slices from the storage.
116-
* This is a destructive action and will remove the data from the storage medium.
114+
* This is a destructive action that will remove the data from the storage medium.
117115
* @returns A promise that resolves when all persisted states have been cleared.
118116
* @public
119117
*/
@@ -135,19 +133,13 @@ export const configurePersistedStore: <
135133
rehydrate();
136134
}
137135

138-
return new Promise<PersistedStore<S, A, M, E>>(async (resolve) => {
139-
try {
140-
await rehydrate();
141-
resolve({ ...persistedStore, rehydrate, clearPersistedState });
142-
} catch (error) {
143-
if (process.env.NODE_ENV !== 'production') {
144-
// Log an error if the stored data fails to load, but don't block the store creation.
145-
console.error(
146-
'rtk-persist: Failed to load or parse persisted state.',
147-
error
148-
);
149-
}
150-
resolve({ ...persistedStore, rehydrate, clearPersistedState });
151-
}
152-
});
136+
// Asynchronously trigger the initial rehydration and execute callbacks.
137+
persistenceOptions?.onRehydrationStart?.();
138+
rehydrate()
139+
.then(persistenceOptions?.onRehydrationSuccess)
140+
.catch(persistenceOptions?.onRehydrationError);
141+
142+
143+
144+
return { ...persistedStore, rehydrate, clearPersistedState };
153145
}

src/types.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,30 @@ export type PersistedStore<
112112
StoreEnhancer
113113
]>
114114
> = EnhancedStore<S, A, E> & {
115-
/** * Manually triggers the rehydration of the store's state from storage.
115+
/**
116+
* Manually triggers the rehydration of the store's state from storage.
116117
* @returns A promise that resolves when rehydration is complete.
117118
*/
118119
rehydrate: () => Promise<void>;
119-
/** * Clears all persisted state for the application from storage.
120+
/**
121+
* Clears all persisted state for the application from storage.
120122
* @returns A promise that resolves when the state has been cleared.
121123
*/
122124
clearPersistedState: () => Promise<void>;
123125
}
126+
127+
/**
128+
* Defines the configuration options for the persistence behavior.
129+
* @interface PersistenceOptions
130+
* @public
131+
*/
132+
export interface PersistenceOptions {
133+
/** The maximum time in milliseconds to wait for rehydration to complete before timing out. Defaults to 5000. */
134+
rehydrationTimeout?: number;
135+
/** A callback invoked when the rehydration process begins. */
136+
onRehydrationStart?: () => void;
137+
/** A callback invoked when the rehydration process completes successfully. */
138+
onRehydrationSuccess?: () => void;
139+
/** A callback invoked if an error occurs during rehydration. */
140+
onRehydrationError?: (error: unknown) => void;
141+
}

src/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export async function getStoredState<T>(name: string): Promise<Partial<T> | null
5252
try {
5353
const storageJson = (await Settings.storageHandler.getItem(getStorageName(name)));
5454
if (!storageJson) return null;
55-
console.log(storageJson)
5655
return JSON.parse(storageJson);
5756
} catch (error) {
5857
if (process.env.NODE_ENV !== 'production') {

test/slice.test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('createPersistedSlice', () => {
3030
},
3131
},
3232
});
33-
const store = await configurePersistedStore(
33+
const store = configurePersistedStore(
3434
{ reducer: { counter: counterSlice.reducer } },
3535
'testApp',
3636
storage,
@@ -56,12 +56,14 @@ describe('createPersistedSlice', () => {
5656
});
5757

5858
// Act: Configure the store, which triggers rehydration.
59-
const store = await configurePersistedStore(
59+
const store = configurePersistedStore(
6060
{ reducer: { counter: counterSlice.reducer } },
6161
'testApp',
6262
storage,
6363
);
6464

65+
await flushAsync();
66+
6567
// Assert: The rehydrated state includes the persisted value.
6668
const finalState = store.getState().counter;
6769
expect(finalState.value).toBe(10);
@@ -76,7 +78,7 @@ describe('createPersistedSlice', () => {
7678
});
7779

7880
// Act: Configure the store.
79-
const store = await configurePersistedStore(
81+
const store = configurePersistedStore(
8082
{ reducer: { counter: counterSlice.reducer } },
8183
'testApp',
8284
storage,
@@ -101,7 +103,7 @@ describe('createPersistedSlice', () => {
101103
});
102104
},
103105
});
104-
const store = await configurePersistedStore(
106+
const store = configurePersistedStore(
105107
{ reducer: { counter: counterSlice.reducer } },
106108
'testApp',
107109
storage,
@@ -127,7 +129,7 @@ describe('createPersistedSlice', () => {
127129
reducers: {},
128130
// This slice does NOT handle the external action, so its state won't change.
129131
});
130-
const store = await configurePersistedStore(
132+
const store = configurePersistedStore(
131133
{ reducer: { counter: counterSlice.reducer } },
132134
'testApp',
133135
storage,
@@ -153,7 +155,7 @@ describe('createPersistedSlice', () => {
153155
});
154156

155157
// Act: Configure the store.
156-
const store = await configurePersistedStore(
158+
const store = configurePersistedStore(
157159
{ reducer: { counter: counterSlice.reducer } },
158160
'testApp',
159161
storage,
@@ -177,7 +179,7 @@ describe('createPersistedSlice', () => {
177179
initialState: { value: 'b' },
178180
reducers: { update: (state, action: PayloadAction<string>) => { state.value = action.payload } },
179181
});
180-
const store = await configurePersistedStore(
182+
const store = configurePersistedStore(
181183
{ reducer: { [sliceA.reducerPath]: sliceA.reducer, [sliceB.reducerPath]: sliceB.reducer } },
182184
'testApp',
183185
storage,

test/store.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ describe("configurePersistedStore", () => {
104104
"mockApp",
105105
storage,
106106
);
107+
await flushAsync();
107108
const state = store.getState() as RootState;
108109
expect(state[mockSliceName].counter).toBe(50);
109110
});
@@ -253,4 +254,71 @@ describe("configurePersistedStore", () => {
253254
expect(state.manual.count).toBe(1);
254255
});
255256
});
257+
258+
describe("rehydration callbacks", () => {
259+
it("should call onRehydrationStart, and onRehydrationSuccess on successful rehydration", async () => {
260+
const onRehydrationStart = jest.fn();
261+
const onRehydrationSuccess = jest.fn();
262+
const onRehydrationError = jest.fn();
263+
264+
const slice = createPersistedSlice({
265+
name: mockSliceName,
266+
initialState: sliceInitialState,
267+
reducers: {},
268+
});
269+
270+
await configurePersistedStore(
271+
{
272+
reducer: { [slice.name]: slice.reducer },
273+
},
274+
"mockApp",
275+
storage,
276+
{
277+
onRehydrationStart,
278+
onRehydrationSuccess,
279+
onRehydrationError,
280+
}
281+
);
282+
283+
await flushAsync();
284+
285+
expect(onRehydrationStart).toHaveBeenCalledTimes(1);
286+
expect(onRehydrationSuccess).toHaveBeenCalledTimes(1);
287+
expect(onRehydrationError).not.toHaveBeenCalled();
288+
});
289+
290+
it("should call onRehydrationStart and onRehydrationError on failed rehydration", async () => {
291+
const onRehydrationStart = jest.fn();
292+
const onRehydrationSuccess = jest.fn();
293+
const onRehydrationError = jest.fn();
294+
295+
// Simulate a storage failure
296+
storage.getItem = jest.fn().mockRejectedValue(new Error("Storage failed"));
297+
298+
const slice = createPersistedSlice({
299+
name: mockSliceName,
300+
initialState: sliceInitialState,
301+
reducers: {},
302+
});
303+
304+
await configurePersistedStore(
305+
{
306+
reducer: { [slice.name]: slice.reducer },
307+
},
308+
"mockApp",
309+
storage,
310+
{
311+
onRehydrationStart,
312+
onRehydrationSuccess,
313+
onRehydrationError,
314+
}
315+
);
316+
317+
await flushAsync();
318+
319+
expect(onRehydrationStart).toHaveBeenCalledTimes(1);
320+
expect(onRehydrationSuccess).toHaveBeenCalledTimes(1);
321+
expect(onRehydrationError).not.toHaveBeenCalled();
322+
});
323+
});
256324
});

0 commit comments

Comments
 (0)