1// @vitest-environment jsdom
2import { act, useEffect, useLayoutEffect, useSyncExternalStore } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6const harness = vi.hoisted(() => {
7 const listeners = new Set<() => void>();
8 const appStateListeners = new Set<(state: string) => void>();
9 const state = { focused: true, appState: "active" };
10 return {
11 state,
12 appStateListeners,
13 subscribe(listener: () => void) {
14 listeners.add(listener);
15 return () => listeners.delete(listener);
16 },
17 setFocused(focused: boolean) {
18 state.focused = focused;
19 for (const listener of listeners) listener();
20 },
21 setAppState(next: string) {
22 state.appState = next;
23 for (const listener of appStateListeners) listener(next);
24 },
25 };
26});
27
28vi.mock("@react-navigation/native", () => ({
29 useIsFocused: () => useSyncExternalStore(harness.subscribe, () => harness.state.focused),
30}));
31vi.mock("react-native", () => ({
32 AppState: {
33 get currentState() {
34 return harness.state.appState;
35 },
36 addEventListener: (_event: "change", listener: (state: string) => void) => {
37 harness.appStateListeners.add(listener);
38 return { remove: () => harness.appStateListeners.delete(listener) };
39 },
40 },
41}));
42
43import { useVisibleSecondClock } from "./use-visible-second-clock";
44
45const START_MS = Date.UTC(2026, 9, 3, 12, 0, 0);
46let root: Root | null = null;
47// Clock value of every committed render, the work a tick costs on device.
48let renders: number[] = [];
49
50function Probe(props: { readonly enabled: boolean }) {
51 const nowMs = useVisibleSecondClock(props.enabled);
52 useEffect(() => {
53 renders.push(nowMs);
54 });
55 return null;
56}
57
58function mount(enabled: boolean) {
59 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
60 root = createRoot(document.createElement("div"));
61 act(() => root!.render(<Probe enabled={enabled} />));
62}
63
64// One act per second, so each tick commits its own render as it would on device.
65function advance(ms: number) {
66 for (let elapsed = 0; elapsed < ms; elapsed += 1_000) {
67 act(() => vi.advanceTimersByTime(Math.min(1_000, ms - elapsed)));
68 }
69}
70
71function latest() {
72 return renders.at(-1);
73}
74
75beforeEach(() => {
76 vi.useFakeTimers();
77 vi.setSystemTime(START_MS);
78 harness.state.focused = true;
79 harness.state.appState = "active";
80 renders = [];
81});
82
83afterEach(() => {
84 act(() => root?.unmount());
85 root = null;
86 vi.useRealTimers();
87});
88
89describe("useVisibleSecondClock", () => {
90 it("ticks once a second while enabled, focused and active", () => {
91 mount(true);
92 const before = renders.length;
93 advance(60_000);
94 expect(renders.length - before).toBe(60);
95 expect(latest()).toBe(START_MS + 60_000);
96 });
97
98 it("does not wake while the app is in the background", () => {
99 harness.state.appState = "background";
100 mount(true);
101 const before = renders.length;
102 advance(60_000);
103 expect(renders.length - before).toBe(0);
104 expect(vi.getTimerCount()).toBe(0);
105 });
106
107 it("does not wake while the screen is unfocused", () => {
108 harness.state.focused = false;
109 mount(true);
110 const before = renders.length;
111 advance(60_000);
112 expect(renders.length - before).toBe(0);
113 expect(vi.getTimerCount()).toBe(0);
114 });
115
116 it("does not wake when disabled, even when visible", () => {
117 mount(false);
118 const before = renders.length;
119 advance(60_000);
120 expect(renders.length - before).toBe(0);
121 });
122
123 it("notices an AppState change between first render and subscribing", () => {
124 // Layout effects run before the hook's passive subscription, so this change has no listener.
125 function BackgroundBeforeSubscribe() {
126 useLayoutEffect(() => harness.setAppState("background"), []);
127 return null;
128 }
129 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
130 root = createRoot(document.createElement("div"));
131 act(() =>
132 root!.render(
133 <>
134 <BackgroundBeforeSubscribe />
135 <Probe enabled />
136 </>,
137 ),
138 );
139 const before = renders.length;
140 advance(60_000);
141 expect(renders.length - before).toBe(0);
142 expect(vi.getTimerCount()).toBe(0);
143 });
144
145 it("pauses on blur and shows wall-clock elapsed time the moment it refocuses", () => {
146 mount(true);
147 advance(5_000);
148 act(() => harness.setFocused(false));
149 const paused = renders.length;
150 advance(120_000);
151 expect(renders.length).toBe(paused);
152
153 act(() => harness.setFocused(true));
154 expect(latest()).toBe(START_MS + 125_000);
155 advance(1_000);
156 expect(latest()).toBe(START_MS + 126_000);
157 });
158
159 it("pauses while backgrounded and catches up on the first active render", () => {
160 mount(true);
161 act(() => harness.setAppState("background"));
162 const paused = renders.length;
163 advance(600_000);
164 expect(renders.length).toBe(paused);
165 expect(vi.getTimerCount()).toBe(0);
166
167 act(() => harness.setAppState("inactive"));
168 advance(10_000);
169 expect(renders.length).toBe(paused);
170
171 act(() => harness.setAppState("active"));
172 expect(latest()).toBe(START_MS + 610_000);
173 const resumed = renders.length;
174 advance(60_000);
175 expect(renders.length - resumed).toBe(60);
176 });
177
178 it("stops when the work it measures settles and refreshes when new work starts", () => {
179 mount(true);
180 advance(3_000);
181 act(() => root!.render(<Probe enabled={false} />));
182 const settled = renders.length;
183 advance(60_000);
184 expect(renders.length).toBe(settled);
185
186 act(() => root!.render(<Probe enabled />));
187 expect(latest()).toBe(START_MS + 63_000);
188 });
189
190 it("clears its interval and AppState subscription on unmount", () => {
191 mount(true);
192 expect(vi.getTimerCount()).toBe(1);
193 expect(harness.appStateListeners.size).toBe(1);
194 act(() => root!.unmount());
195 root = null;
196 expect(vi.getTimerCount()).toBe(0);
197 expect(harness.appStateListeners.size).toBe(0);
198 });
199});