Files
fusion/packages/dashboard/app/hooks/visibilitySuspension.ts
gsxdsm b6243d68fe 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
2026-06-10 09:21:48 -07:00

107 lines
3.3 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef } from "react";
let lastHiddenAt: number | null = null;
let lastVisibleAt: number | null = null;
const SUSPENSION_ERROR_PATTERNS = [
"load failed",
"failed to fetch",
"networkerror when attempting to fetch resource.",
"connection aborted",
"connection closed unexpectedly",
"network error",
];
export function isLikelyTabSuspensionError(message: string): boolean {
const normalized = message.trim().toLowerCase();
if (!normalized) {
return false;
}
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,
visibleAt: lastVisibleAt,
};
}
/**
* Tracks tab visibility transitions and suspension-recovery signals.
* - `onBecameVisible` subscriptions fire only when transitioning hidden -> visible.
* - `lastVisibilityTransition` exposes last hidden/visible timestamps for testing and reconnect logic.
*/
export function useTabVisibilitySuspension() {
const lastHiddenAtRef = useRef<number | null>(lastHiddenAt);
const lastVisibleAtRef = useRef<number | null>(lastVisibleAt);
const visibilityHandlersRef = useRef(new Set<() => void>());
useEffect(() => {
if (typeof document === "undefined") {
return;
}
let previousVisibilityState = document.visibilityState;
const handleVisibilityChange = () => {
const now = Date.now();
const currentVisibilityState = document.visibilityState;
if (currentVisibilityState === "hidden") {
lastHiddenAtRef.current = now;
lastHiddenAt = now;
}
if (currentVisibilityState === "visible") {
lastVisibleAtRef.current = now;
lastVisibleAt = now;
if (previousVisibilityState === "hidden") {
for (const handler of visibilityHandlersRef.current) {
handler();
}
}
}
previousVisibilityState = currentVisibilityState;
};
handleVisibilityChange();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
const isHiddenNow = useCallback(() => typeof document !== "undefined" && document.visibilityState === "hidden", []);
const wasRecentlyHidden = useCallback((windowMs = 5000): boolean => {
const hiddenAt = lastHiddenAtRef.current;
if (hiddenAt === null) {
return false;
}
const now = Date.now();
if (isHiddenNow()) {
return now - hiddenAt <= windowMs;
}
const visibleAt = lastVisibleAtRef.current;
if (visibleAt === null || visibleAt < hiddenAt) {
return false;
}
return now - visibleAt <= windowMs;
}, [isHiddenNow]);
const onBecameVisible = useCallback((handler: () => void) => {
visibilityHandlersRef.current.add(handler);
return () => {
visibilityHandlersRef.current.delete(handler);
};
}, []);
return useMemo(() => ({
isHiddenNow,
wasRecentlyHidden,
onBecameVisible,
}), [isHiddenNow, onBecameVisible, wasRecentlyHidden]);
}