@umituz/react-native-subscription 2.35.22 → 2.35.24

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.35.22",
3
+ "version": "2.35.24",
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",
@@ -3,6 +3,7 @@ import type { InitializeResult } from "../../../../shared/application/ports/IRev
3
3
  export class ConfigurationStateManager {
4
4
  private _isPurchasesConfigured = false;
5
5
  private _configurationPromise: Promise<InitializeResult> | null = null;
6
+ private _resolveConfiguration: ((value: InitializeResult) => void) | null = null;
6
7
 
7
8
  get isPurchasesConfigured(): boolean {
8
9
  return this._isPurchasesConfigured;
@@ -21,16 +22,17 @@ export class ConfigurationStateManager {
21
22
  throw new Error('Configuration already in progress');
22
23
  }
23
24
 
24
- let capturedResolve: ((value: InitializeResult) => void) | null = null;
25
-
26
- this._configurationPromise = new Promise((resolve) => {
27
- capturedResolve = resolve;
25
+ // Create promise and store resolve function atomically
26
+ this._configurationPromise = new Promise<InitializeResult>((resolve) => {
27
+ this._resolveConfiguration = resolve;
28
28
  });
29
29
 
30
+ // Return resolve function
30
31
  return (value: InitializeResult) => {
31
- if (capturedResolve) {
32
- capturedResolve(value);
33
- capturedResolve = null;
32
+ if (this._resolveConfiguration) {
33
+ const resolve = this._resolveConfiguration;
34
+ this._resolveConfiguration = null;
35
+ resolve(value);
34
36
  }
35
37
  };
36
38
  }
@@ -38,18 +40,22 @@ export class ConfigurationStateManager {
38
40
  completeConfiguration(success: boolean): void {
39
41
  this._isPurchasesConfigured = success;
40
42
 
41
- if (success) {
43
+ // Cleanup promise state immediately (no setTimeout)
44
+ // If promise hasn't resolved yet, that's fine - it will still resolve via the callback
45
+ if (this._configurationPromise) {
42
46
  this._configurationPromise = null;
43
- } else {
44
- setTimeout(() => {
45
- this._configurationPromise = null;
46
- }, 1000);
47
+ }
48
+
49
+ // Clear resolve function if it still exists
50
+ if (this._resolveConfiguration) {
51
+ this._resolveConfiguration = null;
47
52
  }
48
53
  }
49
54
 
50
55
  reset(): void {
51
56
  this._isPurchasesConfigured = false;
52
57
  this._configurationPromise = null;
58
+ this._resolveConfiguration = null;
53
59
  }
54
60
  }
55
61
 
@@ -5,5 +5,5 @@ export interface InitializerDeps {
5
5
  isInitialized: () => boolean;
6
6
  getCurrentUserId: () => string | null;
7
7
  setInitialized: (value: boolean) => void;
8
- setCurrentUserId: (userId: string | undefined) => void;
8
+ setCurrentUserId: (userId: string | null | undefined) => void;
9
9
  }
@@ -2,8 +2,8 @@ import Purchases, { type CustomerInfo } from "react-native-purchases";
2
2
  import type { InitializeResult } from "../../../../shared/application/ports/IRevenueCatService";
3
3
  import type { InitializerDeps } from "./RevenueCatInitializer.types";
4
4
  import { FAILED_INITIALIZATION_RESULT } from "./initializerConstants";
5
- import { syncPremiumStatus } from "../../../subscription/infrastructure/utils/PremiumStatusSyncer";
6
5
  import { UserSwitchMutex } from "./UserSwitchMutex";
6
+ import { getPremiumEntitlement } from "../../core/types";
7
7
 
8
8
  declare const __DEV__: boolean;
9
9
 
@@ -151,7 +151,38 @@ export async function handleInitialConfiguration(
151
151
  });
152
152
  }
153
153
 
154
- await syncPremiumStatus(deps.config, currentUserId, customerInfo);
154
+ // Sync premium status via callback (if configured)
155
+ if (deps.config.onPremiumStatusChanged) {
156
+ try {
157
+ const premiumEntitlement = getPremiumEntitlement(
158
+ customerInfo,
159
+ deps.config.entitlementIdentifier
160
+ );
161
+
162
+ if (premiumEntitlement) {
163
+ await deps.config.onPremiumStatusChanged(
164
+ currentUserId,
165
+ true,
166
+ premiumEntitlement.productIdentifier,
167
+ premiumEntitlement.expirationDate ?? undefined,
168
+ premiumEntitlement.willRenew,
169
+ premiumEntitlement.periodType as "NORMAL" | "INTRO" | "TRIAL" | undefined
170
+ );
171
+ } else {
172
+ await deps.config.onPremiumStatusChanged(
173
+ currentUserId,
174
+ false,
175
+ undefined,
176
+ undefined,
177
+ undefined,
178
+ undefined
179
+ );
180
+ }
181
+ } catch (error) {
182
+ // Log error but don't fail initialization
183
+ console.error('[UserSwitchHandler] Premium status sync callback failed:', error);
184
+ }
185
+ }
155
186
 
156
187
  return buildSuccessResult(deps, customerInfo, offerings);
157
188
  } catch (error) {
@@ -36,8 +36,12 @@ export async function startBackgroundInitialization(config: SubscriptionInitConf
36
36
  try {
37
37
  await initializeInBackground(revenueCatUserId);
38
38
  lastUserId = revenueCatUserId;
39
- } catch (_error) {
39
+ } catch (error) {
40
40
  // Background re-initialization errors are non-critical, already logged by SubscriptionManager
41
+ console.error('[BackgroundInitializer] Reinitialization failed:', {
42
+ userId: revenueCatUserId,
43
+ error: error instanceof Error ? error.message : String(error)
44
+ });
41
45
  }
42
46
  }, AUTH_STATE_DEBOUNCE_MS);
43
47
  };
@@ -1,57 +1,52 @@
1
- import { useEffect, useState, useCallback, useRef } from "react";
2
- import Purchases, { type CustomerInfo } from "react-native-purchases";
1
+ /**
2
+ * Customer Info Hook
3
+ * Fetches customer info without registering a listener
4
+ * CustomerInfoListenerManager handles all listener logic
5
+ */
6
+
7
+ import { useQuery, useQueryClient } from "@umituz/react-native-design-system";
8
+ import { useEffect, useRef } from "react";
9
+ import Purchases from "react-native-purchases";
3
10
  import type { UseCustomerInfoResult } from "./types";
11
+ import { SUBSCRIPTION_QUERY_KEYS } from "../subscriptionQueryKeys";
4
12
 
5
13
  export function useCustomerInfo(): UseCustomerInfoResult {
6
- const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
7
- const [loading, setLoading] = useState(true);
8
- const [isFetching, setIsFetching] = useState(false);
9
- const [error, setError] = useState<string | null>(null);
10
-
11
- const fetchCustomerInfo = useCallback(async () => {
12
- try {
13
- setIsFetching(true);
14
- setError(null);
15
-
16
- const info = await Purchases.getCustomerInfo();
17
-
18
- setCustomerInfo(info);
19
- } catch (err) {
20
- const errorMessage =
21
- err instanceof Error ? err.message : "Failed to fetch customer info";
22
- setError(errorMessage);
23
- } finally {
24
- setLoading(false);
25
- setIsFetching(false);
26
- }
27
- }, []);
28
-
29
- const listenerRef = useRef<((info: CustomerInfo) => void) | null>(null);
14
+ const queryClient = useQueryClient();
15
+ const mountedRef = useRef(true);
30
16
 
31
17
  useEffect(() => {
32
- fetchCustomerInfo();
33
-
34
- const listener = (info: CustomerInfo) => {
35
- setCustomerInfo(info);
36
- setError(null);
37
- };
38
-
39
- listenerRef.current = listener;
40
- Purchases.addCustomerInfoUpdateListener(listener);
41
-
18
+ mountedRef.current = true;
42
19
  return () => {
43
- if (listenerRef.current) {
44
- Purchases.removeCustomerInfoUpdateListener(listenerRef.current);
45
- listenerRef.current = null;
46
- }
20
+ mountedRef.current = false;
47
21
  };
48
- }, [fetchCustomerInfo]); // fetchCustomerInfo is stable (empty deps), included for lint
22
+ }, []);
23
+
24
+ const query = useQuery({
25
+ queryKey: SUBSCRIPTION_QUERY_KEYS.customerInfo,
26
+ queryFn: async () => {
27
+ const info = await Purchases.getCustomerInfo();
28
+ return info;
29
+ },
30
+ staleTime: 30 * 1000, // 30 seconds
31
+ gcTime: 5 * 60 * 1000, // 5 minutes
32
+ refetchOnMount: true,
33
+ refetchOnWindowFocus: false,
34
+ retry: 2,
35
+ });
36
+
37
+ // Expose refetch as a method
38
+ const refetch = async () => {
39
+ if (!mountedRef.current) return;
40
+ await queryClient.invalidateQueries({
41
+ queryKey: SUBSCRIPTION_QUERY_KEYS.customerInfo,
42
+ });
43
+ };
49
44
 
50
45
  return {
51
- customerInfo,
52
- loading,
53
- error,
54
- refetch: fetchCustomerInfo,
55
- isFetching,
46
+ customerInfo: query.data ?? null,
47
+ loading: query.isLoading,
48
+ error: query.error?.message ?? null,
49
+ refetch,
50
+ isFetching: query.isFetching,
56
51
  };
57
52
  }
@@ -13,6 +13,7 @@ export const SUBSCRIPTION_QUERY_KEYS = {
13
13
  packages: ["subscription", "packages"] as const,
14
14
  initialized: (userId: string) =>
15
15
  ["subscription", "initialized", userId] as const,
16
+ customerInfo: ["subscription", "customerInfo"] as const,
16
17
  } as const;
17
18
 
18
19
 
@@ -26,22 +26,19 @@ export const useSubscriptionPackages = () => {
26
26
  const queryClient = useQueryClient();
27
27
  const prevUserIdRef = useRef(userId);
28
28
 
29
+ // Check if initialized (BackgroundInitializer handles initialization)
30
+ const isInitialized = userId
31
+ ? SubscriptionManager.isInitializedForUser(userId)
32
+ : SubscriptionManager.isInitialized();
33
+
29
34
  const query = useQuery({
30
35
  queryKey: [...SUBSCRIPTION_QUERY_KEYS.packages, userId ?? "anonymous"] as const,
31
36
  queryFn: async () => {
32
- if (userId) {
33
- if (!SubscriptionManager.isInitializedForUser(userId)) {
34
- await SubscriptionManager.initialize(userId);
35
- }
36
- } else {
37
- if (!SubscriptionManager.isInitialized()) {
38
- await SubscriptionManager.initialize(undefined);
39
- }
40
- }
41
-
37
+ // No side effects - just fetch packages
38
+ // Initialization is handled by BackgroundInitializer
42
39
  return SubscriptionManager.getPackages();
43
40
  },
44
- enabled: isConfigured,
41
+ enabled: isConfigured && isInitialized,
45
42
  gcTime: 5 * 60 * 1000,
46
43
  staleTime: 2 * 60 * 1000,
47
44
  refetchOnMount: true,
@@ -14,8 +14,13 @@ async function handleRenewal(
14
14
 
15
15
  try {
16
16
  await onRenewalDetected(userId, productId, expirationDate, customerInfo);
17
- } catch (_error) {
17
+ } catch (error) {
18
18
  // Callback errors should not break customer info processing
19
+ console.error('[CustomerInfoHandler] Renewal callback failed:', {
20
+ userId,
21
+ productId,
22
+ error: error instanceof Error ? error.message : String(error)
23
+ });
19
24
  }
20
25
  }
21
26
 
@@ -31,8 +36,15 @@ async function handlePlanChange(
31
36
 
32
37
  try {
33
38
  await onPlanChanged(userId, newProductId, previousProductId, isUpgrade, customerInfo);
34
- } catch (_error) {
39
+ } catch (error) {
35
40
  // Callback errors should not break customer info processing
41
+ console.error('[CustomerInfoHandler] Plan change callback failed:', {
42
+ userId,
43
+ newProductId,
44
+ previousProductId,
45
+ isUpgrade,
46
+ error: error instanceof Error ? error.message : String(error)
47
+ });
36
48
  }
37
49
  }
38
50
 
@@ -43,8 +55,12 @@ async function handlePremiumStatusSync(
43
55
  ): Promise<void> {
44
56
  try {
45
57
  await syncPremiumStatus(config, userId, customerInfo);
46
- } catch (_error) {
58
+ } catch (error) {
47
59
  // Sync errors are logged by PremiumStatusSyncer, don't break processing
60
+ console.error('[CustomerInfoHandler] Premium status sync failed:', {
61
+ userId,
62
+ error: error instanceof Error ? error.message : String(error)
63
+ });
48
64
  }
49
65
  }
50
66
 
@@ -1,52 +1,51 @@
1
1
  /**
2
2
  * Initialization Cache
3
3
  * Manages promise caching and user state for initialization
4
- * Thread-safe: Uses mutex pattern to prevent race conditions
4
+ * Thread-safe: Uses atomic promise-based locking pattern
5
5
  */
6
6
 
7
7
  export class InitializationCache {
8
8
  private initPromise: Promise<boolean> | null = null;
9
9
  private currentUserId: string | null = null;
10
- // Mutex to prevent race condition during initialization
11
- private initializationInProgress = false;
12
- // Track which userId the promise is for (separate from currentUserId which is set after completion)
10
+ // Track which userId the promise is for
13
11
  private promiseUserId: string | null = null;
14
- // Track promise completion state to avoid returning failed promises
12
+ // Track promise completion state
15
13
  private promiseCompleted = true;
14
+ // Pending initialization queue
15
+ private pendingQueue: Map<string, Promise<boolean>> = new Map();
16
16
 
17
17
  /**
18
18
  * Atomically check if reinitialization is needed AND reserve the slot
19
19
  * Returns: { shouldInit: boolean, existingPromise: Promise | null }
20
20
  */
21
21
  tryAcquireInitialization(userId: string): { shouldInit: boolean; existingPromise: Promise<boolean> | null } {
22
- // If initialization is in progress for the same user, return existing promise
23
- if (this.initializationInProgress && this.promiseUserId === userId && this.initPromise) {
24
- return { shouldInit: false, existingPromise: this.initPromise };
22
+ // Check if there's already a pending promise for this user in the queue
23
+ const queuedPromise = this.pendingQueue.get(userId);
24
+ if (queuedPromise) {
25
+ return { shouldInit: false, existingPromise: queuedPromise };
25
26
  }
26
27
 
27
- // If already initialized for this user and promise completed successfully, return it
28
- // Only return cached promise if it completed AND it's for the same user
29
- if (this.initPromise && this.currentUserId === userId && !this.initializationInProgress && this.promiseCompleted) {
28
+ // If already initialized for this user and promise completed successfully
29
+ if (
30
+ this.initPromise &&
31
+ this.currentUserId === userId &&
32
+ this.promiseCompleted &&
33
+ this.promiseUserId === userId
34
+ ) {
30
35
  return { shouldInit: false, existingPromise: this.initPromise };
31
36
  }
32
37
 
33
- // Different user, no initialization, or failed promise - need to reinitialize
34
- // Atomically set the flag and clear previous state if needed
35
- if (!this.initializationInProgress) {
36
- this.initializationInProgress = true;
37
- this.promiseUserId = userId;
38
- this.promiseCompleted = false;
39
- return { shouldInit: true, existingPromise: null };
40
- }
41
-
42
- // If we reach here, initialization is in progress for a different user
43
- // Don't return another user's promise - caller should retry
44
- return { shouldInit: false, existingPromise: null };
38
+ // Different user or not initialized - need to initialize
39
+ return { shouldInit: true, existingPromise: null };
45
40
  }
46
41
 
47
42
  setPromise(promise: Promise<boolean>, userId: string): void {
43
+ // Add to pending queue immediately (atomic operation)
44
+ this.pendingQueue.set(userId, promise);
45
+
48
46
  this.initPromise = promise;
49
47
  this.promiseUserId = userId;
48
+ this.promiseCompleted = false;
50
49
 
51
50
  const targetUserId = userId;
52
51
 
@@ -69,9 +68,8 @@ export class InitializationCache {
69
68
  return false;
70
69
  })
71
70
  .finally(() => {
72
- if (this.initializationInProgress) {
73
- this.initializationInProgress = false;
74
- }
71
+ // Remove from queue when complete
72
+ this.pendingQueue.delete(targetUserId);
75
73
  });
76
74
  }
77
75
 
@@ -82,8 +80,8 @@ export class InitializationCache {
82
80
  reset(): void {
83
81
  this.initPromise = null;
84
82
  this.currentUserId = null;
85
- this.initializationInProgress = false;
86
83
  this.promiseUserId = null;
87
84
  this.promiseCompleted = true;
85
+ this.pendingQueue.clear();
88
86
  }
89
87
  }
@@ -46,6 +46,10 @@ export async function syncPremiumStatus(
46
46
  }
47
47
  return { success: true };
48
48
  } catch (error) {
49
+ console.error('[PremiumStatusSyncer] Premium status callback failed:', {
50
+ userId,
51
+ error: error instanceof Error ? error.message : String(error)
52
+ });
49
53
 
50
54
  return {
51
55
  success: false,
@@ -68,8 +72,13 @@ export async function notifyPurchaseCompleted(
68
72
 
69
73
  try {
70
74
  await config.onPurchaseCompleted(userId, productId, customerInfo, source, packageType);
71
- } catch (_error) {
75
+ } catch (error) {
72
76
  // Silently fail callback notifications to prevent crashing the main flow
77
+ console.error('[PremiumStatusSyncer] Purchase completed callback failed:', {
78
+ userId,
79
+ productId,
80
+ error: error instanceof Error ? error.message : String(error)
81
+ });
73
82
  }
74
83
  }
75
84
 
@@ -25,7 +25,6 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
25
25
  const { creditBalanceRef, hasSubscriptionRef, onShowPaywallRef, requiredCreditsRef, isCreditsLoadedRef } = useSyncedRefs(creditBalance, hasSubscription, onShowPaywall, requiredCredits, isCreditsLoaded);
26
26
 
27
27
  useEffect(() => {
28
-
29
28
  const shouldExecute = canExecuteAuthAction(
30
29
  isWaitingForAuthCreditsRef.current,
31
30
  isCreditsLoaded,
@@ -46,9 +45,11 @@ export function useFeatureGate(params: UseFeatureGateParams): UseFeatureGateResu
46
45
  if (isWaitingForAuthCreditsRef.current && isCreditsLoaded && pendingActionRef.current) {
47
46
  isWaitingForAuthCreditsRef.current = false;
48
47
  isWaitingForPurchaseRef.current = true;
49
- onShowPaywall(requiredCredits);
48
+ // Use ref to avoid unstable callback dependency
49
+ onShowPaywallRef.current(requiredCreditsRef.current);
50
50
  }
51
- }, [isCreditsLoaded, creditBalance, hasSubscription, requiredCredits, onShowPaywall]);
51
+ // Removed onShowPaywall from dependencies - using ref instead
52
+ }, [isCreditsLoaded, creditBalance, hasSubscription, requiredCredits, onShowPaywallRef, requiredCreditsRef]);
52
53
 
53
54
  useEffect(() => {
54
55
 
@@ -19,7 +19,9 @@ export const useSubscriptionStatus = (): SubscriptionStatusResult => {
19
19
  const queryClient = useQueryClient();
20
20
  const isConfigured = SubscriptionManager.isConfigured();
21
21
 
22
- const queryEnabled = isAuthenticated(userId) && isConfigured;
22
+ // Check if initialized (BackgroundInitializer handles initialization)
23
+ const isInitialized = userId ? SubscriptionManager.isInitializedForUser(userId) : false;
24
+ const queryEnabled = isAuthenticated(userId) && isConfigured && isInitialized;
23
25
 
24
26
  const { data, status, error, refetch } = useQuery({
25
27
  queryKey: subscriptionStatusQueryKeys.user(userId),
@@ -28,10 +30,8 @@ export const useSubscriptionStatus = (): SubscriptionStatusResult => {
28
30
  return null;
29
31
  }
30
32
 
31
- if (!SubscriptionManager.isInitializedForUser(userId)) {
32
- await SubscriptionManager.initialize(userId);
33
- }
34
-
33
+ // No side effects - just check premium status
34
+ // Initialization is handled by BackgroundInitializer
35
35
  try {
36
36
  const result = await SubscriptionManager.checkPremiumStatus();
37
37
  return result;