diff --git a/surfsense_web/app/(home)/layout.tsx b/surfsense_web/app/(home)/layout.tsx index 9488ee875..f1ceffac0 100644 --- a/surfsense_web/app/(home)/layout.tsx +++ b/surfsense_web/app/(home)/layout.tsx @@ -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 (
{children} - + {!isAuthPage && }
); } diff --git a/surfsense_web/app/(home)/login/page.tsx b/surfsense_web/app/(home)/login/page.tsx index 0026fdcd0..0dc9c445f 100644 --- a/surfsense_web/app/(home)/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -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 ; + 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 ( - }> + ); diff --git a/surfsense_web/app/auth/callback/loading.tsx b/surfsense_web/app/auth/callback/loading.tsx index 676ca3632..0c94e1ee0 100644 --- a/surfsense_web/app/auth/callback/loading.tsx +++ b/surfsense_web/app/auth/callback/loading.tsx @@ -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 ( -
-
-
- -
- - {t("processing_authentication")} - -
-
- ); -} + // 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; +} diff --git a/surfsense_web/app/auth/callback/page.tsx b/surfsense_web/app/auth/callback/page.tsx index 920581654..4050eefb6 100644 --- a/surfsense_web/app/auth/callback/page.tsx +++ b/surfsense_web/app/auth/callback/page.tsx @@ -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 ( - }> + - ); + 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) { diff --git a/surfsense_web/app/dashboard/layout.tsx b/surfsense_web/app/dashboard/layout.tsx index 8ffef229c..6a548ea51 100644 --- a/surfsense_web/app/dashboard/layout.tsx +++ b/surfsense_web/app/dashboard/layout.tsx @@ -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 ; + return null; } return ( diff --git a/surfsense_web/app/dashboard/loading.tsx b/surfsense_web/app/dashboard/loading.tsx index b18c5dd75..2eee93651 100644 --- a/surfsense_web/app/dashboard/loading.tsx +++ b/surfsense_web/app/dashboard/loading.tsx @@ -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 ( -
-
-
- -
- - {t("loading")} - -
-
- ); -} + // 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; +} diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index 440f37123..504d172c3 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -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 ; + // 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 ; - if (searchSpaces.length > 0) { - return ; + if (shouldShowLoading) { + return null; } return ( diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index 2f9921629..3722dc26e 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -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({ > - {children} + + {children} + diff --git a/surfsense_web/atoms/ui/loading.atoms.ts b/surfsense_web/atoms/ui/loading.atoms.ts new file mode 100644 index 000000000..6c6cac1a2 --- /dev/null +++ b/surfsense_web/atoms/ui/loading.atoms.ts @@ -0,0 +1,31 @@ +import { atom } from "jotai"; + +interface GlobalLoadingState { + isLoading: boolean; + message?: string; + variant: "login" | "default"; +} + +export const globalLoadingAtom = atom({ + 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" }); +}); + diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx index 3f98451ef..35408c1b2 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -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 ( - - ); + // Return null - the global provider handles the loading UI + return null; }; export default TokenHandler; diff --git a/surfsense_web/components/providers/ElectricProvider.tsx b/surfsense_web/components/providers/ElectricProvider.tsx index 68263b036..07d736c64 100644 --- a/surfsense_web/components/providers/ElectricProvider.tsx +++ b/surfsense_web/components/providers/ElectricProvider.tsx @@ -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 {children}; } - // 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 ( - - - - ); + return {children}; } // If there's an error, still render but warn diff --git a/surfsense_web/components/providers/GlobalLoadingProvider.tsx b/surfsense_web/components/providers/GlobalLoadingProvider.tsx new file mode 100644 index 000000000..fe816b316 --- /dev/null +++ b/surfsense_web/components/providers/GlobalLoadingProvider.tsx @@ -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 = ( +
+ {variant === "login" ? ( +
+ +
+ +
+
+ {/* Spinner is always mounted, animation never resets */} + +
+ + {message} + +
+
+
+ ) : ( +
+
+
+ {/* Spinner is always mounted, animation never resets */} + +
+ + {message} + +
+
+ )} +
+ ); + + // 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} + + ); +} + diff --git a/surfsense_web/components/ui/unified-loading-screen.tsx b/surfsense_web/components/ui/unified-loading-screen.tsx deleted file mode 100644 index 7ed6b83d9..000000000 --- a/surfsense_web/components/ui/unified-loading-screen.tsx +++ /dev/null @@ -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 = ( -
- -
- ); - - const content = variant === "login" ? ( -
- -
- -
- {spinnerContainer} - {message && ( - - {message} - - )} -
-
-
- ) : ( -
-
- {spinnerContainer} - {message && ( - - {message} - - )} -
-
- ); - - // 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); -} - diff --git a/surfsense_web/hooks/use-global-loading.ts b/surfsense_web/hooks/use-global-loading.ts new file mode 100644 index 000000000..baaa1f089 --- /dev/null +++ b/surfsense_web/hooks/use-global-loading.ts @@ -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 | 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(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]); +}