@umituz/react-native-storage 2.0.0 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +23 -7
- package/src/__tests__/integration.test.ts +391 -0
- package/src/__tests__/mocks/asyncStorage.mock.ts +52 -0
- package/src/__tests__/performance.test.ts +351 -0
- package/src/__tests__/setup.ts +63 -0
- package/src/application/ports/IStorageRepository.ts +0 -12
- package/src/domain/entities/StorageResult.ts +1 -3
- package/src/domain/entities/__tests__/CachedValue.test.ts +149 -0
- package/src/domain/entities/__tests__/StorageResult.test.ts +122 -0
- package/src/domain/errors/StorageError.ts +0 -2
- package/src/domain/errors/__tests__/StorageError.test.ts +127 -0
- package/src/domain/utils/__tests__/devUtils.test.ts +97 -0
- package/src/domain/utils/devUtils.ts +37 -0
- package/src/domain/value-objects/StorageKey.ts +27 -29
- package/src/index.ts +9 -1
- package/src/infrastructure/adapters/StorageService.ts +8 -6
- package/src/infrastructure/repositories/AsyncStorageRepository.ts +27 -108
- package/src/infrastructure/repositories/BaseStorageOperations.ts +101 -0
- package/src/infrastructure/repositories/BatchStorageOperations.ts +42 -0
- package/src/infrastructure/repositories/StringStorageOperations.ts +44 -0
- package/src/infrastructure/repositories/__tests__/AsyncStorageRepository.test.ts +169 -0
- package/src/infrastructure/repositories/__tests__/BaseStorageOperations.test.ts +200 -0
- package/src/presentation/hooks/CacheStorageOperations.ts +95 -0
- package/src/presentation/hooks/__tests__/usePersistentCache.test.ts +404 -0
- package/src/presentation/hooks/__tests__/useStorage.test.ts +246 -0
- package/src/presentation/hooks/__tests__/useStorageState.test.ts +292 -0
- package/src/presentation/hooks/useCacheState.ts +55 -0
- package/src/presentation/hooks/usePersistentCache.ts +30 -39
- package/src/presentation/hooks/useStorage.ts +4 -3
- package/src/presentation/hooks/useStorageState.ts +24 -8
- package/src/presentation/hooks/useStore.ts +3 -1
- package/src/types/global.d.ts +40 -0
- package/LICENSE +0 -22
- package/src/presentation/hooks/usePersistedState.ts +0 -34
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useStorage Hook Tests
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for useStorage hook
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { renderHook, act } from '@testing-library/react-hooks';
|
|
8
|
+
import { useStorage } from '../useStorage';
|
|
9
|
+
import { AsyncStorage } from '../../__tests__/mocks/asyncStorage.mock';
|
|
10
|
+
|
|
11
|
+
describe('useStorage Hook', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
(AsyncStorage as any).__clear();
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('getItem', () => {
|
|
18
|
+
it('should get item from storage', async () => {
|
|
19
|
+
const key = 'test-key';
|
|
20
|
+
const defaultValue = 'default';
|
|
21
|
+
const expectedValue = 'test-value';
|
|
22
|
+
|
|
23
|
+
await AsyncStorage.setItem(key, JSON.stringify(expectedValue));
|
|
24
|
+
|
|
25
|
+
const { result } = renderHook(() => useStorage());
|
|
26
|
+
|
|
27
|
+
const value = await result.current.getItem(key, defaultValue);
|
|
28
|
+
|
|
29
|
+
expect(value).toBe(expectedValue);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return default value for missing key', async () => {
|
|
33
|
+
const key = 'missing-key';
|
|
34
|
+
const defaultValue = 'default';
|
|
35
|
+
|
|
36
|
+
const { result } = renderHook(() => useStorage());
|
|
37
|
+
|
|
38
|
+
const value = await result.current.getItem(key, defaultValue);
|
|
39
|
+
|
|
40
|
+
expect(value).toBe(defaultValue);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should handle StorageKey enum', async () => {
|
|
44
|
+
const key = '@ui_preferences';
|
|
45
|
+
const defaultValue = 'default';
|
|
46
|
+
const expectedValue = 'test-value';
|
|
47
|
+
|
|
48
|
+
await AsyncStorage.setItem(key, JSON.stringify(expectedValue));
|
|
49
|
+
|
|
50
|
+
const { result } = renderHook(() => useStorage());
|
|
51
|
+
|
|
52
|
+
const value = await result.current.getItem(key as any, defaultValue);
|
|
53
|
+
|
|
54
|
+
expect(value).toBe(expectedValue);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('setItem', () => {
|
|
59
|
+
it('should set item to storage', async () => {
|
|
60
|
+
const key = 'test-key';
|
|
61
|
+
const value = 'test-value';
|
|
62
|
+
|
|
63
|
+
const { result } = renderHook(() => useStorage());
|
|
64
|
+
|
|
65
|
+
const success = await result.current.setItem(key, value);
|
|
66
|
+
|
|
67
|
+
expect(success).toBe(true);
|
|
68
|
+
|
|
69
|
+
// Verify storage
|
|
70
|
+
const stored = await AsyncStorage.getItem(key);
|
|
71
|
+
expect(JSON.parse(stored!)).toBe(value);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should return false on failure', async () => {
|
|
75
|
+
const key = 'test-key';
|
|
76
|
+
const value = { circular: {} };
|
|
77
|
+
value.circular = value; // Create circular reference
|
|
78
|
+
|
|
79
|
+
const { result } = renderHook(() => useStorage());
|
|
80
|
+
|
|
81
|
+
const success = await result.current.setItem(key, value);
|
|
82
|
+
|
|
83
|
+
expect(success).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('getString', () => {
|
|
88
|
+
it('should get string from storage', async () => {
|
|
89
|
+
const key = 'test-key';
|
|
90
|
+
const defaultValue = 'default';
|
|
91
|
+
const expectedValue = 'test-string';
|
|
92
|
+
|
|
93
|
+
await AsyncStorage.setItem(key, expectedValue);
|
|
94
|
+
|
|
95
|
+
const { result } = renderHook(() => useStorage());
|
|
96
|
+
|
|
97
|
+
const value = await result.current.getString(key, defaultValue);
|
|
98
|
+
|
|
99
|
+
expect(value).toBe(expectedValue);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('setString', () => {
|
|
104
|
+
it('should set string to storage', async () => {
|
|
105
|
+
const key = 'test-key';
|
|
106
|
+
const value = 'test-string';
|
|
107
|
+
|
|
108
|
+
const { result } = renderHook(() => useStorage());
|
|
109
|
+
|
|
110
|
+
const success = await result.current.setString(key, value);
|
|
111
|
+
|
|
112
|
+
expect(success).toBe(true);
|
|
113
|
+
|
|
114
|
+
// Verify storage
|
|
115
|
+
const stored = await AsyncStorage.getItem(key);
|
|
116
|
+
expect(stored).toBe(value);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('removeItem', () => {
|
|
121
|
+
it('should remove item from storage', async () => {
|
|
122
|
+
const key = 'test-key';
|
|
123
|
+
|
|
124
|
+
// Setup
|
|
125
|
+
await AsyncStorage.setItem(key, 'test-value');
|
|
126
|
+
|
|
127
|
+
const { result } = renderHook(() => useStorage());
|
|
128
|
+
|
|
129
|
+
const success = await result.current.removeItem(key);
|
|
130
|
+
|
|
131
|
+
expect(success).toBe(true);
|
|
132
|
+
|
|
133
|
+
// Verify removal
|
|
134
|
+
const stored = await AsyncStorage.getItem(key);
|
|
135
|
+
expect(stored).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('hasItem', () => {
|
|
140
|
+
it('should return true for existing item', async () => {
|
|
141
|
+
const key = 'test-key';
|
|
142
|
+
|
|
143
|
+
await AsyncStorage.setItem(key, 'test-value');
|
|
144
|
+
|
|
145
|
+
const { result } = renderHook(() => useStorage());
|
|
146
|
+
|
|
147
|
+
const exists = await result.current.hasItem(key);
|
|
148
|
+
|
|
149
|
+
expect(exists).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should return false for missing item', async () => {
|
|
153
|
+
const key = 'missing-key';
|
|
154
|
+
|
|
155
|
+
const { result } = renderHook(() => useStorage());
|
|
156
|
+
|
|
157
|
+
const exists = await result.current.hasItem(key);
|
|
158
|
+
|
|
159
|
+
expect(exists).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('clearAll', () => {
|
|
164
|
+
it('should clear all storage', async () => {
|
|
165
|
+
// Setup
|
|
166
|
+
await AsyncStorage.setItem('key1', 'value1');
|
|
167
|
+
await AsyncStorage.setItem('key2', 'value2');
|
|
168
|
+
|
|
169
|
+
const { result } = renderHook(() => useStorage());
|
|
170
|
+
|
|
171
|
+
const success = await result.current.clearAll();
|
|
172
|
+
|
|
173
|
+
expect(success).toBe(true);
|
|
174
|
+
|
|
175
|
+
// Verify clear
|
|
176
|
+
const keys = await AsyncStorage.getAllKeys();
|
|
177
|
+
expect(keys).toHaveLength(0);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('getItemWithResult', () => {
|
|
182
|
+
it('should return full result object', async () => {
|
|
183
|
+
const key = 'test-key';
|
|
184
|
+
const defaultValue = 'default';
|
|
185
|
+
const expectedValue = 'test-value';
|
|
186
|
+
|
|
187
|
+
await AsyncStorage.setItem(key, JSON.stringify(expectedValue));
|
|
188
|
+
|
|
189
|
+
const { result } = renderHook(() => useStorage());
|
|
190
|
+
|
|
191
|
+
const storageResult = await result.current.getItemWithResult(key, defaultValue);
|
|
192
|
+
|
|
193
|
+
expect(storageResult.success).toBe(true);
|
|
194
|
+
expect(storageResult.data).toBe(expectedValue);
|
|
195
|
+
expect(storageResult.error).toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('Performance', () => {
|
|
200
|
+
it('should memoize return object', () => {
|
|
201
|
+
const { result, rerender } = renderHook(() => useStorage());
|
|
202
|
+
|
|
203
|
+
const firstCall = result.current;
|
|
204
|
+
rerender();
|
|
205
|
+
const secondCall = result.current;
|
|
206
|
+
|
|
207
|
+
// Should be the same reference (memoized)
|
|
208
|
+
expect(firstCall).toBe(secondCall);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should not recreate functions on re-render', () => {
|
|
212
|
+
const { result, rerender } = renderHook(() => useStorage());
|
|
213
|
+
|
|
214
|
+
const firstFunctions = {
|
|
215
|
+
getItem: result.current.getItem,
|
|
216
|
+
setItem: result.current.setItem,
|
|
217
|
+
getString: result.current.getString,
|
|
218
|
+
setString: result.current.setString,
|
|
219
|
+
removeItem: result.current.removeItem,
|
|
220
|
+
hasItem: result.current.hasItem,
|
|
221
|
+
clearAll: result.current.clearAll,
|
|
222
|
+
getItemWithResult: result.current.getItemWithResult,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
rerender();
|
|
226
|
+
|
|
227
|
+
const secondFunctions = {
|
|
228
|
+
getItem: result.current.getItem,
|
|
229
|
+
setItem: result.current.setItem,
|
|
230
|
+
getString: result.current.getString,
|
|
231
|
+
setString: result.current.setString,
|
|
232
|
+
removeItem: result.current.removeItem,
|
|
233
|
+
hasItem: result.current.hasItem,
|
|
234
|
+
clearAll: result.current.clearAll,
|
|
235
|
+
getItemWithResult: result.current.getItemWithResult,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// All functions should be the same reference
|
|
239
|
+
Object.keys(firstFunctions).forEach(key => {
|
|
240
|
+
expect(firstFunctions[key as keyof typeof firstFunctions]).toBe(
|
|
241
|
+
secondFunctions[key as keyof typeof secondFunctions]
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useStorageState Hook Tests
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for useStorageState hook
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { renderHook, act } from '@testing-library/react-hooks';
|
|
8
|
+
import { useStorageState } from '../useStorageState';
|
|
9
|
+
import { AsyncStorage } from '../../__tests__/mocks/asyncStorage.mock';
|
|
10
|
+
|
|
11
|
+
describe('useStorageState Hook', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
(AsyncStorage as any).__clear();
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('Initial Load', () => {
|
|
18
|
+
it('should load initial value from storage', async () => {
|
|
19
|
+
const key = 'test-key';
|
|
20
|
+
const defaultValue = 'default';
|
|
21
|
+
const storedValue = 'stored-value';
|
|
22
|
+
|
|
23
|
+
await AsyncStorage.setItem(key, JSON.stringify(storedValue));
|
|
24
|
+
|
|
25
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
26
|
+
useStorageState(key, defaultValue)
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Initial state
|
|
30
|
+
expect(result.current[0]).toBe(defaultValue);
|
|
31
|
+
expect(result.current[2]).toBe(true); // isLoading
|
|
32
|
+
|
|
33
|
+
// Wait for load
|
|
34
|
+
await act(async () => {
|
|
35
|
+
await waitForNextUpdate();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result.current[0]).toBe(storedValue);
|
|
39
|
+
expect(result.current[2]).toBe(false); // isLoading
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should use default value for missing key', async () => {
|
|
43
|
+
const key = 'missing-key';
|
|
44
|
+
const defaultValue = 'default';
|
|
45
|
+
|
|
46
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
47
|
+
useStorageState(key, defaultValue)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
await act(async () => {
|
|
51
|
+
await waitForNextUpdate();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(result.current[0]).toBe(defaultValue);
|
|
55
|
+
expect(result.current[2]).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should handle StorageKey enum', async () => {
|
|
59
|
+
const key = '@ui_preferences';
|
|
60
|
+
const defaultValue = 'default';
|
|
61
|
+
const storedValue = 'stored-value';
|
|
62
|
+
|
|
63
|
+
await AsyncStorage.setItem(key, JSON.stringify(storedValue));
|
|
64
|
+
|
|
65
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
66
|
+
useStorageState(key as any, defaultValue)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
await act(async () => {
|
|
70
|
+
await waitForNextUpdate();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.current[0]).toBe(storedValue);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('Update State', () => {
|
|
78
|
+
it('should update state and persist to storage', async () => {
|
|
79
|
+
const key = 'test-key';
|
|
80
|
+
const defaultValue = 'default';
|
|
81
|
+
const newValue = 'new-value';
|
|
82
|
+
|
|
83
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
84
|
+
useStorageState(key, defaultValue)
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await act(async () => {
|
|
88
|
+
await waitForNextUpdate();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Update state
|
|
92
|
+
await act(async () => {
|
|
93
|
+
await result.current[1](newValue);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result.current[0]).toBe(newValue);
|
|
97
|
+
|
|
98
|
+
// Verify storage
|
|
99
|
+
const stored = await AsyncStorage.getItem(key);
|
|
100
|
+
expect(JSON.parse(stored!)).toBe(newValue);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should handle update during loading', async () => {
|
|
104
|
+
const key = 'test-key';
|
|
105
|
+
const defaultValue = 'default';
|
|
106
|
+
const newValue = 'new-value';
|
|
107
|
+
|
|
108
|
+
// Mock slow storage
|
|
109
|
+
let resolveStorage: (value: string) => void;
|
|
110
|
+
(AsyncStorage.getItem as jest.Mock).mockImplementation(() =>
|
|
111
|
+
new Promise(resolve => {
|
|
112
|
+
resolveStorage = resolve;
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const { result } = renderHook(() =>
|
|
117
|
+
useStorageState(key, defaultValue)
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Update while loading
|
|
121
|
+
await act(async () => {
|
|
122
|
+
await result.current[1](newValue);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result.current[0]).toBe(newValue);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('Memory Leak Prevention', () => {
|
|
130
|
+
it('should cleanup on unmount', async () => {
|
|
131
|
+
const key = 'test-key';
|
|
132
|
+
const defaultValue = 'default';
|
|
133
|
+
|
|
134
|
+
// Mock slow storage
|
|
135
|
+
let resolveStorage: (value: string) => void;
|
|
136
|
+
(AsyncStorage.getItem as jest.Mock).mockImplementation(() =>
|
|
137
|
+
new Promise(resolve => {
|
|
138
|
+
resolveStorage = resolve;
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const { result, unmount } = renderHook(() =>
|
|
143
|
+
useStorageState(key, defaultValue)
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Unmount before load completes
|
|
147
|
+
unmount();
|
|
148
|
+
|
|
149
|
+
// Resolve storage after unmount
|
|
150
|
+
await act(async () => {
|
|
151
|
+
resolveStorage!('stored-value');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// State should not be updated after unmount
|
|
155
|
+
expect(result.current[0]).toBe(defaultValue);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should handle unmount during update', async () => {
|
|
159
|
+
const key = 'test-key';
|
|
160
|
+
const defaultValue = 'default';
|
|
161
|
+
const newValue = 'new-value';
|
|
162
|
+
|
|
163
|
+
// Mock slow storage
|
|
164
|
+
let resolveStorage: () => void;
|
|
165
|
+
(AsyncStorage.setItem as jest.Mock).mockImplementation(() =>
|
|
166
|
+
new Promise(resolve => {
|
|
167
|
+
resolveStorage = resolve;
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const { result, unmount } = renderHook(() =>
|
|
172
|
+
useStorageState(key, defaultValue)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Start update
|
|
176
|
+
const updatePromise = act(async () => {
|
|
177
|
+
await result.current[1](newValue);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Unmount before update completes
|
|
181
|
+
unmount();
|
|
182
|
+
|
|
183
|
+
// Complete update
|
|
184
|
+
await act(async () => {
|
|
185
|
+
resolveStorage!();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await updatePromise;
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('Performance', () => {
|
|
193
|
+
it('should not re-run effect when defaultValue changes', async () => {
|
|
194
|
+
const key = 'test-key';
|
|
195
|
+
const defaultValue1 = 'default1';
|
|
196
|
+
const defaultValue2 = 'default2';
|
|
197
|
+
|
|
198
|
+
await AsyncStorage.setItem(key, JSON.stringify('stored-value'));
|
|
199
|
+
|
|
200
|
+
const { result, rerender, waitForNextUpdate } = renderHook(
|
|
201
|
+
({ defaultValue }) => useStorageState(key, defaultValue),
|
|
202
|
+
{ initialProps: { defaultValue: defaultValue1 } }
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await act(async () => {
|
|
206
|
+
await waitForNextUpdate();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const getItemCalls = (AsyncStorage.getItem as jest.Mock).mock.calls.length;
|
|
210
|
+
|
|
211
|
+
// Rerender with different default value
|
|
212
|
+
rerender({ defaultValue: defaultValue2 });
|
|
213
|
+
|
|
214
|
+
// Should not trigger another storage read
|
|
215
|
+
expect((AsyncStorage.getItem as jest.Mock).mock.calls.length).toBe(getItemCalls);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should re-run effect when key changes', async () => {
|
|
219
|
+
const key1 = 'key1';
|
|
220
|
+
const key2 = 'key2';
|
|
221
|
+
const defaultValue = 'default';
|
|
222
|
+
|
|
223
|
+
await AsyncStorage.setItem(key1, JSON.stringify('value1'));
|
|
224
|
+
await AsyncStorage.setItem(key2, JSON.stringify('value2'));
|
|
225
|
+
|
|
226
|
+
const { result, rerender, waitForNextUpdate } = renderHook(
|
|
227
|
+
({ key }) => useStorageState(key, defaultValue),
|
|
228
|
+
{ initialProps: { key: key1 } }
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
await act(async () => {
|
|
232
|
+
await waitForNextUpdate();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(result.current[0]).toBe('value1');
|
|
236
|
+
|
|
237
|
+
// Change key
|
|
238
|
+
rerender({ key: key2 });
|
|
239
|
+
|
|
240
|
+
await act(async () => {
|
|
241
|
+
await waitForNextUpdate();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result.current[0]).toBe('value2');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('Error Handling', () => {
|
|
249
|
+
it('should handle storage read error', async () => {
|
|
250
|
+
const key = 'test-key';
|
|
251
|
+
const defaultValue = 'default';
|
|
252
|
+
|
|
253
|
+
// Mock storage error
|
|
254
|
+
(AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('Storage error'));
|
|
255
|
+
|
|
256
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
257
|
+
useStorageState(key, defaultValue)
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
await act(async () => {
|
|
261
|
+
await waitForNextUpdate();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(result.current[0]).toBe(defaultValue);
|
|
265
|
+
expect(result.current[2]).toBe(false); // isLoading should be false
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should handle storage write error', async () => {
|
|
269
|
+
const key = 'test-key';
|
|
270
|
+
const defaultValue = 'default';
|
|
271
|
+
const newValue = 'new-value';
|
|
272
|
+
|
|
273
|
+
// Mock storage error
|
|
274
|
+
(AsyncStorage.setItem as jest.Mock).mockRejectedValue(new Error('Storage error'));
|
|
275
|
+
|
|
276
|
+
const { result, waitForNextUpdate } = renderHook(() =>
|
|
277
|
+
useStorageState(key, defaultValue)
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
await act(async () => {
|
|
281
|
+
await waitForNextUpdate();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// State should still update even if storage fails
|
|
285
|
+
await act(async () => {
|
|
286
|
+
await result.current[1](newValue);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result.current[0]).toBe(newValue);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache State Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages cache state following Single Responsibility Principle
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
8
|
+
import type { CachedValue } from '../../domain/entities/CachedValue';
|
|
9
|
+
import { createCachedValue, isCacheExpired } from '../../domain/entities/CachedValue';
|
|
10
|
+
|
|
11
|
+
export interface CacheState<T> {
|
|
12
|
+
data: T | null;
|
|
13
|
+
isLoading: boolean;
|
|
14
|
+
isExpired: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CacheActions<T> {
|
|
18
|
+
setData: (value: T) => void;
|
|
19
|
+
clearData: () => void;
|
|
20
|
+
setLoading: (loading: boolean) => void;
|
|
21
|
+
setExpired: (expired: boolean) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Hook for managing cache state
|
|
26
|
+
*/
|
|
27
|
+
export function useCacheState<T>(): [CacheState<T>, CacheActions<T>] {
|
|
28
|
+
const [data, setDataState] = useState<T | null>(null);
|
|
29
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
30
|
+
const [isExpired, setIsExpired] = useState(false);
|
|
31
|
+
|
|
32
|
+
const setData = useCallback((value: T) => {
|
|
33
|
+
setDataState(value);
|
|
34
|
+
setIsExpired(false);
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const clearData = useCallback(() => {
|
|
38
|
+
setDataState(null);
|
|
39
|
+
setIsExpired(true);
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const setLoading = useCallback((loading: boolean) => {
|
|
43
|
+
setIsLoading(loading);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const setExpired = useCallback((expired: boolean) => {
|
|
47
|
+
setIsExpired(expired);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
// Memoize state and actions objects for performance
|
|
51
|
+
const state: CacheState<T> = useMemo(() => ({ data, isLoading, isExpired }), [data, isLoading, isExpired]);
|
|
52
|
+
const actions: CacheActions<T> = useMemo(() => ({ setData, clearData, setLoading, setExpired }), [setData, clearData, setLoading, setExpired]);
|
|
53
|
+
|
|
54
|
+
return [state, actions];
|
|
55
|
+
}
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* General-purpose cache hook for any app
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
8
|
+
import { useEffect, useCallback, useMemo } from 'react';
|
|
9
|
+
import { useCacheState } from './useCacheState';
|
|
10
|
+
import { CacheStorageOperations } from './CacheStorageOperations';
|
|
11
|
+
import { isCacheExpired } from '../../domain/entities/CachedValue';
|
|
12
12
|
import { DEFAULT_TTL } from '../../domain/constants/CacheDefaults';
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -91,71 +91,62 @@ export function usePersistentCache<T>(
|
|
|
91
91
|
options: PersistentCacheOptions = {},
|
|
92
92
|
): PersistentCacheResult<T> {
|
|
93
93
|
const { ttl = DEFAULT_TTL.MEDIUM, version, enabled = true } = options;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
94
|
+
const [state, actions] = useCacheState<T>();
|
|
95
|
+
|
|
96
|
+
// Use singleton pattern to prevent memory leaks
|
|
97
|
+
const cacheOps = useMemo(() => CacheStorageOperations.getInstance(), []);
|
|
98
98
|
|
|
99
99
|
const loadFromStorage = useCallback(async () => {
|
|
100
100
|
if (!enabled) {
|
|
101
|
-
|
|
101
|
+
actions.setLoading(false);
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
actions.setLoading(true);
|
|
106
|
+
|
|
105
107
|
try {
|
|
106
|
-
const
|
|
108
|
+
const cached = await cacheOps.loadFromStorage<T>(key, version);
|
|
107
109
|
|
|
108
|
-
if (
|
|
109
|
-
const cached = JSON.parse(result.data) as CachedValue<T>;
|
|
110
|
+
if (cached) {
|
|
110
111
|
const expired = isCacheExpired(cached, version);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
setIsExpired(expired);
|
|
112
|
+
actions.setData(cached.value);
|
|
113
|
+
actions.setExpired(expired);
|
|
114
114
|
} else {
|
|
115
|
-
|
|
116
|
-
setIsExpired(true);
|
|
115
|
+
actions.clearData();
|
|
117
116
|
}
|
|
118
117
|
} catch {
|
|
119
|
-
|
|
120
|
-
setIsExpired(true);
|
|
118
|
+
actions.clearData();
|
|
121
119
|
} finally {
|
|
122
|
-
|
|
120
|
+
actions.setLoading(false);
|
|
123
121
|
}
|
|
124
|
-
}, [key, version, enabled]);
|
|
122
|
+
}, [key, version, enabled, actions, cacheOps]);
|
|
125
123
|
|
|
126
124
|
const setData = useCallback(
|
|
127
125
|
async (value: T) => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const cached = createCachedValue(value, ttl, version);
|
|
131
|
-
await storageRepository.setString(key, JSON.stringify(cached));
|
|
132
|
-
setDataState(value);
|
|
133
|
-
setIsExpired(false);
|
|
126
|
+
await cacheOps.saveToStorage(key, value, { ttl, version, enabled });
|
|
127
|
+
actions.setData(value);
|
|
134
128
|
},
|
|
135
|
-
[key, ttl, version, enabled],
|
|
129
|
+
[key, ttl, version, enabled, actions, cacheOps],
|
|
136
130
|
);
|
|
137
131
|
|
|
138
132
|
const clearData = useCallback(async () => {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
setDataState(null);
|
|
143
|
-
setIsExpired(true);
|
|
144
|
-
}, [key, enabled]);
|
|
133
|
+
await cacheOps.clearFromStorage(key, enabled);
|
|
134
|
+
actions.clearData();
|
|
135
|
+
}, [key, enabled, actions, cacheOps]);
|
|
145
136
|
|
|
146
137
|
const refresh = useCallback(async () => {
|
|
147
|
-
setIsLoading(true);
|
|
148
138
|
await loadFromStorage();
|
|
149
139
|
}, [loadFromStorage]);
|
|
150
140
|
|
|
141
|
+
// Prevent infinite loops by only running when key or enabled changes
|
|
151
142
|
useEffect(() => {
|
|
152
143
|
loadFromStorage();
|
|
153
|
-
}, [
|
|
144
|
+
}, [key, enabled]);
|
|
154
145
|
|
|
155
146
|
return {
|
|
156
|
-
data,
|
|
157
|
-
isLoading,
|
|
158
|
-
isExpired,
|
|
147
|
+
data: state.data,
|
|
148
|
+
isLoading: state.isLoading,
|
|
149
|
+
isExpired: state.isExpired,
|
|
159
150
|
setData,
|
|
160
151
|
clearData,
|
|
161
152
|
refresh,
|