@umituz/react-native-firebase 2.4.83 → 2.4.84
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/domains/auth/domain/utils/user-metadata.util.ts +6 -2
- package/src/domains/auth/domain/utils/user-validation.util.ts +1 -1
- package/src/domains/auth/infrastructure/services/apple-auth.service.ts +3 -3
- package/src/domains/auth/infrastructure/services/crypto.util.ts +10 -1
- package/src/domains/auth/infrastructure/services/password.service.ts +16 -1
- package/src/domains/auth/infrastructure/services/user-document-builder.util.ts +8 -9
- package/src/domains/auth/infrastructure/services/user-document.service.ts +3 -3
- package/src/domains/auth/infrastructure/services/utils/auth-result-converter.util.ts +2 -2
- package/src/domains/auth/presentation/hooks/useGoogleOAuth.ts +12 -6
- package/src/domains/auth/presentation/hooks/useSocialAuth.ts +5 -7
- package/src/domains/firestore/index.ts +8 -9
- package/src/domains/firestore/infrastructure/middleware/QueryDeduplicationMiddleware.ts +2 -3
- package/src/domains/firestore/infrastructure/services/RequestLoggerService.ts +61 -23
- package/src/domains/firestore/presentation/hooks/useFirestoreSnapshot.ts +13 -5
- package/src/domains/firestore/utils/deduplication/pending-query-manager.util.ts +5 -7
- package/src/domains/firestore/utils/pagination.helper.ts +5 -5
- package/src/domains/firestore/utils/query/filters.util.ts +8 -23
- package/src/shared/domain/utils/error-handlers/error-checkers.ts +6 -3
- package/src/domains/auth/infrastructure/services/base/base-auth.service.ts +0 -15
- package/src/domains/firestore/presentation/index.ts +0 -2
- package/src/shared/domain/utils/id-generator.util.ts +0 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-firebase",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.84",
|
|
4
4
|
"description": "Unified Firebase package for React Native apps - Auth and Firestore services using Firebase JS SDK (no native modules).",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -67,6 +67,10 @@ export function isNewUser(input: UserCredential | User | UserMetadata): boolean
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// Convert to timestamps for reliable comparison
|
|
70
|
-
// If creation time === last sign in time, this is the first sign-in
|
|
71
|
-
|
|
70
|
+
// If creation time === last sign in time (within 2 seconds tolerance), this is the first sign-in
|
|
71
|
+
// Tolerance handles Firebase timestamp inconsistencies across different auth providers
|
|
72
|
+
const creationTimestamp = new Date(creationTime).getTime();
|
|
73
|
+
const lastSignInTimestamp = new Date(lastSignInTime).getTime();
|
|
74
|
+
const TOLERANCE_MS = 2000; // 2 second tolerance for timestamp inconsistencies
|
|
75
|
+
return Math.abs(creationTimestamp - lastSignInTimestamp) <= TOLERANCE_MS;
|
|
72
76
|
}
|
|
@@ -42,7 +42,7 @@ export function validateUserUnchanged(
|
|
|
42
42
|
const currentUserId = auth?.currentUser?.uid;
|
|
43
43
|
|
|
44
44
|
if (currentUserId !== originalUserId) {
|
|
45
|
-
if (
|
|
45
|
+
if (__DEV__) {
|
|
46
46
|
console.log(
|
|
47
47
|
`[validateUserUnchanged] User changed during operation. Original: ${originalUserId}, Current: ${currentUserId || 'none'}`
|
|
48
48
|
);
|
|
@@ -14,8 +14,8 @@ import { Platform } from "react-native";
|
|
|
14
14
|
import { generateNonce, hashNonce } from "./crypto.util";
|
|
15
15
|
import type { AppleAuthResult } from "./apple-auth.types";
|
|
16
16
|
import {
|
|
17
|
-
|
|
18
|
-
} from "
|
|
17
|
+
isCancelledError,
|
|
18
|
+
} from "../../../../shared/domain/utils/error-handlers/error-checkers";
|
|
19
19
|
import { executeAuthOperation, type Result } from "../../../../shared/domain/utils";
|
|
20
20
|
import { isNewUser as checkIsNewUser } from "../../domain/utils/user-metadata.util";
|
|
21
21
|
import { convertToOAuthResult } from "./utils/auth-result-converter.util";
|
|
@@ -121,7 +121,7 @@ export class AppleAuthService {
|
|
|
121
121
|
try {
|
|
122
122
|
return await this.signIn(auth);
|
|
123
123
|
} catch (error) {
|
|
124
|
-
if (
|
|
124
|
+
if (isCancelledError(error)) {
|
|
125
125
|
return {
|
|
126
126
|
success: false,
|
|
127
127
|
error: "Apple Sign-In was cancelled",
|
|
@@ -9,7 +9,16 @@ const NONCE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456
|
|
|
9
9
|
|
|
10
10
|
export async function generateNonce(length: number = 32): Promise<string> {
|
|
11
11
|
const bytes = await Crypto.getRandomBytesAsync(length);
|
|
12
|
-
|
|
12
|
+
const charsLength = NONCE_CHARS.length;
|
|
13
|
+
const result: string[] = [];
|
|
14
|
+
|
|
15
|
+
// Optimized: Single pass with direct character access
|
|
16
|
+
for (let i = 0; i < length; i++) {
|
|
17
|
+
const charIndex = bytes[i]! % charsLength; // Non-null assertion: index is always valid
|
|
18
|
+
result.push(NONCE_CHARS[charIndex]!); // Non-null assertion: charIndex is always within bounds
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return result.join('');
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
export async function hashNonce(nonce: string): Promise<string> {
|
|
@@ -4,7 +4,21 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { updatePassword, type User } from 'firebase/auth';
|
|
7
|
-
import { executeAuthOperation, type Result } from '../../../../shared/domain/utils';
|
|
7
|
+
import { executeAuthOperation, type Result, ERROR_MESSAGES } from '../../../../shared/domain/utils';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate password meets Firebase minimum requirements
|
|
11
|
+
* Firebase requires minimum 6 characters
|
|
12
|
+
*/
|
|
13
|
+
function validatePassword(password: string): void {
|
|
14
|
+
if (typeof password !== 'string') {
|
|
15
|
+
throw new Error(ERROR_MESSAGES.AUTH.INVALID_PASSWORD);
|
|
16
|
+
}
|
|
17
|
+
const trimmed = password.trim();
|
|
18
|
+
if (trimmed.length < 6) {
|
|
19
|
+
throw new Error(ERROR_MESSAGES.AUTH.WEAK_PASSWORD);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
8
22
|
|
|
9
23
|
/**
|
|
10
24
|
* Update the current user's password
|
|
@@ -12,6 +26,7 @@ import { executeAuthOperation, type Result } from '../../../../shared/domain/uti
|
|
|
12
26
|
*/
|
|
13
27
|
export async function updateUserPassword(user: User, newPassword: string): Promise<Result<void>> {
|
|
14
28
|
return executeAuthOperation(async () => {
|
|
29
|
+
validatePassword(newPassword);
|
|
15
30
|
await updatePassword(user, newPassword);
|
|
16
31
|
});
|
|
17
32
|
}
|
|
@@ -17,7 +17,7 @@ function hasProviderData(user: unknown): user is { providerData: { providerId: s
|
|
|
17
17
|
typeof user === 'object' &&
|
|
18
18
|
user !== null &&
|
|
19
19
|
'providerData' in user &&
|
|
20
|
-
Array.isArray((user as
|
|
20
|
+
Array.isArray((user as { providerData: unknown }).providerData)
|
|
21
21
|
);
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -54,15 +54,14 @@ export function buildBaseData(
|
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
if (extras) {
|
|
57
|
-
const internalFields = ["signUpMethod", "previousAnonymousUserId"];
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
57
|
+
const internalFields = new Set(["signUpMethod", "previousAnonymousUserId"]);
|
|
58
|
+
// Optimized: Single pass through extras, avoid multiple lookups
|
|
59
|
+
for (const [key, val] of Object.entries(extras)) {
|
|
60
|
+
// Skip internal fields (handled separately)
|
|
61
|
+
if (val !== undefined && !internalFields.has(key)) {
|
|
62
|
+
data[key] = val;
|
|
64
63
|
}
|
|
65
|
-
}
|
|
64
|
+
}
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
return data;
|
|
@@ -58,14 +58,14 @@ export async function ensureUserDocument(
|
|
|
58
58
|
const historyEntry = buildLoginHistoryEntry(user, allExtras);
|
|
59
59
|
const historyRef = collection(db, collectionName, user.uid, "loginHistory");
|
|
60
60
|
addDoc(historyRef, historyEntry).catch((err) => {
|
|
61
|
-
if (
|
|
61
|
+
if (__DEV__) {
|
|
62
62
|
console.warn("[UserDocumentService] Failed to write login history:", err);
|
|
63
63
|
}
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
return true;
|
|
67
67
|
} catch (error) {
|
|
68
|
-
if (
|
|
68
|
+
if (__DEV__) {
|
|
69
69
|
console.error("[UserDocumentService] Failed:", error);
|
|
70
70
|
}
|
|
71
71
|
return false;
|
|
@@ -85,7 +85,7 @@ export async function markUserDeleted(userId: string): Promise<boolean> {
|
|
|
85
85
|
}, { merge: true });
|
|
86
86
|
return true;
|
|
87
87
|
} catch (error) {
|
|
88
|
-
if (
|
|
88
|
+
if (__DEV__) {
|
|
89
89
|
console.error("[UserDocumentService] Failed to mark user deleted:", error);
|
|
90
90
|
}
|
|
91
91
|
return false;
|
|
@@ -62,11 +62,11 @@ export function convertToOAuthResult(
|
|
|
62
62
|
success: true,
|
|
63
63
|
userCredential: result.data.userCredential,
|
|
64
64
|
isNewUser: result.data.isNewUser,
|
|
65
|
-
}
|
|
65
|
+
};
|
|
66
66
|
}
|
|
67
67
|
return {
|
|
68
68
|
success: false,
|
|
69
69
|
error: result.error?.message ?? defaultErrorMessage,
|
|
70
70
|
code: result.error?.code,
|
|
71
|
-
}
|
|
71
|
+
};
|
|
72
72
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* This hook is optional and requires expo-auth-session to be installed
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { useState, useCallback, useEffect, useRef } from "react";
|
|
7
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
8
8
|
import { googleOAuthService } from "../../infrastructure/services/google-oauth.service";
|
|
9
9
|
import { getFirebaseAuth } from "../../infrastructure/config/FirebaseAuthClient";
|
|
10
10
|
import type { GoogleOAuthConfig } from "../../infrastructure/services/google-oauth.service";
|
|
@@ -55,8 +55,9 @@ export function useGoogleOAuth(config?: GoogleOAuthConfig): UseGoogleOAuthResult
|
|
|
55
55
|
const [isLoading, setIsLoading] = useState(false);
|
|
56
56
|
const [googleError, setGoogleError] = useState<string | null>(null);
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
const
|
|
58
|
+
// Memoize service checks to avoid repeated calls on every render
|
|
59
|
+
const googleAvailable = useMemo(() => googleOAuthService.isAvailable(), []);
|
|
60
|
+
const googleConfigured = useMemo(() => googleOAuthService.isConfigured(config), [config]);
|
|
60
61
|
|
|
61
62
|
// Keep config in a ref so the response useEffect doesn't re-run when config object reference changes
|
|
62
63
|
const configRef = useRef(config);
|
|
@@ -86,7 +87,7 @@ export function useGoogleOAuth(config?: GoogleOAuthConfig): UseGoogleOAuthResult
|
|
|
86
87
|
const auth = getFirebaseAuth();
|
|
87
88
|
if (!auth) {
|
|
88
89
|
setGoogleError("Firebase Auth not initialized");
|
|
89
|
-
setIsLoading(false);
|
|
90
|
+
setIsLoading(false);
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
93
|
|
|
@@ -104,11 +105,16 @@ export function useGoogleOAuth(config?: GoogleOAuthConfig): UseGoogleOAuthResult
|
|
|
104
105
|
}
|
|
105
106
|
} else if (response.type === "error") {
|
|
106
107
|
setGoogleError("Google authentication failed");
|
|
107
|
-
setIsLoading(false);
|
|
108
108
|
}
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
// Call async function and catch errors separately
|
|
112
|
+
handleResponse().catch((err) => {
|
|
113
|
+
// Errors are already handled in handleResponse
|
|
114
|
+
if (__DEV__) {
|
|
115
|
+
console.error('[useGoogleOAuth] Unexpected error in handleResponse:', err);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
112
118
|
}, [response, googleAvailable]); // config read via ref to prevent re-running on reference changes
|
|
113
119
|
|
|
114
120
|
const signInWithGoogle = useCallback(async (): Promise<SocialAuthResult> => {
|
|
@@ -35,18 +35,16 @@ export function useSocialAuth(config?: SocialAuthConfig): UseSocialAuthResult {
|
|
|
35
35
|
const [appleAvailable, setAppleAvailable] = useState(false);
|
|
36
36
|
|
|
37
37
|
// Stabilize config objects to prevent unnecessary re-renders and effect re-runs
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
config?.google?.iosClientId,
|
|
41
|
-
config?.google?.androidClientId,
|
|
42
|
-
]);
|
|
38
|
+
// Use full object as dependency instead of individual properties
|
|
39
|
+
const googleConfig = useMemo(() => config?.google, [config?.google]);
|
|
43
40
|
const appleEnabled = useMemo(() => config?.apple?.enabled, [config?.apple?.enabled]);
|
|
44
41
|
|
|
45
|
-
|
|
42
|
+
// Memoize configured check to avoid recalculation on every render
|
|
43
|
+
const googleConfigured = useMemo(() => !!(
|
|
46
44
|
googleConfig?.webClientId ||
|
|
47
45
|
googleConfig?.iosClientId ||
|
|
48
46
|
googleConfig?.androidClientId
|
|
49
|
-
);
|
|
47
|
+
), [googleConfig]);
|
|
50
48
|
|
|
51
49
|
useEffect(() => {
|
|
52
50
|
if (googleConfig) {
|
|
@@ -141,15 +141,14 @@ export {
|
|
|
141
141
|
} from './utils/validation/date-validator.util';
|
|
142
142
|
|
|
143
143
|
// Presentation — TanStack Query integration
|
|
144
|
-
export {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
} from './presentation';
|
|
144
|
+
export { useFirestoreQuery } from './presentation/hooks/useFirestoreQuery';
|
|
145
|
+
export { useFirestoreMutation } from './presentation/hooks/useFirestoreMutation';
|
|
146
|
+
export { useFirestoreSnapshot } from './presentation/hooks/useFirestoreSnapshot';
|
|
147
|
+
export { createFirestoreKeys } from './presentation/query-keys/createFirestoreKeys';
|
|
148
|
+
|
|
149
|
+
export type { UseFirestoreQueryOptions } from './presentation/hooks/useFirestoreQuery';
|
|
150
|
+
export type { UseFirestoreMutationOptions } from './presentation/hooks/useFirestoreMutation';
|
|
151
|
+
export type { UseFirestoreSnapshotOptions } from './presentation/hooks/useFirestoreSnapshot';
|
|
153
152
|
|
|
154
153
|
export { Timestamp } from 'firebase/firestore';
|
|
155
154
|
export type {
|
|
@@ -51,9 +51,8 @@ export class QueryDeduplicationMiddleware {
|
|
|
51
51
|
try {
|
|
52
52
|
return await queryFn();
|
|
53
53
|
} finally {
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// this for extra safety and immediate cleanup
|
|
54
|
+
// Immediate cleanup after completion (success or error)
|
|
55
|
+
// PendingQueryManager will also cleanup via its finally handler
|
|
57
56
|
this.queryManager.remove(key);
|
|
58
57
|
}
|
|
59
58
|
})();
|
|
@@ -4,12 +4,23 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { RequestLog, RequestStats, RequestType } from '../../domain/entities/RequestLog';
|
|
7
|
-
import {
|
|
7
|
+
import { generateUUID } from '@umituz/react-native-design-system/uuid';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Maximum number of logs to keep in memory
|
|
11
|
+
* Prevents unbounded memory growth
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_MAX_LOGS = 1000;
|
|
8
14
|
|
|
9
15
|
export class RequestLoggerService {
|
|
10
16
|
private logs: RequestLog[] = [];
|
|
11
|
-
private readonly
|
|
17
|
+
private readonly maxLogs: number;
|
|
12
18
|
private listeners: Set<(log: RequestLog) => void> = new Set();
|
|
19
|
+
private static readonly LISTENER_ERROR_PREFIX = '[RequestLoggerService] Listener error:';
|
|
20
|
+
|
|
21
|
+
constructor(maxLogs: number = DEFAULT_MAX_LOGS) {
|
|
22
|
+
this.maxLogs = maxLogs;
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
/**
|
|
15
26
|
* Log a request
|
|
@@ -17,13 +28,13 @@ export class RequestLoggerService {
|
|
|
17
28
|
logRequest(log: Omit<RequestLog, 'id' | 'timestamp'>): void {
|
|
18
29
|
const fullLog: RequestLog = {
|
|
19
30
|
...log,
|
|
20
|
-
id:
|
|
31
|
+
id: generateUUID(),
|
|
21
32
|
timestamp: Date.now(),
|
|
22
33
|
};
|
|
23
34
|
|
|
24
35
|
this.logs.push(fullLog);
|
|
25
36
|
|
|
26
|
-
if (this.logs.length > this.
|
|
37
|
+
if (this.logs.length > this.maxLogs) {
|
|
27
38
|
this.logs.shift();
|
|
28
39
|
}
|
|
29
40
|
|
|
@@ -39,40 +50,62 @@ export class RequestLoggerService {
|
|
|
39
50
|
|
|
40
51
|
/**
|
|
41
52
|
* Get logs by type
|
|
53
|
+
* Optimized: Return empty array early if no logs
|
|
42
54
|
*/
|
|
43
55
|
getLogsByType(type: RequestType): RequestLog[] {
|
|
56
|
+
if (this.logs.length === 0) return [];
|
|
44
57
|
return this.logs.filter((log) => log.type === type);
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
/**
|
|
48
61
|
* Get request statistics
|
|
62
|
+
* Optimized: Single-pass calculation O(n) instead of O(7n)
|
|
49
63
|
*/
|
|
50
64
|
getStats(): RequestStats {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
let readRequests = 0;
|
|
66
|
+
let writeRequests = 0;
|
|
67
|
+
let deleteRequests = 0;
|
|
68
|
+
let listenerRequests = 0;
|
|
69
|
+
let cachedRequests = 0;
|
|
70
|
+
let failedRequests = 0;
|
|
71
|
+
let durationSum = 0;
|
|
72
|
+
let durationCount = 0;
|
|
73
|
+
|
|
74
|
+
// Single pass through logs for all statistics
|
|
75
|
+
for (const log of this.logs) {
|
|
76
|
+
switch (log.type) {
|
|
77
|
+
case 'read':
|
|
78
|
+
readRequests++;
|
|
79
|
+
break;
|
|
80
|
+
case 'write':
|
|
81
|
+
writeRequests++;
|
|
82
|
+
break;
|
|
83
|
+
case 'delete':
|
|
84
|
+
deleteRequests++;
|
|
85
|
+
break;
|
|
86
|
+
case 'listener':
|
|
87
|
+
listenerRequests++;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (log.cached) cachedRequests++;
|
|
92
|
+
if (!log.success) failedRequests++;
|
|
93
|
+
|
|
94
|
+
if (log.duration !== undefined) {
|
|
95
|
+
durationSum += log.duration;
|
|
96
|
+
durationCount++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
66
99
|
|
|
67
100
|
return {
|
|
68
|
-
totalRequests,
|
|
101
|
+
totalRequests: this.logs.length,
|
|
69
102
|
readRequests,
|
|
70
103
|
writeRequests,
|
|
71
104
|
deleteRequests,
|
|
72
105
|
listenerRequests,
|
|
73
106
|
cachedRequests,
|
|
74
107
|
failedRequests,
|
|
75
|
-
averageDuration,
|
|
108
|
+
averageDuration: durationCount > 0 ? durationSum / durationCount : 0,
|
|
76
109
|
};
|
|
77
110
|
}
|
|
78
111
|
|
|
@@ -116,8 +149,13 @@ export class RequestLoggerService {
|
|
|
116
149
|
this.listeners.forEach((listener) => {
|
|
117
150
|
try {
|
|
118
151
|
listener(log);
|
|
119
|
-
} catch {
|
|
120
|
-
//
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// Log listener errors in development to help debugging
|
|
154
|
+
// In production, silently ignore to prevent crashing the app
|
|
155
|
+
if (__DEV__) {
|
|
156
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
157
|
+
console.warn(`${RequestLoggerService.LISTENER_ERROR_PREFIX} ${errorMessage}`);
|
|
158
|
+
}
|
|
121
159
|
}
|
|
122
160
|
});
|
|
123
161
|
}
|
|
@@ -47,6 +47,7 @@ export function useFirestoreSnapshot<TData>(
|
|
|
47
47
|
const queryClient = useQueryClient();
|
|
48
48
|
const unsubscribeRef = useRef<(() => void) | null>(null);
|
|
49
49
|
const dataPromiseRef = useRef<{ resolve: (value: TData) => void; reject: (error: Error) => void } | null>(null);
|
|
50
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
50
51
|
|
|
51
52
|
// Stabilize queryKey to prevent unnecessary listener re-subscriptions
|
|
52
53
|
const stableKeyString = JSON.stringify(queryKey);
|
|
@@ -57,10 +58,14 @@ export function useFirestoreSnapshot<TData>(
|
|
|
57
58
|
|
|
58
59
|
unsubscribeRef.current = subscribe((data) => {
|
|
59
60
|
queryClient.setQueryData(stableQueryKey, data);
|
|
60
|
-
// Resolve any pending promise from queryFn
|
|
61
|
+
// Resolve any pending promise from queryFn and clear timeout
|
|
61
62
|
if (dataPromiseRef.current) {
|
|
62
63
|
dataPromiseRef.current.resolve(data);
|
|
63
64
|
dataPromiseRef.current = null;
|
|
65
|
+
if (timeoutRef.current) {
|
|
66
|
+
clearTimeout(timeoutRef.current);
|
|
67
|
+
timeoutRef.current = null;
|
|
68
|
+
}
|
|
64
69
|
}
|
|
65
70
|
});
|
|
66
71
|
|
|
@@ -72,6 +77,11 @@ export function useFirestoreSnapshot<TData>(
|
|
|
72
77
|
dataPromiseRef.current.reject(new Error('Snapshot listener cleanup'));
|
|
73
78
|
dataPromiseRef.current = null;
|
|
74
79
|
}
|
|
80
|
+
// Clear timeout on cleanup
|
|
81
|
+
if (timeoutRef.current) {
|
|
82
|
+
clearTimeout(timeoutRef.current);
|
|
83
|
+
timeoutRef.current = null;
|
|
84
|
+
}
|
|
75
85
|
};
|
|
76
86
|
}, [enabled, queryClient, stableQueryKey, subscribe]);
|
|
77
87
|
|
|
@@ -89,9 +99,10 @@ export function useFirestoreSnapshot<TData>(
|
|
|
89
99
|
dataPromiseRef.current = { resolve, reject };
|
|
90
100
|
|
|
91
101
|
// Timeout to prevent infinite waiting (memory leak protection)
|
|
92
|
-
|
|
102
|
+
timeoutRef.current = setTimeout(() => {
|
|
93
103
|
if (dataPromiseRef.current) {
|
|
94
104
|
dataPromiseRef.current = null;
|
|
105
|
+
timeoutRef.current = null;
|
|
95
106
|
if (initialData !== undefined) {
|
|
96
107
|
resolve(initialData);
|
|
97
108
|
} else {
|
|
@@ -99,9 +110,6 @@ export function useFirestoreSnapshot<TData>(
|
|
|
99
110
|
}
|
|
100
111
|
}
|
|
101
112
|
}, 30000); // 30 second timeout
|
|
102
|
-
|
|
103
|
-
// Clear timeout on promise resolution
|
|
104
|
-
return () => clearTimeout(timeoutId);
|
|
105
113
|
});
|
|
106
114
|
},
|
|
107
115
|
enabled,
|
|
@@ -46,17 +46,15 @@ export class PendingQueryManager {
|
|
|
46
46
|
* Also attaches cleanup handlers to prevent memory leaks.
|
|
47
47
|
*/
|
|
48
48
|
add(key: string, promise: Promise<unknown>): void {
|
|
49
|
-
// Attach cleanup
|
|
49
|
+
// Attach cleanup handler to ensure query is removed from map
|
|
50
50
|
// even if caller's finally block doesn't execute (e.g., unhandled rejection)
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
this.pendingQueries.delete(key);
|
|
55
|
-
}, 100);
|
|
51
|
+
promise.finally(() => {
|
|
52
|
+
// Immediate cleanup - no delay needed for better performance
|
|
53
|
+
this.pendingQueries.delete(key);
|
|
56
54
|
});
|
|
57
55
|
|
|
58
56
|
this.pendingQueries.set(key, {
|
|
59
|
-
promise
|
|
57
|
+
promise,
|
|
60
58
|
timestamp: Date.now(),
|
|
61
59
|
});
|
|
62
60
|
}
|
|
@@ -44,13 +44,13 @@ export class PaginationHelper<T> {
|
|
|
44
44
|
const resultCount = getResultCount(items.length, pageLimit);
|
|
45
45
|
const resultItems = safeSlice(items, 0, resultCount);
|
|
46
46
|
|
|
47
|
-
//
|
|
47
|
+
// Extract next cursor from last item
|
|
48
|
+
// Safe: resultItems is guaranteed to have at least one item when hasMoreValue is true
|
|
48
49
|
let nextCursor: string | null = null;
|
|
49
50
|
if (hasMoreValue && resultItems.length > 0) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
51
|
+
// Access is safe because we checked length > 0
|
|
52
|
+
const lastItem = resultItems[resultItems.length - 1]!;
|
|
53
|
+
nextCursor = getCursor(lastItem);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
return {
|
|
@@ -11,26 +11,7 @@ import {
|
|
|
11
11
|
type WhereFilterOp,
|
|
12
12
|
type Query,
|
|
13
13
|
} from "firebase/firestore";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Chunk array into smaller arrays (local copy for this module)
|
|
17
|
-
* Inlined here to avoid circular dependencies
|
|
18
|
-
*/
|
|
19
|
-
function chunkArray(array: readonly (string | number)[], chunkSize: number): (string | number)[][] {
|
|
20
|
-
if (chunkSize <= 0) {
|
|
21
|
-
throw new Error('chunkSize must be greater than 0');
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const chunks: (string | number)[][] = [];
|
|
25
|
-
const len = array.length;
|
|
26
|
-
|
|
27
|
-
for (let i = 0; i < len; i += chunkSize) {
|
|
28
|
-
const end = Math.min(i + chunkSize, len);
|
|
29
|
-
chunks.push(array.slice(i, end));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return chunks;
|
|
33
|
-
}
|
|
14
|
+
import { chunkArray } from "../../../../shared/domain/utils/calculation.util";
|
|
34
15
|
|
|
35
16
|
export interface FieldFilter {
|
|
36
17
|
field: string;
|
|
@@ -38,7 +19,11 @@ export interface FieldFilter {
|
|
|
38
19
|
value: string | number | boolean | string[] | number[];
|
|
39
20
|
}
|
|
40
21
|
|
|
41
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Maximum number of values in a single 'in' query
|
|
24
|
+
* Firestore limits 'in' queries to 10 values
|
|
25
|
+
*/
|
|
26
|
+
export const MAX_IN_OPERATOR_VALUES = 10;
|
|
42
27
|
|
|
43
28
|
/**
|
|
44
29
|
* Apply field filter with 'in' operator and chunking support
|
|
@@ -53,8 +38,8 @@ export function applyFieldFilter(q: Query, filter: FieldFilter): Query {
|
|
|
53
38
|
}
|
|
54
39
|
|
|
55
40
|
// Split into chunks of 10 and use 'or' operator
|
|
56
|
-
// Optimized: Uses
|
|
57
|
-
const chunks = chunkArray(value, MAX_IN_OPERATOR_VALUES);
|
|
41
|
+
// Optimized: Uses centralized chunkArray utility
|
|
42
|
+
const chunks = chunkArray(value as readonly (string | number)[], MAX_IN_OPERATOR_VALUES);
|
|
58
43
|
const orConditions = chunks.map((chunk) => where(field, "in", chunk));
|
|
59
44
|
return query(q, or(...orConditions));
|
|
60
45
|
}
|
|
@@ -33,10 +33,13 @@ const RETRYABLE_ERROR_CODES = ['unavailable', 'deadline-exceeded', 'aborted'];
|
|
|
33
33
|
/**
|
|
34
34
|
* Check if error is a cancelled/auth cancelled error
|
|
35
35
|
*/
|
|
36
|
-
export function isCancelledError(error:
|
|
36
|
+
export function isCancelledError(error: unknown): boolean {
|
|
37
|
+
if (!error || typeof error !== 'object') return false;
|
|
38
|
+
if (!('code' in error) || !('message' in error)) return false;
|
|
39
|
+
const err = error as { code: string; message: string };
|
|
37
40
|
return (
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
err.code === 'auth/cancelled' ||
|
|
42
|
+
err.message.includes('ERR_CANCELED')
|
|
40
43
|
);
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Base Auth Service
|
|
3
|
-
*
|
|
4
|
-
* Provides common authentication service functionality
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Check if error is a cancellation error
|
|
9
|
-
*/
|
|
10
|
-
export function isCancellationError(error: unknown): boolean {
|
|
11
|
-
if (error instanceof Error) {
|
|
12
|
-
return error.message.includes('ERR_CANCELED');
|
|
13
|
-
}
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ID Generator Utility
|
|
3
|
-
* Centralized ID generation utilities for unique identifiers
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import * as Crypto from "expo-crypto";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Generate a unique ID using timestamp and cryptographically secure random string
|
|
10
|
-
* Format: timestamp-randomstring
|
|
11
|
-
* @returns Unique identifier string
|
|
12
|
-
*/
|
|
13
|
-
export function generateUniqueId(): string {
|
|
14
|
-
const randomBytes = Crypto.getRandomBytes(8);
|
|
15
|
-
const hex = Array.from(randomBytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
16
|
-
return `${Date.now()}-${hex}`;
|
|
17
|
-
}
|