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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user