@umituz/react-native-firebase 3.0.3 → 3.0.6
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 +7 -1
- package/src/domains/account-deletion/index.ts +15 -10
- package/src/domains/account-deletion/infrastructure/services/account-deletion.service.ts +235 -26
- package/src/domains/account-deletion/infrastructure/services/reauthentication.service.ts +160 -0
- package/src/domains/auth/domain/value-objects/FirebaseAuthConfig.ts +1 -1
- package/src/domains/auth/index.ts +156 -6
- package/src/domains/auth/infrastructure/config/FirebaseAuthClient.ts +60 -48
- package/src/domains/auth/infrastructure/config/initializers/FirebaseAuthInitializer.ts +41 -5
- package/src/domains/auth/infrastructure/stores/auth.store.ts +4 -1
- package/src/domains/auth/presentation/hooks/useAnonymousAuth.ts +3 -1
- package/src/domains/auth/presentation/hooks/useGoogleOAuth.ts +115 -20
- package/src/domains/auth/presentation/hooks/utils/auth-state-change.handler.ts +5 -11
- package/src/domains/firestore/domain/constants/QuotaLimits.ts +101 -0
- package/src/domains/firestore/domain/entities/QuotaMetrics.ts +26 -0
- package/src/domains/firestore/domain/entities/RequestLog.ts +28 -0
- package/src/domains/firestore/domain/services/QuotaCalculator.ts +71 -0
- package/src/domains/firestore/index.ts +85 -31
- package/src/domains/firestore/infrastructure/config/FirestoreClient.ts +82 -45
- package/src/domains/firestore/infrastructure/config/initializers/FirebaseFirestoreInitializer.ts +249 -4
- package/src/domains/firestore/infrastructure/middleware/QueryDeduplicationMiddleware.ts +306 -0
- package/src/domains/firestore/infrastructure/middleware/QuotaTrackingMiddleware.ts +92 -0
- package/src/domains/firestore/infrastructure/repositories/BasePaginatedRepository.ts +9 -1
- package/src/domains/firestore/infrastructure/repositories/BaseQueryRepository.ts +34 -8
- package/src/domains/firestore/infrastructure/repositories/BaseRepository.ts +48 -9
- package/src/domains/firestore/infrastructure/services/RequestLoggerService.ts +168 -0
- package/src/domains/firestore/presentation/hooks/index.ts +10 -0
- package/src/domains/firestore/presentation/hooks/useFirestoreMutation.ts +1 -1
- package/src/domains/firestore/presentation/hooks/useFirestoreQuery.ts +1 -1
- package/src/domains/firestore/presentation/hooks/useFirestoreSnapshot.ts +2 -1
- package/src/domains/firestore/presentation/hooks/useSmartFirestoreSnapshot.ts +362 -0
- package/src/domains/firestore/presentation/query-keys/createFirestoreKeys.ts +32 -0
- package/src/domains/firestore/presentation/query-keys/index.ts +1 -0
- package/src/domains/firestore/utils/deduplication/pending-query-manager.util.ts +126 -0
- package/src/domains/firestore/utils/deduplication/query-key-generator.util.ts +41 -0
- package/src/domains/firestore/utils/deduplication/timer-manager.util.ts +83 -0
- package/src/domains/firestore/utils/pagination.helper.ts +5 -2
- package/src/domains/firestore/utils/transaction/transaction.util.ts +8 -2
- package/src/index.ts +324 -32
- package/src/shared/domain/utils/calculation.util.ts +305 -17
- package/src/shared/domain/utils/error-handlers/error-messages.ts +0 -15
- package/src/shared/domain/utils/index.ts +5 -0
- package/src/shared/infrastructure/config/base/ClientStateManager.ts +82 -0
- package/src/shared/infrastructure/config/base/ServiceClientSingleton.ts +136 -20
- package/src/shared/infrastructure/config/clients/FirebaseClientSingleton.ts +1 -1
- package/src/shared/infrastructure/config/initializers/FirebaseAppInitializer.ts +9 -0
- package/src/shared/infrastructure/config/services/FirebaseInitializationService.ts +1 -1
- package/src/shared/infrastructure/config/state/FirebaseClientState.ts +14 -36
- package/src/application/auth/index.ts +0 -10
- package/src/application/auth/use-cases/index.ts +0 -6
- package/src/domains/account-deletion/domain/index.ts +0 -8
- package/src/domains/account-deletion/infrastructure/services/AccountDeletionExecutor.ts +0 -79
- package/src/domains/account-deletion/infrastructure/services/AccountDeletionTypes.ts +0 -32
- package/src/domains/auth/domain.ts +0 -16
- package/src/domains/auth/infrastructure/config/index.ts +0 -2
- package/src/domains/auth/infrastructure/config/initializers/index.ts +0 -1
- package/src/domains/auth/infrastructure/services/index.ts +0 -16
- package/src/domains/auth/infrastructure/services/utils/index.ts +0 -1
- package/src/domains/auth/infrastructure/stores/index.ts +0 -1
- package/src/domains/auth/infrastructure/utils/index.ts +0 -1
- package/src/domains/auth/infrastructure.ts +0 -11
- package/src/domains/auth/presentation/hooks/useAppleAuth.ts +0 -82
- package/src/domains/auth/presentation.ts +0 -31
- package/src/domains/firestore/domain/entities/Collection.ts +0 -122
- package/src/domains/firestore/domain/entities/CollectionFactory.ts +0 -55
- package/src/domains/firestore/domain/entities/CollectionHelpers.ts +0 -143
- package/src/domains/firestore/domain/entities/CollectionUtils.ts +0 -72
- package/src/domains/firestore/domain/entities/CollectionValidation.ts +0 -138
- package/src/domains/firestore/domain/index.ts +0 -61
- package/src/domains/firestore/domain/value-objects/QueryOptions.ts +0 -143
- package/src/domains/firestore/domain/value-objects/QueryOptionsFactory.ts +0 -95
- package/src/domains/firestore/domain/value-objects/QueryOptionsHelpers.ts +0 -110
- package/src/domains/firestore/domain/value-objects/WhereClause.ts +0 -114
- package/src/domains/firestore/domain/value-objects/WhereClauseFactory.ts +0 -101
- package/src/domains/firestore/domain/value-objects/WhereClauseHelpers.ts +0 -123
- package/src/domains/firestore/domain/value-objects/WhereClauseValidation.ts +0 -83
- package/src/shared/infrastructure/base/ErrorHandler.ts +0 -81
- package/src/shared/infrastructure/base/ServiceBase.ts +0 -62
- package/src/shared/infrastructure/base/TypedGuard.ts +0 -131
- package/src/shared/infrastructure/base/index.ts +0 -34
- package/src/shared/types/firebase.types.ts +0 -274
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request Logger Service
|
|
3
|
+
* Infrastructure service for logging Firestore requests
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { RequestLog, RequestStats, RequestType } from '../../domain/entities/RequestLog';
|
|
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;
|
|
14
|
+
|
|
15
|
+
export class RequestLoggerService {
|
|
16
|
+
private logs: RequestLog[] = [];
|
|
17
|
+
private readonly maxLogs: number;
|
|
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
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Log a request
|
|
27
|
+
*/
|
|
28
|
+
logRequest(log: Omit<RequestLog, 'id' | 'timestamp'>): void {
|
|
29
|
+
const fullLog: RequestLog = {
|
|
30
|
+
...log,
|
|
31
|
+
id: generateUUID(),
|
|
32
|
+
timestamp: Date.now(),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
this.logs.push(fullLog);
|
|
36
|
+
|
|
37
|
+
if (this.logs.length > this.maxLogs) {
|
|
38
|
+
this.logs.shift();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.notifyListeners(fullLog);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get all logs
|
|
46
|
+
* PERF: Return array directly without copy - logs is private and immutable from outside
|
|
47
|
+
* Callers should not modify the returned array.
|
|
48
|
+
*/
|
|
49
|
+
getLogs(): RequestLog[] {
|
|
50
|
+
return this.logs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get logs by type
|
|
55
|
+
* Optimized: Return empty array early if no logs
|
|
56
|
+
*/
|
|
57
|
+
getLogsByType(type: RequestType): RequestLog[] {
|
|
58
|
+
if (this.logs.length === 0) return [];
|
|
59
|
+
return this.logs.filter((log) => log.type === type);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get request statistics
|
|
64
|
+
* Optimized: Single-pass calculation O(n) instead of O(7n)
|
|
65
|
+
*/
|
|
66
|
+
getStats(): RequestStats {
|
|
67
|
+
let readRequests = 0;
|
|
68
|
+
let writeRequests = 0;
|
|
69
|
+
let deleteRequests = 0;
|
|
70
|
+
let listenerRequests = 0;
|
|
71
|
+
let cachedRequests = 0;
|
|
72
|
+
let failedRequests = 0;
|
|
73
|
+
let durationSum = 0;
|
|
74
|
+
let durationCount = 0;
|
|
75
|
+
|
|
76
|
+
// Single pass through logs for all statistics
|
|
77
|
+
for (const log of this.logs) {
|
|
78
|
+
switch (log.type) {
|
|
79
|
+
case 'read':
|
|
80
|
+
readRequests++;
|
|
81
|
+
break;
|
|
82
|
+
case 'write':
|
|
83
|
+
writeRequests++;
|
|
84
|
+
break;
|
|
85
|
+
case 'delete':
|
|
86
|
+
deleteRequests++;
|
|
87
|
+
break;
|
|
88
|
+
case 'listener':
|
|
89
|
+
listenerRequests++;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (log.cached) cachedRequests++;
|
|
94
|
+
if (!log.success) failedRequests++;
|
|
95
|
+
|
|
96
|
+
if (log.duration !== undefined) {
|
|
97
|
+
durationSum += log.duration;
|
|
98
|
+
durationCount++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
totalRequests: this.logs.length,
|
|
104
|
+
readRequests,
|
|
105
|
+
writeRequests,
|
|
106
|
+
deleteRequests,
|
|
107
|
+
listenerRequests,
|
|
108
|
+
cachedRequests,
|
|
109
|
+
failedRequests,
|
|
110
|
+
averageDuration: durationCount > 0 ? durationSum / durationCount : 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Clear all logs
|
|
116
|
+
*/
|
|
117
|
+
clearLogs(): void {
|
|
118
|
+
this.logs = [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Add log listener
|
|
123
|
+
*/
|
|
124
|
+
addListener(listener: (log: RequestLog) => void): () => void {
|
|
125
|
+
this.listeners.add(listener);
|
|
126
|
+
return () => {
|
|
127
|
+
this.listeners.delete(listener);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Remove all listeners
|
|
133
|
+
* Prevents memory leaks when service is destroyed
|
|
134
|
+
*/
|
|
135
|
+
removeAllListeners(): void {
|
|
136
|
+
this.listeners.clear();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Destroy service and cleanup resources
|
|
141
|
+
*/
|
|
142
|
+
destroy(): void {
|
|
143
|
+
this.removeAllListeners();
|
|
144
|
+
this.clearLogs();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Notify all listeners
|
|
149
|
+
* PERF: Use for...of instead of forEach for better performance
|
|
150
|
+
*/
|
|
151
|
+
private notifyListeners(log: RequestLog): void {
|
|
152
|
+
for (const listener of this.listeners) {
|
|
153
|
+
try {
|
|
154
|
+
listener(log);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// Log listener errors in development to help debugging
|
|
157
|
+
// In production, silently ignore to prevent crashing the app
|
|
158
|
+
if (__DEV__) {
|
|
159
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
160
|
+
console.warn(`${RequestLoggerService.LISTENER_ERROR_PREFIX} ${errorMessage}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const requestLoggerService = new RequestLoggerService();
|
|
168
|
+
|
|
@@ -12,3 +12,13 @@ export {
|
|
|
12
12
|
useFirestoreSnapshot,
|
|
13
13
|
type UseFirestoreSnapshotOptions,
|
|
14
14
|
} from './useFirestoreSnapshot';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
useSmartFirestoreSnapshot,
|
|
18
|
+
useSmartListenerControl,
|
|
19
|
+
} from './useSmartFirestoreSnapshot';
|
|
20
|
+
|
|
21
|
+
export type {
|
|
22
|
+
UseSmartFirestoreSnapshotOptions,
|
|
23
|
+
BackgroundStrategy,
|
|
24
|
+
} from './useSmartFirestoreSnapshot';
|
|
@@ -50,7 +50,8 @@ export function useFirestoreSnapshot<TData>(
|
|
|
50
50
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
51
51
|
|
|
52
52
|
// Stabilize queryKey to prevent unnecessary listener re-subscriptions
|
|
53
|
-
|
|
53
|
+
// PERF: Memoize stringified key to avoid expensive JSON.stringify on every render
|
|
54
|
+
const stableKeyString = useMemo(() => JSON.stringify(queryKey), [queryKey]);
|
|
54
55
|
const stableQueryKey = useMemo(() => queryKey, [stableKeyString]);
|
|
55
56
|
|
|
56
57
|
useEffect(() => {
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSmartFirestoreSnapshot Hook (Enhanced)
|
|
3
|
+
*
|
|
4
|
+
* Smart real-time listener with automatic lifecycle management
|
|
5
|
+
*
|
|
6
|
+
* FEATURES:
|
|
7
|
+
* - Automatic listener suspension when app backgrounds
|
|
8
|
+
* - Resume listeners when app foregrounds
|
|
9
|
+
* - Configurable background timeout (default: 30s)
|
|
10
|
+
* - Memory leak prevention
|
|
11
|
+
* - Battery and data savings
|
|
12
|
+
*
|
|
13
|
+
* COST SAVINGS: ~80% reduction in background listener reads
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
|
17
|
+
import {
|
|
18
|
+
useQuery,
|
|
19
|
+
useQueryClient,
|
|
20
|
+
type UseQueryResult,
|
|
21
|
+
type QueryKey,
|
|
22
|
+
} from '@tanstack/react-query';
|
|
23
|
+
import { AppState, AppStateStatus } from 'react-native';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Background behavior strategy
|
|
27
|
+
*/
|
|
28
|
+
export type BackgroundStrategy =
|
|
29
|
+
| 'suspend' // Suspend listeners when app backgrounds
|
|
30
|
+
| 'keep' // Keep listeners active (default behavior)
|
|
31
|
+
| 'timeout'; // Keep listeners for timeout, then suspend
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Smart snapshot options with enhanced lifecycle management
|
|
35
|
+
*/
|
|
36
|
+
export interface UseSmartFirestoreSnapshotOptions<TData> {
|
|
37
|
+
/** Unique query key for caching */
|
|
38
|
+
queryKey: QueryKey;
|
|
39
|
+
|
|
40
|
+
/** Sets up the onSnapshot listener. Must return the unsubscribe function. */
|
|
41
|
+
subscribe: (onData: (data: TData) => void) => () => void;
|
|
42
|
+
|
|
43
|
+
/** Whether the subscription should be active */
|
|
44
|
+
enabled?: boolean;
|
|
45
|
+
|
|
46
|
+
/** Initial data before first snapshot arrives */
|
|
47
|
+
initialData?: TData;
|
|
48
|
+
|
|
49
|
+
/** Background behavior strategy (default: 'suspend') */
|
|
50
|
+
backgroundStrategy?: BackgroundStrategy;
|
|
51
|
+
|
|
52
|
+
/** Timeout in ms before suspending background listeners (default: 30000) */
|
|
53
|
+
backgroundTimeout?: number;
|
|
54
|
+
|
|
55
|
+
/** Delay in ms before resuming after foreground (default: 0) */
|
|
56
|
+
resumeDelay?: number;
|
|
57
|
+
|
|
58
|
+
/** Callback when listener is suspended */
|
|
59
|
+
onSuspend?: () => void;
|
|
60
|
+
|
|
61
|
+
/** Callback when listener is resumed */
|
|
62
|
+
onResume?: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Internal state for listener lifecycle
|
|
67
|
+
*/
|
|
68
|
+
interface ListenerState {
|
|
69
|
+
isSuspended: boolean;
|
|
70
|
+
isBackgrounded: boolean;
|
|
71
|
+
suspendTimer: ReturnType<typeof setTimeout> | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Smart Firestore snapshot hook with automatic lifecycle management
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```typescript
|
|
79
|
+
* const { data: matches, isLoading } = useSmartFirestoreSnapshot<Match[]>({
|
|
80
|
+
* queryKey: ["matches", userId],
|
|
81
|
+
* subscribe: (onData) => {
|
|
82
|
+
* if (!userId) return () => {};
|
|
83
|
+
* return onSnapshot(matchesCol(userId), (snap) => {
|
|
84
|
+
* onData(snap.docs.map(d => d.data() as Match));
|
|
85
|
+
* });
|
|
86
|
+
* },
|
|
87
|
+
* enabled: !!userId,
|
|
88
|
+
* initialData: [],
|
|
89
|
+
* backgroundStrategy: 'suspend', // Suspend when app backgrounds
|
|
90
|
+
* backgroundTimeout: 30000, // 30s timeout
|
|
91
|
+
* });
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export function useSmartFirestoreSnapshot<TData>(
|
|
95
|
+
options: UseSmartFirestoreSnapshotOptions<TData>
|
|
96
|
+
): UseQueryResult<TData, Error> {
|
|
97
|
+
const {
|
|
98
|
+
queryKey,
|
|
99
|
+
subscribe,
|
|
100
|
+
enabled = true,
|
|
101
|
+
initialData,
|
|
102
|
+
backgroundStrategy = 'suspend',
|
|
103
|
+
backgroundTimeout = 30000,
|
|
104
|
+
resumeDelay = 0,
|
|
105
|
+
onSuspend,
|
|
106
|
+
onResume,
|
|
107
|
+
} = options;
|
|
108
|
+
|
|
109
|
+
const queryClient = useQueryClient();
|
|
110
|
+
const unsubscribeRef = useRef<(() => void) | null>(null);
|
|
111
|
+
const dataPromiseRef = useRef<{ resolve: (value: TData) => void; reject: (error: Error) => void } | null>(null);
|
|
112
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
113
|
+
|
|
114
|
+
// Listener state management
|
|
115
|
+
const [listenerState, setListenerState] = useState<ListenerState>({
|
|
116
|
+
isSuspended: false,
|
|
117
|
+
isBackgrounded: false,
|
|
118
|
+
suspendTimer: null,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Stabilize queryKey to prevent unnecessary listener re-subscriptions
|
|
122
|
+
// PERF: Memoize stringified key to avoid expensive JSON.stringify on every render
|
|
123
|
+
const stableKeyString = useMemo(() => JSON.stringify(queryKey), [queryKey]);
|
|
124
|
+
const stableQueryKey = useMemo(() => queryKey, [stableKeyString]);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Suspend the listener (stop receiving updates)
|
|
128
|
+
*/
|
|
129
|
+
const suspendListener = useCallback(() => {
|
|
130
|
+
if (unsubscribeRef.current && !listenerState.isSuspended) {
|
|
131
|
+
unsubscribeRef.current();
|
|
132
|
+
unsubscribeRef.current = null;
|
|
133
|
+
|
|
134
|
+
setListenerState(prev => ({ ...prev, isSuspended: true }));
|
|
135
|
+
|
|
136
|
+
// Clear pending promise to prevent memory leaks
|
|
137
|
+
if (dataPromiseRef.current) {
|
|
138
|
+
dataPromiseRef.current.reject(new Error('Listener suspended'));
|
|
139
|
+
dataPromiseRef.current = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Clear timeout
|
|
143
|
+
if (timeoutRef.current) {
|
|
144
|
+
clearTimeout(timeoutRef.current);
|
|
145
|
+
timeoutRef.current = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Notify callback
|
|
149
|
+
onSuspend?.();
|
|
150
|
+
|
|
151
|
+
if (__DEV__) {
|
|
152
|
+
console.log(`[SmartSnapshot] Listener suspended for query:`, queryKey);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}, [queryKey, listenerState.isSuspended, onSuspend]);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resume the listener (start receiving updates again)
|
|
159
|
+
*/
|
|
160
|
+
const resumeListener = useCallback(() => {
|
|
161
|
+
if (!unsubscribeRef.current && enabled && !listenerState.isBackgrounded) {
|
|
162
|
+
setListenerState(prev => ({ ...prev, isSuspended: false }));
|
|
163
|
+
|
|
164
|
+
// Notify callback
|
|
165
|
+
onResume?.();
|
|
166
|
+
|
|
167
|
+
if (__DEV__) {
|
|
168
|
+
console.log(`[SmartSnapshot] Listener resumed for query:`, queryKey);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}, [enabled, listenerState.isBackgrounded, onResume, queryKey]);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Handle app state changes (foreground/background)
|
|
175
|
+
*/
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
178
|
+
|
|
179
|
+
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
|
180
|
+
const isBackgrounded = nextAppState.match(/inactive|background/);
|
|
181
|
+
|
|
182
|
+
setListenerState(prev => {
|
|
183
|
+
// Clear existing suspend timer
|
|
184
|
+
if (prev.suspendTimer) {
|
|
185
|
+
clearTimeout(prev.suspendTimer);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// App entering background
|
|
189
|
+
if (isBackgrounded && !prev.isBackgrounded) {
|
|
190
|
+
if (__DEV__) {
|
|
191
|
+
console.log(`[SmartSnapshot] App entering background for query:`, queryKey);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
switch (backgroundStrategy) {
|
|
195
|
+
case 'suspend':
|
|
196
|
+
// Suspend immediately - track timer for cleanup
|
|
197
|
+
const suspendTimer = setTimeout(() => suspendListener(), 0);
|
|
198
|
+
timers.push(suspendTimer);
|
|
199
|
+
break;
|
|
200
|
+
|
|
201
|
+
case 'timeout':
|
|
202
|
+
// Suspend after timeout - timer stored in state for cleanup
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
suspendListener();
|
|
205
|
+
}, backgroundTimeout);
|
|
206
|
+
return { ...prev, isBackgrounded: true, suspendTimer: timer };
|
|
207
|
+
|
|
208
|
+
case 'keep':
|
|
209
|
+
// Keep listener active
|
|
210
|
+
return { ...prev, isBackgrounded: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { ...prev, isBackgrounded: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// App entering foreground
|
|
217
|
+
if (!isBackgrounded && prev.isBackgrounded) {
|
|
218
|
+
if (__DEV__) {
|
|
219
|
+
console.log(`[SmartSnapshot] App entering foreground for query:`, queryKey);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Resume listener (with optional delay) - track timer for cleanup
|
|
223
|
+
const resumeTimer = setTimeout(() => {
|
|
224
|
+
resumeListener();
|
|
225
|
+
}, resumeDelay);
|
|
226
|
+
timers.push(resumeTimer);
|
|
227
|
+
|
|
228
|
+
return { ...prev, isBackgrounded: false, suspendTimer: null };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return prev;
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
|
236
|
+
|
|
237
|
+
return () => {
|
|
238
|
+
subscription.remove();
|
|
239
|
+
// Clean up any pending timers
|
|
240
|
+
timers.forEach(timer => clearTimeout(timer));
|
|
241
|
+
};
|
|
242
|
+
}, [queryKey, backgroundStrategy, backgroundTimeout, resumeDelay, suspendListener, resumeListener]);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Setup the snapshot listener
|
|
246
|
+
* Automatically manages listener lifecycle based on app state
|
|
247
|
+
*/
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
// Don't subscribe if disabled, suspended, or backgrounded (unless 'keep' strategy)
|
|
250
|
+
if (!enabled || listenerState.isSuspended) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (listenerState.isBackgrounded && backgroundStrategy !== 'keep') {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Setup listener
|
|
259
|
+
unsubscribeRef.current = subscribe((data) => {
|
|
260
|
+
queryClient.setQueryData(stableQueryKey, data);
|
|
261
|
+
|
|
262
|
+
// Resolve any pending promise from queryFn
|
|
263
|
+
if (dataPromiseRef.current) {
|
|
264
|
+
dataPromiseRef.current.resolve(data);
|
|
265
|
+
dataPromiseRef.current = null;
|
|
266
|
+
if (timeoutRef.current) {
|
|
267
|
+
clearTimeout(timeoutRef.current);
|
|
268
|
+
timeoutRef.current = null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
return () => {
|
|
274
|
+
if (unsubscribeRef.current) {
|
|
275
|
+
unsubscribeRef.current();
|
|
276
|
+
unsubscribeRef.current = null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Reject pending promise on cleanup to prevent memory leaks
|
|
280
|
+
if (dataPromiseRef.current) {
|
|
281
|
+
dataPromiseRef.current.reject(new Error('Snapshot listener cleanup'));
|
|
282
|
+
dataPromiseRef.current = null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Clear timeout on cleanup
|
|
286
|
+
if (timeoutRef.current) {
|
|
287
|
+
clearTimeout(timeoutRef.current);
|
|
288
|
+
timeoutRef.current = null;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}, [
|
|
292
|
+
enabled,
|
|
293
|
+
listenerState.isSuspended,
|
|
294
|
+
listenerState.isBackgrounded,
|
|
295
|
+
backgroundStrategy,
|
|
296
|
+
queryClient,
|
|
297
|
+
stableQueryKey,
|
|
298
|
+
subscribe,
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* TanStack Query integration
|
|
303
|
+
* Data comes from the snapshot listener, not from a fetch
|
|
304
|
+
*/
|
|
305
|
+
return useQuery<TData, Error>({
|
|
306
|
+
queryKey,
|
|
307
|
+
queryFn: () => {
|
|
308
|
+
const cached = queryClient.getQueryData<TData>(queryKey);
|
|
309
|
+
if (cached !== undefined) return cached;
|
|
310
|
+
if (initialData !== undefined) return initialData;
|
|
311
|
+
|
|
312
|
+
// Return a promise that resolves when snapshot provides data
|
|
313
|
+
// This prevents hanging promises and memory leaks
|
|
314
|
+
return new Promise<TData>((resolve, reject) => {
|
|
315
|
+
dataPromiseRef.current = { resolve, reject };
|
|
316
|
+
|
|
317
|
+
// Timeout to prevent infinite waiting (memory leak protection)
|
|
318
|
+
timeoutRef.current = setTimeout(() => {
|
|
319
|
+
if (dataPromiseRef.current) {
|
|
320
|
+
dataPromiseRef.current = null;
|
|
321
|
+
timeoutRef.current = null;
|
|
322
|
+
if (initialData !== undefined) {
|
|
323
|
+
resolve(initialData);
|
|
324
|
+
} else {
|
|
325
|
+
reject(new Error('Snapshot listener timeout'));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}, 30000); // 30 second timeout
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
enabled,
|
|
332
|
+
initialData,
|
|
333
|
+
// Never refetch — data comes from the real-time listener
|
|
334
|
+
staleTime: Infinity,
|
|
335
|
+
refetchOnMount: false,
|
|
336
|
+
refetchOnWindowFocus: false,
|
|
337
|
+
refetchOnReconnect: false,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Hook to manually control listener lifecycle
|
|
343
|
+
* Useful for complex scenarios with custom logic
|
|
344
|
+
*/
|
|
345
|
+
export function useSmartListenerControl() {
|
|
346
|
+
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
|
|
347
|
+
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
const subscription = AppState.addEventListener('change', setAppState);
|
|
350
|
+
return () => subscription.remove();
|
|
351
|
+
}, []);
|
|
352
|
+
|
|
353
|
+
// Compute background status once to avoid duplicate regex matching
|
|
354
|
+
const isBackgrounded = appState.match(/inactive|background/);
|
|
355
|
+
const isForegrounded = !isBackgrounded;
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
isBackgrounded,
|
|
359
|
+
isForegrounded,
|
|
360
|
+
appState,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Firestore Query Key Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates consistent, type-safe query keys for Firestore collections.
|
|
5
|
+
* Keys follow the pattern: [resource, scope, ...args]
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const conversationKeys = createFirestoreKeys("conversations");
|
|
10
|
+
*
|
|
11
|
+
* conversationKeys.all() // ["conversations"]
|
|
12
|
+
* conversationKeys.lists() // ["conversations", "list"]
|
|
13
|
+
* conversationKeys.list({ status: "active" }) // ["conversations", "list", { status: "active" }]
|
|
14
|
+
* conversationKeys.detail("abc") // ["conversations", "detail", "abc"]
|
|
15
|
+
* conversationKeys.userScoped("uid123") // ["conversations", "user", "uid123"]
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export function createFirestoreKeys(resource: string) {
|
|
20
|
+
return {
|
|
21
|
+
all: () => [resource] as const,
|
|
22
|
+
lists: () => [resource, 'list'] as const,
|
|
23
|
+
list: (filters?: Record<string, unknown>) =>
|
|
24
|
+
filters
|
|
25
|
+
? ([resource, 'list', filters] as const)
|
|
26
|
+
: ([resource, 'list'] as const),
|
|
27
|
+
details: () => [resource, 'detail'] as const,
|
|
28
|
+
detail: (id: string | number) => [resource, 'detail', id] as const,
|
|
29
|
+
userScoped: (userId: string) => [resource, 'user', userId] as const,
|
|
30
|
+
custom: (...args: unknown[]) => [resource, ...args] as const,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createFirestoreKeys } from './createFirestoreKeys';
|