Skip to content

Commit 0b4507f

Browse files
Merge branch 'main' into feat/add-use-counter
2 parents 27a9a19 + 5df91aa commit 0b4507f

4 files changed

Lines changed: 124 additions & 21 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-simplikit",
3-
"version": "0.0.29",
3+
"version": "0.0.30",
44
"main": "./src/index.ts",
55
"type": "module",
66
"sideEffects": false,

src/hooks/useStorageState/useStorageState.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,44 @@ describe('useStorageState', () => {
190190

191191
expect(result2.current[0]).toBe('updated value');
192192
});
193+
194+
it('should refresh storage state', async () => {
195+
const { result } = await renderHookSSR(() => useStorageState('test-key', { storage }));
196+
197+
storage.set('test-key', JSON.stringify({ hello: 'world' }));
198+
199+
act(() => {
200+
result.current[2]();
201+
});
202+
203+
expect(result.current[0]).toEqual({ hello: 'world' });
204+
});
205+
206+
it('should work with custom serializer and deserializer', async () => {
207+
const serializer = (value: any) =>
208+
['string', 'number', 'boolean'].includes(typeof value) ? value : JSON.stringify(value);
209+
const deserializer = (value: any) =>
210+
/^(\d+)|(true|false)|([^[].*)|([^{].*)$/.test(value) ? value : JSON.parse(value);
211+
212+
const { result } = await renderHookSSR(() => useStorageState('test-key', { storage, serializer, deserializer }));
213+
214+
act(() => {
215+
result.current[1]('hello');
216+
});
217+
218+
expect(result.current[0]).toEqual('hello');
219+
});
220+
221+
it('should throw error when value is not serializable', async () => {
222+
expect(
223+
async () => await renderHookSSR(() => useStorageState('test-key', { storage, defaultValue: () => 'world' }))
224+
).rejects.toThrow('Received a non-serializable value');
225+
226+
expect(
227+
async () =>
228+
await renderHookSSR(() => useStorageState('test-key', { storage, defaultValue: new (class Cls {})() }))
229+
).rejects.toThrow('Received a non-serializable value');
230+
});
193231
};
194232

195233
describe('MemoStorage', () => {

src/hooks/useStorageState/useStorageState.ts

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,66 @@
1+
/* eslint-disable react-hooks/exhaustive-deps */
12
import { SetStateAction, useCallback, useRef, useSyncExternalStore } from 'react';
23

34
import { safeLocalStorage, Storage } from './storage.ts';
45

5-
type ToPrimitive<T> = T extends string ? string : T extends number ? number : T extends boolean ? boolean : never;
66
type ToObject<T> = T extends unknown[] | Record<string, unknown> ? T : never;
77

8-
export type Serializable<T> = T extends string | number | boolean ? ToPrimitive<T> : ToObject<T>;
8+
export type Serializable<T> = T extends string | number | boolean ? T : ToObject<T>;
99

1010
type StorageStateOptions<T> = {
1111
storage?: Storage;
12-
defaultValue?: Serializable<T>;
12+
defaultValue?: T;
1313
};
1414

1515
type StorageStateOptionsWithDefaultValue<T> = StorageStateOptions<T> & {
16-
defaultValue: Serializable<T>;
16+
defaultValue: T;
1717
};
1818

19+
type StorageStateOptionsWithSerializer<T> = StorageStateOptions<T> & {
20+
serializer: (value: Serializable<T>) => string;
21+
deserializer: (value: string) => Serializable<T>;
22+
};
23+
24+
type SerializableGuard<T extends readonly any[]> = T[0] extends any
25+
? T
26+
: T[0] extends never
27+
? 'Received a non-serializable value'
28+
: T;
29+
1930
const listeners = new Set<() => void>();
2031

2132
const emitListeners = () => {
2233
listeners.forEach(listener => listener());
2334
};
2435

36+
function isPlainObject(value: unknown): value is Record<PropertyKey, any> {
37+
if (typeof value !== 'object') {
38+
return false;
39+
}
40+
41+
const proto = Object.getPrototypeOf(value) as typeof Object.prototype | null;
42+
43+
const hasObjectPrototype = proto === Object.prototype;
44+
45+
if (!hasObjectPrototype) {
46+
return false;
47+
}
48+
49+
return Object.prototype.toString.call(value) === '[object Object]';
50+
}
51+
52+
const ensureSerializable = <T extends readonly any[]>(value: T): SerializableGuard<T> => {
53+
if (
54+
value[0] != null &&
55+
!['string', 'number', 'boolean'].includes(typeof value[0]) &&
56+
!(isPlainObject(value[0]) || Array.isArray(value[0]))
57+
) {
58+
throw new Error('Received a non-serializable value');
59+
}
60+
61+
return value as SerializableGuard<T>;
62+
};
63+
2564
/**
2665
* @description
2766
* A React hook that functions like `useState` but persists the state value in browser storage.
@@ -31,11 +70,13 @@ const emitListeners = () => {
3170
* @param {Object} [options] - Configuration options for storage behavior.
3271
* @param {Storage} [options.storage=localStorage] - The storage type (`localStorage` or `sessionStorage`). Defaults to `localStorage`.
3372
* @param {T} [options.defaultValue] - The initial value if no existing value is found.
73+
* @param {Function} [options.serializer] - A function to serialize the state value to a string.
74+
* @param {Function} [options.deserializer] - A function to deserialize the state value from a string.
3475
*
35-
* @returns {readonly [state: Serializable<T> | undefined, setState: (value: SetStateAction<Serializable<T> | undefined>) => void]} A tuple:
76+
* @returns {readonly [state: Serializable<T> | undefined, setState: (value: SetStateAction<Serializable<T> | undefined>) => void, refreshState: () => void]} A tuple:
3677
* - state `Serializable<T> | undefined` - The current state value retrieved from storage;
3778
* - setState `(value: SetStateAction<Serializable<T> | undefined>) => void` - A function to update and persist the state;
38-
*
79+
* - refreshState `() => void` - A function to refresh the state from storage;
3980
* @example
4081
* // Counter with persistent state
4182
* import { useStorageState } from 'react-simplikit';
@@ -50,35 +91,53 @@ const emitListeners = () => {
5091
*/
5192
export function useStorageState<T>(
5293
key: string
53-
): readonly [Serializable<T> | undefined, (value: SetStateAction<Serializable<T> | undefined>) => void];
94+
): SerializableGuard<
95+
readonly [Serializable<T> | undefined, (value: SetStateAction<Serializable<T> | undefined>) => void, () => void]
96+
>;
5497
export function useStorageState<T>(
5598
key: string,
56-
{ storage, defaultValue }: StorageStateOptionsWithDefaultValue<T>
57-
): readonly [Serializable<T>, (value: SetStateAction<Serializable<T>>) => void];
99+
options: StorageStateOptionsWithDefaultValue<T>
100+
): SerializableGuard<readonly [Serializable<T>, (value: SetStateAction<Serializable<T>>) => void, () => void]>;
58101
export function useStorageState<T>(
59102
key: string,
60-
{ storage, defaultValue }: StorageStateOptions<T>
61-
): readonly [Serializable<T> | undefined, (value: SetStateAction<Serializable<T> | undefined>) => void];
103+
options: StorageStateOptions<T>
104+
): SerializableGuard<
105+
readonly [Serializable<T> | undefined, (value: SetStateAction<Serializable<T> | undefined>) => void, () => void]
106+
>;
62107
export function useStorageState<T>(
63108
key: string,
64-
{ storage = safeLocalStorage, defaultValue }: StorageStateOptions<T> = {}
65-
): readonly [Serializable<T> | undefined, (value: SetStateAction<Serializable<T> | undefined>) => void] {
109+
options: StorageStateOptionsWithSerializer<T>
110+
): SerializableGuard<
111+
readonly [Serializable<T> | undefined, (value: SetStateAction<Serializable<T> | undefined>) => void, () => void]
112+
>;
113+
export function useStorageState<T>(
114+
key: string,
115+
{
116+
storage = safeLocalStorage,
117+
defaultValue,
118+
...options
119+
}: StorageStateOptions<T> | StorageStateOptionsWithSerializer<T> = {}
120+
): SerializableGuard<
121+
readonly [Serializable<T> | undefined, (value: SetStateAction<Serializable<T> | undefined>) => void, () => void]
122+
> {
123+
const serializedDefaultValue = defaultValue as Serializable<T>;
66124
const cache = useRef<{
67125
data: string | null;
68126
parsed: Serializable<T> | undefined;
69127
}>({
70128
data: null,
71-
parsed: defaultValue,
129+
parsed: serializedDefaultValue,
72130
});
73131

74132
const getSnapshot = useCallback(() => {
133+
const deserializer = 'deserializer' in options ? options.deserializer : JSON.parse;
75134
const data = storage.get(key);
76135

77136
if (data !== cache.current.data) {
78137
try {
79-
cache.current.parsed = data != null ? JSON.parse(data) : defaultValue;
138+
cache.current.parsed = data != null ? deserializer(data) : defaultValue;
80139
} catch {
81-
cache.current.parsed = defaultValue;
140+
cache.current.parsed = serializedDefaultValue;
82141
}
83142
cache.current.data = data;
84143
}
@@ -104,22 +163,28 @@ export function useStorageState<T>(
104163
};
105164
},
106165
() => getSnapshot(),
107-
() => defaultValue
166+
() => serializedDefaultValue
108167
);
109168

110169
const setStorageState = useCallback(
111170
(value: SetStateAction<Serializable<T> | undefined>) => {
171+
const serializer = 'serializer' in options ? options.serializer : JSON.stringify;
172+
112173
const nextValue = typeof value === 'function' ? value(getSnapshot()) : value;
113174

114175
if (nextValue == null) {
115176
storage.remove(key);
116177
} else {
117-
storage.set(key, JSON.stringify(nextValue));
178+
storage.set(key, serializer(nextValue));
118179
}
119180
emitListeners();
120181
},
121182
[getSnapshot, key, storage]
122183
);
123184

124-
return [storageState, setStorageState] as const;
185+
const refreshStorageState = useCallback(() => {
186+
setStorageState(getSnapshot());
187+
}, [storage, getSnapshot, setStorageState]);
188+
189+
return ensureSerializable([storageState, setStorageState, refreshStorageState] as const);
125190
}

vitest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default defineConfig({
1313
reporter: ['text', 'json'],
1414
extension: ['.ts', '.tsx'],
1515
include: ['src/**/*.ts?(x)'],
16-
exclude: ['src/**/index.ts', 'src/**/*.spec.ts?(x)'],
16+
exclude: ['src/_internal', 'src/**/index.ts', 'src/**/*.spec.ts?(x)'],
1717
},
1818
},
1919
});

0 commit comments

Comments
 (0)