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 (
+
+
+
+ {t("executor.connecting", "Connecting…")}
+
+
+ );
+ }
+
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,