diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx index 6bc4fb1f1..23c9c647d 100644 --- a/web/src/components/ErrorBoundary.tsx +++ b/web/src/components/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import { AlertCircle, RefreshCw } from "lucide-react"; import { Component, type ErrorInfo, type ReactNode } from "react"; +import { useRouteError } from "react-router-dom"; import { Button } from "./ui/button"; interface Props { @@ -68,3 +69,35 @@ export class ErrorBoundary extends Component { return this.props.children; } } + +// React Router errorElement for route-level errors (e.g., failed chunk loads after redeployment). +export function ChunkLoadErrorFallback() { + const error = useRouteError() as Error | undefined; + + return ( +
+
+
+ +

Something went wrong

+
+ +

+ An unexpected error occurred. This could be due to a network issue or an application update. Reloading usually fixes it. +

+ + {error?.message && ( +
+ Error details +
{error.message}
+
+ )} + + +
+
+ ); +} diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index c9afba287..0436ec32b 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -24,7 +24,10 @@ const RootLayout = () => { useEffect(() => { if (!currentUser) { - if (pathname === ROUTES.ROOT && !memoRelatedSetting.disallowPublicVisibility) { + if (memoRelatedSetting.disallowPublicVisibility) { + // When public visibility is disallowed, always redirect unauth users to auth. + redirectOnAuthFailure(true); + } else if (pathname === ROUTES.ROOT) { navigateTo(ROUTES.EXPLORE); } else { redirectOnAuthFailure(); diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 99f3a26f9..960631bc3 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -1,23 +1,41 @@ import { lazy } from "react"; import { createBrowserRouter } from "react-router-dom"; import App from "@/App"; +import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary"; import MainLayout from "@/layouts/MainLayout"; import RootLayout from "@/layouts/RootLayout"; import Home from "@/pages/Home"; -const AdminSignIn = lazy(() => import("@/pages/AdminSignIn")); -const Archived = lazy(() => import("@/pages/Archived")); -const AuthCallback = lazy(() => import("@/pages/AuthCallback")); -const Explore = lazy(() => import("@/pages/Explore")); -const Inboxes = lazy(() => import("@/pages/Inboxes")); -const MemoDetail = lazy(() => import("@/pages/MemoDetail")); -const NotFound = lazy(() => import("@/pages/NotFound")); -const PermissionDenied = lazy(() => import("@/pages/PermissionDenied")); -const Attachments = lazy(() => import("@/pages/Attachments")); -const Setting = lazy(() => import("@/pages/Setting")); -const SignIn = lazy(() => import("@/pages/SignIn")); -const SignUp = lazy(() => import("@/pages/SignUp")); -const UserProfile = lazy(() => import("@/pages/UserProfile")); +// Wrap lazy imports to auto-reload on chunk load failure (e.g., after redeployment). +function lazyWithReload(factory: () => Promise<{ default: T }>) { + return lazy(() => + factory().catch((error) => { + const isChunkError = + error?.message?.includes("Failed to fetch dynamically imported module") || + error?.message?.includes("Importing a module script failed"); + const reloadKey = "chunk-reload"; + if (isChunkError && !sessionStorage.getItem(reloadKey)) { + sessionStorage.setItem(reloadKey, "1"); + window.location.reload(); + } + throw error; + }), + ); +} + +const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn")); +const Archived = lazyWithReload(() => import("@/pages/Archived")); +const AuthCallback = lazyWithReload(() => import("@/pages/AuthCallback")); +const Explore = lazyWithReload(() => import("@/pages/Explore")); +const Inboxes = lazyWithReload(() => import("@/pages/Inboxes")); +const MemoDetail = lazyWithReload(() => import("@/pages/MemoDetail")); +const NotFound = lazyWithReload(() => import("@/pages/NotFound")); +const PermissionDenied = lazyWithReload(() => import("@/pages/PermissionDenied")); +const Attachments = lazyWithReload(() => import("@/pages/Attachments")); +const Setting = lazyWithReload(() => import("@/pages/Setting")); +const SignIn = lazyWithReload(() => import("@/pages/SignIn")); +const SignUp = lazyWithReload(() => import("@/pages/SignUp")); +const UserProfile = lazyWithReload(() => import("@/pages/UserProfile")); import { ROUTES } from "./routes"; @@ -29,6 +47,7 @@ const router = createBrowserRouter([ { path: "/", element: , + errorElement: , children: [ { path: Routes.AUTH, diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts index a82e981e4..7f53c4111 100644 --- a/web/src/utils/auth-redirect.ts +++ b/web/src/utils/auth-redirect.ts @@ -8,28 +8,23 @@ const PUBLIC_ROUTES = [ "/memos/", // Individual memo detail pages (dynamic) ] as const; -const PRIVATE_ROUTES = [ROUTES.ROOT, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.ARCHIVED, ROUTES.SETTING] as const; - function isPublicRoute(path: string): boolean { return PUBLIC_ROUTES.some((route) => path.startsWith(route)); } -function isPrivateRoute(path: string): boolean { - return PRIVATE_ROUTES.includes(path as (typeof PRIVATE_ROUTES)[number]); -} - -export function redirectOnAuthFailure(): void { +export function redirectOnAuthFailure(forceRedirect = false): void { const currentPath = window.location.pathname; - // Don't redirect if it's a public route - if (isPublicRoute(currentPath)) { + // Already on auth page, nothing to do. + if (currentPath.startsWith(ROUTES.AUTH)) { return; } - // Always redirect to auth page on auth failure - the user's session expired - // and they should re-authenticate rather than being sent to explore. - if (isPrivateRoute(currentPath)) { - clearAccessToken(); - window.location.replace(ROUTES.AUTH); + // Don't redirect if it's a public route (unless forced, e.g. public visibility is disallowed). + if (!forceRedirect && isPublicRoute(currentPath)) { + return; } + + clearAccessToken(); + window.location.replace(ROUTES.AUTH); }