import React, { createContext, useContext, useMemo, useState, useEffect, ReactNode, useCallback, } from "react"; import apiClient from "@app/services/apiClient"; import { getSimulatedAppConfig } from "@app/testing/serverExperienceSimulations"; import type { AppConfig, AppConfigBootstrapMode } from "@app/types/appConfig"; import { useJwtConfigSync } from "@app/hooks/useJwtConfigSync"; /** * Sleep utility for delays */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export interface AppConfigRetryOptions { maxRetries?: number; initialDelay?: number; } export type { AppConfig, AppConfigBootstrapMode }; interface AppConfigContextValue { config: AppConfig | null; loading: boolean; error: string | null; refetch: () => Promise; } const AppConfigContext = createContext({ config: null, loading: true, error: null, refetch: async () => {}, }); /** * Provider component that fetches and provides app configuration * Should be placed at the top level of the app, before any components that need config */ export interface AppConfigProviderProps { children: ReactNode; retryOptions?: AppConfigRetryOptions; initialConfig?: AppConfig | null; bootstrapMode?: AppConfigBootstrapMode; autoFetch?: boolean; onConfigLoaded?: (config: AppConfig) => void; } export const AppConfigProvider: React.FC = ({ children, retryOptions, initialConfig = null, bootstrapMode = "blocking", autoFetch = true, onConfigLoaded, }) => { const isBlockingMode = bootstrapMode === "blocking"; const [config, setConfig] = useState(initialConfig); const [error, setError] = useState(null); // Track how many times we've attempted to fetch. useRef avoids re-renders that can trigger loops. const fetchCountRef = React.useRef(0); const [hasResolvedConfig, setHasResolvedConfig] = useState( Boolean(initialConfig) && !isBlockingMode, ); const [loading, setLoading] = useState(!hasResolvedConfig); const onConfigLoadedRef = React.useRef(onConfigLoaded); onConfigLoadedRef.current = onConfigLoaded; const maxRetries = retryOptions?.maxRetries ?? 0; const initialDelay = retryOptions?.initialDelay ?? 1000; const fetchConfig = useCallback( async (force = false) => { // Prevent duplicate fetches unless forced if (!force && fetchCountRef.current > 0) { console.debug("[AppConfig] Already fetched, skipping"); return; } // Mark that we've attempted a fetch to prevent repeated auto-fetch loops fetchCountRef.current += 1; const shouldBlockUI = !hasResolvedConfig || isBlockingMode; if (shouldBlockUI) { setLoading(true); } setError(null); const startTime = performance.now(); for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const testConfig = getSimulatedAppConfig(); if (testConfig) { setConfig(testConfig); setHasResolvedConfig(true); setLoading(false); return; } if (attempt > 0) { const delay = initialDelay * Math.pow(2, attempt - 1); console.log( `[AppConfig] Retry attempt ${attempt}/${maxRetries} after ${delay}ms delay...`, ); await sleep(delay); } else { console.log("[AppConfig] Fetching app config..."); } // apiClient automatically adds JWT header if available via interceptors // Always suppress error toast - we handle 401 errors locally console.debug("[AppConfig] Fetching app config", { attempt, force, path: window.location.pathname, }); const response = await apiClient.get( "/api/v1/config/app-config", { suppressErrorToast: true, skipAuthRedirect: true, } as any, ); const data = response.data; console.debug("[AppConfig] Config fetched successfully:", data); console.debug( "[AppConfig] Fetch duration ms:", (performance.now() - startTime).toFixed(2), ); setConfig(data); setHasResolvedConfig(true); setLoading(false); onConfigLoadedRef.current?.(data); return; // Success - exit function } catch (err: any) { const status = err?.response?.status; // On 401 (not authenticated), use default config with login enabled // This allows the app to work even without authentication if (status === 401) { console.debug( "[AppConfig] 401 error - using default config (login enabled)", ); console.debug( "[AppConfig] Fetch duration ms:", (performance.now() - startTime).toFixed(2), ); setConfig({ enableLogin: true }); setHasResolvedConfig(true); setLoading(false); return; } // Check if we should retry (network errors or 5xx errors) const shouldRetry = (!status || status >= 500) && attempt < maxRetries; if (shouldRetry) { console.warn( `[AppConfig] Attempt ${attempt + 1} failed (status ${status || "network error"}):`, err.message, "- will retry...", ); continue; } // Final attempt failed or non-retryable error (4xx) const errorMessage = err?.response?.data?.message || err?.message || "Unknown error occurred"; setError(errorMessage); console.error( `[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`, err, ); console.debug( "[AppConfig] Fetch duration ms:", (performance.now() - startTime).toFixed(2), ); // Preserve existing config (initial default or previous fetch). If nothing is set, assume login enabled. setConfig((current) => current ?? { enableLogin: true }); setHasResolvedConfig(true); break; } } setLoading(false); }, [hasResolvedConfig, isBlockingMode, maxRetries, initialDelay], ); const { isAuthPage } = useJwtConfigSync(fetchConfig); useEffect(() => { if (isAuthPage) { console.debug( "[AppConfig] On auth page - using default config, skipping fetch", { path: window.location.pathname }, ); setConfig({ enableLogin: true }); setHasResolvedConfig(true); setLoading(false); return; } if (autoFetch) { fetchConfig(); } }, [autoFetch, fetchConfig, isAuthPage]); const refetch = useCallback(() => fetchConfig(true), [fetchConfig]); const value = useMemo( () => ({ config, loading, error, refetch, }), [config, loading, error, refetch], ); return ( {children} ); }; /** * Hook to access application configuration * Must be used within AppConfigProvider */ export function useAppConfig(): AppConfigContextValue { const context = useContext(AppConfigContext); if (context === undefined) { throw new Error("useAppConfig must be used within AppConfigProvider"); } return context; }