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 <noreply@anthropic.com>
This commit is contained in:
willchen96 2026-07-25 23:58:58 +08:00
parent 62da94be67
commit cb2306c5d6
4 changed files with 23 additions and 26 deletions

View file

@ -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 (
<div className="flex h-screen items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-700" />
</div>
);
return <FullScreenLoader />;
}
if (!isAuthenticated) return null;

View file

@ -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 (
<AuthProvider>
<UserProfileProvider>
<Suspense fallback={<ProviderLoader />}>
<Suspense fallback={<FullScreenLoader />}>
<MfaLoginGate>{children}</MfaLoginGate>
</Suspense>
</UserProfileProvider>
</AuthProvider>
);
}
function ProviderLoader() {
return (
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
</div>
);
}

View file

@ -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 (
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
</div>
);
}

View file

@ -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}</>
) : (
<FullScreenGateLoader />
<FullScreenLoader />
);
}
@ -98,15 +99,15 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {
return <>{children}</>;
}
if (gateState === "verified" && isVerifyPage) {
return <FullScreenGateLoader />;
return <FullScreenLoader />;
}
if (gateState === "verified") {
return <>{children}</>;
}
if (gateState === "required" && !isVerifyPage) {
return <FullScreenGateLoader />;
return <FullScreenLoader />;
}
return <FullScreenGateLoader />;
return <FullScreenLoader />;
}
return <>{children}</>;
@ -120,14 +121,6 @@ function safeNextPath(value: string | null) {
return value;
}
function FullScreenGateLoader() {
return (
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
</div>
);
}
export function markMfaVerifiedForGate() {
window.sessionStorage.setItem(MFA_VERIFIED_AT_KEY, String(Date.now()));
}