mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
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:
parent
2d17d1a1b6
commit
66a3c877ef
15 changed files with 294 additions and 140 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
31
surfsense_web/atoms/ui/loading.atoms.ts
Normal file
31
surfsense_web/atoms/ui/loading.atoms.ts
Normal 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" });
|
||||
});
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
80
surfsense_web/components/providers/GlobalLoadingProvider.tsx
Normal file
80
surfsense_web/components/providers/GlobalLoadingProvider.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
104
surfsense_web/hooks/use-global-loading.ts
Normal file
104
surfsense_web/hooks/use-global-loading.ts
Normal 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]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue