Manage state of price-lookup calls (#5915)

Now calls stripe-price-lookup once when prices are required rather then
bombarding on every rerender
This commit is contained in:
ConnorYoh
2026-03-11 13:53:49 +00:00
committed by GitHub
parent 32cf6866f3
commit d5d03b9ada
7 changed files with 136 additions and 143 deletions
@@ -1,10 +1,14 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef, useMemo } from 'react';
import { saasBillingService, BillingStatus, PlanPrice } from '@app/services/saasBillingService';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
import type { TierLevel } from '@app/types/billing';
// How long plan pricing is considered fresh. Lives at module level so both the
// provider and usePlanPricing (the consumer hook) can reference it.
const PLANS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
/**
* Desktop implementation of SaaS Billing Context
* Provides billing and plan management for users connected to SaaS backend
@@ -34,6 +38,7 @@ interface SaasBillingContextType {
plans: Map<string, PlanPrice>;
plansLoading: boolean;
plansError: string | null;
plansLastFetchTime: number | null;
// Derived State
isManagedTeamMember: boolean;
@@ -63,6 +68,7 @@ const SaasBillingContext = createContext<SaasBillingContextType>({
plans: new Map(),
plansLoading: false,
plansError: null,
plansLastFetchTime: null,
isManagedTeamMember: false,
loading: false,
error: null,
@@ -80,7 +86,19 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
const [plansError, setPlansError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); // Start false (lazy load)
const [error, setError] = useState<string | null>(null);
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null);
// lastFetchTimeRef is the source of truth for cache logic (always current, no stale closure).
// lastFetchTimeValue mirrors it as state purely to drive re-renders and expose through context.
const lastFetchTimeRef = useRef<number | null>(null);
const [lastFetchTimeValue, setLastFetchTimeValue] = useState<number | null>(null);
// billingStatusRef mirrors billingStatus so fetchBillingData can read the current value
// without needing billingStatus in its useCallback dep array.
const billingStatusRef = useRef<BillingStatus | null>(null);
// plansLastFetchTimeRef is the source of truth for timing; plansLastFetchTimeValue
// is the state mirror exposed via context so consumers can react to it.
const plansLastFetchTimeRef = useRef<number | null>(null);
const [plansLastFetchTimeValue, setPlansLastFetchTimeValue] = useState<number | null>(null);
// In-flight deduplication — prevents concurrent duplicate network requests.
const plansFetchInProgressRef = useRef(false);
const [isSaasMode, setIsSaasMode] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -133,19 +151,21 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
// Cache check: Skip if fresh data exists (<5 min old)
const now = Date.now();
if (billingStatus && lastFetchTime && (now - lastFetchTime) < 300000) {
if (billingStatusRef.current && lastFetchTimeRef.current && (now - lastFetchTimeRef.current) < 300000) {
return;
}
// Only set loading if no existing data (prevents flicker on refresh)
if (!billingStatus) {
if (!billingStatusRef.current) {
setLoading(true);
}
try {
const status = await saasBillingService.getBillingStatus();
setBillingStatus(status); // Atomic update
setLastFetchTime(now);
billingStatusRef.current = status;
setBillingStatus(status);
lastFetchTimeRef.current = now;
setLastFetchTimeValue(now);
setError(null);
} catch (err) {
console.error('[SaasBillingContext] Failed to fetch billing:', err);
@@ -154,26 +174,34 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
} finally {
setLoading(false);
}
}, [isSaasMode, isAuthenticated, isManagedTeamMember, billingStatus, lastFetchTime, teamLoading]);
}, [isSaasMode, isAuthenticated, isManagedTeamMember, teamLoading]);
// Fetch available plans
// Raw plan fetch — auth guard + in-flight deduplication only.
// TTL cache logic lives in usePlanPricing so the consumer hook controls staleness policy.
const fetchPlansData = useCallback(async () => {
// Guard: Skip if not in SaaS mode or not authenticated
if (!isSaasMode || !isAuthenticated) {
return;
}
if (plansFetchInProgressRef.current) {
return;
}
plansFetchInProgressRef.current = true;
setPlansLoading(true);
setPlansError(null);
try {
const priceMap = await saasBillingService.getAvailablePlans('usd');
setPlans(priceMap);
const now = Date.now();
plansLastFetchTimeRef.current = now;
setPlansLastFetchTimeValue(now);
} catch (err) {
console.error('[SaasBillingContext] Failed to fetch plans:', err);
setPlansError(err instanceof Error ? err.message : 'Failed to fetch plans');
// Non-blocking: continue with empty plans
} finally {
plansFetchInProgressRef.current = false;
setPlansLoading(false);
}
}, [isSaasMode, isAuthenticated]);
@@ -182,9 +210,14 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!isSaasMode || !isAuthenticated) {
// Clear state when not in SaaS mode or not authenticated
billingStatusRef.current = null;
setBillingStatus(null);
setPlans(new Map());
setLastFetchTime(null);
lastFetchTimeRef.current = null;
setLastFetchTimeValue(null);
plansLastFetchTimeRef.current = null;
setPlansLastFetchTimeValue(null);
plansFetchInProgressRef.current = false;
setLoading(false);
setError(null);
setPlansError(null);
@@ -199,12 +232,12 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
isAuthenticated &&
!teamLoading &&
!isManagedTeamMember &&
billingStatus === null &&
lastFetchTime === null
billingStatusRef.current === null &&
lastFetchTimeRef.current === null
) {
fetchBillingData();
}
}, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, billingStatus, lastFetchTime, fetchBillingData]);
}, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, fetchBillingData]);
// Public refresh methods
const refreshBilling = useCallback(async () => {
@@ -212,11 +245,12 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
return;
}
// Force cache invalidation
setLastFetchTime(null);
// Force cache invalidation — write to ref synchronously so fetchBillingData
// reads null immediately (not a stale closure value from the previous render).
lastFetchTimeRef.current = null;
setLastFetchTimeValue(null);
await fetchBillingData();
await fetchPlansData();
}, [isSaasMode, isAuthenticated, fetchBillingData, fetchPlansData]);
}, [isSaasMode, isAuthenticated, fetchBillingData]);
const refreshPlans = useCallback(async () => {
await fetchPlansData();
@@ -240,10 +274,9 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
if (typeof newBalance === 'number' && billingStatus) {
// Update credit balance in billing status without full refresh
setBillingStatus({
...billingStatus,
creditBalance: newBalance,
});
const updated = { ...billingStatus, creditBalance: newBalance };
billingStatusRef.current = updated;
setBillingStatus(updated);
// Dispatch exhausted event if credits hit 0
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
@@ -260,29 +293,36 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
};
}, [billingStatus]);
const contextValue = useMemo(() => ({
billingStatus,
tier: isManagedTeamMember ? 'team' : (billingStatus?.tier ?? 'free'),
subscription: billingStatus?.subscription ?? null,
usage: billingStatus?.meterUsage ?? null,
isTrialing: billingStatus?.isTrialing ?? false,
trialDaysRemaining: billingStatus?.trialDaysRemaining,
price: plans.get('team')?.price,
currency: plans.get('team')?.currency,
creditBalance: billingStatus?.creditBalance ?? 0,
plans,
plansLoading,
plansError,
plansLastFetchTime: plansLastFetchTimeValue,
isManagedTeamMember,
loading: loading || teamLoading,
error,
lastFetchTime: lastFetchTimeValue,
refreshBilling,
refreshCredits: refreshBilling,
refreshPlans,
openBillingPortal,
}), [
billingStatus, isManagedTeamMember, plans, plansLoading, plansError,
plansLastFetchTimeValue, loading, teamLoading, error, lastFetchTimeValue,
refreshBilling, refreshPlans, openBillingPortal,
]);
return (
<SaasBillingContext.Provider value={{
billingStatus,
tier: isManagedTeamMember ? 'team' : (billingStatus?.tier ?? 'free'),
subscription: billingStatus?.subscription ?? null,
usage: billingStatus?.meterUsage ?? null,
isTrialing: billingStatus?.isTrialing ?? false,
trialDaysRemaining: billingStatus?.trialDaysRemaining,
price: billingStatus?.price,
currency: billingStatus?.currency,
creditBalance: billingStatus?.creditBalance ?? 0,
plans,
plansLoading,
plansError,
isManagedTeamMember,
loading: loading || teamLoading,
error,
lastFetchTime,
refreshBilling,
refreshCredits: refreshBilling, // Alias for clarity
refreshPlans,
openBillingPortal,
}}>
<SaasBillingContext.Provider value={contextValue}>
{children}
</SaasBillingContext.Provider>
);
@@ -311,5 +351,26 @@ export function useSaaSBilling() {
return context;
}
/**
* Hook for components that display plan pricing data.
* Fetches on first use; re-fetches only after PLANS_CACHE_TTL_MS (1 hour).
* Safe to call from multiple components — in-flight deduplication is handled by fetchPlansData.
*/
export function usePlanPricing() {
const { plans, plansLoading, plansError, plansLastFetchTime, refreshPlans } = useContext(SaasBillingContext);
useEffect(() => {
const isFresh =
plansLastFetchTime !== null &&
Date.now() - plansLastFetchTime < PLANS_CACHE_TTL_MS;
if (!isFresh) {
refreshPlans();
}
}, [plansLastFetchTime, refreshPlans]);
return { plans, plansLoading, plansError };
}
export { SaasBillingContext };
export type { SaasBillingContextType };