@umituz/react-native-subscription 2.27.6 → 2.27.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-subscription",
3
- "version": "2.27.6",
3
+ "version": "2.27.8",
4
4
  "description": "Complete subscription management with RevenueCat, paywall UI, and credits system for React Native apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -2,10 +2,12 @@
2
2
  * useFreeCreditsInit Hook
3
3
  *
4
4
  * Handles free credits initialization for newly registered users.
5
- * Separated from useCredits for better maintainability and testability.
5
+ * Uses singleton pattern to prevent race conditions across multiple hook instances.
6
+ *
7
+ * @see https://medium.com/@shubhamkandharkar/creating-a-singleton-hook-in-react-a-practical-guide-fe5bf9aaefed
6
8
  */
7
9
 
8
- import { useState, useEffect, useCallback } from "react";
10
+ import { useEffect, useCallback, useSyncExternalStore } from "react";
9
11
  import {
10
12
  getCreditsRepository,
11
13
  getCreditsConfig,
@@ -14,8 +16,82 @@ import {
14
16
 
15
17
  declare const __DEV__: boolean;
16
18
 
19
+ // ============================================================================
20
+ // SINGLETON STATE - Shared across all hook instances
21
+ // ============================================================================
17
22
  const freeCreditsInitAttempted = new Set<string>();
23
+ const freeCreditsInitInProgress = new Set<string>();
24
+ const initPromises = new Map<string, Promise<boolean>>();
25
+ const subscribers = new Set<() => void>();
26
+
27
+ function notifySubscribers(): void {
28
+ subscribers.forEach((cb) => cb());
29
+ }
30
+
31
+ function subscribe(callback: () => void): () => void {
32
+ subscribers.add(callback);
33
+ return () => subscribers.delete(callback);
34
+ }
35
+
36
+ function getSnapshot(): Set<string> {
37
+ return freeCreditsInitInProgress;
38
+ }
39
+
40
+ async function initializeFreeCreditsForUser(
41
+ userId: string,
42
+ onComplete: () => void
43
+ ): Promise<boolean> {
44
+ // Already completed for this user
45
+ if (freeCreditsInitAttempted.has(userId) && !freeCreditsInitInProgress.has(userId)) {
46
+ return true;
47
+ }
48
+
49
+ // Already in progress - return existing promise
50
+ const existingPromise = initPromises.get(userId);
51
+ if (existingPromise) {
52
+ return existingPromise;
53
+ }
54
+
55
+ // Mark as attempted and in progress
56
+ freeCreditsInitAttempted.add(userId);
57
+ freeCreditsInitInProgress.add(userId);
58
+ notifySubscribers();
59
+
60
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
61
+ console.log("[useFreeCreditsInit] Initializing free credits:", userId.slice(0, 8));
62
+ }
63
+
64
+ const promise = (async () => {
65
+ try {
66
+ const repository = getCreditsRepository();
67
+ const result = await repository.initializeFreeCredits(userId);
68
+
69
+ if (result.success) {
70
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
71
+ console.log("[useFreeCreditsInit] Free credits initialized:", result.data?.credits);
72
+ }
73
+ onComplete();
74
+ return true;
75
+ } else {
76
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
77
+ console.warn("[useFreeCreditsInit] Free credits init failed:", result.error?.message);
78
+ }
79
+ return false;
80
+ }
81
+ } finally {
82
+ freeCreditsInitInProgress.delete(userId);
83
+ initPromises.delete(userId);
84
+ notifySubscribers();
85
+ }
86
+ })();
87
+
88
+ initPromises.set(userId, promise);
89
+ return promise;
90
+ }
18
91
 
92
+ // ============================================================================
93
+ // HOOK INTERFACE
94
+ // ============================================================================
19
95
  export interface UseFreeCreditsInitParams {
20
96
  userId: string | null | undefined;
21
97
  isRegisteredUser: boolean;
@@ -32,13 +108,19 @@ export interface UseFreeCreditsInitResult {
32
108
 
33
109
  export function useFreeCreditsInit(params: UseFreeCreditsInitParams): UseFreeCreditsInitResult {
34
110
  const { userId, isRegisteredUser, isAnonymous, hasCredits, querySuccess, onInitComplete } = params;
35
- const [isInitializing, setIsInitializing] = useState(false);
111
+
112
+ // Subscribe to singleton state changes
113
+ const inProgressSet = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
36
114
 
37
115
  const isConfigured = isCreditsRepositoryConfigured();
38
116
  const config = getCreditsConfig();
39
117
  const freeCredits = config.freeCredits ?? 0;
40
118
  const autoInit = config.autoInitializeFreeCredits !== false && freeCredits > 0;
41
119
 
120
+ // Check if THIS user's init is in progress (shared across all hook instances)
121
+ const isInitializing = userId ? inProgressSet.has(userId) : false;
122
+
123
+ // Need init if: query succeeded, registered user, no credits, not attempted yet
42
124
  const needsInit =
43
125
  querySuccess &&
44
126
  !!userId &&
@@ -48,40 +130,28 @@ export function useFreeCreditsInit(params: UseFreeCreditsInitParams): UseFreeCre
48
130
  autoInit &&
49
131
  !freeCreditsInitAttempted.has(userId);
50
132
 
51
- const initializeFreeCredits = useCallback(async (uid: string) => {
52
- freeCreditsInitAttempted.add(uid);
53
- setIsInitializing(true);
54
-
55
- if (typeof __DEV__ !== "undefined" && __DEV__) {
56
- console.log("[useFreeCreditsInit] Initializing free credits:", uid.slice(0, 8));
57
- }
58
-
59
- const repository = getCreditsRepository();
60
- const result = await repository.initializeFreeCredits(uid);
61
- setIsInitializing(false);
62
-
63
- if (result.success) {
64
- if (typeof __DEV__ !== "undefined" && __DEV__) {
65
- console.log("[useFreeCreditsInit] Free credits initialized:", result.data?.credits);
66
- }
67
- onInitComplete();
68
- } else if (typeof __DEV__ !== "undefined" && __DEV__) {
69
- console.warn("[useFreeCreditsInit] Free credits init failed:", result.error?.message);
70
- }
133
+ // Stable callback reference
134
+ const stableOnComplete = useCallback(() => {
135
+ onInitComplete();
71
136
  }, [onInitComplete]);
72
137
 
73
138
  useEffect(() => {
74
- if (needsInit && userId) {
75
- initializeFreeCredits(userId);
76
- } else if (querySuccess && userId && isAnonymous && !hasCredits && autoInit) {
139
+ if (!userId) return;
140
+
141
+ if (needsInit) {
142
+ // Double-check inside effect to handle race conditions
143
+ if (!freeCreditsInitAttempted.has(userId)) {
144
+ initializeFreeCreditsForUser(userId, stableOnComplete);
145
+ }
146
+ } else if (querySuccess && isAnonymous && !hasCredits && autoInit) {
77
147
  if (typeof __DEV__ !== "undefined" && __DEV__) {
78
148
  console.log("[useFreeCreditsInit] Skipping - anonymous user must register first");
79
149
  }
80
150
  }
81
- }, [needsInit, userId, querySuccess, isAnonymous, hasCredits, autoInit, initializeFreeCredits]);
151
+ }, [needsInit, userId, querySuccess, isAnonymous, hasCredits, autoInit, stableOnComplete]);
82
152
 
83
153
  return {
84
- isInitializing,
154
+ isInitializing: isInitializing || needsInit,
85
155
  needsInit,
86
156
  };
87
157
  }