1import { act, createElement } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 LIVE_REFRESH_IDLE_AFTER_MS,
7 LIVE_REFRESH_INTERVAL_MS,
8 LIVE_REFRESH_MIN_INTERVAL_MS,
9 shouldLiveRefresh,
10 shouldRefreshOnArrival,
11 shouldRefreshOnInterval,
12 useLiveRefresh,
13} from "./useLiveRefresh";
14
15describe("live refresh cadence", () => {
16 it("polls every 45 seconds, pauses while hidden or busy, respects disabling, and cleans up", () => {
17 vi.useFakeTimers();
18 vi.setSystemTime(0);
19 const document = Object.assign(new EventTarget(), { visibilityState: "visible" });
20 const window = new EventTarget();
21 vi.stubGlobal("document", document);
22 vi.stubGlobal("window", window);
23 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
24 const refresh = vi.fn();
25 let renderer: ReactTestRenderer | undefined;
26 function Probe({ pending = false, enabled = true }) {
27 useLiveRefresh(pending ? null : refresh, { intervalMs: 45_000, enabled });
28 return null;
29 }
30 try {
31 act(() => {
32 renderer = create(createElement(Probe));
33 });
34 act(() => vi.advanceTimersByTime(44_999));
35 expect(refresh).not.toHaveBeenCalled();
36 act(() => vi.advanceTimersByTime(1));
37 expect(refresh).toHaveBeenCalledTimes(1);
38 act(() => vi.advanceTimersByTime(45_000));
39 expect(refresh).toHaveBeenCalledTimes(2);
40 document.visibilityState = "hidden";
41 document.dispatchEvent(new Event("visibilitychange"));
42 act(() => vi.advanceTimersByTime(90_000));
43 expect(refresh).toHaveBeenCalledTimes(2);
44 document.visibilityState = "visible";
45 document.dispatchEvent(new Event("visibilitychange"));
46 window.dispatchEvent(new Event("focus"));
47 expect(refresh).toHaveBeenCalledTimes(3);
48 act(() => renderer?.update(createElement(Probe, { pending: true })));
49 act(() => vi.advanceTimersByTime(45_000));
50 expect(refresh).toHaveBeenCalledTimes(3);
51 act(() => renderer?.update(createElement(Probe)));
52 act(() => vi.advanceTimersByTime(45_000));
53 expect(refresh).toHaveBeenCalledTimes(4);
54 act(() => renderer?.update(createElement(Probe, { enabled: false })));
55 act(() => vi.advanceTimersByTime(90_000));
56 window.dispatchEvent(new Event("focus"));
57 expect(refresh).toHaveBeenCalledTimes(4);
58 act(() => renderer?.unmount());
59 act(() => vi.advanceTimersByTime(45_000));
60 expect(refresh).toHaveBeenCalledTimes(4);
61 } finally {
62 act(() => renderer?.unmount());
63 vi.useRealTimers();
64 vi.unstubAllGlobals();
65 }
66 });
67
68 it("waits five minutes between automatic host reads", () => {
69 expect(LIVE_REFRESH_INTERVAL_MS).toBe(5 * 60_000);
70 });
71
72 it.each(["focus", "visibilitychange"])(
73 "keeps an idle view paused after %s until input",
74 (event) => {
75 vi.useFakeTimers();
76 vi.setSystemTime(1_000_000);
77 const document = Object.assign(new EventTarget(), { visibilityState: "visible" });
78 const window = new EventTarget();
79 vi.stubGlobal("document", document);
80 vi.stubGlobal("window", window);
81 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
82 const refresh = vi.fn();
83 let renderer: ReactTestRenderer | undefined;
84 function Probe() {
85 useLiveRefresh(refresh, { intervalMs: 45_000 });
86 return null;
87 }
88 try {
89 act(() => {
90 renderer = create(createElement(Probe));
91 });
92 act(() => vi.advanceTimersByTime(LIVE_REFRESH_IDLE_AFTER_MS));
93 refresh.mockClear();
94 act(() => (event === "focus" ? window : document).dispatchEvent(new Event(event)));
95 expect(refresh).not.toHaveBeenCalled();
96 act(() => vi.advanceTimersByTime(90_000));
97 expect(refresh).not.toHaveBeenCalled();
98 act(() => document.dispatchEvent(new Event("pointerdown")));
99 expect(refresh).toHaveBeenCalledTimes(1);
100 act(() => vi.advanceTimersByTime(45_000));
101 expect(refresh).toHaveBeenCalledTimes(2);
102 } finally {
103 act(() => renderer?.unmount());
104 vi.useRealTimers();
105 vi.unstubAllGlobals();
106 }
107 },
108 );
109});
110
111describe("shouldLiveRefresh", () => {
112 const at = (now: number, lastRefreshedAt: number, visible = true) =>
113 shouldLiveRefresh({ visible, now, lastRefreshedAt });
114
115 it("reads a view again when it is navigated to", () => {
116 expect(at(LIVE_REFRESH_MIN_INTERVAL_MS, 0)).toBe(true);
117 });
118
119 it("does not read a view again that was left and returned to seconds later", () => {
120 expect(at(3_000, 0)).toBe(false);
121 });
122
123 it("reads again when the interval comes round on a view left open", () => {
124 expect(at(LIVE_REFRESH_INTERVAL_MS, 0)).toBe(true);
125 });
126
127 it("does not read again for every window tabbed through", () => {
128 expect(at(1_000, 0)).toBe(false);
129 });
130
131 it("stays quiet while the window is not showing", () => {
132 // A focus event can arrive for a window that is still hidden behind another one.
133 expect(at(LIVE_REFRESH_MIN_INTERVAL_MS * 5, 0, false)).toBe(false);
134 });
135
136 it("reads once for a window hidden an hour, not once per interval it missed", () => {
137 const hour = 60 * 60_000;
138 let lastRefreshedAt = 0;
139 let reads = 0;
140 const tick = (now: number, visible: boolean) => {
141 if (!at(now, lastRefreshedAt, visible)) return;
142 lastRefreshedAt = now;
143 reads += 1;
144 };
145
146 for (let now = LIVE_REFRESH_INTERVAL_MS; now < hour; now += LIVE_REFRESH_INTERVAL_MS) {
147 tick(now, false);
148 }
149 // Coming back raises a visibility change and a focus event, one straight after the other.
150 tick(hour, true);
151 tick(hour, true);
152
153 expect(reads).toBe(1);
154 });
155});
156
157describe("shouldRefreshOnArrival", () => {
158 it("leaves a view alone the first time it is opened, because it is already reading", () => {
159 expect(shouldRefreshOnArrival({ visible: true, now: 5_000, lastRefreshedAt: undefined })).toBe(
160 false,
161 );
162 });
163
164 it("reads a view that was read earlier in the session and returned to", () => {
165 expect(shouldRefreshOnArrival({ visible: true, now: 90_000, lastRefreshedAt: 0 })).toBe(true);
166 });
167
168 it("keeps the minimum interval on a view returned to straight away", () => {
169 expect(shouldRefreshOnArrival({ visible: true, now: 2_000, lastRefreshedAt: 0 })).toBe(false);
170 });
171});
172
173describe("shouldRefreshOnInterval", () => {
174 const tick = (now: number, lastInteractedAt: number) =>
175 shouldRefreshOnInterval({ visible: true, now, lastRefreshedAt: 0, lastInteractedAt });
176
177 it("reads for a reader who is here", () => {
178 expect(tick(LIVE_REFRESH_INTERVAL_MS, LIVE_REFRESH_INTERVAL_MS - 1_000)).toBe(true);
179 });
180
181 it("reads on the first interval after an untouched mount", () => {
182 expect(tick(LIVE_REFRESH_INTERVAL_MS + 1_000, 0)).toBe(true);
183 });
184
185 it("stops reading for a window left showing on a desk nobody is at", () => {
186 expect(tick(LIVE_REFRESH_IDLE_AFTER_MS + 60_000, 0)).toBe(false);
187 });
188
189 it("starts reading again once the reader touches the window", () => {
190 const away = LIVE_REFRESH_IDLE_AFTER_MS + 60_000;
191 expect(tick(away + LIVE_REFRESH_MIN_INTERVAL_MS, away)).toBe(true);
192 });
193});