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

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