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:
5
.changeset/tame-tab-resume-fetch.md
Normal file
5
.changeset/tame-tab-resume-fetch.md
Normal 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.
|
||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user