FN-6197: suppress tab-resume fetch errors in dashboard

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
This commit is contained in:
gsxdsm
2026-06-10 09:21:48 -07:00
parent d9d67fb7ff
commit b6243d68fe
16 changed files with 435 additions and 34 deletions

View File

@@ -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.

View File

@@ -77,6 +77,7 @@ import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers"; import { useTaskHandlers } from "./hooks/useTaskHandlers";
import { useRemoteNodeData } from "./hooks/useRemoteNodeData"; import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents"; import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
import { isLikelyTabSuspensionError } from "./hooks/visibilitySuspension";
import { NodeProvider, useNodeContext } from "./context/NodeContext"; import { NodeProvider, useNodeContext } from "./context/NodeContext";
import { FileBrowserProvider } from "./context/FileBrowserContext"; import { FileBrowserProvider } from "./context/FileBrowserContext";
import { ShellProvider } from "./context/ShellContext"; import { ShellProvider } from "./context/ShellContext";
@@ -257,8 +258,15 @@ function AppInner() {
// Project management hooks - MUST be called before any conditional logic // Project management hooks - MUST be called before any conditional logic
const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects } = useProjects(); const { projects, loading: projectsLoading, error: projectsError, refresh: refreshProjects } = useProjects();
const hasEverLoadedProjectsRef = useRef(projects.length > 0);
const { nodes } = useNodes(); 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 // Node context for local/remote node switching - must be called before useCurrentProject
const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext(); const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext();
@@ -1396,12 +1404,18 @@ function AppInner() {
} }
}, [shellState]); }, [shellState]);
const isSuppressedProjectResumeError =
Boolean(projectsError) &&
isLikelyTabSuspensionError(projectsError ?? "") &&
hasEverLoadedProjectsRef.current;
const showBackendConnectionErrorPage = const showBackendConnectionErrorPage =
!projectsLoading && !projectsLoading &&
!currentProjectLoading && !currentProjectLoading &&
projects.length === 0 && projects.length === 0 &&
!currentProject && !currentProject &&
Boolean(projectsError); Boolean(projectsError) &&
!isSuppressedProjectResumeError;
// Render main content based on view mode // Render main content based on view mode
const renderMainContent = () => { const renderMainContent = () => {

View File

@@ -29,6 +29,7 @@
background: var(--executor-status-error-bg); background: var(--executor-status-error-bg);
} }
.executor-status-bar--connecting,
.executor-status-bar--loading { .executor-status-bar--loading {
background: var(--surface); background: var(--surface);
} }
@@ -102,6 +103,14 @@
background: var(--color-error); 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 */ /* Numeric count display */
.executor-status-bar__count { .executor-status-bar__count {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -251,13 +260,21 @@
} }
/* Error message */ /* Error message */
.executor-status-bar__error { .executor-status-bar__error,
.executor-status-bar__connecting {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
}
.executor-status-bar__error {
color: var(--color-error); color: var(--color-error);
} }
.executor-status-bar__connecting {
color: var(--color-warning);
}
/* Loading message */ /* Loading message */
.executor-status-bar__loading-text { .executor-status-bar__loading-text {
color: var(--text-muted); color: var(--text-muted);

View File

@@ -10,6 +10,7 @@ import {
import { AlertTriangle, Clock, Folder, Pause, Play, Zap } from "lucide-react"; import { AlertTriangle, Clock, Folder, Pause, Play, Zap } from "lucide-react";
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout"; import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
import { useExecutorStats } from "../hooks/useExecutorStats"; import { useExecutorStats } from "../hooks/useExecutorStats";
import { isLikelyTabSuspensionError } from "../hooks/visibilitySuspension";
import type { ExecutorState, AiSessionSummary } from "../api"; import type { ExecutorState, AiSessionSummary } from "../api";
import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator"; import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator";
@@ -124,6 +125,17 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
if (hideWhenKeyboardOpen) return null; if (hideWhenKeyboardOpen) return null;
if (error) { if (error) {
if (isLikelyTabSuspensionError(error)) {
return (
<div className="executor-status-bar executor-status-bar--connecting" role="status" aria-label={t("executor.status", "Executor status")}>
<span className="executor-status-bar__connecting">
<span className="executor-status-bar__indicator executor-status-bar__indicator--connecting executor-status-bar__indicator--active" aria-hidden="true" />
{t("executor.connecting", "Connecting…")}
</span>
</div>
);
}
return ( return (
<div className="executor-status-bar executor-status-bar--error" role="status" aria-label={t("executor.status", "Executor status")}> <div className="executor-status-bar executor-status-bar--error" role="status" aria-label={t("executor.status", "Executor status")}>
<span className="executor-status-bar__error"> <span className="executor-status-bar__error">

View File

@@ -285,17 +285,33 @@ describe("ExecutorStatusBar", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({ vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats, stats: defaultStats,
loading: false, loading: false,
error: "Failed to fetch stats", error: "Stats unavailable",
refresh: vi.fn(), refresh: vi.fn(),
}); });
render(<ExecutorStatusBar tasks={emptyTasks} />); render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status"); const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Failed to fetch stats"); expect(statusBar).toHaveTextContent("Stats unavailable");
expect(statusBar).toHaveClass("executor-status-bar--error"); 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(<ExecutorStatusBar tasks={emptyTasks} />);
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", () => { it("does not show stat segments when error is present", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({ vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats, stats: defaultStats,

View File

@@ -1,9 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 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 { useNodes } from "../useNodes";
import { useManagedDockerNodes } from "../useManagedDockerNodes";
import { useMeshState } from "../useMeshState";
import * as api from "../../api"; import * as api from "../../api";
import * as nodeApi from "../../api-node"; 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", () => ({ vi.mock("../../api", () => ({
fetchNodes: vi.fn(), fetchNodes: vi.fn(),
@@ -11,6 +14,11 @@ vi.mock("../../api", () => ({
updateNode: vi.fn(), updateNode: vi.fn(),
unregisterNode: vi.fn(), unregisterNode: vi.fn(),
checkNodeHealth: vi.fn(), checkNodeHealth: vi.fn(),
fetchMeshState: vi.fn(),
fetchManagedDockerNodes: vi.fn(),
fetchManagedDockerNodeContainerStatus: vi.fn(),
fetchDockerNodeLogs: vi.fn(),
createManagedDockerNode: vi.fn(),
})); }));
vi.mock("../../api-node", () => ({ vi.mock("../../api-node", () => ({
@@ -22,6 +30,8 @@ const mockRegisterNode = vi.mocked(api.registerNode);
const mockUpdateNode = vi.mocked(api.updateNode); const mockUpdateNode = vi.mocked(api.updateNode);
const mockUnregisterNode = vi.mocked(api.unregisterNode); const mockUnregisterNode = vi.mocked(api.unregisterNode);
const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth); const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth);
const mockFetchMeshState = vi.mocked(api.fetchMeshState);
const mockFetchManagedDockerNodes = vi.mocked(api.fetchManagedDockerNodes);
const mockPersistNodeProjectPathMappings = vi.mocked(nodeApi.persistNodeProjectPathMappings); const mockPersistNodeProjectPathMappings = vi.mocked(nodeApi.persistNodeProjectPathMappings);
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo { function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
@@ -38,19 +48,70 @@ function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
}; };
} }
function makeMeshNode(overrides: Partial<NodeMeshState> = {}): 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> = {}): 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<void> { async function flushPromises(): Promise<void> {
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
} }
function setVisibilityState(state: DocumentVisibilityState): void {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => state,
});
}
async function simulateHiddenToVisibleResume(): Promise<void> {
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", () => { describe("useNodes", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });
setVisibilityState("visible");
mockFetchNodes.mockReset(); mockFetchNodes.mockReset();
mockRegisterNode.mockReset(); mockRegisterNode.mockReset();
mockUpdateNode.mockReset(); mockUpdateNode.mockReset();
mockUnregisterNode.mockReset(); mockUnregisterNode.mockReset();
mockCheckNodeHealth.mockReset(); mockCheckNodeHealth.mockReset();
mockFetchMeshState.mockReset();
mockFetchManagedDockerNodes.mockReset();
mockPersistNodeProjectPathMappings.mockReset(); mockPersistNodeProjectPathMappings.mockReset();
}); });
@@ -277,6 +338,26 @@ describe("useNodes", () => {
expect(result.current.nodes[0].name).toBe("After Refresh"); 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 () => { it("refetches when visibility changes back to visible", async () => {
const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
mockFetchNodes 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();
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 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 { useProjects } from "../useProjects";
import * as api from "../../api"; import * as api from "../../api";
import * as swrCache from "../../utils/swrCache"; import * as swrCache from "../../utils/swrCache";
@@ -40,9 +40,17 @@ async function flushPromises(): Promise<void> {
await Promise.resolve(); await Promise.resolve();
} }
function setVisibilityState(state: DocumentVisibilityState): void {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => state,
});
}
describe("useProjects", () => { describe("useProjects", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });
setVisibilityState("visible");
mockFetchProjectsAcrossNodes.mockReset(); mockFetchProjectsAcrossNodes.mockReset();
mockRegisterProject.mockReset(); mockRegisterProject.mockReset();
mockUpdateProject.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 () => { it("refreshes projects using the same normalization", async () => {
mockHasNodeMappingsSupport.mockReturnValue(false); mockHasNodeMappingsSupport.mockReturnValue(false);
mockFetchProjectsAcrossNodes mockFetchProjectsAcrossNodes

View File

@@ -2,6 +2,7 @@ import { act, fireEvent, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
isLikelyTabSuspensionError, isLikelyTabSuspensionError,
isVisibilityResumeError,
lastVisibilityTransition, lastVisibilityTransition,
useTabVisibilitySuspension, useTabVisibilitySuspension,
} from "../visibilitySuspension"; } from "../visibilitySuspension";
@@ -23,6 +24,18 @@ describe("visibilitySuspension", () => {
expect(isLikelyTabSuspensionError("Validation error: missing key")).toBe(false); 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", () => { it("tracks recently hidden window", () => {
vi.useFakeTimers(); vi.useFakeTimers();

View File

@@ -3,6 +3,7 @@ import type { Task } from "@fusion/core";
import { fetchExecutorStats } from "../api"; import { fetchExecutorStats } from "../api";
import type { ExecutorStats, ExecutorState } from "../api"; import type { ExecutorStats, ExecutorState } from "../api";
import { isTaskStuck } from "../utils/taskStuck"; import { isTaskStuck } from "../utils/taskStuck";
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
const POLL_INTERVAL_MS = 5000; // 5 seconds - different from useProjectHealth's 10s 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<string | null>(null); const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(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 () => { const refresh = useCallback(async () => {
// Cancel any in-flight requests // Cancel any in-flight requests
@@ -129,17 +136,21 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
setLoading(true); setLoading(true);
setError(null); setError(null);
const data = await fetchExecutorStats(projectId); const data = await fetchExecutorStats(projectId);
hasFetchedStatsRef.current = true;
setApiData(data); setApiData(data);
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === "AbortError") { if (err instanceof Error && err.name === "AbortError") {
// Ignore abort errors // Ignore abort errors
return; 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 { } finally {
setLoading(false); setLoading(false);
} }
}, [projectId]); }, [projectId, shouldSuppressVisibilityResumeError]);
// Initial fetch // Initial fetch
useEffect(() => { useEffect(() => {

View File

@@ -8,6 +8,7 @@ import {
fetchManagedDockerNodes, fetchManagedDockerNodes,
} from "../api"; } from "../api";
import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
export interface UseManagedDockerNodesResult { export interface UseManagedDockerNodesResult {
dockerNodes: ManagedDockerNodeInfo[]; dockerNodes: ManagedDockerNodeInfo[];
@@ -28,6 +29,16 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const lastVisibilityRefreshRef = useRef<number>(0); const lastVisibilityRefreshRef = useRef<number>(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 () => { const refresh = useCallback(async () => {
try { try {
@@ -35,9 +46,12 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
const data = await fetchManagedDockerNodes(); const data = await fetchManagedDockerNodes();
setDockerNodes(data); setDockerNodes(data);
} catch (err) { } 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -51,8 +65,9 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
setError(null); setError(null);
} }
} catch (err) { } catch (err) {
if (!cancelled) { const errorMessage = err instanceof Error ? err.message : "Failed to fetch managed Docker nodes";
setError(err instanceof Error ? err.message : "Failed to fetch managed Docker nodes"); if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) {
setError(errorMessage);
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -98,7 +113,7 @@ export function useManagedDockerNodes(): UseManagedDockerNodesResult {
cancelled = true; cancelled = true;
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [refresh]); }, [refresh, shouldSuppressVisibilityResumeError]);
useEffect(() => { useEffect(() => {
if (loading) { if (loading) {

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import type { NodeMeshState } from "@fusion/core"; import type { NodeMeshState } from "@fusion/core";
import { fetchMeshState } from "../api"; import { fetchMeshState } from "../api";
import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
const POLL_INTERVAL_MS = 10000; const POLL_INTERVAL_MS = 10000;
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000; const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
@@ -21,6 +22,16 @@ export function useMeshState(): UseMeshStateResult {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const lastVisibilityRefreshRef = useRef<number>(0); const lastVisibilityRefreshRef = useRef<number>(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 () => { const refresh = useCallback(async () => {
try { try {
@@ -28,9 +39,12 @@ export function useMeshState(): UseMeshStateResult {
const data = await fetchMeshState(); const data = await fetchMeshState();
setMeshState(data.nodes); setMeshState(data.nodes);
} catch (err) { } 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -44,8 +58,9 @@ export function useMeshState(): UseMeshStateResult {
setError(null); setError(null);
} }
} catch (err) { } catch (err) {
if (!cancelled) { const errorMessage = err instanceof Error ? err.message : t("mesh.failedToFetchMeshState", "Failed to fetch mesh state");
setError(err instanceof Error ? err.message : t("mesh.failedToFetchMeshState", "Failed to fetch mesh state")); if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) {
setError(errorMessage);
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -87,7 +102,7 @@ export function useMeshState(): UseMeshStateResult {
cancelled = true; cancelled = true;
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [refresh]); }, [refresh, shouldSuppressVisibilityResumeError, t]);
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;

View File

@@ -14,6 +14,7 @@ import {
} from "../api"; } from "../api";
import { persistNodeProjectPathMappings } from "../api-node"; import { persistNodeProjectPathMappings } from "../api-node";
import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
export interface UseNodesResult { export interface UseNodesResult {
nodes: NodeInfo[]; nodes: NodeInfo[];
@@ -45,6 +46,16 @@ export function useNodes(): UseNodesResult {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const lastVisibilityRefreshRef = useRef<number>(0); const lastVisibilityRefreshRef = useRef<number>(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 () => { const refresh = useCallback(async () => {
try { try {
@@ -52,10 +63,13 @@ export function useNodes(): UseNodesResult {
const data = await fetchNodes(); const data = await fetchNodes();
setNodes(data); setNodes(data);
} catch (err) { } 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 // Keep stale data visible if polling refresh fails
} }
}, [t]); }, [shouldSuppressVisibilityResumeError, t]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -69,8 +83,9 @@ export function useNodes(): UseNodesResult {
setError(null); setError(null);
} }
} catch (err) { } catch (err) {
if (!cancelled) { const errorMessage = err instanceof Error ? err.message : t("nodes.errorFetching", "Failed to fetch nodes");
setError(err instanceof Error ? err.message : t("nodes.errorFetching", "Failed to fetch nodes")); if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) {
setError(errorMessage);
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -116,7 +131,7 @@ export function useNodes(): UseNodesResult {
cancelled = true; cancelled = true;
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [refresh]); }, [refresh, shouldSuppressVisibilityResumeError, t]);
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import type { ProjectHealth } from "../api"; import type { ProjectHealth } from "../api";
import { fetchProjectHealth } from "../api"; import { fetchProjectHealth } from "../api";
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
export interface UseMultiProjectHealthResult { export interface UseMultiProjectHealthResult {
/** Map of project ID to health data */ /** Map of project ID to health data */
@@ -36,9 +37,19 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const healthMapRef = useRef(healthMap);
const visibilitySuspension = useTabVisibilitySuspension();
// Track if we've completed the initial load // Track if we've completed the initial load
const initialLoadCompleteRef = useRef(false); 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. * Refresh health data for all projects.
* This is called both for initial load and for background polling. * This is called both for initial load and for background polling.
@@ -105,13 +116,16 @@ export function useProjectHealth(projectIds: string[]): UseMultiProjectHealthRes
// Ignore abort errors // Ignore abort errors
return; 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 // Mark initial load complete even on error so we don't stay in loading state
initialLoadCompleteRef.current = true; initialLoadCompleteRef.current = true;
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [projectIds]); }, [projectIds, shouldSuppressVisibilityResumeError]);
const refreshProject = useCallback(async (projectId: string) => { const refreshProject = useCallback(async (projectId: string) => {
try { try {

View File

@@ -12,6 +12,7 @@ import {
} from "../api"; } from "../api";
import { SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, clearCache, readCache, writeCache } from "../utils/swrCache"; import { SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, clearCache, readCache, writeCache } from "../utils/swrCache";
import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
export interface UseProjectsResult { export interface UseProjectsResult {
/** List of all registered projects (local + remote) */ /** List of all registered projects (local + remote) */
@@ -91,6 +92,16 @@ export function useProjects(): UseProjectsResult {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
const lastVisibilityRefreshRef = useRef<number>(0); const lastVisibilityRefreshRef = useRef<number>(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 () => { const refresh = useCallback(async () => {
try { try {
@@ -100,10 +111,13 @@ export function useProjects(): UseProjectsResult {
setProjects(normalizedData); setProjects(normalizedData);
writeCache(SWR_CACHE_KEYS.PROJECTS, normalizedData); writeCache(SWR_CACHE_KEYS.PROJECTS, normalizedData);
} catch (err) { } 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 // Don't clear existing projects on error - keep showing stale data
} }
}, []); }, [shouldSuppressVisibilityResumeError]);
// Initial fetch and visibility change handler // Initial fetch and visibility change handler
useEffect(() => { useEffect(() => {
@@ -127,9 +141,10 @@ export function useProjects(): UseProjectsResult {
} }
} catch (err) { } catch (err) {
const elapsed = Math.round(performance.now() - t0); const elapsed = Math.round(performance.now() - t0);
console.warn(`[useProjects] initial fetch failed after ${elapsed}ms: ${err instanceof Error ? err.message : String(err)}`); const errorMessage = err instanceof Error ? err.message : "Failed to fetch projects";
if (!cancelled) { console.warn(`[useProjects] initial fetch failed after ${elapsed}ms: ${errorMessage}`);
setError(err instanceof Error ? err.message : "Failed to fetch projects"); if (!cancelled && !shouldSuppressVisibilityResumeError(errorMessage)) {
setError(errorMessage);
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -175,7 +190,7 @@ export function useProjects(): UseProjectsResult {
cancelled = true; cancelled = true;
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [projects.length, refresh]); }, [projects.length, refresh, shouldSuppressVisibilityResumeError]);
// Polling for updates // Polling for updates
useEffect(() => { useEffect(() => {

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { getErrorMessage } from "@fusion/core"; import { getErrorMessage } from "@fusion/core";
import { fetchUsageData, type ProviderUsage } from "../api"; import { fetchUsageData, type ProviderUsage } from "../api";
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
interface UsageDataState { interface UsageDataState {
providers: ProviderUsage[]; providers: ProviderUsage[];
@@ -40,6 +41,16 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(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) => { const fetchData = useCallback(async (isManual = false) => {
// Cancel any in-flight request // Cancel any in-flight request
@@ -65,14 +76,23 @@ export function useUsageData(options: UseUsageDataOptions = {}) {
// Don't update state if the request was aborted // Don't update state if the request was aborted
if (err instanceof Error && err.name === "AbortError") return; 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) => ({ setState((prev) => ({
...prev, ...prev,
loading: false, loading: false,
error: getErrorMessage(err) || "Failed to fetch usage data", error: errorMessage,
hasFetched: true, hasFetched: true,
})); }));
} }
}, []); }, [shouldSuppressVisibilityResumeError]);
// Initial fetch // Initial fetch
useEffect(() => { useEffect(() => {

View File

@@ -20,6 +20,10 @@ export function isLikelyTabSuspensionError(message: string): boolean {
return SUSPENSION_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern)); 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 } { export function lastVisibilityTransition(): { hiddenAt: number | null; visibleAt: number | null } {
return { return {
hiddenAt: lastHiddenAt, hiddenAt: lastHiddenAt,