Skip to content

Commit 7480198

Browse files
Update nesting support
1 parent 9483e47 commit 7480198

File tree

9 files changed

+947
-772
lines changed

9 files changed

+947
-772
lines changed

src/reducer.ts

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import {
66
import { Builder } from './extraReducersBuilder';
77
import { listenerMiddleware } from './middleware';
88
import Settings from './settings';
9-
import { NotFunction, ReducerWithInitialState, RehydrateActionPayload } from './types';
9+
import { NestedPath, NotFunction, PersistedReducer, RehydrateActionPayload } from './types';
1010
import UpdatedAtHelper from './updatedAtHelper';
11-
import { REHYDRATE, writePersistedStorage } from './utils';
11+
import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils';
1212

1313
/**
1414
* A utility function that creates a persisted reducer. It wraps the standard
@@ -24,6 +24,7 @@ import { REHYDRATE, writePersistedStorage } from './utils';
2424
* @param reducerName - A unique string name for the reducer. This name is used as the key in the root state object and for storage.
2525
* @param initialState - The initial state for the reducer. Can be a value or a lazy initializer function.
2626
* @param mapOrBuilderCallback - A callback that receives a `builder` object to define case reducers via `builder.addCase`, `builder.addMatcher`, and `builder.addDefaultCase`.
27+
* @param nesting - An optional dot-separated string path indicating where the reducer is nested within the root state.
2728
* @example
2829
```ts
2930
import {
@@ -64,24 +65,28 @@ const reducer = createPersistedReducer(
6465
```
6566
* @public
6667
*/
67-
export const createPersistedReducer: <
68+
export const createPersistedReducer = <
6869
ReducerName extends string,
69-
S extends NotFunction<any>
70+
S extends NotFunction<any>,
71+
Nesting extends string | undefined = undefined
7072
>(
7173
reducerName: ReducerName,
7274
initialState: S | (() => S),
7375
mapOrBuilderCallback: (builder: ActionReducerMapBuilder<S>) => void,
74-
) => ReducerWithInitialState<S> = <ReducerName extends string, S extends NotFunction<any>>(
75-
reducerName: ReducerName,
76-
initialState: S | (() => S),
77-
mapOrBuilderCallback: (builder: ActionReducerMapBuilder<S>) => void,
78-
) => {
76+
nesting?: Nesting,
77+
): PersistedReducer<S, ReducerName, Nesting> => {
7978
/**
8079
* Registers the reducer's name to the list of persisted slices.
8180
* This allows the persistence logic to identify which parts of the state to manage.
8281
*/
8382
Settings.subscribeSlice(reducerName);
8483

84+
/**
85+
* The full dot-separated path to the slice's state within the root state object.
86+
* @internal
87+
*/
88+
const nestedPath = (nesting && nesting !== '' ? `${nesting}.${reducerName}` : reducerName) as NestedPath<ReducerName, Nesting>;
89+
8590
/**
8691
* A timeout variable to manage the debouncing of the storage write.
8792
* @internal
@@ -94,10 +99,11 @@ export const createPersistedReducer: <
9499
* @param state - The current root state of the Redux store.
95100
* @internal
96101
*/
97-
const onDump = (state: Record<ReducerName, S>) => {
102+
const onDump = (state: Record<string, any>) => {
98103
if (debounceTimeout) clearTimeout(debounceTimeout);
99104
debounceTimeout = setTimeout(() => {
100-
writePersistedStorage(state[reducerName], reducerName);
105+
const [reducerState] = deepGetByPath(state, nestedPath);
106+
writePersistedStorage(reducerState, reducerName);
101107
}, 100);
102108
};
103109

@@ -106,9 +112,7 @@ export const createPersistedReducer: <
106112
* @internal
107113
*/
108114
const startAppListening =
109-
listenerMiddleware.startListening.withTypes<
110-
Record<ReducerName, S>
111-
>();
115+
listenerMiddleware.startListening.withTypes<Record<string, any>>();
112116

113117
/**
114118
* Creates the main reducer, extending the builder to track state changes
@@ -122,7 +126,7 @@ export const createPersistedReducer: <
122126
});
123127
const b = new Builder(builder, UpdatedAtHelper.onStateChange.bind(null, reducerName));
124128
mapOrBuilderCallback(b);
125-
}) as ReducerWithInitialState<S>;
129+
});
126130

127131
/**
128132
* Listens for any action (except rehydration) to check if the state
@@ -151,13 +155,10 @@ export const createPersistedReducer: <
151155
effect: () => UpdatedAtHelper.onSave(reducerName),
152156
});
153157

154-
/**
155-
* Attaches the unique reducer name to the reducer function itself.
156-
* This allows other parts of the persistence logic to identify the reducer
157-
* and its corresponding state slice.
158-
* @public
159-
*/
160-
reducer.reducerName = reducerName;
161-
162-
return reducer;
158+
// This is the cleanest way to construct the final object and apply the
159+
// necessary type assertion once.
160+
return Object.assign(reducer, {
161+
reducerName,
162+
nestedPath,
163+
}) as PersistedReducer<S, ReducerName, Nesting>;
163164
};

src/slice.ts

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@ import {
22
createSlice,
33
CreateSliceOptions,
44
PayloadAction,
5-
Slice,
65
SliceCaseReducers,
76
SliceSelectors
87
} from '@reduxjs/toolkit';
98
import { Builder } from './extraReducersBuilder';
109
import { listenerMiddleware } from './middleware';
1110
import Settings from './settings';
12-
import { RehydrateActionPayload } from './types';
11+
import { NestedPath, PersistedSlice, RehydrateActionPayload } from './types';
1312
import UpdatedAtHelper from './updatedAtHelper';
14-
import { REHYDRATE, writePersistedStorage } from './utils';
13+
import { deepGetByPath, REHYDRATE, writePersistedStorage } from './utils';
1514

1615
/**
1716
* A wrapper around the standard RTK `createSlice()` function that adds
@@ -21,18 +20,18 @@ import { REHYDRATE, writePersistedStorage } from './utils';
2120
* This function requires the use of {@link configurePersistedStore}.
2221
*
2322
* @param sliceOptions - The standard `CreateSliceOptions` object from Redux Toolkit.
24-
* @returns A Redux slice object with persistence enabled.
23+
* @param nesting - An optional dot-separated string path indicating where the slice is nested within the root state.
24+
* @returns A Redux slice object with persistence enabled, enhanced with a `nestedPath` property that has a correctly inferred literal type.
2525
*
2626
* @public
2727
*/
28-
export const createPersistedSlice: <
28+
export const createPersistedSlice = <
2929
SliceState,
30-
Name extends string = string,
31-
PCR extends
32-
SliceCaseReducers<SliceState> = SliceCaseReducers<SliceState>,
30+
Name extends string,
31+
PCR extends SliceCaseReducers<SliceState>,
3332
ReducerPath extends string = Name,
34-
PeristedSelectors extends
35-
SliceSelectors<SliceState> = SliceSelectors<SliceState>,
33+
PeristedSelectors extends SliceSelectors<SliceState> = SliceSelectors<SliceState>,
34+
Nesting extends string | undefined = undefined
3635
>(
3736
sliceOptions: CreateSliceOptions<
3837
SliceState,
@@ -41,29 +40,21 @@ export const createPersistedSlice: <
4140
ReducerPath,
4241
PeristedSelectors
4342
>,
44-
) => Slice<SliceState, PCR, Name, ReducerPath, PeristedSelectors> = <
45-
SliceState,
46-
Name extends string = string,
47-
PCR extends
48-
SliceCaseReducers<SliceState> = SliceCaseReducers<SliceState>,
49-
ReducerPath extends string = Name,
50-
PeristedSelectors extends
51-
SliceSelectors<SliceState> = SliceSelectors<SliceState>,
52-
>(
53-
sliceOptions: CreateSliceOptions<
54-
SliceState,
55-
PCR,
56-
Name,
57-
ReducerPath,
58-
PeristedSelectors
59-
>,
60-
) => {
43+
nesting?: Nesting,
44+
): PersistedSlice<SliceState, PCR, Name, ReducerPath, PeristedSelectors, Nesting> => {
6145
/**
6246
* Registers the slice's name to the list of persisted slices.
6347
* This allows the persistence logic to identify which parts of the state to manage.
6448
*/
6549
Settings.subscribeSlice(sliceOptions.name);
6650

51+
const name = sliceOptions.reducerPath ?? sliceOptions.name;
52+
/**
53+
* The full dot-separated path to the slice's state within the root state object.
54+
* @internal
55+
*/
56+
const nestedPath = (nesting && nesting !== '' ? `${nesting}.${name}` : name) as NestedPath<ReducerPath, Nesting>;
57+
6758
/**
6859
* A timeout variable to manage the debouncing of the storage write.
6960
* @internal
@@ -76,10 +67,12 @@ export const createPersistedSlice: <
7667
* @param state - The current root state of the Redux store.
7768
* @internal
7869
*/
79-
const onDump = (state: Record<Name | ReducerPath, SliceState>) => {
70+
const onDump = (state: Record<string, any>) => {
8071
if (debounceTimeout) clearTimeout(debounceTimeout);
8172
debounceTimeout = setTimeout(() => {
82-
writePersistedStorage(state[sliceOptions.reducerPath ?? sliceOptions.name], sliceOptions.name);
73+
// Use deepGetByPath with the single correct path
74+
const sliceState = deepGetByPath(state, nestedPath);
75+
writePersistedStorage(sliceState, sliceOptions.name);
8376
}, 100);
8477
};
8578

@@ -88,9 +81,7 @@ export const createPersistedSlice: <
8881
* @internal
8982
*/
9083
const startAppListening =
91-
listenerMiddleware.startListening.withTypes<
92-
Record<Name | ReducerPath, SliceState>
93-
>();
84+
listenerMiddleware.startListening.withTypes<Record<string, any>>();
9485

9586
/**
9687
* Creates the slice using the default options passed by the user,
@@ -118,7 +109,7 @@ export const createPersistedSlice: <
118109
Object.keys(slice.actions).forEach(type => {
119110
startAppListening({
120111
type: `${slice.name}/${type}`,
121-
effect: (_action, { getState, }) => {
112+
effect: (_action, { getState }) => {
122113
const state = getState();
123114
onDump(state);
124115
},
@@ -152,5 +143,5 @@ export const createPersistedSlice: <
152143
effect: () => UpdatedAtHelper.onSave(sliceOptions.name),
153144
});
154145

155-
return slice;
146+
return { ...slice, nestedPath };
156147
};

src/store.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,10 @@ export const configurePersistedStore: <
137137
persistedStore.replaceReducer = (nR) => {
138138
_replaceReducer.call(persistedStore, nR);
139139
rehydrate();
140-
}
140+
};
141141

142142
// Asynchronously trigger the initial rehydration.
143143
rehydrate();
144144

145-
146-
147145
return { ...persistedStore, rehydrate, clearPersistedState };
148146
}

src/types.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
Middleware,
77
Reducer,
88
SerializableStateInvariantMiddlewareOptions,
9+
Slice,
10+
SliceCaseReducers,
11+
SliceSelectors,
912
StoreEnhancer,
1013
ThunkMiddleware,
1114
Tuple,
@@ -86,13 +89,48 @@ export type ExtractDispatchExtensions<M> = M extends Tuple<infer MiddlewareTuple
8689
export type NotFunction<T> = T extends Function ? never : T;
8790

8891
/**
89-
* A utility type that enhances a Redux reducer with properties needed for persistence,
90-
* such as its name and a function to get its initial state.
92+
* A conditional type that constructs the full dot-notation path for a nested slice or reducer.
93+
* If `Nesting` is undefined or an empty string, it returns the `ReducerPath` directly.
94+
* Otherwise, it prepends the nesting path.
95+
*
96+
* @internal
97+
*/
98+
export type NestedPath<
99+
ReducerPath extends string,
100+
Nesting extends string | undefined
101+
> = Nesting extends '' | undefined
102+
? ReducerPath
103+
: `${NonNullable<Nesting>}.${ReducerPath}`
104+
105+
/**
106+
* A utility type that enhances a Redux reducer with properties needed for persistence.
107+
* It adds `reducerName` and the calculated `nestedPath` to the standard reducer type.
108+
* @internal
109+
*/
110+
export type PersistedReducer<
111+
S extends NotFunction<any>,
112+
ReducerName extends string,
113+
Nesting extends string | undefined
114+
> = Reducer<S> & {
115+
reducerName: ReducerName;
116+
nestedPath: NestedPath<ReducerName, Nesting>;
117+
};
118+
119+
/**
120+
* A utility type that enhances a Redux slice with the `nestedPath` property,
121+
* which represents its full path within the root state.
122+
*
91123
* @internal
92124
*/
93-
export interface ReducerWithInitialState<S extends NotFunction<any>> extends Reducer<S> {
94-
getInitialState: () => S;
95-
reducerName: string;
125+
export type PersistedSlice<
126+
SliceState,
127+
PCR extends SliceCaseReducers<SliceState>,
128+
Name extends string,
129+
ReducerPath extends string,
130+
PeristedSelectors extends SliceSelectors<SliceState>,
131+
Nesting extends string | undefined
132+
> = Slice<SliceState, PCR, Name, ReducerPath, PeristedSelectors> & {
133+
nestedPath: NestedPath<ReducerPath, Nesting>;
96134
};
97135

98136
/**

src/utils.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,72 @@ export async function clearPersistedStorage(name: string) {
7676
const storageName = getStorageName(name);
7777
await Settings.storageHandler.removeItem(storageName);
7878
}
79+
80+
/**
81+
* Safely retrieves a nested value from an object using an array of keys.
82+
* It traverses the object according to the sequence of keys provided.
83+
*
84+
* @param obj The object to query. Can be of any type, but functions correctly with nested objects and arrays.
85+
* @param keys An array of strings or numbers representing the path to the desired value.
86+
* @returns The nested value if found, otherwise null if the path is invalid or the value is null/undefined.
87+
*/
88+
export const deepGet = <Name extends string = string>(obj: any, keys: (Name)[]): any => {
89+
return keys.reduce((xs, x) => (xs?.[x] !== undefined && xs?.[x] !== null) ? xs[x] : null, obj);
90+
};
91+
92+
/**
93+
* Retrieves multiple nested values from an object based on string paths.
94+
* It supports both dot notation (e.g., 'prop1.prop2') and bracket notation for array access (e.g., 'prop1[0]').
95+
*
96+
* @param obj The object to query.
97+
* @param paths A rest parameter of string paths to retrieve values for.
98+
* @returns An array containing the retrieved values. If a path is not found, the corresponding value in the array will be null.
99+
*/
100+
export const deepGetByPath = (obj: any, path: string): any => {
101+
// Convert bracket notation to dot notation, then split into an array of keys.
102+
const keys = path
103+
.replace(/\[([^\[\]]*)\]/g, '.$1.')
104+
.split('.')
105+
.filter(t => t !== ''); // Filter out empty strings that may arise from the regex replacement.
106+
107+
return deepGet(obj, keys);
108+
};
109+
110+
/**
111+
* A helper type to identify primitive values that the recursive
112+
* path generation should not traverse into.
113+
*/
114+
type Primitive = string | number | boolean | null | undefined;
115+
116+
/**
117+
* Creates a union of all possible dot-notation paths for a given object type T.
118+
* This is useful for creating strongly-typed functions that access nested
119+
* properties of an object like a Redux state. An empty string is also considered a valid path.
120+
*
121+
* @example
122+
* type MyState = { user: { name: string }, posts: { id: number }[] }
123+
* type MyStatePaths = Paths<MyState>
124+
* // "" | "user" | "user.name" | "posts" | `posts.${number}` | `posts.${number}.id`
125+
*/
126+
export type Paths<T> = "" | (T extends Primitive
127+
? never // Base case: Don't generate paths for primitive types.
128+
: T extends (infer U)[]
129+
? `${number}` | `${number}.${Paths<U>}` // Handle array paths with numeric indices.
130+
: {
131+
// For each key in the object...
132+
[K in keyof T & string]: T[K] extends Primitive
133+
? K // If the property is primitive, the path is just the key.
134+
: K | `${K}.${Paths<T[K]>}`; // Otherwise, recurse into the nested object.
135+
}[keyof T & string]); // Create a union of all the generated path strings.
136+
137+
/**
138+
* A strongly-typed path validation function for a given state object type.
139+
* This function doesn't do anything at runtime; its purpose is to provide
140+
* compile-time feedback (linter errors) for invalid paths.
141+
*
142+
* @param path A path that must be a valid key path within the generic type T.
143+
*/
144+
export const createStatePathValidator = <T extends object>(_path: Paths<T>): void => {
145+
// This function is intentionally empty. Its sole purpose is to enforce
146+
// type-checking on the 'path' argument at compile time.
147+
};

0 commit comments

Comments
 (0)