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())); }