From b6243d68fec60a3647aa0f7f71cd09928cac0e5f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 10 Jun 2026 09:21:48 -0700 Subject: [PATCH] FN-6197: suppress tab-resume fetch errors in dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep cached dashboard data visible while reconnecting after transient tab-resume fetch failures. - detect likely tab suspension and visibility-resume fetch errors across dashboard data hooks - suppress transient "Failed to fetch" errors when cached project and node data already exist - show a "Connecting…" executor status state instead of surfacing raw resume-time fetch errors - add dashboard tests covering visibility suspension handling and executor reconnect rendering - add a patch changeset for the published CLI package Files changed: .changeset/tame-tab-resume-fetch.md | 5 + packages/dashboard/app/App.tsx | 16 ++- .../dashboard/app/components/ExecutorStatusBar.css | 19 ++- .../dashboard/app/components/ExecutorStatusBar.tsx | 12 ++ .../__tests__/ExecutorStatusBar.test.tsx | 20 ++- .../dashboard/app/hooks/__tests__/useNodes.test.ts | 149 ++++++++++++++++++++- .../app/hooks/__tests__/useProjects.test.ts | 72 +++++++++- .../hooks/__tests__/visibilitySuspension.test.ts | 13 ++ packages/dashboard/app/hooks/useExecutorStats.ts | 15 ++- .../dashboard/app/hooks/useManagedDockerNodes.ts | 25 +++- packages/dashboard/app/hooks/useMeshState.ts | 25 +++- packages/dashboard/app/hooks/useNodes.ts | 25 +++- packages/dashboard/app/hooks/useProjectHealth.ts | 18 ++- packages/dashboard/app/hooks/useProjects.ts | 27 +++- packages/dashboard/app/hooks/useUsageData.ts | 24 +++- .../dashboard/app/hooks/visibilitySuspension.ts | 4 + 16 files changed, 435 insertions(+), 34 deletions(-) Fusion-Task-Id: FN-6197 Fusion-Task-Lineage: 834f56d8-8391-414f-8cbd-7e626c5724d0 --- .changeset/tame-tab-resume-fetch.md | 5 + packages/dashboard/app/App.tsx | 16 +- .../app/components/ExecutorStatusBar.css | 19 ++- .../app/components/ExecutorStatusBar.tsx | 12 ++ .../__tests__/ExecutorStatusBar.test.tsx | 20 ++- .../app/hooks/__tests__/useNodes.test.ts | 149 +++++++++++++++++- .../app/hooks/__tests__/useProjects.test.ts | 72 ++++++++- .../__tests__/visibilitySuspension.test.ts | 13 ++ .../dashboard/app/hooks/useExecutorStats.ts | 15 +- .../app/hooks/useManagedDockerNodes.ts | 25 ++- packages/dashboard/app/hooks/useMeshState.ts | 25 ++- packages/dashboard/app/hooks/useNodes.ts | 25 ++- .../dashboard/app/hooks/useProjectHealth.ts | 18 ++- packages/dashboard/app/hooks/useProjects.ts | 27 +++- packages/dashboard/app/hooks/useUsageData.ts | 24 ++- .../app/hooks/visibilitySuspension.ts | 4 + 16 files changed, 435 insertions(+), 34 deletions(-) create mode 100644 .changeset/tame-tab-resume-fetch.md diff --git a/.changeset/tame-tab-resume-fetch.md b/.changeset/tame-tab-resume-fetch.md new file mode 100644 index 0000000000..3ee13b2b47 --- /dev/null +++ b/.changeset/tame-tab-resume-fetch.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Suppress transient dashboard fetch errors after tab resume so cached data remains visible and executor status shows a reconnecting state instead of raw network errors. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index de03100020..d4e6272c93 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -77,6 +77,7 @@ import { useProjectActions } from "./hooks/useProjectActions"; import { useTaskHandlers } from "./hooks/useTaskHandlers"; import { useRemoteNodeData } from "./hooks/useRemoteNodeData"; import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents"; +import { isLikelyTabSuspensionError } from "./hooks/visibilitySuspension"; import { NodeProvider, useNodeContext } from "./context/NodeContext"; import { FileBrowserProvider } from "./context/FileBrowserContext"; import { ShellProvider } from "./context/ShellContext"; @@ -257,8 +258,15 @@ function AppInner() { // Project management hooks - MUST be called before any conditional logic const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects } = useProjects(); + const hasEverLoadedProjectsRef = useRef(projects.length > 0); const { nodes } = useNodes(); + useEffect(() => { + if (projects.length > 0) { + hasEverLoadedProjectsRef.current = true; + } + }, [projects.length]); + // Node context for local/remote node switching - must be called before useCurrentProject const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext(); @@ -1396,12 +1404,18 @@ function AppInner() { } }, [shellState]); + const isSuppressedProjectResumeError = + Boolean(projectsError) && + isLikelyTabSuspensionError(projectsError ?? "") && + hasEverLoadedProjectsRef.current; + const showBackendConnectionErrorPage = !projectsLoading && !currentProjectLoading && projects.length === 0 && !currentProject && - Boolean(projectsError); + Boolean(projectsError) && + !isSuppressedProjectResumeError; // Render main content based on view mode const renderMainContent = () => { diff --git a/packages/dashboard/app/components/ExecutorStatusBar.css b/packages/dashboard/app/components/ExecutorStatusBar.css index 8f773dda95..e683c38f40 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.css +++ b/packages/dashboard/app/components/ExecutorStatusBar.css @@ -29,6 +29,7 @@ background: var(--executor-status-error-bg); } +.executor-status-bar--connecting, .executor-status-bar--loading { background: var(--surface); } @@ -102,6 +103,14 @@ background: var(--color-error); } +.executor-status-bar__indicator--connecting { + background: var(--color-warning); +} + +.executor-status-bar__indicator--connecting.executor-status-bar__indicator--active { + animation: executor-pulse 1.5s ease-in-out infinite; +} + /* Numeric count display */ .executor-status-bar__count { font-family: var(--font-mono); @@ -251,13 +260,21 @@ } /* Error message */ -.executor-status-bar__error { +.executor-status-bar__error, +.executor-status-bar__connecting { display: flex; align-items: center; gap: 6px; +} + +.executor-status-bar__error { color: var(--color-error); } +.executor-status-bar__connecting { + color: var(--color-warning); +} + /* Loading message */ .executor-status-bar__loading-text { color: var(--text-muted); diff --git a/packages/dashboard/app/components/ExecutorStatusBar.tsx b/packages/dashboard/app/components/ExecutorStatusBar.tsx index 36451e0d92..267af6eaee 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.tsx +++ b/packages/dashboard/app/components/ExecutorStatusBar.tsx @@ -10,6 +10,7 @@ import { import { AlertTriangle, Clock, Folder, Pause, Play, Zap } from "lucide-react"; import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout"; import { useExecutorStats } from "../hooks/useExecutorStats"; +import { isLikelyTabSuspensionError } from "../hooks/visibilitySuspension"; import type { ExecutorState, AiSessionSummary } from "../api"; import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator"; @@ -124,6 +125,17 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH if (hideWhenKeyboardOpen) return null; if (error) { + if (isLikelyTabSuspensionError(error)) { + return ( +
+ + +
+ ); + } + return (
diff --git a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx index 7f9f53fcc3..8a11164231 100644 --- a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx +++ b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx @@ -285,17 +285,33 @@ describe("ExecutorStatusBar", () => { vi.mocked(mockUseExecutorStats).mockReturnValue({ stats: defaultStats, loading: false, - error: "Failed to fetch stats", + error: "Stats unavailable", refresh: vi.fn(), }); render(); const statusBar = screen.getByRole("status"); - expect(statusBar).toHaveTextContent("Failed to fetch stats"); + expect(statusBar).toHaveTextContent("Stats unavailable"); expect(statusBar).toHaveClass("executor-status-bar--error"); }); + it("shows connecting state instead of suspension error text", () => { + vi.mocked(mockUseExecutorStats).mockReturnValue({ + stats: defaultStats, + loading: false, + error: "Failed to fetch", + refresh: vi.fn(), + }); + + render(); + + const statusBar = screen.getByRole("status"); + expect(statusBar).toHaveTextContent("Connecting…"); + expect(statusBar).not.toHaveTextContent("Failed to fetch"); + expect(statusBar).toHaveClass("executor-status-bar--connecting"); + }); + it("does not show stat segments when error is present", () => { vi.mocked(mockUseExecutorStats).mockReturnValue({ stats: defaultStats, diff --git a/packages/dashboard/app/hooks/__tests__/useNodes.test.ts b/packages/dashboard/app/hooks/__tests__/useNodes.test.ts index 82223efb57..b0d1a93ff9 100644 --- a/packages/dashboard/app/hooks/__tests__/useNodes.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useNodes.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; +import { renderHook, act, fireEvent } from "@testing-library/react"; import { useNodes } from "../useNodes"; +import { useManagedDockerNodes } from "../useManagedDockerNodes"; +import { useMeshState } from "../useMeshState"; import * as api from "../../api"; import * as nodeApi from "../../api-node"; -import type { NodeInfo, NodeOnboardingInput } from "../../api"; +import type { ManagedDockerNodeInfo, NodeInfo, NodeOnboardingInput } from "../../api"; +import type { NodeMeshState } from "@fusion/core"; vi.mock("../../api", () => ({ fetchNodes: vi.fn(), @@ -11,6 +14,11 @@ vi.mock("../../api", () => ({ updateNode: vi.fn(), unregisterNode: vi.fn(), checkNodeHealth: vi.fn(), + fetchMeshState: vi.fn(), + fetchManagedDockerNodes: vi.fn(), + fetchManagedDockerNodeContainerStatus: vi.fn(), + fetchDockerNodeLogs: vi.fn(), + createManagedDockerNode: vi.fn(), })); vi.mock("../../api-node", () => ({ @@ -22,6 +30,8 @@ const mockRegisterNode = vi.mocked(api.registerNode); const mockUpdateNode = vi.mocked(api.updateNode); const mockUnregisterNode = vi.mocked(api.unregisterNode); const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth); +const mockFetchMeshState = vi.mocked(api.fetchMeshState); +const mockFetchManagedDockerNodes = vi.mocked(api.fetchManagedDockerNodes); const mockPersistNodeProjectPathMappings = vi.mocked(nodeApi.persistNodeProjectPathMappings); function makeNode(overrides: Partial = {}): NodeInfo { @@ -38,19 +48,70 @@ function makeNode(overrides: Partial = {}): NodeInfo { }; } +function makeMeshNode(overrides: Partial = {}): NodeMeshState { + return { + id: "node_local", + name: "Local Node", + type: "local", + status: "online", + capabilities: ["executor"], + maxConcurrent: 2, + activeTasks: 0, + queuedTasks: 0, + lastHeartbeat: "2026-01-01T00:00:00.000Z", + ...overrides, + } as NodeMeshState; +} + +function makeDockerNode(overrides: Partial = {}): ManagedDockerNodeInfo { + return { + id: "docker-1", + name: "Docker Node", + nodeId: "node-docker-1", + status: "running", + hostConfig: { type: "local" }, + volumeMounts: [], + ...overrides, + } as ManagedDockerNodeInfo; +} + async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); } +function setVisibilityState(state: DocumentVisibilityState): void { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }); +} + +async function simulateHiddenToVisibleResume(): Promise { + setVisibilityState("hidden"); + act(() => { + fireEvent(document, new Event("visibilitychange")); + vi.advanceTimersByTime(1100); + }); + + setVisibilityState("visible"); + await act(async () => { + fireEvent(document, new Event("visibilitychange")); + await flushPromises(); + }); +} + describe("useNodes", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); + setVisibilityState("visible"); mockFetchNodes.mockReset(); mockRegisterNode.mockReset(); mockUpdateNode.mockReset(); mockUnregisterNode.mockReset(); mockCheckNodeHealth.mockReset(); + mockFetchMeshState.mockReset(); + mockFetchManagedDockerNodes.mockReset(); mockPersistNodeProjectPathMappings.mockReset(); }); @@ -277,6 +338,26 @@ describe("useNodes", () => { expect(result.current.nodes[0].name).toBe("After Refresh"); }); + it("suppresses visibility-resume suspension errors when nodes already exist", async () => { + mockFetchNodes + .mockResolvedValueOnce([makeNode({ name: "Initial" })]) + .mockRejectedValueOnce(new Error("Failed to fetch")); + + const { result } = renderHook(() => useNodes()); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.nodes[0].name).toBe("Initial"); + expect(result.current.error).toBeNull(); + + await simulateHiddenToVisibleResume(); + + expect(result.current.nodes[0].name).toBe("Initial"); + expect(result.current.error).toBeNull(); + }); + it("refetches when visibility changes back to visible", async () => { const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); mockFetchNodes @@ -319,3 +400,67 @@ describe("useNodes", () => { } }); }); + +describe("useMeshState", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + setVisibilityState("visible"); + mockFetchMeshState.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("suppresses visibility-resume suspension errors when mesh state already exists", async () => { + mockFetchMeshState + .mockResolvedValueOnce({ nodes: [makeMeshNode({ name: "Initial Mesh" })] }) + .mockRejectedValueOnce(new Error("Failed to fetch")); + + const { result } = renderHook(() => useMeshState()); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.meshState[0].name).toBe("Initial Mesh"); + expect(result.current.error).toBeNull(); + + await simulateHiddenToVisibleResume(); + + expect(result.current.meshState[0].name).toBe("Initial Mesh"); + expect(result.current.error).toBeNull(); + }); +}); + +describe("useManagedDockerNodes", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + setVisibilityState("visible"); + mockFetchManagedDockerNodes.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("suppresses visibility-resume suspension errors when docker nodes already exist", async () => { + mockFetchManagedDockerNodes + .mockResolvedValueOnce([makeDockerNode({ name: "Initial Docker" })]) + .mockRejectedValueOnce(new Error("Failed to fetch")); + + const { result } = renderHook(() => useManagedDockerNodes()); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.dockerNodes[0].name).toBe("Initial Docker"); + expect(result.current.error).toBeNull(); + + await simulateHiddenToVisibleResume(); + + expect(result.current.dockerNodes[0].name).toBe("Initial Docker"); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useProjects.test.ts b/packages/dashboard/app/hooks/__tests__/useProjects.test.ts index 0259c3ddc5..661a18a82e 100644 --- a/packages/dashboard/app/hooks/__tests__/useProjects.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useProjects.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; +import { renderHook, act, fireEvent } from "@testing-library/react"; import { useProjects } from "../useProjects"; import * as api from "../../api"; import * as swrCache from "../../utils/swrCache"; @@ -40,9 +40,17 @@ async function flushPromises(): Promise { await Promise.resolve(); } +function setVisibilityState(state: DocumentVisibilityState): void { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }); +} + describe("useProjects", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); + setVisibilityState("visible"); mockFetchProjectsAcrossNodes.mockReset(); mockRegisterProject.mockReset(); mockUpdateProject.mockReset(); @@ -171,6 +179,68 @@ describe("useProjects", () => { ]); }); + it("suppresses visibility-resume suspension errors when projects already exist", async () => { + mockHasNodeMappingsSupport.mockReturnValue(false); + mockFetchProjectsAcrossNodes + .mockResolvedValueOnce([makeProject({ id: "proj-1" })]) + .mockResolvedValueOnce([makeProject({ id: "proj-1" })]) + .mockRejectedValueOnce(new Error("Failed to fetch")); + + const { result } = renderHook(() => useProjects()); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.projects[0]?.id).toBe("proj-1"); + expect(result.current.error).toBeNull(); + + setVisibilityState("hidden"); + act(() => { + fireEvent(document, new Event("visibilitychange")); + vi.advanceTimersByTime(1100); + }); + + setVisibilityState("visible"); + await act(async () => { + fireEvent(document, new Event("visibilitychange")); + await flushPromises(); + }); + + expect(result.current.projects[0]?.id).toBe("proj-1"); + expect(result.current.error).toBeNull(); + }); + + it("keeps connection errors visible when no projects exist", async () => { + mockHasNodeMappingsSupport.mockReturnValue(false); + mockFetchProjectsAcrossNodes.mockRejectedValueOnce(new Error("Failed to fetch")); + + const { result } = renderHook(() => useProjects()); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.projects).toEqual([]); + expect(result.current.error).toBe("Failed to fetch"); + }); + + it("suppresses initial revalidation suspension errors when cache has projects", async () => { + mockReadCache.mockReturnValueOnce([makeProject({ id: "cached-project" })]); + mockHasNodeMappingsSupport.mockReturnValue(false); + mockFetchProjectsAcrossNodes.mockRejectedValueOnce(new Error("Failed to fetch")); + setVisibilityState("hidden"); + + const { result } = renderHook(() => useProjects()); + + await act(async () => { + await flushPromises(); + }); + + expect(result.current.projects[0]?.id).toBe("cached-project"); + expect(result.current.error).toBeNull(); + }); + it("refreshes projects using the same normalization", async () => { mockHasNodeMappingsSupport.mockReturnValue(false); mockFetchProjectsAcrossNodes diff --git a/packages/dashboard/app/hooks/__tests__/visibilitySuspension.test.ts b/packages/dashboard/app/hooks/__tests__/visibilitySuspension.test.ts index da07aca41c..7a02d41e2b 100644 --- a/packages/dashboard/app/hooks/__tests__/visibilitySuspension.test.ts +++ b/packages/dashboard/app/hooks/__tests__/visibilitySuspension.test.ts @@ -2,6 +2,7 @@ import { act, fireEvent, renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { isLikelyTabSuspensionError, + isVisibilityResumeError, lastVisibilityTransition, useTabVisibilitySuspension, } from "../visibilitySuspension"; @@ -23,6 +24,18 @@ describe("visibilitySuspension", () => { expect(isLikelyTabSuspensionError("Validation error: missing key")).toBe(false); }); + it("matches visibility-resume errors when recently hidden and suspension-like", () => { + expect(isVisibilityResumeError("Failed to fetch", true)).toBe(true); + }); + + it("rejects visibility-resume errors when not recently hidden", () => { + expect(isVisibilityResumeError("Failed to fetch", false)).toBe(false); + }); + + it("rejects visibility-resume errors for unrelated failures", () => { + expect(isVisibilityResumeError("Request failed: 500", true)).toBe(false); + }); + it("tracks recently hidden window", () => { vi.useFakeTimers(); diff --git a/packages/dashboard/app/hooks/useExecutorStats.ts b/packages/dashboard/app/hooks/useExecutorStats.ts index 20a4a9a60e..2a0d0873db 100644 --- a/packages/dashboard/app/hooks/useExecutorStats.ts +++ b/packages/dashboard/app/hooks/useExecutorStats.ts @@ -3,6 +3,7 @@ import type { Task } from "@fusion/core"; import { fetchExecutorStats } from "../api"; import type { ExecutorStats, ExecutorState } from "../api"; import { isTaskStuck } from "../utils/taskStuck"; +import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension"; const POLL_INTERVAL_MS = 5000; // 5 seconds - different from useProjectHealth's 10s @@ -117,6 +118,12 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim const [error, setError] = useState(null); const intervalRef = useRef(null); const abortRef = useRef(null); + const hasFetchedStatsRef = useRef(false); + const visibilitySuspension = useTabVisibilitySuspension(); + + const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => { + return hasFetchedStatsRef.current && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden()); + }, [visibilitySuspension]); const refresh = useCallback(async () => { // Cancel any in-flight requests @@ -129,17 +136,21 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim setLoading(true); setError(null); const data = await fetchExecutorStats(projectId); + hasFetchedStatsRef.current = true; setApiData(data); } catch (err) { if (err instanceof Error && err.name === "AbortError") { // Ignore abort errors return; } - setError(err instanceof Error ? err.message : "Failed to fetch executor stats"); + const errorMessage = err instanceof Error ? err.message : "Failed to fetch executor stats"; + if (!shouldSuppressVisibilityResumeError(errorMessage)) { + setError(errorMessage); + } } finally { setLoading(false); } - }, [projectId]); + }, [projectId, shouldSuppressVisibilityResumeError]); // Initial fetch useEffect(() => { diff --git a/packages/dashboard/app/hooks/useManagedDockerNodes.ts b/packages/dashboard/app/hooks/useManagedDockerNodes.ts index 51fbc0b928..d3096b0bf1 100644 --- a/packages/dashboard/app/hooks/useManagedDockerNodes.ts +++ b/packages/dashboard/app/hooks/useManagedDockerNodes.ts @@ -8,6 +8,7 @@ import { fetchManagedDockerNodes, } from "../api"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; +import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension"; export interface UseManagedDockerNodesResult { dockerNodes: ManagedDockerNodeInfo[]; @@ -28,6 +29,16 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult { const [error, setError] = useState(null); const intervalRef = useRef(null); const lastVisibilityRefreshRef = useRef(0); + const dockerNodesRef = useRef(dockerNodes); + const visibilitySuspension = useTabVisibilitySuspension(); + + useEffect(() => { + dockerNodesRef.current = dockerNodes; + }, [dockerNodes]); + + const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => { + return dockerNodesRef.current.length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden()); + }, [visibilitySuspension]); const refresh = useCallback(async () => { try { @@ -35,9 +46,12 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult { const data = await fetchManagedDockerNodes(); setDockerNodes(data); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes"); + const errorMessage = err instanceof Error ? err.message : "Failed to fetch managed Docker nodes"; + if (!shouldSuppressVisibilityResumeError(errorMessage)) { + setError(errorMessage); + } } - }, []); + }, [shouldSuppressVisibilityResumeError]); useEffect(() => { let cancelled = false; @@ -51,8 +65,9 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult { setError(null); } } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes"); + const errorMessage = err instanceof Error ? err.message : "Failed to fetch managed Docker nodes"; + if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) { + setError(errorMessage); } } finally { if (!cancelled) { @@ -98,7 +113,7 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult { cancelled = true; document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [refresh]); + }, [refresh, shouldSuppressVisibilityResumeError]); useEffect(() => { if (loading) { diff --git a/packages/dashboard/app/hooks/useMeshState.ts b/packages/dashboard/app/hooks/useMeshState.ts index f53f968392..06e779a2ea 100644 --- a/packages/dashboard/app/hooks/useMeshState.ts +++ b/packages/dashboard/app/hooks/useMeshState.ts @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import type { NodeMeshState } from "@fusion/core"; import { fetchMeshState } from "../api"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; +import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension"; const POLL_INTERVAL_MS = 10000; const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000; @@ -21,6 +22,16 @@ export function useMeshState(): UseMeshStateResult { const [error, setError] = useState(null); const intervalRef = useRef(null); const lastVisibilityRefreshRef = useRef(0); + const meshStateRef = useRef(meshState); + const visibilitySuspension = useTabVisibilitySuspension(); + + useEffect(() => { + meshStateRef.current = meshState; + }, [meshState]); + + const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => { + return meshStateRef.current.length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden()); + }, [visibilitySuspension]); const refresh = useCallback(async () => { try { @@ -28,9 +39,12 @@ export function useMeshState(): UseMeshStateResult { const data = await fetchMeshState(); setMeshState(data.nodes); } catch (err) { - setError(err instanceof Error ? err.message : t("mesh.failedToFetchMeshState", "Failed to fetch mesh state")); + const errorMessage = err instanceof Error ? err.message : t("mesh.failedToFetchMeshState", "Failed to fetch mesh state"); + if (!shouldSuppressVisibilityResumeError(errorMessage)) { + setError(errorMessage); + } } - }, [t]); + }, [shouldSuppressVisibilityResumeError, t]); useEffect(() => { let cancelled = false; @@ -44,8 +58,9 @@ export function useMeshState(): UseMeshStateResult { setError(null); } } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : t("mesh.failedToFetchMeshState", "Failed to fetch mesh state")); + const errorMessage = err instanceof Error ? err.message : t("mesh.failedToFetchMeshState", "Failed to fetch mesh state"); + if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) { + setError(errorMessage); } } finally { if (!cancelled) { @@ -87,7 +102,7 @@ export function useMeshState(): UseMeshStateResult { cancelled = true; document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [refresh]); + }, [refresh, shouldSuppressVisibilityResumeError, t]); useEffect(() => { if (loading) return; diff --git a/packages/dashboard/app/hooks/useNodes.ts b/packages/dashboard/app/hooks/useNodes.ts index 2c1fb289c3..a60ecfa37e 100644 --- a/packages/dashboard/app/hooks/useNodes.ts +++ b/packages/dashboard/app/hooks/useNodes.ts @@ -14,6 +14,7 @@ import { } from "../api"; import { persistNodeProjectPathMappings } from "../api-node"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; +import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension"; export interface UseNodesResult { nodes: NodeInfo[]; @@ -45,6 +46,16 @@ export function useNodes(): UseNodesResult { const [error, setError] = useState(null); const intervalRef = useRef(null); const lastVisibilityRefreshRef = useRef(0); + const nodesRef = useRef(nodes); + const visibilitySuspension = useTabVisibilitySuspension(); + + useEffect(() => { + nodesRef.current = nodes; + }, [nodes]); + + const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => { + return nodesRef.current.length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden()); + }, [visibilitySuspension]); const refresh = useCallback(async () => { try { @@ -52,10 +63,13 @@ export function useNodes(): UseNodesResult { const data = await fetchNodes(); setNodes(data); } catch (err) { - setError(err instanceof Error ? err.message : t("nodes.errorFetching", "Failed to fetch nodes")); + const errorMessage = err instanceof Error ? err.message : t("nodes.errorFetching", "Failed to fetch nodes"); + if (!shouldSuppressVisibilityResumeError(errorMessage)) { + setError(errorMessage); + } // Keep stale data visible if polling refresh fails } - }, [t]); + }, [shouldSuppressVisibilityResumeError, t]); useEffect(() => { let cancelled = false; @@ -69,8 +83,9 @@ export function useNodes(): UseNodesResult { setError(null); } } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : t("nodes.errorFetching", "Failed to fetch nodes")); + const errorMessage = err instanceof Error ? err.message : t("nodes.errorFetching", "Failed to fetch nodes"); + if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) { + setError(errorMessage); } } finally { if (!cancelled) { @@ -116,7 +131,7 @@ export function useNodes(): UseNodesResult { cancelled = true; document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [refresh]); + }, [refresh, shouldSuppressVisibilityResumeError, t]); useEffect(() => { if (loading) return; diff --git a/packages/dashboard/app/hooks/useProjectHealth.ts b/packages/dashboard/app/hooks/useProjectHealth.ts index 7683dd0fa2..fddbf5c09e 100644 --- a/packages/dashboard/app/hooks/useProjectHealth.ts +++ b/packages/dashboard/app/hooks/useProjectHealth.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import type { ProjectHealth } from "../api"; import { fetchProjectHealth } from "../api"; +import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension"; export interface UseMultiProjectHealthResult { /** Map of project ID to health data */ @@ -36,9 +37,19 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes const [error, setError] = useState(null); const intervalRef = useRef(null); const abortRef = useRef(null); + const healthMapRef = useRef(healthMap); + const visibilitySuspension = useTabVisibilitySuspension(); // Track if we've completed the initial load const initialLoadCompleteRef = useRef(false); + useEffect(() => { + healthMapRef.current = healthMap; + }, [healthMap]); + + const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => { + return Object.keys(healthMapRef.current).length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden()); + }, [visibilitySuspension]); + /** * Refresh health data for all projects. * This is called both for initial load and for background polling. @@ -105,13 +116,16 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes // Ignore abort errors return; } - setError(err instanceof Error ? err.message : "Failed to fetch health data"); + const errorMessage = err instanceof Error ? err.message : "Failed to fetch health data"; + if (!shouldSuppressVisibilityResumeError(errorMessage)) { + setError(errorMessage); + } // Mark initial load complete even on error so we don't stay in loading state initialLoadCompleteRef.current = true; } finally { setLoading(false); } - }, [projectIds]); + }, [projectIds, shouldSuppressVisibilityResumeError]); const refreshProject = useCallback(async (projectId: string) => { try { diff --git a/packages/dashboard/app/hooks/useProjects.ts b/packages/dashboard/app/hooks/useProjects.ts index 17cc5a3f76..416a446e03 100644 --- a/packages/dashboard/app/hooks/useProjects.ts +++ b/packages/dashboard/app/hooks/useProjects.ts @@ -12,6 +12,7 @@ import { } from "../api"; import { SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, clearCache, readCache, writeCache } from "../utils/swrCache"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; +import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension"; export interface UseProjectsResult { /** List of all registered projects (local + remote) */ @@ -91,6 +92,16 @@ export function useProjects(): UseProjectsResult { const [error, setError] = useState(null); const intervalRef = useRef(null); const lastVisibilityRefreshRef = useRef(0); + const projectsRef = useRef(projects); + const visibilitySuspension = useTabVisibilitySuspension(); + + useEffect(() => { + projectsRef.current = projects; + }, [projects]); + + const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => { + return projectsRef.current.length > 0 && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden()); + }, [visibilitySuspension]); const refresh = useCallback(async () => { try { @@ -100,10 +111,13 @@ export function useProjects(): UseProjectsResult { setProjects(normalizedData); writeCache(SWR_CACHE_KEYS.PROJECTS, normalizedData); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to fetch projects"); + const errorMessage = err instanceof Error ? err.message : "Failed to fetch projects"; + if (!shouldSuppressVisibilityResumeError(errorMessage)) { + setError(errorMessage); + } // Don't clear existing projects on error - keep showing stale data } - }, []); + }, [shouldSuppressVisibilityResumeError]); // Initial fetch and visibility change handler useEffect(() => { @@ -127,9 +141,10 @@ export function useProjects(): UseProjectsResult { } } catch (err) { const elapsed = Math.round(performance.now() - t0); - console.warn(`[useProjects] initial fetch failed after ${elapsed}ms: ${err instanceof Error ? err.message : String(err)}`); - if (!cancelled) { - setError(err instanceof Error ? err.message : "Failed to fetch projects"); + const errorMessage = err instanceof Error ? err.message : "Failed to fetch projects"; + console.warn(`[useProjects] initial fetch failed after ${elapsed}ms: ${errorMessage}`); + if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) { + setError(errorMessage); } } finally { if (!cancelled) { @@ -175,7 +190,7 @@ export function useProjects(): UseProjectsResult { cancelled = true; document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [projects.length, refresh]); + }, [projects.length, refresh, shouldSuppressVisibilityResumeError]); // Polling for updates useEffect(() => { diff --git a/packages/dashboard/app/hooks/useUsageData.ts b/packages/dashboard/app/hooks/useUsageData.ts index e5a196bbcb..82926682ab 100644 --- a/packages/dashboard/app/hooks/useUsageData.ts +++ b/packages/dashboard/app/hooks/useUsageData.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { getErrorMessage } from "@fusion/core"; import { fetchUsageData, type ProviderUsage } from "../api"; +import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension"; interface UsageDataState { providers: ProviderUsage[]; @@ -40,6 +41,16 @@ export function useUsageData(options: UseUsageDataOptions = {}) { const pollRef = useRef | null>(null); const abortRef = useRef(null); + const stateRef = useRef(state); + const visibilitySuspension = useTabVisibilitySuspension(); + + useEffect(() => { + stateRef.current = state; + }, [state]); + + const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => { + return stateRef.current.hasFetched && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden()); + }, [visibilitySuspension]); const fetchData = useCallback(async (isManual = false) => { // Cancel any in-flight request @@ -65,14 +76,23 @@ export function useUsageData(options: UseUsageDataOptions = {}) { // Don't update state if the request was aborted if (err instanceof Error && err.name === "AbortError") return; + const errorMessage = getErrorMessage(err) || "Failed to fetch usage data"; + if (shouldSuppressVisibilityResumeError(errorMessage)) { + setState((prev) => ({ + ...prev, + loading: false, + })); + return; + } + setState((prev) => ({ ...prev, loading: false, - error: getErrorMessage(err) || "Failed to fetch usage data", + error: errorMessage, hasFetched: true, })); } - }, []); + }, [shouldSuppressVisibilityResumeError]); // Initial fetch useEffect(() => { diff --git a/packages/dashboard/app/hooks/visibilitySuspension.ts b/packages/dashboard/app/hooks/visibilitySuspension.ts index f4da459806..77fa2ffcce 100644 --- a/packages/dashboard/app/hooks/visibilitySuspension.ts +++ b/packages/dashboard/app/hooks/visibilitySuspension.ts @@ -20,6 +20,10 @@ export function isLikelyTabSuspensionError(message: string): boolean { return SUSPENSION_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern)); } +export function isVisibilityResumeError(errorMessage: string, wasRecentlyHiddenResult: boolean): boolean { + return wasRecentlyHiddenResult && isLikelyTabSuspensionError(errorMessage); +} + export function lastVisibilityTransition(): { hiddenAt: number | null; visibleAt: number | null } { return { hiddenAt: lastHiddenAt,