@umituz/react-native-design-system 4.23.113 → 4.23.115
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 +1 -1
- package/src/atoms/AtomicTouchable.tsx +22 -0
- package/src/atoms/badge/AtomicBadge.tsx +26 -28
- package/src/atoms/chip/AtomicChip.tsx +5 -5
- package/src/atoms/datepicker/components/DatePickerModal.tsx +4 -3
- package/src/atoms/input/hooks/useInputState.ts +1 -1
- package/src/atoms/picker/components/PickerModal.tsx +1 -1
- package/src/atoms/picker/hooks/usePickerState.ts +28 -15
- package/src/atoms/skeleton/AtomicSkeleton.tsx +5 -5
- package/src/device/infrastructure/services/DeviceCapabilityService.ts +1 -12
- package/src/filesystem/infrastructure/services/directory.service.ts +37 -9
- package/src/filesystem/infrastructure/services/download.service.ts +62 -11
- package/src/filesystem/infrastructure/services/file-manager.service.ts +42 -11
- package/src/filesystem/infrastructure/services/file-writer.service.ts +8 -3
- package/src/media/infrastructure/services/MediaPickerService.ts +32 -8
- package/src/media/infrastructure/services/MediaSaveService.ts +7 -2
- package/src/media/presentation/hooks/useMedia.ts +60 -22
- package/src/molecules/BaseModal.tsx +1 -0
- package/src/molecules/ConfirmationModalMain.tsx +1 -0
- package/src/molecules/ListItem.tsx +15 -1
- package/src/molecules/avatar/Avatar.tsx +28 -11
- package/src/molecules/bottom-sheet/components/BottomSheet.tsx +1 -0
- package/src/molecules/calendar/presentation/components/AtomicCalendar.tsx +1 -1
- package/src/responsive/useResponsive.ts +1 -1
- package/src/services/api/ApiClient.ts +37 -6
- package/src/storage/presentation/hooks/usePersistentCache.ts +20 -12
- package/src/storage/presentation/hooks/useStore.ts +1 -0
- package/src/tanstack/presentation/hooks/usePrefetch.ts +14 -0
- package/src/theme/infrastructure/stores/themeStore.ts +13 -11
- package/src/timezone/infrastructure/services/BusinessCalendarManager.ts +1 -0
- package/src/timezone/infrastructure/services/CalendarManager.ts +2 -2
- package/src/timezone/infrastructure/services/DateComparisonUtils.ts +1 -0
- package/src/timezone/infrastructure/services/DateFormatter.ts +3 -2
- package/src/timezone/infrastructure/services/DateRangeUtils.ts +1 -0
- package/src/timezone/infrastructure/utils/TimezoneParsers.ts +27 -0
- package/src/utilities/sharing/presentation/hooks/useSharing.ts +44 -17
- package/src/utils/async/index.ts +12 -0
- package/src/utils/async/retryWithBackoff.ts +177 -0
- package/src/utils/errors/DesignSystemError.ts +117 -0
- package/src/utils/errors/ErrorHandler.ts +137 -0
- package/src/utils/errors/index.ts +7 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { File } from "expo-file-system";
|
|
7
7
|
import type { FileEncoding, FileOperationResult } from "../../domain/entities/File";
|
|
8
8
|
import { getEncodingType, type ExpoEncodingType } from "./encoding.service";
|
|
9
|
+
import { ErrorHandler, ErrorCodes } from "../../../utils/errors";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Write string to file
|
|
@@ -18,15 +19,19 @@ export async function writeFile(
|
|
|
18
19
|
try {
|
|
19
20
|
const encodingType = getEncodingType(encoding);
|
|
20
21
|
const file = new File(uri);
|
|
21
|
-
file.write(content, {
|
|
22
|
+
await file.write(content, {
|
|
22
23
|
encoding: encodingType as ExpoEncodingType,
|
|
23
24
|
});
|
|
24
25
|
return { success: true, uri };
|
|
25
26
|
} catch (error) {
|
|
26
|
-
const
|
|
27
|
+
const handled = ErrorHandler.handleAndLog(
|
|
28
|
+
error,
|
|
29
|
+
'writeFile',
|
|
30
|
+
{ uri, encoding, contentLength: content.length }
|
|
31
|
+
);
|
|
27
32
|
return {
|
|
28
33
|
success: false,
|
|
29
|
-
error:
|
|
34
|
+
error: handled.getUserMessage(),
|
|
30
35
|
};
|
|
31
36
|
}
|
|
32
37
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "../utils/mediaPickerMappers";
|
|
23
23
|
import { PermissionManager } from "../utils/PermissionManager";
|
|
24
24
|
import { FileValidator } from "../../domain/utils/FileValidator";
|
|
25
|
+
import { ErrorHandler, ErrorCodes } from "../../../utils/errors";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Media picker service for selecting images/videos
|
|
@@ -33,7 +34,11 @@ export class MediaPickerService {
|
|
|
33
34
|
try {
|
|
34
35
|
const permission = await PermissionManager.requestCameraPermission();
|
|
35
36
|
if (!PermissionManager.isPermissionGranted(permission)) {
|
|
36
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
canceled: true,
|
|
39
|
+
error: MediaValidationError.PERMISSION_DENIED,
|
|
40
|
+
errorMessage: "Camera permission was denied",
|
|
41
|
+
};
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
const result = await ImagePicker.launchCameraAsync({
|
|
@@ -45,8 +50,13 @@ export class MediaPickerService {
|
|
|
45
50
|
});
|
|
46
51
|
|
|
47
52
|
return mapPickerResult(result);
|
|
48
|
-
} catch {
|
|
49
|
-
|
|
53
|
+
} catch (error) {
|
|
54
|
+
ErrorHandler.handleAndLog(error, 'launchCamera', { options });
|
|
55
|
+
return {
|
|
56
|
+
canceled: true,
|
|
57
|
+
error: MediaValidationError.PICKER_ERROR,
|
|
58
|
+
errorMessage: "Failed to launch camera",
|
|
59
|
+
};
|
|
50
60
|
}
|
|
51
61
|
}
|
|
52
62
|
|
|
@@ -56,7 +66,11 @@ export class MediaPickerService {
|
|
|
56
66
|
try {
|
|
57
67
|
const permission = await PermissionManager.requestCameraPermission();
|
|
58
68
|
if (!PermissionManager.isPermissionGranted(permission)) {
|
|
59
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
canceled: true,
|
|
71
|
+
error: MediaValidationError.PERMISSION_DENIED,
|
|
72
|
+
errorMessage: "Camera permission was denied",
|
|
73
|
+
};
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
const result = await ImagePicker.launchCameraAsync({
|
|
@@ -67,8 +81,13 @@ export class MediaPickerService {
|
|
|
67
81
|
});
|
|
68
82
|
|
|
69
83
|
return mapPickerResult(result);
|
|
70
|
-
} catch {
|
|
71
|
-
|
|
84
|
+
} catch (error) {
|
|
85
|
+
ErrorHandler.handleAndLog(error, 'launchCameraForVideo', { options });
|
|
86
|
+
return {
|
|
87
|
+
canceled: true,
|
|
88
|
+
error: MediaValidationError.PICKER_ERROR,
|
|
89
|
+
errorMessage: "Failed to launch camera for video",
|
|
90
|
+
};
|
|
72
91
|
}
|
|
73
92
|
}
|
|
74
93
|
|
|
@@ -114,8 +133,13 @@ export class MediaPickerService {
|
|
|
114
133
|
}
|
|
115
134
|
|
|
116
135
|
return mappedResult;
|
|
117
|
-
} catch {
|
|
118
|
-
|
|
136
|
+
} catch (error) {
|
|
137
|
+
ErrorHandler.handleAndLog(error, 'pickImage', { options });
|
|
138
|
+
return {
|
|
139
|
+
canceled: true,
|
|
140
|
+
error: MediaValidationError.PICKER_ERROR,
|
|
141
|
+
errorMessage: "Failed to pick image from library",
|
|
142
|
+
};
|
|
119
143
|
}
|
|
120
144
|
}
|
|
121
145
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import * as FileSystem from "expo-file-system";
|
|
7
7
|
import { MediaLibraryPermission } from "../../domain/entities/Media";
|
|
8
|
+
import { ErrorHandler, ErrorCodes } from "../../../utils/errors";
|
|
8
9
|
|
|
9
10
|
export interface SaveResult {
|
|
10
11
|
success: boolean;
|
|
@@ -87,10 +88,14 @@ export class MediaSaveService {
|
|
|
87
88
|
path: destination,
|
|
88
89
|
};
|
|
89
90
|
} catch (error) {
|
|
90
|
-
const
|
|
91
|
+
const handled = ErrorHandler.handleAndLog(
|
|
92
|
+
error,
|
|
93
|
+
'saveToStorage',
|
|
94
|
+
{ uri, mediaType }
|
|
95
|
+
);
|
|
91
96
|
return {
|
|
92
97
|
success: false,
|
|
93
|
-
error: `Failed to save media: ${
|
|
98
|
+
error: `Failed to save media: ${handled.getUserMessage()}`,
|
|
94
99
|
};
|
|
95
100
|
}
|
|
96
101
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Provides camera, gallery picking functionality.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { useState, useCallback } from "react";
|
|
8
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
9
9
|
import { MediaPickerService } from "../../infrastructure/services/MediaPickerService";
|
|
10
10
|
import { PermissionManager } from "../../infrastructure/utils/PermissionManager";
|
|
11
11
|
import type {
|
|
@@ -39,24 +39,38 @@ export const useMedia = () => {
|
|
|
39
39
|
const [isLoading, setIsLoading] = useState(false);
|
|
40
40
|
const [error, setError] = useState<string | null>(null);
|
|
41
41
|
|
|
42
|
+
// Track mounted state to prevent setState on unmounted component
|
|
43
|
+
const isMountedRef = useRef(true);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
return () => {
|
|
46
|
+
isMountedRef.current = false;
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
42
50
|
const pickImage = useCallback(
|
|
43
51
|
async (options?: MediaPickerOptions): Promise<MediaPickerResult> => {
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
if (isMountedRef.current) {
|
|
53
|
+
setIsLoading(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
}
|
|
46
56
|
try {
|
|
47
57
|
const result = await MediaPickerService.pickSingleImage(options);
|
|
48
58
|
// Set error from validation result if present
|
|
49
|
-
if (result.errorMessage) {
|
|
59
|
+
if (result.errorMessage && isMountedRef.current) {
|
|
50
60
|
setError(result.errorMessage);
|
|
51
61
|
}
|
|
52
62
|
return result;
|
|
53
63
|
} catch (err) {
|
|
54
64
|
const errorMessage =
|
|
55
65
|
err instanceof Error ? err.message : "Failed to pick image";
|
|
56
|
-
|
|
66
|
+
if (isMountedRef.current) {
|
|
67
|
+
setError(errorMessage);
|
|
68
|
+
}
|
|
57
69
|
return { canceled: true };
|
|
58
70
|
} finally {
|
|
59
|
-
|
|
71
|
+
if (isMountedRef.current) {
|
|
72
|
+
setIsLoading(false);
|
|
73
|
+
}
|
|
60
74
|
}
|
|
61
75
|
},
|
|
62
76
|
[]
|
|
@@ -64,18 +78,24 @@ export const useMedia = () => {
|
|
|
64
78
|
|
|
65
79
|
const pickMultipleImages = useCallback(
|
|
66
80
|
async (options?: MediaPickerOptions): Promise<MediaPickerResult> => {
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
if (isMountedRef.current) {
|
|
82
|
+
setIsLoading(true);
|
|
83
|
+
setError(null);
|
|
84
|
+
}
|
|
69
85
|
try {
|
|
70
86
|
const result = await MediaPickerService.pickMultipleImages(options);
|
|
71
87
|
return result;
|
|
72
88
|
} catch (err) {
|
|
73
89
|
const errorMessage =
|
|
74
90
|
err instanceof Error ? err.message : "Failed to pick images";
|
|
75
|
-
|
|
91
|
+
if (isMountedRef.current) {
|
|
92
|
+
setError(errorMessage);
|
|
93
|
+
}
|
|
76
94
|
return { canceled: true };
|
|
77
95
|
} finally {
|
|
78
|
-
|
|
96
|
+
if (isMountedRef.current) {
|
|
97
|
+
setIsLoading(false);
|
|
98
|
+
}
|
|
79
99
|
}
|
|
80
100
|
},
|
|
81
101
|
[]
|
|
@@ -83,18 +103,24 @@ export const useMedia = () => {
|
|
|
83
103
|
|
|
84
104
|
const pickVideo = useCallback(
|
|
85
105
|
async (options?: MediaPickerOptions): Promise<MediaPickerResult> => {
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
if (isMountedRef.current) {
|
|
107
|
+
setIsLoading(true);
|
|
108
|
+
setError(null);
|
|
109
|
+
}
|
|
88
110
|
try {
|
|
89
111
|
const result = await MediaPickerService.pickVideo(options);
|
|
90
112
|
return result;
|
|
91
113
|
} catch (err) {
|
|
92
114
|
const errorMessage =
|
|
93
115
|
err instanceof Error ? err.message : "Failed to pick video";
|
|
94
|
-
|
|
116
|
+
if (isMountedRef.current) {
|
|
117
|
+
setError(errorMessage);
|
|
118
|
+
}
|
|
95
119
|
return { canceled: true };
|
|
96
120
|
} finally {
|
|
97
|
-
|
|
121
|
+
if (isMountedRef.current) {
|
|
122
|
+
setIsLoading(false);
|
|
123
|
+
}
|
|
98
124
|
}
|
|
99
125
|
},
|
|
100
126
|
[]
|
|
@@ -102,18 +128,24 @@ export const useMedia = () => {
|
|
|
102
128
|
|
|
103
129
|
const launchCamera = useCallback(
|
|
104
130
|
async (options?: CameraOptions): Promise<MediaPickerResult> => {
|
|
105
|
-
|
|
106
|
-
|
|
131
|
+
if (isMountedRef.current) {
|
|
132
|
+
setIsLoading(true);
|
|
133
|
+
setError(null);
|
|
134
|
+
}
|
|
107
135
|
try {
|
|
108
136
|
const result = await MediaPickerService.launchCamera(options);
|
|
109
137
|
return result;
|
|
110
138
|
} catch (err) {
|
|
111
139
|
const errorMessage =
|
|
112
140
|
err instanceof Error ? err.message : "Failed to launch camera";
|
|
113
|
-
|
|
141
|
+
if (isMountedRef.current) {
|
|
142
|
+
setError(errorMessage);
|
|
143
|
+
}
|
|
114
144
|
return { canceled: true };
|
|
115
145
|
} finally {
|
|
116
|
-
|
|
146
|
+
if (isMountedRef.current) {
|
|
147
|
+
setIsLoading(false);
|
|
148
|
+
}
|
|
117
149
|
}
|
|
118
150
|
},
|
|
119
151
|
[]
|
|
@@ -121,18 +153,24 @@ export const useMedia = () => {
|
|
|
121
153
|
|
|
122
154
|
const launchCameraForVideo = useCallback(
|
|
123
155
|
async (options?: CameraOptions): Promise<MediaPickerResult> => {
|
|
124
|
-
|
|
125
|
-
|
|
156
|
+
if (isMountedRef.current) {
|
|
157
|
+
setIsLoading(true);
|
|
158
|
+
setError(null);
|
|
159
|
+
}
|
|
126
160
|
try {
|
|
127
161
|
const result = await MediaPickerService.launchCameraForVideo(options);
|
|
128
162
|
return result;
|
|
129
163
|
} catch (err) {
|
|
130
164
|
const errorMessage =
|
|
131
165
|
err instanceof Error ? err.message : "Failed to record video";
|
|
132
|
-
|
|
166
|
+
if (isMountedRef.current) {
|
|
167
|
+
setError(errorMessage);
|
|
168
|
+
}
|
|
133
169
|
return { canceled: true };
|
|
134
170
|
} finally {
|
|
135
|
-
|
|
171
|
+
if (isMountedRef.current) {
|
|
172
|
+
setIsLoading(false);
|
|
173
|
+
}
|
|
136
174
|
}
|
|
137
175
|
},
|
|
138
176
|
[]
|
|
@@ -14,8 +14,22 @@ export const ListItem: React.FC<ListItemProps> = ({
|
|
|
14
14
|
const listItemStyles = getListItemStyles(tokens);
|
|
15
15
|
const Component = onPress ? TouchableOpacity : View;
|
|
16
16
|
|
|
17
|
+
const accessibilityProps = onPress
|
|
18
|
+
? {
|
|
19
|
+
accessibilityRole: 'button' as const,
|
|
20
|
+
accessibilityLabel: title,
|
|
21
|
+
accessibilityState: { disabled },
|
|
22
|
+
}
|
|
23
|
+
: {};
|
|
24
|
+
|
|
17
25
|
return (
|
|
18
|
-
<Component
|
|
26
|
+
<Component
|
|
27
|
+
style={[listItemStyles.container, disabled ? listItemStyles.disabled : undefined, style]}
|
|
28
|
+
onPress={onPress}
|
|
29
|
+
disabled={disabled}
|
|
30
|
+
activeOpacity={0.7}
|
|
31
|
+
{...accessibilityProps}
|
|
32
|
+
>
|
|
19
33
|
{leftIcon && (
|
|
20
34
|
<AtomicIcon
|
|
21
35
|
name={leftIcon}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* Handles loading states, fallbacks, and status indicators.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React from 'react';
|
|
9
|
-
import { View, Image, StyleSheet, type StyleProp, type ViewStyle, type ImageStyle } from 'react-native';
|
|
8
|
+
import React, { useMemo } from 'react';
|
|
9
|
+
import { View, Image, StyleSheet, TouchableOpacity, type StyleProp, type ViewStyle, type ImageStyle } from 'react-native';
|
|
10
10
|
import { useAppDesignTokens } from '../../theme';
|
|
11
11
|
import { AtomicText, AtomicIcon } from '../../atoms';
|
|
12
12
|
import type { AvatarSize, AvatarShape } from './Avatar.types';
|
|
@@ -62,20 +62,29 @@ export const Avatar: React.FC<AvatarProps> = ({
|
|
|
62
62
|
onPress,
|
|
63
63
|
}) => {
|
|
64
64
|
const tokens = useAppDesignTokens();
|
|
65
|
-
const config = SIZE_CONFIGS[size];
|
|
65
|
+
const config = useMemo(() => SIZE_CONFIGS[size], [size]);
|
|
66
66
|
|
|
67
67
|
// Determine avatar type and content
|
|
68
68
|
const hasImage = !!uri;
|
|
69
69
|
const hasName = !!name;
|
|
70
|
-
const initials =
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const initials = useMemo(
|
|
71
|
+
() => (hasName ? AvatarUtils.generateInitials(name) : AVATAR_CONSTANTS.FALLBACK_INITIALS),
|
|
72
|
+
[hasName, name]
|
|
73
|
+
);
|
|
74
|
+
const bgColor = useMemo(
|
|
75
|
+
() => backgroundColor || (hasName ? AvatarUtils.getColorForName(name) : tokens.colors.surfaceSecondary),
|
|
76
|
+
[backgroundColor, hasName, name, tokens.colors.surfaceSecondary]
|
|
77
|
+
);
|
|
78
|
+
const borderRadius = useMemo(
|
|
79
|
+
() => AvatarUtils.getBorderRadius(shape, config.size),
|
|
80
|
+
[shape, config.size]
|
|
81
|
+
);
|
|
73
82
|
|
|
74
83
|
// Status indicator position
|
|
75
|
-
const statusPosition = {
|
|
84
|
+
const statusPosition = useMemo(() => ({
|
|
76
85
|
bottom: 0,
|
|
77
86
|
right: 0,
|
|
78
|
-
};
|
|
87
|
+
}), []);
|
|
79
88
|
|
|
80
89
|
const renderContent = () => {
|
|
81
90
|
if (hasImage) {
|
|
@@ -122,8 +131,10 @@ export const Avatar: React.FC<AvatarProps> = ({
|
|
|
122
131
|
);
|
|
123
132
|
};
|
|
124
133
|
|
|
134
|
+
const AvatarWrapper = onPress ? TouchableOpacity : View;
|
|
135
|
+
|
|
125
136
|
return (
|
|
126
|
-
<
|
|
137
|
+
<AvatarWrapper
|
|
127
138
|
style={[
|
|
128
139
|
styles.container,
|
|
129
140
|
{
|
|
@@ -134,7 +145,11 @@ export const Avatar: React.FC<AvatarProps> = ({
|
|
|
134
145
|
},
|
|
135
146
|
style,
|
|
136
147
|
]}
|
|
137
|
-
|
|
148
|
+
onPress={onPress}
|
|
149
|
+
disabled={!onPress}
|
|
150
|
+
accessibilityRole={onPress ? 'button' : 'image'}
|
|
151
|
+
accessibilityLabel={name || 'User avatar'}
|
|
152
|
+
accessible={true}
|
|
138
153
|
>
|
|
139
154
|
{renderContent()}
|
|
140
155
|
|
|
@@ -153,9 +168,11 @@ export const Avatar: React.FC<AvatarProps> = ({
|
|
|
153
168
|
...statusPosition,
|
|
154
169
|
},
|
|
155
170
|
]}
|
|
171
|
+
accessibilityLabel={`Status: ${status}`}
|
|
172
|
+
accessibilityRole="none"
|
|
156
173
|
/>
|
|
157
174
|
)}
|
|
158
|
-
</
|
|
175
|
+
</AvatarWrapper>
|
|
159
176
|
);
|
|
160
177
|
};
|
|
161
178
|
|
|
@@ -99,6 +99,7 @@ export const BottomSheet = forwardRef<BottomSheetRef, BottomSheetProps>((props,
|
|
|
99
99
|
animationType="none"
|
|
100
100
|
onRequestClose={dismiss}
|
|
101
101
|
statusBarTranslucent
|
|
102
|
+
accessibilityViewIsModal={true}
|
|
102
103
|
>
|
|
103
104
|
<Pressable style={styles.overlay} onPress={dismiss}>
|
|
104
105
|
<View style={styles.container}>
|
|
@@ -124,7 +124,7 @@ export const useResponsive = (): UseResponsiveReturn => {
|
|
|
124
124
|
getGridCols,
|
|
125
125
|
};
|
|
126
126
|
},
|
|
127
|
-
[width, height, getLogoSize, getInputHeight, getIconSize, getMaxWidth, getFontSize, getGridCols],
|
|
127
|
+
[width, height, insets, getLogoSize, getInputHeight, getIconSize, getMaxWidth, getFontSize, getGridCols],
|
|
128
128
|
);
|
|
129
129
|
|
|
130
130
|
return responsiveValues;
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
isSuccessfulResponse,
|
|
21
21
|
fetchWithTimeout,
|
|
22
22
|
} from './utils/responseHandler';
|
|
23
|
+
import { retryWithBackoff, isNetworkError, isRetryableHttpStatus } from '../../utils/async';
|
|
24
|
+
import { ErrorHandler } from '../../utils/errors';
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Applies interceptors to a value
|
|
@@ -46,7 +48,7 @@ export class ApiClient {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
|
-
* Makes an HTTP request
|
|
51
|
+
* Makes an HTTP request with automatic retry for retryable errors
|
|
50
52
|
*/
|
|
51
53
|
async request<T>(requestConfig: ApiRequestConfig): Promise<ApiResponse<T>> {
|
|
52
54
|
try {
|
|
@@ -61,11 +63,39 @@ export class ApiClient {
|
|
|
61
63
|
headers: { ...this.config.headers, ...config.headers },
|
|
62
64
|
});
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
// Retry only for GET requests and retryable errors
|
|
67
|
+
const shouldRetry = config.method === 'GET';
|
|
68
|
+
const timeout = config.timeout || this.config.timeout || 30000;
|
|
69
|
+
|
|
70
|
+
const response = shouldRetry
|
|
71
|
+
? await retryWithBackoff(
|
|
72
|
+
() => fetchWithTimeout(fullURL, fetchOptions, timeout),
|
|
73
|
+
{
|
|
74
|
+
maxRetries: 3,
|
|
75
|
+
baseDelay: 1000,
|
|
76
|
+
shouldRetry: (error) => {
|
|
77
|
+
// Retry on network errors
|
|
78
|
+
if (isNetworkError(error as Error)) return true;
|
|
79
|
+
|
|
80
|
+
// Retry on specific HTTP status codes (5xx, 429, 408)
|
|
81
|
+
if ('status' in error && typeof error.status === 'number') {
|
|
82
|
+
return isRetryableHttpStatus(error.status);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return false;
|
|
86
|
+
},
|
|
87
|
+
onRetry: (error, attempt, delay) => {
|
|
88
|
+
if (__DEV__) {
|
|
89
|
+
ErrorHandler.log({
|
|
90
|
+
name: 'ApiRetry',
|
|
91
|
+
message: `Retrying API request (attempt ${attempt}) after ${delay}ms`,
|
|
92
|
+
context: { url: fullURL, error: error.message },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
: await fetchWithTimeout(fullURL, fetchOptions, timeout);
|
|
69
99
|
|
|
70
100
|
if (!isSuccessfulResponse(response)) {
|
|
71
101
|
const error = await handleHttpError(response);
|
|
@@ -78,6 +108,7 @@ export class ApiClient {
|
|
|
78
108
|
return parsedResponse;
|
|
79
109
|
} catch (error) {
|
|
80
110
|
const apiError = handleNetworkError(error);
|
|
111
|
+
ErrorHandler.log(apiError);
|
|
81
112
|
throw await applyInterceptors(apiError, this.config.errorInterceptors);
|
|
82
113
|
}
|
|
83
114
|
}
|
|
@@ -99,16 +99,24 @@ export function usePersistentCache<T>(
|
|
|
99
99
|
// Track if component is mounted to prevent state updates after unmount
|
|
100
100
|
const isMountedRef = useRef(true);
|
|
101
101
|
|
|
102
|
+
// Stabilize actions to prevent circular dependency
|
|
103
|
+
const stableActionsRef = useRef(actions);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
stableActionsRef.current = actions;
|
|
106
|
+
});
|
|
107
|
+
|
|
102
108
|
const loadFromStorage = useCallback(async () => {
|
|
109
|
+
const currentActions = stableActionsRef.current;
|
|
110
|
+
|
|
103
111
|
if (!enabled) {
|
|
104
112
|
if (isMountedRef.current) {
|
|
105
|
-
|
|
113
|
+
currentActions.setLoading(false);
|
|
106
114
|
}
|
|
107
115
|
return;
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
if (isMountedRef.current) {
|
|
111
|
-
|
|
119
|
+
currentActions.setLoading(true);
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
try {
|
|
@@ -117,35 +125,35 @@ export function usePersistentCache<T>(
|
|
|
117
125
|
if (isMountedRef.current) {
|
|
118
126
|
if (cached) {
|
|
119
127
|
const expired = isCacheExpired(cached, version);
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
currentActions.setData(cached.value);
|
|
129
|
+
currentActions.setExpired(expired);
|
|
122
130
|
} else {
|
|
123
|
-
|
|
131
|
+
currentActions.clearData();
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
134
|
} catch {
|
|
127
135
|
if (isMountedRef.current) {
|
|
128
|
-
|
|
136
|
+
currentActions.clearData();
|
|
129
137
|
}
|
|
130
138
|
} finally {
|
|
131
139
|
if (isMountedRef.current) {
|
|
132
|
-
|
|
140
|
+
currentActions.setLoading(false);
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
|
-
}, [key, version, enabled,
|
|
143
|
+
}, [key, version, enabled, cacheOps]); // Removed actions from dependencies
|
|
136
144
|
|
|
137
145
|
const setData = useCallback(
|
|
138
146
|
async (value: T) => {
|
|
139
147
|
await cacheOps.saveToStorage(key, value, { ttl, version, enabled });
|
|
140
|
-
|
|
148
|
+
stableActionsRef.current.setData(value);
|
|
141
149
|
},
|
|
142
|
-
[key, ttl, version, enabled,
|
|
150
|
+
[key, ttl, version, enabled, cacheOps],
|
|
143
151
|
);
|
|
144
152
|
|
|
145
153
|
const clearData = useCallback(async () => {
|
|
146
154
|
await cacheOps.clearFromStorage(key, enabled);
|
|
147
|
-
|
|
148
|
-
}, [key, enabled,
|
|
155
|
+
stableActionsRef.current.clearData();
|
|
156
|
+
}, [key, enabled, cacheOps]);
|
|
149
157
|
|
|
150
158
|
const refresh = useCallback(async () => {
|
|
151
159
|
await loadFromStorage();
|
|
@@ -30,6 +30,13 @@ export function usePrefetchQuery<
|
|
|
30
30
|
const queryClient = useQueryClient();
|
|
31
31
|
const prefetchingRef = useRef(new Set<TVariables>());
|
|
32
32
|
|
|
33
|
+
// Cleanup on unmount
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
return () => {
|
|
36
|
+
prefetchingRef.current.clear();
|
|
37
|
+
};
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
33
40
|
return useCallback(
|
|
34
41
|
async (variables: TVariables) => {
|
|
35
42
|
if (prefetchingRef.current.has(variables)) return;
|
|
@@ -62,6 +69,13 @@ export function usePrefetchInfiniteQuery<
|
|
|
62
69
|
const queryClient = useQueryClient();
|
|
63
70
|
const hasPrefetchedRef = useRef(false);
|
|
64
71
|
|
|
72
|
+
// Cleanup on unmount
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
return () => {
|
|
75
|
+
hasPrefetchedRef.current = false;
|
|
76
|
+
};
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
65
79
|
return useCallback(async () => {
|
|
66
80
|
if (hasPrefetchedRef.current) return;
|
|
67
81
|
|