From ad8577e2bde3946a886d2db64421867a671e7755 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 19 Apr 2026 09:41:35 +0700 Subject: [PATCH] fix(web): stop flooding console with 401 ApiError during initial load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five compounding problems caused hundreds of "Console ApiError: Unauthorized" entries on every load of /dashboard (and friends) while unauthenticated or while the auth cookie was stale: 1. QueryClient had `throwOnError: true` as a blanket default, so every 401 from any react-query hook propagated to the nearest error boundary instead of staying in the query's `error` state. That also invited React to re-render and re-fire the boundary multiple times per failing query. 2. React Query retried all failures 3 times with exponential backoff, so a single 401 became four requests. 401 isn't fixable by retry, so this is just noise. 3. Dashboard layout rendered `` unconditionally, which polled /notifications/unread-count on mount even when no user was signed in → 401 on every mount. 4. Dashboard + Admin layouts had no redirect-to-login guard, so protected queries (market-report, heatmap, admin/dashboard, …) all mounted and fired against the API before the user ever saw the login screen. 5. Admin layout waited on `user` but had no way to distinguish "store still initialising" from "user genuinely absent" — so an expired cookie left the page stuck on a spinner while the same 401 storm played out in the background. Fixes - query-client.ts: `throwOnError` and `retry` are now predicates. Only 5xx / network errors bubble to boundaries and are retried; 4xx (auth, validation, not-found) stay in query error state so the component can render an empty/auth placeholder. - auth-store.ts: new `isInitialized` flag set in a finally block at the end of `initialize()`. Downstream guards use it to distinguish "still booting" from "definitely logged out". - (dashboard)/layout.tsx: redirects to /login?next= once initialised and unauthenticated, and renders a lightweight loading screen in the meantime so child queries never mount. - (admin)/layout.tsx: same guard. Non-ADMIN logged-in users still bounce to /dashboard. - notification-bell.tsx: short-circuits `fetchUnreadCount` when `isAuthenticated` is false. Verified in dev: visiting /vi/dashboard unauthenticated now redirects to /login?redirect=/dashboard with zero console errors and no /analytics/… calls to the backend. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/app/[locale]/(admin)/layout.tsx | 14 ++++++-- apps/web/app/[locale]/(dashboard)/layout.tsx | 30 ++++++++++++++-- .../notifications/notification-bell.tsx | 8 +++-- apps/web/lib/auth-store.ts | 13 +++++-- apps/web/lib/query-client.ts | 36 +++++++++++++++++-- apps/web/tsconfig.tsbuildinfo | 2 +- 6 files changed, 89 insertions(+), 14 deletions(-) diff --git a/apps/web/app/[locale]/(admin)/layout.tsx b/apps/web/app/[locale]/(admin)/layout.tsx index 478a832..a1e0785 100644 --- a/apps/web/app/[locale]/(admin)/layout.tsx +++ b/apps/web/app/[locale]/(admin)/layout.tsx @@ -21,7 +21,7 @@ import { cn } from '@/lib/utils'; export default function AdminLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const router = useRouter(); - const { user, logout } = useAuthStore(); + const { user, isAuthenticated, isInitialized, logout } = useAuthStore(); const [sidebarOpen, setSidebarOpen] = useState(false); const t = useTranslations(); @@ -33,12 +33,20 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) ]; useEffect(() => { + // Once the auth store finished its initial cookie→profile probe: + // - no session → push to /login (don't leave a spinner forever) + // - authenticated but not ADMIN → push to regular dashboard + if (!isInitialized) return; + if (!isAuthenticated) { + router.replace(`/login?next=${encodeURIComponent(pathname)}`); + return; + } if (user && user.role !== 'ADMIN') { router.replace('/dashboard'); } - }, [user, router]); + }, [isInitialized, isAuthenticated, user, router, pathname]); - if (!user) { + if (!isInitialized || !user) { return (
{t('common.loading')}
diff --git a/apps/web/app/[locale]/(dashboard)/layout.tsx b/apps/web/app/[locale]/(dashboard)/layout.tsx index 26e5be3..65b14a9 100644 --- a/apps/web/app/[locale]/(dashboard)/layout.tsx +++ b/apps/web/app/[locale]/(dashboard)/layout.tsx @@ -22,7 +22,8 @@ import { } from 'lucide-react'; import { usePathname } from 'next/navigation'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useRouter } from '@/i18n/navigation'; import { NotificationBell } from '@/components/notifications/notification-bell'; import { useTheme } from '@/components/providers/theme-provider'; import { Button } from '@/components/ui/button'; @@ -44,11 +45,34 @@ interface NavGroup { export default function DashboardLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); - const { user, logout } = useAuthStore(); + const router = useRouter(); + const { user, isAuthenticated, isInitialized, logout } = useAuthStore(); const { theme, toggleTheme } = useTheme(); const t = useTranslations(); const [sidebarOpen, setSidebarOpen] = useState(false); + // Auth guard — redirect unauthenticated users to /login once the auth store + // has finished its cookie→profile probe. Without this, protected queries + // inside the dashboard fire against the API and flood the console with + // 401 ApiErrors before the user even sees the sign-in screen. + useEffect(() => { + if (isInitialized && !isAuthenticated) { + const next = encodeURIComponent(pathname); + router.replace(`/login?next=${next}`); + } + }, [isInitialized, isAuthenticated, pathname, router]); + + // While the auth store initialises, OR right after we've decided to redirect, + // render a lightweight skeleton rather than the full dashboard so no queries + // mount and fire. + if (!isInitialized || !isAuthenticated) { + return ( +
+ {t('common.loading')} +
+ ); + } + const navGroups: NavGroup[] = [ { label: t('dashboard.title'), @@ -251,7 +275,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod {user.fullName} )} - + {user && }