1import { useEffect, useEffectEvent, useId } from "react";
2
3export const LIVE_REFRESH_MIN_INTERVAL_MS = 10_000;
4
5const LIVE_REFRESH_INTERVAL_MS = 5 * 60_000;
6
7const LIVE_REFRESH_IDLE_AFTER_MS = 6 * 60_000;
8
9function shouldLiveRefresh(input: {
10 readonly visible: boolean;
11 readonly now: number;
12 readonly lastRefreshedAt: number;
13}): boolean {
14 return input.visible && input.now - input.lastRefreshedAt >= LIVE_REFRESH_MIN_INTERVAL_MS;
15}
16
17/**
18 * A view nobody has read this session is left alone: mounting it starts its own read, and
19 * refreshing on top of that cancels the request in flight and pays for the same answer twice.
20 */
21function shouldRefreshOnArrival(input: {
22 readonly visible: boolean;
23 readonly now: number;
24 readonly lastRefreshedAt: number | undefined;
25}): boolean {
26 return (
27 input.lastRefreshedAt !== undefined &&
28 shouldLiveRefresh({ ...input, lastRefreshedAt: input.lastRefreshedAt })
29 );
30}
31
32function shouldRefreshOnInterval(input: {
33 readonly visible: boolean;
34 readonly now: number;
35 readonly lastRefreshedAt: number;
36 readonly lastInteractedAt: number;
37}): boolean {
38 return (
39 input.now - input.lastInteractedAt < LIVE_REFRESH_IDLE_AFTER_MS && shouldLiveRefresh(input)
40 );
41}
42
43// Survive remounts so returning to a view does not make fresh reads look overdue.
44const lastRefreshedAtByView = new Map<string, number>();
45
46let lastInteractedAt = 0;
47const interactionWatchers = new Set<() => void>();
48const INTERACTION_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel"] as const;
49const noteInteraction = () => {
50 const now = Date.now();
51 const wasIdle = now - lastInteractedAt >= LIVE_REFRESH_IDLE_AFTER_MS;
52 lastInteractedAt = now;
53 if (wasIdle) for (const resume of interactionWatchers) resume();
54};
55
56function watchInteraction(resume: () => void): () => void {
57 if (interactionWatchers.size === 0) {
58 lastInteractedAt = Date.now();
59 for (const event of INTERACTION_EVENTS) {
60 document.addEventListener(event, noteInteraction, { passive: true });
61 }
62 }
63 interactionWatchers.add(resume);
64 return () => {
65 interactionWatchers.delete(resume);
66 if (interactionWatchers.size > 0) return;
67 for (const event of INTERACTION_EVENTS) {
68 document.removeEventListener(event, noteInteraction);
69 }
70 };
71}
72
73export function useLiveRefresh(
74 refresh: (() => void) | null,
75 options: {
76 readonly enabled?: boolean;
77 readonly key?: string;
78 /** `null` turns off the periodic refresh, for a read too costly to repeat unasked. */
79 readonly intervalMs?: number | null;
80 /**
81 * Refresh every time the view is enabled unless it read within the floor, skipping the
82 * first-read rule: for a view that stays mounted while hidden, so its mount-time read can be
83 * long stale when shown.
84 */
85 readonly refreshOnShow?: boolean;
86 } = {},
87): void {
88 const {
89 enabled = true,
90 key,
91 intervalMs = LIVE_REFRESH_INTERVAL_MS,
92 refreshOnShow = false,
93 } = options;
94 const runRefresh = useEffectEvent((viewId: string, now: number) => {
95 if (refresh === null) return;
96 lastRefreshedAtByView.set(viewId, now);
97 refresh();
98 });
99 const fallbackViewId = useId();
100 const viewId = key ?? fallbackViewId;
101
102 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refresh interval tied to page visibility and user interaction listeners
103 useEffect(() => {
104 if (!enabled) return;
105 const read = (now: number) => runRefresh(viewId, now);
106 const visible = () => document.visibilityState === "visible";
107 const onArrival = () => {
108 const now = Date.now();
109 if (now - lastInteractedAt >= LIVE_REFRESH_IDLE_AFTER_MS) return;
110 const lastRefreshedAt = lastRefreshedAtByView.get(viewId);
111 if (lastRefreshedAt === undefined) {
112 lastRefreshedAtByView.set(viewId, now);
113 return;
114 }
115 if (shouldRefreshOnArrival({ visible: visible(), now, lastRefreshedAt })) read(now);
116 };
117 const onInterval = () => {
118 const now = Date.now();
119 const lastRefreshedAt = lastRefreshedAtByView.get(viewId) ?? now;
120 if (shouldRefreshOnInterval({ visible: visible(), now, lastRefreshedAt, lastInteractedAt })) {
121 read(now);
122 }
123 };
124
125 let timer: ReturnType<typeof setInterval> | undefined;
126 const syncTimer = () => {
127 clearInterval(timer);
128 timer = visible() && intervalMs !== null ? setInterval(onInterval, intervalMs) : undefined;
129 };
130 const onVisibilityChange = () => {
131 onArrival();
132 syncTimer();
133 };
134
135 const stopWatchingInteraction = watchInteraction(() => {
136 onArrival();
137 syncTimer();
138 });
139 if (refreshOnShow) {
140 const now = Date.now();
141 const lastRefreshedAt = lastRefreshedAtByView.get(viewId);
142 if (lastRefreshedAt === undefined || now - lastRefreshedAt >= LIVE_REFRESH_MIN_INTERVAL_MS) {
143 read(now);
144 }
145 } else onArrival();
146 syncTimer();
147 window.addEventListener("focus", onArrival);
148 document.addEventListener("visibilitychange", onVisibilityChange);
149 return () => {
150 clearInterval(timer);
151 window.removeEventListener("focus", onArrival);
152 document.removeEventListener("visibilitychange", onVisibilityChange);
153 stopWatchingInteraction();
154 };
155 }, [enabled, viewId, intervalMs, refreshOnShow]);
156}