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
107 lines
3.3 KiB
TypeScript
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]);
|
|
}
|