mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 18:18:37 +00:00
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:
1 parent
d205691a13
commit
32f84f8f1c
4 files changed
+88
-33
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user