1/**
2 * Keeping what a reader is looking at true, whichever way they arrived at it.
3 *
4 * A pull request changes while nobody is looking at it — a push lands, a check finishes, somebody
5 * approves — and a view that only reads on mount shows yesterday's answer for as long as the tab
6 * stays open. Three moments deserve a read: arriving at the view, coming back to the window, and
7 * simply sitting on it while it is open. All three are the same policy, so they live in one hook
8 * rather than three; every one of them is bounded by the same minimum interval, so the moments
9 * arriving together still cost one read.
10 *
11 * Every read here shells out to a host's API from the server, so the policy is written to spend as
12 * little as it can get away with: the very first arrival at a view spends nothing, because the
13 * view's own first read is already on its way, and the interval stops as soon as the reader stops
14 * reading.
15 */
16import { useEffect, useId, useRef } from "react";
17
18/** Long enough that alt-tabbing through windows does not become a request per tab stop. */
19export const LIVE_REFRESH_MIN_INTERVAL_MS = 10_000;
20
21/** Slow enough to preserve host quota while still updating a view left open. */
22export const LIVE_REFRESH_INTERVAL_MS = 5 * 60_000;
23
24/**
25 * How long a showing window goes untouched before it stops reading. This leaves one minute after
26 * the five-minute interval for the first timer tick to run. A window left open on a
27 * monitor nobody is sitting at is showing in every sense the browser knows about, and polling it
28 * until morning spends a night of somebody's rate limit on an answer nobody read. Their next
29 * click, key or scroll starts the interval up again.
30 */
31export const LIVE_REFRESH_IDLE_AFTER_MS = 6 * 60_000;
32
33/**
34 * Whether a view should read again now. Separate from the hook because the rule is the whole of
35 * it: only while the window is actually showing, and not again straight away — a reader passing
36 * through three windows, or returning to a tab that missed an hour of ticks, is asking for one
37 * read, not one per thing that happened while they were away.
38 */
39export function shouldLiveRefresh(input: {
40 readonly visible: boolean;
41 readonly now: number;
42 readonly lastRefreshedAt: number;
43}): boolean {
44 return input.visible && input.now - input.lastRefreshedAt >= LIVE_REFRESH_MIN_INTERVAL_MS;
45}
46
47/**
48 * Whether arriving at a view should read it. A view nobody has read this session is left alone:
49 * mounting it starts its own read, and refreshing on top of that cancels the request in flight
50 * and pays for the same answer twice. Every later arrival is an ordinary refresh.
51 */
52export function shouldRefreshOnArrival(input: {
53 readonly visible: boolean;
54 readonly now: number;
55 readonly lastRefreshedAt: number | undefined;
56}): boolean {
57 return (
58 input.lastRefreshedAt !== undefined &&
59 shouldLiveRefresh({ ...input, lastRefreshedAt: input.lastRefreshedAt })
60 );
61}
62
63/**
64 * Whether the interval should read again. The same rule as any other read, plus the reader having
65 * been here recently enough for the answer to be for somebody.
66 */
67export function shouldRefreshOnInterval(input: {
68 readonly visible: boolean;
69 readonly now: number;
70 readonly lastRefreshedAt: number;
71 readonly lastInteractedAt: number;
72}): boolean {
73 return (
74 input.now - input.lastInteractedAt < LIVE_REFRESH_IDLE_AFTER_MS && shouldLiveRefresh(input)
75 );
76}
77
78/**
79 * When each view last read, kept outside React because a mount is exactly what it has to outlive:
80 * a reader who navigates away and straight back mounts a fresh hook, and a timestamp that started
81 * at zero would call that first read due. Keyed by whatever the caller calls the view, because two
82 * views on screen at once each owe their own reader an answer. Entries are one number and never
83 * pruned; there is one per view the session ever read.
84 */
85const lastRefreshedAtByView = new Map<string, number>();
86
87/**
88 * When the reader last did anything. Shared rather than per view: a person is present in the
89 * window, not in one component of it. Only tracked while a live view is mounted.
90 */
91let lastInteractedAt = 0;
92const interactionWatchers = new Set<() => void>();
93const INTERACTION_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel"] as const;
94const noteInteraction = () => {
95 const now = Date.now();
96 const wasIdle = now - lastInteractedAt >= LIVE_REFRESH_IDLE_AFTER_MS;
97 lastInteractedAt = now;
98 if (wasIdle) for (const resume of interactionWatchers) resume();
99};
100
101function watchInteraction(resume: () => void): () => void {
102 if (interactionWatchers.size === 0) {
103 // Arriving is itself the reader doing something, and it is what makes the first interval tick
104 // after a mount count.
105 lastInteractedAt = Date.now();
106 for (const event of INTERACTION_EVENTS) {
107 document.addEventListener(event, noteInteraction, { passive: true });
108 }
109 }
110 interactionWatchers.add(resume);
111 return () => {
112 interactionWatchers.delete(resume);
113 if (interactionWatchers.size > 0) return;
114 for (const event of INTERACTION_EVENTS) {
115 document.removeEventListener(event, noteInteraction);
116 }
117 };
118}
119
120export function useLiveRefresh(
121 refresh: (() => void) | null,
122 options: {
123 readonly enabled?: boolean;
124 readonly key?: string;
125 readonly intervalMs?: number;
126 } = {},
127): void {
128 const { enabled = true, key, intervalMs = LIVE_REFRESH_INTERVAL_MS } = options;
129 // Held in a ref so a caller can pass a fresh closure every render without re-arming the
130 // listeners, which would otherwise refresh on every render that changed anything at all.
131 const latest = useRef(refresh);
132 latest.current = refresh;
133 // A caller that shows a different thing in the same place — one panel, a pull request at a time —
134 // says which thing it is showing, so the reads it owes follow the thing rather than the slot.
135 // Anything else is one view per place it appears, which is what the tree position already means.
136 const fallbackViewId = useId();
137 const viewId = key ?? fallbackViewId;
138
139 useEffect(() => {
140 if (!enabled) return;
141 const read = (now: number) => {
142 if (latest.current === null) return;
143 lastRefreshedAtByView.set(viewId, now);
144 latest.current();
145 };
146 const visible = () => document.visibilityState === "visible";
147 const onArrival = () => {
148 const now = Date.now();
149 if (now - lastInteractedAt >= LIVE_REFRESH_IDLE_AFTER_MS) return;
150 const lastRefreshedAt = lastRefreshedAtByView.get(viewId);
151 if (lastRefreshedAt === undefined) {
152 // Nothing read yet, so nothing to refresh: the mount's own read is what fills this in.
153 lastRefreshedAtByView.set(viewId, now);
154 return;
155 }
156 if (shouldRefreshOnArrival({ visible: visible(), now, lastRefreshedAt })) read(now);
157 };
158 const onInterval = () => {
159 const now = Date.now();
160 const lastRefreshedAt = lastRefreshedAtByView.get(viewId) ?? now;
161 if (shouldRefreshOnInterval({ visible: visible(), now, lastRefreshedAt, lastInteractedAt })) {
162 read(now);
163 }
164 };
165
166 let timer: ReturnType<typeof setInterval> | undefined;
167 // The timer exists only while the window is showing: a tab sitting behind another one should
168 // cost the host nothing, and the reads it missed collapse into the single one it makes on the
169 // way back. Nothing else moves the window between showing and hidden, so nothing else re-arms.
170 const syncTimer = () => {
171 clearInterval(timer);
172 timer = visible() ? setInterval(onInterval, intervalMs) : undefined;
173 };
174 const onVisibilityChange = () => {
175 onArrival();
176 syncTimer();
177 };
178
179 const stopWatchingInteraction = watchInteraction(() => {
180 onArrival();
181 syncTimer();
182 });
183 onArrival();
184 syncTimer();
185 window.addEventListener("focus", onArrival);
186 document.addEventListener("visibilitychange", onVisibilityChange);
187 return () => {
188 clearInterval(timer);
189 window.removeEventListener("focus", onArrival);
190 document.removeEventListener("visibilitychange", onVisibilityChange);
191 stopWatchingInteraction();
192 };
193 }, [enabled, viewId, intervalMs]);
194}