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]);
+}