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
This commit is contained in:
viettranx committed 2026-03-17 12:20:14 +07:00
1 parent d205691a13
commit 32f84f8f1c
4 files changed
+88 -33

No files matched your search

+6 -2
View File
@@ -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() {
</div>
)}
<main className="flex-1 overflow-y-auto">
<Outlet />
<ErrorBoundary key={location.pathname}>
<Outlet />
</ErrorBoundary>
</main>
</div>
</div>
+17 -1
View File
@@ -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);
+31
View File
@@ -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<T extends ComponentType<unknown>>(
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;
}
}
});
}
+34 -30
View File
@@ -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 (
<ErrorBoundary>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path={ROUTES.LOGIN} element={<LoginPage />} />
@@ -171,5 +174,6 @@ export function AppRoutes() {
<Route path="*" element={<Navigate to={ROUTES.OVERVIEW} replace />} />
</Routes>
</Suspense>
</ErrorBoundary>
);
}