diff --git a/web/src/features/users/components/dialogs/__tests__/user-binding-dialog.test.tsx b/web/src/features/users/components/dialogs/__tests__/user-binding-dialog.test.tsx index 877b4e938f23..ed98e71f6b9d 100644 --- a/web/src/features/users/components/dialogs/__tests__/user-binding-dialog.test.tsx +++ b/web/src/features/users/components/dialogs/__tests__/user-binding-dialog.test.tsx @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest' @@ -23,6 +24,19 @@ import { api } from '@/lib/api' import { UserBindingDialog } from '../user-binding-dialog' +/** + * The dialog reads `/api/status` through the shared React Query cache, so it + * needs a provider. A fresh client per render keeps tests isolated. + */ +function renderWithQueryClient(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }) + return render( + {ui} + ) +} + type ApiMethod = (url: string) => Promise<{ data: unknown }> type MockableApi = { get: ApiMethod @@ -117,7 +131,9 @@ describe('UserBindingDialog built-in bindings', () => { return { data: { success: true, message: 'success' } } } - render( undefined} />) + renderWithQueryClient( + undefined} /> + ) const expectedBindings = [ ['Email', 'email'], diff --git a/web/src/features/users/components/dialogs/user-binding-dialog.tsx b/web/src/features/users/components/dialogs/user-binding-dialog.tsx index 0c1897a3453d..a684cf3fb9f4 100644 --- a/web/src/features/users/components/dialogs/user-binding-dialog.tsx +++ b/web/src/features/users/components/dialogs/user-binding-dialog.tsx @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { useQueryClient } from '@tanstack/react-query' import { Mail, Globe, @@ -44,8 +45,8 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' -import { api } from '@/lib/api' import { indexCustomOAuthBindings, type CustomOAuthBinding } from '@/lib/oauth' +import { ensureStatus } from '@/lib/status-query' import { getUser, @@ -168,6 +169,7 @@ export function UserBindingDialog(props: Props) { const [showBoundOnly, setShowBoundOnly] = useState(true) const [unbindTarget, setUnbindTarget] = useState(null) const [unbinding, setUnbinding] = useState(false) + const queryClient = useQueryClient() const fetchData = useCallback(async () => { if (!props.userId) return @@ -179,13 +181,7 @@ export function UserBindingDialog(props: Props) { success: false, data: [], })), - api - .get('/api/status') - .then((r) => r.data) - .catch(() => ({ - success: false, - data: {}, - })), + ensureStatus(queryClient).catch(() => null), ]) if (userRes.success && userRes.data) { setUser(userRes.data) @@ -193,15 +189,15 @@ export function UserBindingDialog(props: Props) { if (oauthRes.success && oauthRes.data) { setOauthBindings(oauthRes.data) } - if (statusRes.success && statusRes.data) { - setStatusInfo(statusRes.data as StatusInfo) + if (statusRes) { + setStatusInfo(statusRes as StatusInfo) } } catch { toast.error(t('Failed to load')) } finally { setLoading(false) } - }, [props.userId, t]) + }, [props.userId, queryClient, t]) useEffect(() => { if (props.open && props.userId) { diff --git a/web/src/hooks/use-status.ts b/web/src/hooks/use-status.ts index 723ea5474897..af4cdac7ce74 100644 --- a/web/src/hooks/use-status.ts +++ b/web/src/hooks/use-status.ts @@ -19,63 +19,22 @@ For commercial licensing, please contact support@quantumnous.com import { useQuery } from '@tanstack/react-query' import type { SystemStatus } from '@/features/auth/types' -import { getStatus } from '@/lib/api' -import { useSystemConfigStore } from '@/stores/system-config-store' - -import { mapStatusDataToConfig } from './use-system-config' +import { readCachedStatus, statusQueryOptions } from '@/lib/status-query' // Get initial cache from localStorage function getInitialStatus(): SystemStatus | undefined { - try { - if (typeof window !== 'undefined') { - const saved = window.localStorage.getItem('status') - return saved ? (JSON.parse(saved) as SystemStatus) : undefined - } - } catch { - /* empty */ - } - return undefined + return (readCachedStatus() as SystemStatus | null) ?? undefined } export function useStatus() { const { data, isLoading, error } = useQuery({ - queryKey: ['status'], - queryFn: async () => { - const status = await getStatus() - try { - if (status) { - const { setConfig } = useSystemConfigStore.getState() - setConfig(mapStatusDataToConfig(status)) - } - } catch (err) { - if (import.meta.env.DEV) { - // eslint-disable-next-line no-console - console.warn( - '[useStatus] Failed to sync status to system config', - err - ) - } - } - // Save to localStorage - try { - if (typeof window !== 'undefined' && status) { - window.localStorage.setItem('status', JSON.stringify(status)) - } - } catch { - /* empty */ - } - return status as SystemStatus | null - }, + ...statusQueryOptions, // Use localStorage data as initial data placeholderData: getInitialStatus(), - // Data becomes stale after 5 minutes - staleTime: 5 * 60 * 1000, - // Cache expires after 30 minutes - gcTime: 30 * 60 * 1000, }) return { - status: data ?? null, + status: (data as SystemStatus | null) ?? null, loading: isLoading, error, } diff --git a/web/src/hooks/use-system-config.ts b/web/src/hooks/use-system-config.ts index 23f92a633137..cc6b4e47c8df 100644 --- a/web/src/hooks/use-system-config.ts +++ b/web/src/hooks/use-system-config.ts @@ -16,103 +16,19 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { useQueryClient } from '@tanstack/react-query' import { useEffect, useCallback } from 'react' -import { DEFAULT_SYSTEM_NAME, DEFAULT_LOGO } from '@/lib/constants' +import { DEFAULT_LOGO } from '@/lib/constants' import { applyFaviconToDom } from '@/lib/dom-utils' -import { - useSystemConfigStore, - type CurrencyConfig, - type CurrencyDisplayType, - type SystemConfig, - DEFAULT_CURRENCY_CONFIG, -} from '@/stores/system-config-store' +import { ensureStatus } from '@/lib/status-query' +import { useSystemConfigStore } from '@/stores/system-config-store' interface UseSystemConfigOptions { /** Automatically fetch config from backend (use only in root component) */ autoLoad?: boolean } -interface StatusApiResponse { - success: boolean - data: { - system_name?: string - logo?: string - footer_html?: string - demo_site_enabled?: boolean - display_token_stat_enabled?: boolean - display_in_currency?: boolean - quota_display_type?: CurrencyDisplayType - quota_per_unit?: number - usd_exchange_rate?: number - custom_currency_symbol?: string - custom_currency_exchange_rate?: number - } -} - -function toNumber(value: unknown, fallback: number): number { - if (typeof value === 'number' && !Number.isNaN(value)) return value - if (typeof value === 'string') { - const parsed = Number(value) - if (!Number.isNaN(parsed)) return parsed - } - return fallback -} - -/** - * Map `/api/status` response data to our persisted system config structure - */ -export function mapStatusDataToConfig( - data: StatusApiResponse['data'] | undefined -): Partial { - if (!data) return {} - - const quotaDisplayType = - (data.quota_display_type as CurrencyDisplayType | undefined) ?? - DEFAULT_CURRENCY_CONFIG.quotaDisplayType - - const currency: CurrencyConfig = { - displayInCurrency: - data.display_in_currency ?? DEFAULT_CURRENCY_CONFIG.displayInCurrency, - quotaDisplayType, - quotaPerUnit: toNumber( - data.quota_per_unit, - DEFAULT_CURRENCY_CONFIG.quotaPerUnit - ), - usdExchangeRate: toNumber( - data.usd_exchange_rate, - DEFAULT_CURRENCY_CONFIG.usdExchangeRate - ), - customCurrencySymbol: - data.custom_currency_symbol?.trim() || - DEFAULT_CURRENCY_CONFIG.customCurrencySymbol, - customCurrencyExchangeRate: toNumber( - data.custom_currency_exchange_rate, - DEFAULT_CURRENCY_CONFIG.customCurrencyExchangeRate - ), - } - - return { - systemName: data.system_name || DEFAULT_SYSTEM_NAME, - logo: data.logo || DEFAULT_LOGO, - footerHtml: data.footer_html, - demoSiteEnabled: data.demo_site_enabled, - displayTokenStatEnabled: data.display_token_stat_enabled, - currency, - } -} - -// Fetch system config from API -async function fetchSystemConfig(): Promise> { - const response = await fetch('/api/status') - if (!response.ok) throw new Error('Failed to fetch status') - - const data: StatusApiResponse = await response.json() - if (!data.success) throw new Error('API returned error') - - return mapStatusDataToConfig(data.data) -} - // Preload image and return cleanup function function preloadImage( src: string, @@ -143,28 +59,24 @@ function preloadImage( */ export function useSystemConfig(options: UseSystemConfigOptions = {}) { const { autoLoad = false } = options - const { - config, - loading, - loadedLogoUrl, - setConfig, - setLoadedLogoUrl, - setLoading, - } = useSystemConfigStore() + const queryClient = useQueryClient() + const { config, loading, loadedLogoUrl, setLoadedLogoUrl, setLoading } = + useSystemConfigStore() - // Load config from backend + // Load config from backend via the shared `/api/status` cache. + // `ensureStatus` writes the mapped config into this store itself, so there is + // no second request and no second mapping path here. const loadConfig = useCallback(async () => { try { setLoading(true) - const newConfig = await fetchSystemConfig() - setConfig(newConfig) + await ensureStatus(queryClient) } catch (error) { // eslint-disable-next-line no-console console.error('Failed to load system config:', error) } finally { setLoading(false) } - }, [setConfig, setLoading]) + }, [queryClient, setLoading]) useEffect(() => { if (autoLoad) loadConfig() diff --git a/web/src/lib/nav-modules.ts b/web/src/lib/nav-modules.ts index 2e8611d2218c..e4a0126520fc 100644 --- a/web/src/lib/nav-modules.ts +++ b/web/src/lib/nav-modules.ts @@ -16,7 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { getStatus } from '@/lib/api' +import type { QueryClient } from '@tanstack/react-query' + +import { ensureStatus, readCachedStatus } from '@/lib/status-query' export type ModuleAccess = { enabled: boolean; requireAuth: boolean } @@ -142,26 +144,6 @@ export function parseHeaderNavModulesFromStatus( return parseHeaderNavModules(status?.HeaderNavModules) } -function getCachedStatus(): Record | null { - try { - if (typeof window === 'undefined') return null - const raw = window.localStorage.getItem('status') - return raw ? (JSON.parse(raw) as Record) : null - } catch { - return null - } -} - -function cacheStatus(status: Record | null): void { - try { - if (typeof window !== 'undefined' && status) { - window.localStorage.setItem('status', JSON.stringify(status)) - } - } catch { - /* empty */ - } -} - export function getModuleAccessFromStatus( status: Record | null, module: HeaderNavModule @@ -170,15 +152,23 @@ export function getModuleAccessFromStatus( } export function getModuleAccess(module: HeaderNavModule): ModuleAccess { - return getModuleAccessFromStatus(getCachedStatus(), module) + return getModuleAccessFromStatus(readCachedStatus(), module) } +/** + * Resolve module access for a router guard. + * + * Goes through the shared `['status']` cache, so a guard on a fresh page load + * reuses the request already started during boot instead of issuing its own. + * Once the cache is warm this resolves without blocking on the network — see + * `ensureStatus` for the exact fresh/stale resolution rules. + */ export async function getFreshModuleAccess( + queryClient: QueryClient, module: HeaderNavModule ): Promise { try { - const status = (await getStatus()) as Record | null - cacheStatus(status) + const status = await ensureStatus(queryClient) return getModuleAccessFromStatus(status, module) } catch { return { enabled: false, requireAuth: true } @@ -189,7 +179,7 @@ export function isSidebarModuleEnabled( section: string, module: string ): boolean { - const status = getCachedStatus() + const status = readCachedStatus() if (!status) return true const raw = status.SidebarModulesAdmin diff --git a/web/src/lib/status-query.ts b/web/src/lib/status-query.ts new file mode 100644 index 000000000000..c36713d60647 --- /dev/null +++ b/web/src/lib/status-query.ts @@ -0,0 +1,177 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { queryOptions, type QueryClient } from '@tanstack/react-query' + +import { getStatus } from '@/lib/api' +import { DEFAULT_SYSTEM_NAME, DEFAULT_LOGO } from '@/lib/constants' +import { + useSystemConfigStore, + type CurrencyConfig, + type CurrencyDisplayType, + type SystemConfig, + DEFAULT_CURRENCY_CONFIG, +} from '@/stores/system-config-store' + +/** + * Single source of truth for `/api/status`. + * + * `/api/status` is entirely global on the backend: every field is read from + * in-memory option maps under a read lock, with no user context and no auth + * middleware on the route. That makes it safe to share one cache entry across + * every consumer — branding, nav module gates, and the setup guard. + * + * Anything that needs status must go through `statusQueryOptions` so React + * Query can dedupe. Calling `getStatus()` directly re-introduces the duplicate + * requests this module exists to collapse. + */ +export const STATUS_QUERY_KEY = ['status'] as const + +export const STATUS_STORAGE_KEY = 'status' + +/** Status payload shape — loose on purpose; the backend map is open-ended. */ +export type StatusData = Record + +function toNumber(value: unknown, fallback: number): number { + if (typeof value === 'number' && !Number.isNaN(value)) return value + if (typeof value === 'string') { + const parsed = Number(value) + if (!Number.isNaN(parsed)) return parsed + } + return fallback +} + +/** + * Map `/api/status` response data to our persisted system config structure + */ +export function mapStatusDataToConfig( + data: StatusData | undefined | null +): Partial { + if (!data) return {} + + const quotaDisplayType = + (data.quota_display_type as CurrencyDisplayType | undefined) ?? + DEFAULT_CURRENCY_CONFIG.quotaDisplayType + + const currency: CurrencyConfig = { + displayInCurrency: + (data.display_in_currency as boolean | undefined) ?? + DEFAULT_CURRENCY_CONFIG.displayInCurrency, + quotaDisplayType, + quotaPerUnit: toNumber( + data.quota_per_unit, + DEFAULT_CURRENCY_CONFIG.quotaPerUnit + ), + usdExchangeRate: toNumber( + data.usd_exchange_rate, + DEFAULT_CURRENCY_CONFIG.usdExchangeRate + ), + customCurrencySymbol: + (data.custom_currency_symbol as string | undefined)?.trim() || + DEFAULT_CURRENCY_CONFIG.customCurrencySymbol, + customCurrencyExchangeRate: toNumber( + data.custom_currency_exchange_rate, + DEFAULT_CURRENCY_CONFIG.customCurrencyExchangeRate + ), + } + + return { + systemName: (data.system_name as string | undefined) || DEFAULT_SYSTEM_NAME, + logo: (data.logo as string | undefined) || DEFAULT_LOGO, + footerHtml: data.footer_html as string | undefined, + demoSiteEnabled: data.demo_site_enabled as boolean | undefined, + displayTokenStatEnabled: data.display_token_stat_enabled as + | boolean + | undefined, + currency, + } +} + +/** Read the last known status from localStorage (survives reload, may be stale). */ +export function readCachedStatus(): StatusData | null { + try { + if (typeof window === 'undefined') return null + const raw = window.localStorage.getItem(STATUS_STORAGE_KEY) + return raw ? (JSON.parse(raw) as StatusData) : null + } catch { + return null + } +} + +function writeCachedStatus(status: StatusData | null): void { + try { + if (typeof window !== 'undefined' && status) { + window.localStorage.setItem(STATUS_STORAGE_KEY, JSON.stringify(status)) + } + } catch { + /* Storage can be unavailable in private mode. */ + } +} + +async function fetchStatus(): Promise { + const status = (await getStatus()) as StatusData | null + + if (status) { + try { + useSystemConfigStore.getState().setConfig(mapStatusDataToConfig(status)) + } catch (err) { + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.warn('[status] Failed to sync status to system config', err) + } + } + writeCachedStatus(status) + } + + return status +} + +export const statusQueryOptions = queryOptions({ + queryKey: STATUS_QUERY_KEY, + queryFn: fetchStatus, + // Data becomes stale after 5 minutes + staleTime: 5 * 60 * 1000, + // Cache expires after 30 minutes + gcTime: 30 * 60 * 1000, +}) + +/** + * Await status from the shared cache. + * + * Use this from router `beforeLoad` guards. Concurrent callers share one + * in-flight request, so the boot path costs a single `/api/status` round trip + * no matter how many guards and components ask for it. + * + * Resolution rules, which are `ensureQueryData`'s and not `staleTime`'s: + * - No cached entry: fetches and awaits the response. + * - Cached entry, fresh: resolves from cache, no network. + * - Cached entry, stale: resolves from cache *immediately* and kicks off a + * background refresh (`revalidateIfStale`). Guards never block on a + * revalidation, so a stale entry can be read once before the refresh lands. + * + * The React Query cache is memory-only (no persister is installed), so a hard + * reload always starts from an empty cache and fetches. + */ +export async function ensureStatus( + queryClient: QueryClient +): Promise { + return queryClient.ensureQueryData({ + ...statusQueryOptions, + revalidateIfStale: true, + }) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index b2cf827c1ece..f97f719be89d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -28,12 +28,12 @@ import { StrictMode } from 'react' import ReactDOM from 'react-dom/client' import { toast } from 'sonner' -import { getStatus } from '@/lib/api' import { installBuildMetadata } from '@/lib/build-metadata' import { applyFaviconToDom } from '@/lib/dom-utils' import '@/lib/dayjs' import { initializeFrontendCache } from '@/lib/frontend-cache' import { handleServerError } from '@/lib/handle-server-error' +import { readCachedStatus, statusQueryOptions } from '@/lib/status-query' import { DirectionProvider } from './context/direction-provider' import { FontProvider } from './context/font-provider' @@ -125,27 +125,18 @@ if (!rootElement) { if (metaTitle) metaTitle.setAttribute('content', name) } // Cache-first - try { - const saved = localStorage.getItem('status') - if (saved) { - const s = JSON.parse(saved) - if (s?.system_name) apply(s.system_name) - if (s?.logo) applyFaviconToDom(s.logo) - } - } catch { - /* empty */ - } - // Background refresh - getStatus() + const cached = readCachedStatus() + if (cached?.system_name) apply(cached.system_name as string) + if (cached?.logo) applyFaviconToDom(cached.logo as string) + + // Background refresh through the shared cache. This primes ['status'] + // before React mounts, so the root guard and every status consumer reuse + // this one request instead of firing their own. `fetchStatus` owns the + // localStorage write and the system-config store sync. + queryClient + .ensureQueryData(statusQueryOptions) .then((s) => { - if (s?.system_name) { - apply(s.system_name as string) - try { - localStorage.setItem('status', JSON.stringify(s)) - } catch { - /* empty */ - } - } + if (s?.system_name) apply(s.system_name as string) if (s?.logo) applyFaviconToDom(s.logo as string) }) .catch(() => { diff --git a/web/src/routes/pricing/$modelId/index.tsx b/web/src/routes/pricing/$modelId/index.tsx index 3eb46a78ea05..7394aa49cb33 100644 --- a/web/src/routes/pricing/$modelId/index.tsx +++ b/web/src/routes/pricing/$modelId/index.tsx @@ -38,8 +38,8 @@ const modelDetailsSearchSchema = z.object({ export const Route = createFileRoute('/pricing/$modelId/')({ validateSearch: modelDetailsSearchSchema, - beforeLoad: async ({ location }) => { - const access = await getFreshModuleAccess('pricing') + beforeLoad: async ({ context, location }) => { + const access = await getFreshModuleAccess(context.queryClient, 'pricing') if (!access.enabled) { throw redirect({ to: '/' }) } diff --git a/web/src/routes/pricing/index.tsx b/web/src/routes/pricing/index.tsx index 3af5e4f91012..997fb296fdf7 100644 --- a/web/src/routes/pricing/index.tsx +++ b/web/src/routes/pricing/index.tsx @@ -38,8 +38,8 @@ const pricingSearchSchema = z.object({ export const Route = createFileRoute('/pricing/')({ validateSearch: pricingSearchSchema, - beforeLoad: async ({ location }) => { - const access = await getFreshModuleAccess('pricing') + beforeLoad: async ({ context, location }) => { + const access = await getFreshModuleAccess(context.queryClient, 'pricing') if (!access.enabled) { throw redirect({ to: '/' }) } diff --git a/web/src/routes/rankings/index.tsx b/web/src/routes/rankings/index.tsx index 4e1ea6d2b6b9..cd6685a9f1cb 100644 --- a/web/src/routes/rankings/index.tsx +++ b/web/src/routes/rankings/index.tsx @@ -32,8 +32,8 @@ const rankingsSearchSchema = z.object({ export const Route = createFileRoute('/rankings/')({ validateSearch: rankingsSearchSchema, - beforeLoad: async ({ location }) => { - const access = await getFreshModuleAccess('rankings') + beforeLoad: async ({ context, location }) => { + const access = await getFreshModuleAccess(context.queryClient, 'rankings') if (!access.enabled) { throw redirect({ to: '/' }) }