From 32f84f8f1c46d0eaedf30ac229af4b321604b1b5 Mon Sep 17 00:00:00 2001 From: viettranx Date: Tue, 17 Mar 2026 12:20:14 +0700 Subject: [PATCH] fix(ui): prevent white screen on navigation with ErrorBoundary + chunk retry - Add ErrorBoundary wrapping routes (outer) and Outlet (inner with key reset) - Replace lazy() with lazyWithRetry() to retry failed chunk loads + auto-reload - Invalidate all queries on WS reconnect to prevent stale/empty data renders --- ui/web/src/components/layout/app-layout.tsx | 8 ++- ui/web/src/hooks/use-query-invalidation.ts | 18 +++++- ui/web/src/lib/lazy-with-retry.ts | 31 ++++++++++ ui/web/src/routes.tsx | 64 +++++++++++---------- 4 files changed, 88 insertions(+), 33 deletions(-) create mode 100644 ui/web/src/lib/lazy-with-retry.ts diff --git a/ui/web/src/components/layout/app-layout.tsx b/ui/web/src/components/layout/app-layout.tsx index 07437c24..6a144484 100644 --- a/ui/web/src/components/layout/app-layout.tsx +++ b/ui/web/src/components/layout/app-layout.tsx @@ -1,8 +1,9 @@ -import { Outlet } from "react-router"; +import { Outlet, useLocation } from "react-router"; import { WifiOff } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Sidebar } from "./sidebar"; import { Topbar } from "./topbar"; +import { ErrorBoundary } from "@/components/shared/error-boundary"; import { useUiStore } from "@/stores/use-ui-store"; import { useAuthStore } from "@/stores/use-auth-store"; import { useIsMobile } from "@/hooks/use-media-query"; @@ -10,6 +11,7 @@ import { cn } from "@/lib/utils"; export function AppLayout() { const { t } = useTranslation("common"); + const location = useLocation(); const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed); const mobileSidebarOpen = useUiStore((s) => s.mobileSidebarOpen); const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen); @@ -49,7 +51,9 @@ export function AppLayout() { )}
- + + +
diff --git a/ui/web/src/hooks/use-query-invalidation.ts b/ui/web/src/hooks/use-query-invalidation.ts index c19f52db..2bccc64b 100644 --- a/ui/web/src/hooks/use-query-invalidation.ts +++ b/ui/web/src/hooks/use-query-invalidation.ts @@ -1,6 +1,7 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useWsEvent } from "./use-ws-event"; +import { useAuthStore } from "@/stores/use-auth-store"; import { Events } from "@/api/protocol"; import { queryKeys } from "@/lib/query-keys"; import type { AgentEventPayload } from "@/types/chat"; @@ -61,6 +62,21 @@ export function useWsQueryInvalidation() { [queryClient], ); + // Invalidate all queries on WS reconnect so pages get fresh data + const connected = useAuthStore((s) => s.connected); + const wasConnectedRef = useRef(false); + const hasConnectedOnceRef = useRef(false); + + useEffect(() => { + if (connected && !wasConnectedRef.current && hasConnectedOnceRef.current) { + queryClient.invalidateQueries(); + } + if (connected) { + hasConnectedOnceRef.current = true; + } + wasConnectedRef.current = connected; + }, [connected, queryClient]); + useWsEvent(Events.AGENT, handleAgentEvent); useWsEvent(Events.TRACE_UPDATED, handleTraceUpdated); useWsEvent(Events.CRON, handleCronEvent); diff --git a/ui/web/src/lib/lazy-with-retry.ts b/ui/web/src/lib/lazy-with-retry.ts new file mode 100644 index 00000000..a1a3be39 --- /dev/null +++ b/ui/web/src/lib/lazy-with-retry.ts @@ -0,0 +1,31 @@ +import { lazy, type ComponentType } from "react"; + +/** + * Wraps React.lazy() with retry logic for failed chunk loads. + * - Retries the import once on failure (handles transient network errors) + * - If retry fails, forces a page reload to clear stale chunk URLs (e.g. after a deploy) + * - sessionStorage guard prevents infinite reload loops + */ +export function lazyWithRetry>( + importFn: () => Promise<{ default: T }>, +) { + return lazy(async () => { + try { + return await importFn(); + } catch { + // Retry once after transient failure + try { + return await importFn(); + } catch (retryError) { + // Force reload to clear stale chunks — but only once per session + const alreadyReloaded = sessionStorage.getItem("chunk-failed-reload"); + if (!alreadyReloaded) { + sessionStorage.setItem("chunk-failed-reload", "1"); + window.location.reload(); + } + // Already reloaded and still failing — let ErrorBoundary handle it + throw retryError; + } + } + }); +} diff --git a/ui/web/src/routes.tsx b/ui/web/src/routes.tsx index 741469c3..46c2cb4b 100644 --- a/ui/web/src/routes.tsx +++ b/ui/web/src/routes.tsx @@ -1,96 +1,98 @@ -import { lazy, Suspense } from "react"; +import { Suspense } from "react"; import { Routes, Route, Navigate } from "react-router"; import { AppLayout } from "@/components/layout/app-layout"; import { RequireAuth } from "@/components/shared/require-auth"; import { RequireSetup } from "@/components/shared/require-setup"; +import { ErrorBoundary } from "@/components/shared/error-boundary"; import { ROUTES } from "@/lib/constants"; +import { lazyWithRetry } from "@/lib/lazy-with-retry"; // Lazy-loaded pages -const LoginPage = lazy(() => +const LoginPage = lazyWithRetry(() => import("@/pages/login/login-page").then((m) => ({ default: m.LoginPage })), ); -const OverviewPage = lazy(() => +const OverviewPage = lazyWithRetry(() => import("@/pages/overview/overview-page").then((m) => ({ default: m.OverviewPage })), ); -const ChatPage = lazy(() => +const ChatPage = lazyWithRetry(() => import("@/pages/chat/chat-page").then((m) => ({ default: m.ChatPage })), ); -const AgentsPage = lazy(() => +const AgentsPage = lazyWithRetry(() => import("@/pages/agents/agents-page").then((m) => ({ default: m.AgentsPage })), ); -const SessionsPage = lazy(() => +const SessionsPage = lazyWithRetry(() => import("@/pages/sessions/sessions-page").then((m) => ({ default: m.SessionsPage })), ); -const SkillsPage = lazy(() => +const SkillsPage = lazyWithRetry(() => import("@/pages/skills/skills-page").then((m) => ({ default: m.SkillsPage })), ); -const CronPage = lazy(() => +const CronPage = lazyWithRetry(() => import("@/pages/cron/cron-page").then((m) => ({ default: m.CronPage })), ); -const ConfigPage = lazy(() => +const ConfigPage = lazyWithRetry(() => import("@/pages/config/config-page").then((m) => ({ default: m.ConfigPage })), ); -const TracesPage = lazy(() => +const TracesPage = lazyWithRetry(() => import("@/pages/traces/traces-page").then((m) => ({ default: m.TracesPage })), ); -const ChannelsPage = lazy(() => +const ChannelsPage = lazyWithRetry(() => import("@/pages/channels/channels-page").then((m) => ({ default: m.ChannelsPage })), ); -const ApprovalsPage = lazy(() => +const ApprovalsPage = lazyWithRetry(() => import("@/pages/approvals/approvals-page").then((m) => ({ default: m.ApprovalsPage })), ); -const NodesPage = lazy(() => +const NodesPage = lazyWithRetry(() => import("@/pages/nodes/nodes-page").then((m) => ({ default: m.NodesPage })), ); -const LogsPage = lazy(() => +const LogsPage = lazyWithRetry(() => import("@/pages/logs/logs-page").then((m) => ({ default: m.LogsPage })), ); -const ProvidersPage = lazy(() => +const ProvidersPage = lazyWithRetry(() => import("@/pages/providers/providers-page").then((m) => ({ default: m.ProvidersPage })), ); -const CustomToolsPage = lazy(() => +const CustomToolsPage = lazyWithRetry(() => import("@/pages/custom-tools/custom-tools-page").then((m) => ({ default: m.CustomToolsPage })), ); -const MCPPage = lazy(() => +const MCPPage = lazyWithRetry(() => import("@/pages/mcp/mcp-page").then((m) => ({ default: m.MCPPage })), ); -const TeamsPage = lazy(() => +const TeamsPage = lazyWithRetry(() => import("@/pages/teams/teams-page").then((m) => ({ default: m.TeamsPage })), ); -const BuiltinToolsPage = lazy(() => +const BuiltinToolsPage = lazyWithRetry(() => import("@/pages/builtin-tools/builtin-tools-page").then((m) => ({ default: m.BuiltinToolsPage })), ); -const TtsPage = lazy(() => +const TtsPage = lazyWithRetry(() => import("@/pages/tts/tts-page").then((m) => ({ default: m.TtsPage })), ); -const EventsPage = lazy(() => +const EventsPage = lazyWithRetry(() => import("@/pages/events/events-page").then((m) => ({ default: m.EventsPage })), ); -const StoragePage = lazy(() => +const StoragePage = lazyWithRetry(() => import("@/pages/storage/storage-page").then((m) => ({ default: m.StoragePage })), ); -const SetupPage = lazy(() => +const SetupPage = lazyWithRetry(() => import("@/pages/setup/setup-page").then((m) => ({ default: m.SetupPage })), ); -const PendingMessagesPage = lazy(() => +const PendingMessagesPage = lazyWithRetry(() => import("@/pages/pending-messages/pending-messages-page").then((m) => ({ default: m.PendingMessagesPage })), ); -const MemoryPage = lazy(() => +const MemoryPage = lazyWithRetry(() => import("@/pages/memory/memory-page").then((m) => ({ default: m.MemoryPage })), ); -const KnowledgeGraphPage = lazy(() => +const KnowledgeGraphPage = lazyWithRetry(() => import("@/pages/knowledge-graph/knowledge-graph-page").then((m) => ({ default: m.KnowledgeGraphPage })), ); -const ContactsPage = lazy(() => +const ContactsPage = lazyWithRetry(() => import("@/pages/contacts/contacts-page").then((m) => ({ default: m.ContactsPage })), ); -const ActivityPage = lazy(() => +const ActivityPage = lazyWithRetry(() => import("@/pages/activity/activity-page").then((m) => ({ default: m.ActivityPage })), ); -const CliCredentialsPage = lazy(() => +const CliCredentialsPage = lazyWithRetry(() => import("@/pages/cli-credentials/cli-credentials-page").then((m) => ({ default: m.CliCredentialsPage })), ); -const ApiKeysPage = lazy(() => +const ApiKeysPage = lazyWithRetry(() => import("@/pages/api-keys/api-keys-page").then((m) => ({ default: m.ApiKeysPage })), ); @@ -104,6 +106,7 @@ function PageLoader() { export function AppRoutes() { return ( + }> } /> @@ -171,5 +174,6 @@ export function AppRoutes() { } /> + ); }