111 lines
4.1 KiB
TypeScript
111 lines
4.1 KiB
TypeScript
import { useSyncExternalStore } from "react";
|
|
|
|
/**
|
|
* Page-visibility helpers for restore-storm mitigation (PAP-12556 / Phase 1).
|
|
*
|
|
* A tab can be in one of three practical states that matter for polling:
|
|
* - hidden: `document.visibilityState !== "visible"` — the user is not looking at it.
|
|
* - visible: on screen but not the focused window (e.g. split-screen, another window on top).
|
|
* - focused: visible AND `document.hasFocus()` — the tab the user is actively using.
|
|
*
|
|
* The distinction matters because the UX guidance (PAP-12552 §6, "active-tab exemption")
|
|
* requires the focused tab to keep near-normal cadence and minimal jitter, while merely
|
|
* restored/background tabs may slow down and jitter widely. Header value is a
|
|
* non-authoritative observability hint only — never a security signal.
|
|
*/
|
|
|
|
export interface PageVisibility {
|
|
/** `document.visibilityState === "visible"`. */
|
|
visible: boolean;
|
|
/** Visible AND the document currently has focus. */
|
|
focused: boolean;
|
|
}
|
|
|
|
const HIDDEN: PageVisibility = { visible: false, focused: false };
|
|
|
|
export function getPageVisibility(): PageVisibility {
|
|
if (typeof document === "undefined") {
|
|
// SSR / non-browser: treat as focused so data loads normally.
|
|
return { visible: true, focused: true };
|
|
}
|
|
const visible = document.visibilityState === "visible";
|
|
if (!visible) return HIDDEN;
|
|
const focused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
|
|
return { visible: true, focused };
|
|
}
|
|
|
|
/** Stable header value for `X-Paperclip-Tab-Visible`: "focused" | "visible" | "hidden". */
|
|
export function getVisibilityHeaderValue(state: PageVisibility = getPageVisibility()): string {
|
|
if (!state.visible) return "hidden";
|
|
return state.focused ? "focused" : "visible";
|
|
}
|
|
|
|
// --- Shared subscription (single set of DOM listeners for all consumers) ---
|
|
|
|
const listeners = new Set<() => void>();
|
|
let cached: PageVisibility = getPageVisibility();
|
|
let attached = false;
|
|
|
|
function sameState(a: PageVisibility, b: PageVisibility): boolean {
|
|
return a.visible === b.visible && a.focused === b.focused;
|
|
}
|
|
|
|
function recompute() {
|
|
const next = getPageVisibility();
|
|
if (sameState(next, cached)) return;
|
|
cached = next;
|
|
for (const listener of listeners) listener();
|
|
}
|
|
|
|
function attach() {
|
|
if (attached || typeof document === "undefined") return;
|
|
attached = true;
|
|
document.addEventListener("visibilitychange", recompute);
|
|
window.addEventListener("focus", recompute);
|
|
window.addEventListener("blur", recompute);
|
|
window.addEventListener("pageshow", recompute);
|
|
}
|
|
|
|
function detach() {
|
|
if (!attached || typeof document === "undefined") return;
|
|
attached = false;
|
|
document.removeEventListener("visibilitychange", recompute);
|
|
window.removeEventListener("focus", recompute);
|
|
window.removeEventListener("blur", recompute);
|
|
window.removeEventListener("pageshow", recompute);
|
|
}
|
|
|
|
/** Subscribe to visibility/focus transitions. Returns an unsubscribe fn. */
|
|
export function subscribePageVisibility(listener: () => void): () => void {
|
|
listeners.add(listener);
|
|
attach();
|
|
// Re-sync in case state changed between last recompute and subscription.
|
|
cached = getPageVisibility();
|
|
return () => {
|
|
listeners.delete(listener);
|
|
if (listeners.size === 0) detach();
|
|
};
|
|
}
|
|
|
|
/** Snapshot for `useSyncExternalStore` — returns a referentially-stable object between changes. */
|
|
export function getPageVisibilitySnapshot(): PageVisibility {
|
|
// Browser visibility can change before the first subscriber attaches (for
|
|
// example when a restored tab mounts while already hidden). Reconcile on
|
|
// every snapshot read so the initial render cannot briefly enable polling.
|
|
const next = getPageVisibility();
|
|
if (!sameState(next, cached)) cached = next;
|
|
return cached;
|
|
}
|
|
|
|
function getServerSnapshot(): PageVisibility {
|
|
return { visible: true, focused: true };
|
|
}
|
|
|
|
/**
|
|
* React hook returning the current `{ visible, focused }` state, re-rendering on transitions.
|
|
* Backed by a single shared set of DOM listeners regardless of how many components subscribe.
|
|
*/
|
|
export function usePageVisibility(): PageVisibility {
|
|
return useSyncExternalStore(subscribePageVisibility, getPageVisibilitySnapshot, getServerSnapshot);
|
|
}
|