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 { 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 = () => {

View File

@@ -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);

View File

@@ -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 (
<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 (
<div className="executor-status-bar executor-status-bar--error" role="status" aria-label={t("executor.status", "Executor status")}>
<span className="executor-status-bar__error">

View File

@@ -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(<ExecutorStatusBar tasks={emptyTasks} />);
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(<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", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: defaultStats,

View File

@@ -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> = {}): 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> {
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", () => {
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();
});
});

View File

@@ -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<void> {
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

View File

@@ -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();

View File

@@ -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<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | 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 () => {
// 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(() => {

View File

@@ -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<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
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 () => {
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) {

View File

@@ -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<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
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 () => {
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;

View File

@@ -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<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
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 () => {
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;

View File

@@ -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<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const abortRef = useRef<AbortController | null>(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 {

View File

@@ -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<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
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 () => {
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(() => {

View File

@@ -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<ReturnType<typeof setInterval> | 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) => {
// 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(() => {

View File

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