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