From cb2306c5d6ba4f821bcd05ac3920f0e3e22c1961 Mon Sep 17 00:00:00 2001 From: willchen96 Date: Sat, 25 Jul 2026 23:58:58 +0800 Subject: [PATCH] fix(frontend): unify full-screen loaders to remove hydration mismatch The provider Suspense fallback, the MFA gate loader, and the (pages) layout auth loader were three near-identical spinners with different Tailwind classes. Server and client can resolve those gates differently on first paint (auth settles from localStorage before the boundary hydrates), so the differing attributes surfaced as a hydration mismatch on nearly every dev page load. One shared FullScreenLoader makes every branch render byte-identical DOM. Co-Authored-By: Claude Fable 5 --- frontend/src/app/(pages)/layout.tsx | 7 ++----- frontend/src/app/components/providers.tsx | 11 ++--------- .../app/components/shared/FullScreenLoader.tsx | 14 ++++++++++++++ .../src/app/components/shared/MfaLoginGate.tsx | 17 +++++------------ 4 files changed, 23 insertions(+), 26 deletions(-) create mode 100644 frontend/src/app/components/shared/FullScreenLoader.tsx diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index 107c5144..ffd5066d 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -8,6 +8,7 @@ import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext"; import { SidebarContext } from "@/app/contexts/SidebarContext"; import { PageChromeContext } from "@/app/contexts/PageChromeContext"; import { AppSidebar } from "@/app/components/shared/AppSidebar"; +import { FullScreenLoader } from "@/app/components/shared/FullScreenLoader"; export default function MikeLayout({ children, @@ -75,11 +76,7 @@ export default function MikeLayout({ }, [authLoading, isAuthenticated, router]); if (authLoading) { - return ( -
-
-
- ); + return ; } if (!isAuthenticated) return null; diff --git a/frontend/src/app/components/providers.tsx b/frontend/src/app/components/providers.tsx index 039934b7..c5ef4442 100644 --- a/frontend/src/app/components/providers.tsx +++ b/frontend/src/app/components/providers.tsx @@ -4,23 +4,16 @@ import { Suspense } from "react"; import { AuthProvider } from "@/app/contexts/AuthContext"; import { UserProfileProvider } from "@/app/contexts/UserProfileContext"; import { MfaLoginGate } from "@/app/components/shared/MfaLoginGate"; +import { FullScreenLoader } from "@/app/components/shared/FullScreenLoader"; export function Providers({ children }: { children: React.ReactNode }) { return ( - }> + }> {children} ); } - -function ProviderLoader() { - return ( -
-
-
- ); -} diff --git a/frontend/src/app/components/shared/FullScreenLoader.tsx b/frontend/src/app/components/shared/FullScreenLoader.tsx new file mode 100644 index 00000000..6b8b10d9 --- /dev/null +++ b/frontend/src/app/components/shared/FullScreenLoader.tsx @@ -0,0 +1,14 @@ +/** + * The single full-screen loading state. Every gate in the provider/layout + * chain (Suspense fallback, MFA gate, auth-loading layout) must render this + * exact markup: the server and client can resolve those gates differently on + * first paint, and identical DOM is what keeps that from being a hydration + * mismatch. + */ +export function FullScreenLoader() { + return ( +
+
+
+ ); +} diff --git a/frontend/src/app/components/shared/MfaLoginGate.tsx b/frontend/src/app/components/shared/MfaLoginGate.tsx index bf0f367a..e3236264 100644 --- a/frontend/src/app/components/shared/MfaLoginGate.tsx +++ b/frontend/src/app/components/shared/MfaLoginGate.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/app/contexts/AuthContext"; import { useUserProfile } from "@/app/contexts/UserProfileContext"; +import { FullScreenLoader } from "@/app/components/shared/FullScreenLoader"; import { needsMfaVerification } from "../popups/MfaVerificationPopup"; type GateState = "idle" | "checking" | "required" | "verified"; @@ -89,7 +90,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) { return gateState === "verified" ? ( <>{children} ) : ( - + ); } @@ -98,15 +99,15 @@ export function MfaLoginGate({ children }: { children: ReactNode }) { return <>{children}; } if (gateState === "verified" && isVerifyPage) { - return ; + return ; } if (gateState === "verified") { return <>{children}; } if (gateState === "required" && !isVerifyPage) { - return ; + return ; } - return ; + return ; } return <>{children}; @@ -120,14 +121,6 @@ function safeNextPath(value: string | null) { return value; } -function FullScreenGateLoader() { - return ( -
-
-
- ); -} - export function markMfaVerifiedForGate() { window.sessionStorage.setItem(MFA_VERIFIED_AT_KEY, String(Date.now())); }