feat: replace unified loading approach with global loading provider and refactor loading handling across components

- Introduced GlobalLoadingProvider to manage a consistent loading overlay across the application.
- Replaced existing loading implementations with useGlobalLoadingEffect hook for better control and to prevent animation resets.
- Updated components such as LoginPage, AuthCallbackPage, and DashboardLayout to utilize the new global loading mechanism.
- Removed UnifiedLoadingScreen component to streamline loading management and enhance user experience.
This commit is contained in:
Anish Sarkar 2026-01-25 16:15:25 +05:30
parent 2d17d1a1b6
commit 66a3c877ef
15 changed files with 294 additions and 140 deletions

View file

@ -1,14 +1,18 @@
"use client";
import { usePathname } from "next/navigation";
import { FooterNew } from "@/components/homepage/footer-new";
import { Navbar } from "@/components/homepage/navbar";
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isAuthPage = pathname === "/login" || pathname === "/register";
return (
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
<Navbar />
{children}
<FooterNew />
{!isAuthPage && <FooterNew />}
</main>
);
}

View file

@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/Logo";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config";
import { AmbientBackground } from "./AmbientBackground";
@ -92,9 +92,12 @@ function LoginContent() {
setIsLoading(false);
}, [searchParams, t, tCommon]);
// Show loading state while determining auth type
// Use global loading screen for auth type determination - spinner animation won't reset
useGlobalLoadingEffect(isLoading, tCommon("loading"), "login");
// Show nothing while loading - the GlobalLoadingProvider handles the loading UI
if (isLoading) {
return <UnifiedLoadingScreen variant="login" message={tCommon("loading")} />;
return null;
}
if (authType === "GOOGLE") {
@ -176,8 +179,9 @@ function LoginContent() {
}
export default function LoginPage() {
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
return (
<Suspense fallback={<UnifiedLoadingScreen variant="login" message="Loading" />}>
<Suspense fallback={null}>
<LoginContent />
</Suspense>
);

View file

@ -1,21 +1,14 @@
"use client";
import { useTranslations } from "next-intl";
import { Spinner } from "@/components/ui/spinner";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export default function AuthCallbackLoading() {
const t = useTranslations("auth");
return (
<div className="fixed inset-0 z-[9999] flex min-h-screen flex-col items-center justify-center bg-background">
<div className="flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
<Spinner size="xl" className="text-primary" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
{t("processing_authentication")}
</span>
</div>
</div>
);
}
// Use global loading - spinner animation won't reset when page transitions
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
// Return null - the GlobalLoadingProvider handles the loading UI
return null;
}

View file

@ -1,15 +1,13 @@
"use client";
import { Suspense } from "react";
import { useTranslations } from "next-intl";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import TokenHandler from "@/components/TokenHandler";
export default function AuthCallbackPage() {
const t = useTranslations("auth");
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
// TokenHandler uses useGlobalLoadingEffect to show the loading screen
return (
<Suspense fallback={<UnifiedLoadingScreen variant="default" message={t("processing_authentication")} />}>
<Suspense fallback={null}>
<TokenHandler
redirectPath="/dashboard"
tokenParamName="token"

View file

@ -18,7 +18,7 @@ import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export function DashboardClientLayout({
children,
@ -146,18 +146,22 @@ export function DashboardClientLayout({
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
}, [search_space_id, setActiveSearchSpaceIdState]);
if (
(!hasCheckedOnboarding &&
// Determine if we should show loading
const shouldShowLoading =
((!hasCheckedOnboarding &&
(loading || accessLoading || globalConfigsLoading) &&
!isOnboardingPage) ||
isAutoConfiguring
) {
return (
<UnifiedLoadingScreen
variant="default"
message={isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs")}
/>
);
isAutoConfiguring);
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(
shouldShowLoading,
isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs"),
"default"
);
if (shouldShowLoading) {
return null;
}
if (error && !hasCheckedOnboarding && !isOnboardingPage) {

View file

@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
interface DashboardLayoutProps {
@ -13,6 +13,9 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
const t = useTranslations("dashboard");
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
// Use the global loading screen - spinner animation won't reset
useGlobalLoadingEffect(isCheckingAuth, t("checking_auth"), "default");
useEffect(() => {
// Check if user is authenticated
const token = getBearerToken();
@ -24,9 +27,9 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
setIsCheckingAuth(false);
}, []);
// Show loading screen while checking authentication
// Return null while loading - the global provider handles the loading UI
if (isCheckingAuth) {
return <UnifiedLoadingScreen variant="default" message={t("checking_auth")} />;
return null;
}
return (

View file

@ -1,21 +1,14 @@
"use client";
import { useTranslations } from "next-intl";
import { Spinner } from "@/components/ui/spinner";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export default function DashboardLoading() {
const t = useTranslations("common");
return (
<div className="fixed inset-0 z-[9999] flex min-h-screen flex-col items-center justify-center bg-background">
<div className="flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
<Spinner size="xl" className="text-primary" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
{t("loading")}
</span>
</div>
</div>
);
}
// Use global loading - spinner animation won't reset when page transitions
useGlobalLoadingEffect(true, t("loading"), "default");
// Return null - the GlobalLoadingProvider handles the loading UI
return null;
}

View file

@ -18,7 +18,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
function ErrorScreen({ message }: { message: string }) {
const t = useTranslations("dashboard");
@ -102,11 +102,16 @@ export default function DashboardPage() {
}
}, [isLoading, searchSpaces, router]);
if (isLoading) return <UnifiedLoadingScreen variant="default" message={t("fetching_spaces")} />;
// Show loading while fetching or while we have spaces and are about to redirect
const shouldShowLoading = isLoading || searchSpaces.length > 0;
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(shouldShowLoading, t("fetching_spaces"), "default");
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
if (searchSpaces.length > 0) {
return <UnifiedLoadingScreen variant="default" message={t("fetching_spaces")} />;
if (shouldShowLoading) {
return null;
}
return (

View file

@ -3,6 +3,7 @@ import "./globals.css";
import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google";
import { ElectricProvider } from "@/components/providers/ElectricProvider";
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
import { I18nProvider } from "@/components/providers/I18nProvider";
import { PostHogProvider } from "@/components/providers/PostHogProvider";
import { ThemeProvider } from "@/components/theme/theme-provider";
@ -104,7 +105,9 @@ export default function RootLayout({
>
<RootProvider>
<ReactQueryClientProvider>
<ElectricProvider>{children}</ElectricProvider>
<ElectricProvider>
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
</ElectricProvider>
</ReactQueryClientProvider>
<Toaster />
</RootProvider>

View file

@ -0,0 +1,31 @@
import { atom } from "jotai";
interface GlobalLoadingState {
isLoading: boolean;
message?: string;
variant: "login" | "default";
}
export const globalLoadingAtom = atom<GlobalLoadingState>({
isLoading: false,
message: undefined,
variant: "default",
});
// Helper atom for showing global loading
export const showGlobalLoadingAtom = atom(
null,
(
get,
set,
{ message, variant = "default" }: { message?: string; variant?: "login" | "default" }
) => {
set(globalLoadingAtom, { isLoading: true, message, variant });
}
);
// Helper atom for hiding global loading
export const hideGlobalLoadingAtom = atom(null, (get, set) => {
set(globalLoadingAtom, { isLoading: false, message: undefined, variant: "default" });
});

View file

@ -3,7 +3,7 @@
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
import { trackLoginSuccess } from "@/lib/posthog/events";
@ -30,6 +30,9 @@ const TokenHandler = ({
const t = useTranslations("auth");
const searchParams = useSearchParams();
// Always show loading for this component - spinner animation won't reset
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
useEffect(() => {
// Only run on client-side
if (typeof window === "undefined") return;
@ -69,9 +72,8 @@ const TokenHandler = ({
}
}, [searchParams, tokenParamName, storageKey, redirectPath]);
return (
<UnifiedLoadingScreen variant="default" message={t("processing_authentication")} />
);
// Return null - the global provider handles the loading UI
return null;
};
export default TokenHandler;

View file

@ -4,7 +4,7 @@ import { useAtomValue } from "jotai";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken } from "@/lib/auth-utils";
import {
cleanupElectric,
@ -113,19 +113,21 @@ export function ElectricProvider({ children }: ElectricProviderProps) {
// This prevents showing loading screen for unauthenticated users on homepage
const hasToken = typeof window !== "undefined" && !!getBearerToken();
// Determine if we should show loading
const shouldShowLoading = hasToken && isUserLoaded && !!user?.id && !electricClient && !error;
// Use global loading hook with ownership tracking - prevents flash during transitions
useGlobalLoadingEffect(shouldShowLoading, t("initializing"), "default");
// For non-authenticated pages (like landing page), render immediately with null context
// Also render immediately if user query failed (e.g., token expired)
if (!hasToken || !isUserLoaded || !user?.id || isUserError) {
return <ElectricContext.Provider value={null}>{children}</ElectricContext.Provider>;
}
// Show loading state while initializing for authenticated users
// Return children with null context while initializing - the global provider handles the loading UI
if (!electricClient && !error) {
return (
<ElectricContext.Provider value={null}>
<UnifiedLoadingScreen variant="default" message={t("initializing")} />
</ElectricContext.Provider>
);
return <ElectricContext.Provider value={null}>{children}</ElectricContext.Provider>;
}
// If there's an error, still render but warn

View file

@ -0,0 +1,80 @@
"use client";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
import { cn } from "@/lib/utils";
/**
* GlobalLoadingProvider renders a persistent loading overlay.
* The spinner is ALWAYS in the DOM to prevent animation reset when
* loading states change between different pages/components.
*
* Visibility is controlled via CSS opacity/pointer-events, NOT mounting/unmounting.
*/
export function GlobalLoadingProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
const { isLoading, message, variant } = useAtomValue(globalLoadingAtom);
useEffect(() => {
setMounted(true);
}, []);
// The overlay is ALWAYS rendered, but visibility is controlled by CSS
// This prevents the spinner animation from resetting
const loadingOverlay = (
<div
className={cn(
"fixed inset-0 z-[9999]",
isLoading
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none transition-opacity duration-150"
)}
aria-hidden={!isLoading}
>
{variant === "login" ? (
<div className="relative w-full h-full overflow-hidden bg-background">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
{/* Spinner is always mounted, animation never resets */}
<Spinner size="lg" className="text-muted-foreground" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-xs">
{message}
</span>
</div>
</div>
</div>
) : (
<div className="flex min-h-screen flex-col items-center justify-center bg-background">
<div className="flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
{/* Spinner is always mounted, animation never resets */}
<Spinner size="xl" className="text-primary" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
{message}
</span>
</div>
</div>
)}
</div>
);
// Render inline during SSR/before hydration, use portal after mounting
// This prevents the white flash during initial render
return (
<>
{children}
{mounted ? createPortal(loadingOverlay, document.body) : loadingOverlay}
</>
);
}

View file

@ -1,72 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
interface UnifiedLoadingScreenProps {
/** Optional message to display below the spinner */
message?: string;
/** Visual style variant */
variant?: "login" | "default";
}
export function UnifiedLoadingScreen({
message,
variant = "default",
}: UnifiedLoadingScreenProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Fixed-size container to prevent layout shifts
const spinnerContainer = (
<div className="h-12 w-12 flex items-center justify-center">
<Spinner
size={variant === "login" ? "lg" : "xl"}
className={variant === "login" ? "text-muted-foreground" : "text-primary"}
/>
</div>
);
const content = variant === "login" ? (
<div className="fixed inset-0 z-[9999] relative w-full overflow-hidden bg-background">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex flex-col items-center space-y-4">
{spinnerContainer}
{message && (
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-xs">
{message}
</span>
)}
</div>
</div>
</div>
) : (
<div className="fixed inset-0 z-[9999] flex min-h-screen flex-col items-center justify-center bg-background">
<div className="flex flex-col items-center space-y-4">
{spinnerContainer}
{message && (
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
{message}
</span>
)}
</div>
</div>
);
// Render inline during SSR, use portal after mounting
// This prevents the black flash during initial render
if (!mounted) {
return content;
}
return createPortal(content, document.body);
}

View file

@ -0,0 +1,104 @@
"use client";
import { useAtom } from "jotai";
import { useCallback, useEffect, useRef } from "react";
import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
// Global counter to generate unique IDs for each loading request
let loadingIdCounter = 0;
// Track the current active loading ID globally
let currentLoadingId: number | null = null;
// Pending hide timeout - allows new loading requests to take over before hiding
let pendingHideTimeout: ReturnType<typeof setTimeout> | null = null;
/**
* Hook to control the global loading screen.
* The spinner is always mounted in the DOM to prevent animation reset.
*/
export function useGlobalLoading() {
const [loading, setLoading] = useAtom(globalLoadingAtom);
const show = useCallback(
(message?: string, variant: "login" | "default" = "default") => {
// Cancel any pending hide - new loading request takes over
if (pendingHideTimeout) {
clearTimeout(pendingHideTimeout);
pendingHideTimeout = null;
}
const id = ++loadingIdCounter;
currentLoadingId = id;
setLoading({ isLoading: true, message, variant });
return id;
},
[setLoading]
);
const hide = useCallback(
(id?: number) => {
// Only hide if this is the current loading, or if no ID provided (force hide)
if (id === undefined || id === currentLoadingId) {
// Use a small delay to allow React to flush pending mounts
// This prevents flash when transitioning between loading states
if (pendingHideTimeout) {
clearTimeout(pendingHideTimeout);
}
pendingHideTimeout = setTimeout(() => {
// Double-check we're still the current loading after the delay
if (id === undefined || id === currentLoadingId) {
currentLoadingId = null;
setLoading({ isLoading: false, message: undefined, variant: "default" });
}
pendingHideTimeout = null;
}, 50); // Small delay to allow next component to mount and show loading
}
},
[setLoading]
);
return { show, hide, isLoading: loading.isLoading };
}
/**
* Hook that automatically shows/hides the global loading screen based on a condition.
* Useful for components that show loading on mount and hide on unmount.
*
* Uses ownership tracking to prevent flashes when multiple components
* transition loading states (e.g., layout page).
*
* @param shouldShow - Whether the loading screen should be visible
* @param message - Optional message to display
* @param variant - Visual style variant ("login" or "default")
*/
export function useGlobalLoadingEffect(
shouldShow: boolean,
message?: string,
variant: "login" | "default" = "default"
) {
const { show, hide } = useGlobalLoading();
const loadingIdRef = useRef<number | null>(null);
useEffect(() => {
if (shouldShow) {
// Show loading and store the ID
loadingIdRef.current = show(message, variant);
} else if (loadingIdRef.current !== null) {
// Only hide if we were the ones showing loading
hide(loadingIdRef.current);
loadingIdRef.current = null;
}
}, [shouldShow, message, variant, show, hide]);
// Cleanup on unmount - only hide if we're still the active loading
useEffect(() => {
return () => {
if (loadingIdRef.current !== null) {
hide(loadingIdRef.current);
loadingIdRef.current = null;
}
};
}, [hide]);
}