Files
fusion/packages/dashboard/app/hooks/visibilitySuspension.ts
Fusion 0dc42334ce feat(FN-4350): complete Step 1 — visibility became-visible subscription
Fusion-Task-Id: FN-4350
Fusion-Task-Lineage: 11fff2c2-1cd1-4562-9b01-032a15217479
2026-05-13 12:26:07 -07:00

103 lines
3.1 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 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]);
}