feat(FN-4350): complete Step 1 — visibility became-visible subscription

Fusion-Task-Id: FN-4350
Fusion-Task-Lineage: 11fff2c2-1cd1-4562-9b01-032a15217479
This commit is contained in:
Fusion
2026-05-13 12:01:43 -07:00
committed by gsxdsm
parent 6e5e2b0747
commit 138038971f
2 changed files with 153 additions and 7 deletions

View File

@@ -1,6 +1,10 @@
import { act, fireEvent, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { isLikelyTabSuspensionError, useTabVisibilitySuspension } from "../visibilitySuspension";
import {
isLikelyTabSuspensionError,
lastVisibilityTransition,
useTabVisibilitySuspension,
} from "../visibilitySuspension";
describe("visibilitySuspension", () => {
it.each([
@@ -44,6 +48,8 @@ describe("visibilitySuspension", () => {
fireEvent(document, new Event("visibilitychange"));
});
expect(lastVisibilityTransition().hiddenAt).not.toBeNull();
expect(lastVisibilityTransition().visibleAt).not.toBeNull();
expect(result.current.wasRecentlyHidden(5000)).toBe(true);
act(() => {
@@ -54,4 +60,110 @@ describe("visibilitySuspension", () => {
vi.useRealTimers();
});
it("fires onBecameVisible on hidden to visible transition", () => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
const { result } = renderHook(() => useTabVisibilitySuspension());
const callback = vi.fn();
result.current.onBecameVisible(callback);
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
act(() => {
fireEvent(document, new Event("visibilitychange"));
});
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
act(() => {
fireEvent(document, new Event("visibilitychange"));
});
expect(callback).toHaveBeenCalledTimes(1);
});
it("does not fire onBecameVisible when moving visible to hidden", () => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
const { result } = renderHook(() => useTabVisibilitySuspension());
const callback = vi.fn();
result.current.onBecameVisible(callback);
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
act(() => {
fireEvent(document, new Event("visibilitychange"));
});
expect(callback).not.toHaveBeenCalled();
});
it("unsubscribes onBecameVisible callback", () => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
const { result } = renderHook(() => useTabVisibilitySuspension());
const callback = vi.fn();
const unsubscribe = result.current.onBecameVisible(callback);
unsubscribe();
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
act(() => {
fireEvent(document, new Event("visibilitychange"));
});
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
act(() => {
fireEvent(document, new Event("visibilitychange"));
});
expect(callback).not.toHaveBeenCalled();
});
it("fires all onBecameVisible subscribers", () => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
const { result } = renderHook(() => useTabVisibilitySuspension());
const callbackA = vi.fn();
const callbackB = vi.fn();
result.current.onBecameVisible(callbackA);
result.current.onBecameVisible(callbackB);
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
act(() => {
fireEvent(document, new Event("visibilitychange"));
});
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
act(() => {
fireEvent(document, new Event("visibilitychange"));
});
expect(callbackA).toHaveBeenCalledTimes(1);
expect(callbackB).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,8 @@
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",
@@ -17,24 +20,47 @@ export function isLikelyTabSuspensionError(message: string): boolean {
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>(null);
const lastVisibleAtRef = useRef<number | null>(null);
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();
if (document.visibilityState === "hidden") {
const currentVisibilityState = document.visibilityState;
if (currentVisibilityState === "hidden") {
lastHiddenAtRef.current = now;
return;
lastHiddenAt = now;
}
if (document.visibilityState === "visible") {
if (currentVisibilityState === "visible") {
lastVisibleAtRef.current = now;
lastVisibleAt = now;
if (previousVisibilityState === "hidden") {
for (const handler of visibilityHandlersRef.current) {
handler();
}
}
}
previousVisibilityState = currentVisibilityState;
};
handleVisibilityChange();
@@ -61,8 +87,16 @@ export function useTabVisibilitySuspension() {
return now - visibleAt <= windowMs;
}, [isHiddenNow]);
const onBecameVisible = useCallback((handler: () => void) => {
visibilityHandlersRef.current.add(handler);
return () => {
visibilityHandlersRef.current.delete(handler);
};
}, []);
return useMemo(() => ({
isHiddenNow,
wasRecentlyHidden,
}), [isHiddenNow, wasRecentlyHidden]);
onBecameVisible,
}), [isHiddenNow, onBecameVisible, wasRecentlyHidden]);
}