Skip to content

Commit 532cfaf

Browse files
committed
fix: 避免后台隐藏页面刷新状态
1 parent e7cc234 commit 532cfaf

2 files changed

Lines changed: 181 additions & 6 deletions

File tree

frontend/src/App.test.tsx

Lines changed: 155 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ describe("App", () => {
9191
});
9292

9393
afterEach(() => {
94+
Object.defineProperty(document, "visibilityState", {
95+
configurable: true,
96+
value: "visible",
97+
});
9498
vi.unstubAllGlobals();
9599
vi.useRealTimers();
96100
});
@@ -389,7 +393,6 @@ describe("App", () => {
389393
await act(async () => {
390394
await Promise.resolve();
391395
});
392-
393396
expect(screen.getByText(/accounts-sync:0/)).toBeInTheDocument();
394397
expect(mockedUpdateService.check).toHaveBeenCalledTimes(1);
395398

@@ -605,7 +608,6 @@ describe("App", () => {
605608
await act(async () => {
606609
await Promise.resolve();
607610
});
608-
609611
expect(screen.getByText(/accounts-sync:0/)).toBeInTheDocument();
610612
expect(vi.mocked(refreshDesktopTrayState)).toHaveBeenCalledTimes(1);
611613

@@ -619,6 +621,69 @@ describe("App", () => {
619621
expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:6789/ai-router/api/settings/proxy/status");
620622
});
621623

624+
it("skips periodic status refresh while the main page is hidden", async () => {
625+
vi.useFakeTimers();
626+
const fetchMock = vi.fn((input: RequestInfo | URL) => {
627+
const url = String(input);
628+
if (url === "http://127.0.0.1:6789/ai-router/api/settings/proxy/status") {
629+
return Promise.resolve(new Response(JSON.stringify({ enabled: false }), { status: 200, headers: { "Content-Type": "application/json" } }));
630+
}
631+
if (url === "http://127.0.0.1:6789/ai-router/api/settings/app") {
632+
return Promise.resolve(
633+
new Response(
634+
JSON.stringify({
635+
launch_at_login: false,
636+
silent_start: false,
637+
close_to_tray: true,
638+
show_proxy_switch_on_home: true,
639+
show_home_update_indicator: false,
640+
status_refresh_interval_seconds: 5,
641+
proxy_host: "127.0.0.1",
642+
proxy_port: 6789,
643+
auto_failover_enabled: false,
644+
auto_backup_interval_hours: 24,
645+
backup_retention_count: 10,
646+
audit_limit_message: 200,
647+
audit_limit_function_call: 100,
648+
audit_limit_function_call_output: 100,
649+
audit_limit_reasoning: 40,
650+
audit_limit_custom_tool_call: 100,
651+
audit_limit_custom_tool_call_output: 100,
652+
language: "zh-CN",
653+
theme_mode: "system",
654+
}),
655+
{ status: 200, headers: { "Content-Type": "application/json" } },
656+
),
657+
);
658+
}
659+
return Promise.resolve(new Response(null, { status: 404 }));
660+
});
661+
vi.stubGlobal("fetch", fetchMock);
662+
vi.mocked(subscribeDesktopBackendStateChanged).mockResolvedValue(() => {});
663+
664+
render(<App />);
665+
666+
await act(async () => {
667+
await Promise.resolve();
668+
});
669+
670+
expect(screen.getByText(/accounts-sync:0/)).toBeInTheDocument();
671+
expect(vi.mocked(refreshDesktopTrayState)).toHaveBeenCalledTimes(1);
672+
673+
Object.defineProperty(document, "visibilityState", {
674+
configurable: true,
675+
value: "hidden",
676+
});
677+
678+
await act(async () => {
679+
vi.advanceTimersByTime(5_000);
680+
await Promise.resolve();
681+
});
682+
683+
expect(screen.getByText(/accounts-sync:0/)).toBeInTheDocument();
684+
expect(vi.mocked(refreshDesktopTrayState)).toHaveBeenCalledTimes(1);
685+
});
686+
622687
it("opens an update modal when the home update indicator is clicked", async () => {
623688
mockedUpdateService.check.mockResolvedValue({
624689
supported: true,
@@ -1228,6 +1293,9 @@ describe("App", () => {
12281293
render(<App />);
12291294

12301295
expect(await screen.findByText(/accounts-sync:0/)).toBeInTheDocument();
1296+
await waitFor(() => {
1297+
expect(subscribeDesktopBackendStateChanged).toHaveBeenCalled();
1298+
});
12311299

12321300
await act(async () => {
12331301
window.dispatchEvent(new Event("online"));
@@ -1242,9 +1310,84 @@ describe("App", () => {
12421310
});
12431311
});
12441312

1313+
it("backs off immediate usage refresh after a failed online recovery", async () => {
1314+
const fetchMock = vi.fn((input: RequestInfo | URL) => {
1315+
const url = String(input);
1316+
if (url === "http://127.0.0.1:6789/ai-router/api/settings/proxy/status") {
1317+
return Promise.resolve(new Response(JSON.stringify({ enabled: false }), { status: 200, headers: { "Content-Type": "application/json" } }));
1318+
}
1319+
if (url === "http://127.0.0.1:6789/ai-router/api/settings/app") {
1320+
return Promise.resolve(
1321+
new Response(
1322+
JSON.stringify({
1323+
launch_at_login: false,
1324+
silent_start: false,
1325+
close_to_tray: true,
1326+
show_proxy_switch_on_home: true,
1327+
show_home_update_indicator: false,
1328+
status_refresh_interval_seconds: 3600,
1329+
usage_request_timeout_seconds: 15,
1330+
proxy_host: "127.0.0.1",
1331+
proxy_port: 6789,
1332+
auto_failover_enabled: true,
1333+
auto_backup_interval_hours: 24,
1334+
backup_retention_count: 10,
1335+
audit_limit_message: 200,
1336+
audit_limit_function_call: 100,
1337+
audit_limit_function_call_output: 100,
1338+
audit_limit_reasoning: 40,
1339+
audit_limit_custom_tool_call: 100,
1340+
audit_limit_custom_tool_call_output: 100,
1341+
language: "zh-CN",
1342+
theme_mode: "system",
1343+
}),
1344+
{ status: 200, headers: { "Content-Type": "application/json" } },
1345+
),
1346+
);
1347+
}
1348+
if (url === "http://127.0.0.1:6789/ai-router/api/accounts/usage/refresh") {
1349+
return Promise.resolve(new Response("vpn unavailable", { status: 500 }));
1350+
}
1351+
return Promise.resolve(new Response(null, { status: 404 }));
1352+
});
1353+
vi.stubGlobal("fetch", fetchMock);
1354+
vi.mocked(subscribeDesktopBackendStateChanged).mockResolvedValue(() => {});
1355+
1356+
render(<App />);
1357+
1358+
expect(await screen.findByText(/accounts-sync:0/)).toBeInTheDocument();
1359+
vi.useFakeTimers();
1360+
1361+
await act(async () => {
1362+
window.dispatchEvent(new Event("online"));
1363+
await vi.advanceTimersByTimeAsync(400);
1364+
});
1365+
1366+
expect(fetchMock).toHaveBeenCalledWith(
1367+
"http://127.0.0.1:6789/ai-router/api/accounts/usage/refresh",
1368+
expect.objectContaining({ method: "POST" }),
1369+
);
1370+
1371+
const refreshCallsAfterFailure = fetchMock.mock.calls.filter(
1372+
([input]) => String(input) === "http://127.0.0.1:6789/ai-router/api/accounts/usage/refresh",
1373+
).length;
1374+
1375+
await act(async () => {
1376+
window.dispatchEvent(new Event("online"));
1377+
await vi.advanceTimersByTimeAsync(400);
1378+
});
1379+
1380+
expect(
1381+
fetchMock.mock.calls.filter(
1382+
([input]) => String(input) === "http://127.0.0.1:6789/ai-router/api/accounts/usage/refresh",
1383+
),
1384+
).toHaveLength(refreshCallsAfterFailure);
1385+
});
1386+
12451387
it("triggers an immediate usage refresh when the page becomes visible after a long hidden period", async () => {
12461388
let currentTime = new Date("2026-03-20T09:00:00Z").getTime();
12471389
vi.spyOn(Date, "now").mockImplementation(() => currentTime);
1390+
const addDocumentEventListenerSpy = vi.spyOn(document, "addEventListener");
12481391
vi.stubGlobal(
12491392
"fetch",
12501393
vi.fn((input: RequestInfo | URL) => {
@@ -1295,27 +1438,34 @@ describe("App", () => {
12951438
render(<App />);
12961439

12971440
expect(await screen.findByText(/accounts-sync:0/)).toBeInTheDocument();
1441+
await waitFor(() => {
1442+
expect(addDocumentEventListenerSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
1443+
});
1444+
const visibilityChangeHandler = addDocumentEventListenerSpy.mock.calls.toReversed().find(([eventName]) => eventName === "visibilitychange")?.[1] as
1445+
| EventListener
1446+
| undefined;
1447+
expect(visibilityChangeHandler).toEqual(expect.any(Function));
12981448

12991449
await act(async () => {
13001450
Object.defineProperty(document, "visibilityState", {
13011451
configurable: true,
13021452
value: "hidden",
13031453
});
1304-
document.dispatchEvent(new Event("visibilitychange"));
1454+
visibilityChangeHandler?.(new Event("visibilitychange"));
13051455
currentTime += 16_000;
13061456
Object.defineProperty(document, "visibilityState", {
13071457
configurable: true,
13081458
value: "visible",
13091459
});
1310-
document.dispatchEvent(new Event("visibilitychange"));
1460+
visibilityChangeHandler?.(new Event("visibilitychange"));
13111461
});
13121462

13131463
await waitFor(() => {
13141464
expect(fetch).toHaveBeenCalledWith(
13151465
"http://127.0.0.1:6789/ai-router/api/accounts/usage/refresh",
13161466
expect.objectContaining({ method: "POST" }),
13171467
);
1318-
expect(screen.getByText(/accounts-sync:1/)).toBeInTheDocument();
1468+
expect(screen.getByText(/accounts-sync:[1-9]/)).toBeInTheDocument();
13191469
});
13201470
});
13211471

frontend/src/App.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const appSettingsBootstrapRetryDelays = [0, 150, 300, 600, 1_000];
2020
const homeUpdateCheckIntervalMs = 60 * 60 * 1_000;
2121
const defaultStatusRefreshIntervalSeconds = 60;
2222
const immediateUsageRefreshDebounceMs = 400;
23+
const immediateUsageRefreshFailureBackoffMs = 60_000;
2324
const resumeGapWatchIntervalMs = 5_000;
2425
const resumeGapThresholdMs = 30_000;
2526
const hiddenResumeThresholdMs = 15_000;
@@ -32,6 +33,10 @@ function sleep(ms: number): Promise<void> {
3233
});
3334
}
3435

36+
function isMainPageVisible(): boolean {
37+
return typeof document === "undefined" || document.visibilityState === "visible";
38+
}
39+
3540
export function App() {
3641
const [messageApi, contextHolder] = message.useMessage();
3742
const [view, setView] = useState<AppView>("accounts");
@@ -50,6 +55,7 @@ export function App() {
5055
const immediateUsageRefreshInFlightRef = useRef(false);
5156
const immediateUsageRefreshPendingRef = useRef(false);
5257
const immediateUsageRefreshTimerRef = useRef<number | null>(null);
58+
const immediateUsageRefreshLastFailedAtRef = useRef(0);
5359
const hiddenSinceRef = useRef<number | null>(null);
5460
const language = normalizeLanguage(appSettings?.language);
5561
const t = createTranslator(language);
@@ -106,8 +112,11 @@ export function App() {
106112
immediateUsageRefreshPendingRef.current = false;
107113
try {
108114
await refreshAccountUsage();
115+
immediateUsageRefreshLastFailedAtRef.current = 0;
109116
} catch {
110117
// Network and wake-up recovery should stay silent and retry on the next trigger.
118+
immediateUsageRefreshLastFailedAtRef.current = Date.now();
119+
immediateUsageRefreshPendingRef.current = false;
111120
}
112121
setAccountsSyncToken((value) => value + 1);
113122
void refreshDesktopTrayState();
@@ -122,9 +131,16 @@ export function App() {
122131
if (!shellReady || typeof window === "undefined") {
123132
return;
124133
}
134+
if (!isMainPageVisible()) {
135+
return;
136+
}
125137
if (trigger === "online" && typeof navigator !== "undefined" && navigator.onLine === false) {
126138
return;
127139
}
140+
const lastFailureAt = immediateUsageRefreshLastFailedAtRef.current;
141+
if (lastFailureAt > 0 && Date.now() - lastFailureAt < immediateUsageRefreshFailureBackoffMs) {
142+
return;
143+
}
128144
if (immediateUsageRefreshTimerRef.current !== null) {
129145
window.clearTimeout(immediateUsageRefreshTimerRef.current);
130146
}
@@ -206,6 +222,9 @@ export function App() {
206222
let disposed = false;
207223
let unlisten: undefined | (() => void);
208224
const handleBackendStateChanged = () => {
225+
if (!isMainPageVisible()) {
226+
return;
227+
}
209228
void refreshProxyState();
210229
void refreshDesktopTrayState();
211230
setAccountsSyncToken((value) => value + 1);
@@ -241,6 +260,9 @@ export function App() {
241260
}
242261
const hiddenSince = hiddenSinceRef.current;
243262
hiddenSinceRef.current = null;
263+
void refreshProxyState();
264+
void refreshDesktopTrayState();
265+
setAccountsSyncToken((value) => value + 1);
244266
if (hiddenSince !== null && Date.now() - hiddenSince >= hiddenResumeThresholdMs) {
245267
queueImmediateUsageRefresh("visibility_resume");
246268
}
@@ -249,7 +271,7 @@ export function App() {
249271
const now = Date.now();
250272
const elapsed = now - lastTickAt;
251273
lastTickAt = now;
252-
if (elapsed >= resumeGapThresholdMs) {
274+
if (isMainPageVisible() && elapsed >= resumeGapThresholdMs) {
253275
queueImmediateUsageRefresh("resume_gap");
254276
}
255277
}, resumeGapWatchIntervalMs);
@@ -275,6 +297,9 @@ export function App() {
275297

276298
const refreshIntervalSeconds = Math.min(Math.max(appSettings.status_refresh_interval_seconds ?? defaultStatusRefreshIntervalSeconds, 5), 3600);
277299
const timer = window.setInterval(() => {
300+
if (!isMainPageVisible()) {
301+
return;
302+
}
278303
void refreshProxyState();
279304
void refreshDesktopTrayState();
280305
setAccountsSyncToken((value) => value + 1);

0 commit comments

Comments
 (0)