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: '/' })
}