perf(ip-info-card): reduce IP API load even more (#6263)

* refactor(ip-info-card): reduce retry, use succeed as lastFetchTs

* refactor(ip-info-card): stop countdown during revalidation

* perf(ip-info-card): avoid aggressive schedule revalidation

* perf(ip-info-card): try stop interval on window minimized

* perf(ip-info-card): only mutate after card scroll into view once

* perf(ip-info-card): interval only when card has been visible

* chore: add more debug information

* Update src/components/home/ip-info-card.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: reset countdown state after mutate finishes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sukka 2026-02-06 17:09:36 +08:00 committed by GitHub
parent 781313e8f0
commit e9d63aee5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 377 additions and 274 deletions

View File

@ -1,5 +1,5 @@
import { Box, Typography, alpha, useTheme } from "@mui/material";
import { ReactNode } from "react";
import React, { forwardRef, ReactNode } from "react";
// 自定义卡片组件接口
interface EnhancedCardProps {
@ -19,7 +19,9 @@ interface EnhancedCardProps {
}
// 自定义卡片组件
export const EnhancedCard = ({
export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
(
{
title,
icon,
action,
@ -27,7 +29,9 @@ export const EnhancedCard = ({
iconColor = "primary",
minHeight,
noContentPadding = false,
}: EnhancedCardProps) => {
},
ref,
) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
@ -50,6 +54,7 @@ export const EnhancedCard = ({
borderRadius: 2,
backgroundColor: isDark ? "#282a36" : "#ffffff",
}}
ref={ref}
>
<Box
sx={{
@ -118,4 +123,5 @@ export const EnhancedCard = ({
</Box>
</Box>
);
};
},
);

View File

@ -7,9 +7,18 @@ import {
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useEffect } from "foxact/use-abortable-effect";
import { memo, useCallback, useState, useEffectEvent, useMemo } from "react";
import { useIntersection } from "foxact/use-intersection";
import type { XOR } from "foxts/ts-xor";
import {
memo,
useCallback,
useState,
useEffectEvent,
useMemo,
forwardRef,
} from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import { getIpInfo } from "@/services/api";
@ -54,26 +63,60 @@ const getCountryFlag = (countryCode: string | undefined) => {
return String.fromCodePoint(...codePoints);
};
type CountDownState = XOR<
{
type: "countdown";
remainingSeconds: number;
},
{
type: "revalidating";
}
>;
const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
({ children }, ref) => {
const { t } = useTranslation();
const { mutate } = useIPInfo();
return (
<EnhancedCard
title={t("home.components.ipInfo.title")}
icon={<LocationOnOutlined />}
iconColor="info"
ref={ref}
action={
<IconButton size="small" onClick={() => mutate()}>
<RefreshOutlined />
</IconButton>
}
>
{children}
</EnhancedCard>
);
},
);
// IP信息卡片组件
export const IpInfoCard = () => {
const { t } = useTranslation();
const [showIp, setShowIp] = useState(false);
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS);
const {
data: ipInfo,
error,
isLoading,
mutate,
} = useSWR(IP_INFO_CACHE_KEY, getIpInfo, {
refreshInterval: 0,
refreshWhenOffline: false,
revalidateOnFocus: true,
shouldRetryOnError: true,
// track ip info card has been in viewport or not
// hasIntersected default to false, and will be true once the card is in viewport
// and will never be false again afterwards (unless resetIntersected is called or
// the component is unmounted)
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
rootMargin: "0px",
});
const [countdown, setCountdown] = useState<CountDownState>({
type: "countdown",
remainingSeconds: IP_REFRESH_SECONDS,
});
const { data: ipInfo, error, isLoading, mutate } = useIPInfo();
// function useEffectEvent
const onCountdownTick = useEffectEvent(async () => {
const now = Date.now();
@ -86,9 +129,30 @@ export const IpInfoCard = () => {
const remaining = IP_REFRESH_SECONDS - elapsed;
if (remaining <= 0) {
if (navigator.onLine && (await appWindow.isVisible())) {
mutate();
setCountdown(IP_REFRESH_SECONDS);
if (
// has intersected at least once
// this avoids unncessary revalidation if user never scrolls down,
// then we will only load initially once.
hasIntersected &&
// is online
navigator.onLine &&
// there is no ongoing revalidation already scheduled
countdown.type !== "revalidating" &&
// window is visible
(await appWindow.isVisible())
) {
setCountdown({ type: "revalidating" });
// we do not care about the result of mutate here. after mutate is done,
// simply wait for next interval tick with `setCountdown({ type: "countdown", ... })`
try {
await mutate();
} finally {
// in case mutate throws error, we still need to reset the countdown state
setCountdown({
type: "countdown",
remainingSeconds: IP_REFRESH_SECONDS,
});
}
} else {
// do nothing. we even skip "setCountdown" to reduce re-renders
//
@ -97,58 +161,86 @@ export const IpInfoCard = () => {
// or network online again, we mutate() immediately in the following tick.
}
} else {
setCountdown(remaining);
setCountdown({
type: "countdown",
remainingSeconds: remaining,
});
}
});
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
useEffect(() => {
const timer: number | null = window.setInterval(onCountdownTick, 1000);
let timer: number | null = null;
// Do not add document.hidden check here as it is not reliable in Tauri.
//
// Thank god IntersectionObserver is a DOM API that relies on DOM/webview
// instead of Tauri, which is reliable enough.
if (hasIntersected) {
console.debug(
"IP info card has entered the viewport, starting the countdown interval.",
);
timer = window.setInterval(onCountdownTick, 1000);
} else {
console.debug(
"IP info card has not yet entered the viewport, no counting down.",
);
}
// This will fire when the window is minimized or restored
document.addEventListener("visibilitychange", onVisibilityChange);
// Tauri's visibility change detection is actually broken on some platforms:
// https://github.com/tauri-apps/tauri/issues/10592
//
// It is working on macOS though (tested).
// So at least we should try to pause countdown on supported platforms to
// reduce power consumption.
function onVisibilityChange() {
if (document.hidden) {
console.debug("Document hidden, pause the interval");
// Pause the timer
if (timer != null) {
clearInterval(timer);
timer = null;
}
} else if (hasIntersected) {
console.debug("Document visible, resume the interval");
// Resume the timer only when previous one is cleared
if (timer == null) {
timer = window.setInterval(onCountdownTick, 1000);
}
} else {
console.debug(
"Document visible, but IP info card has never entered the viewport, not even once, not starting the interval.",
);
}
}
return () => {
if (timer != null) clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, []);
}, [hasIntersected]);
const toggleShowIp = useCallback(() => {
setShowIp((prev) => !prev);
}, []);
// Loading
if (isLoading) {
return (
<EnhancedCard
title={t("home.components.ipInfo.title")}
icon={<LocationOnOutlined />}
iconColor="info"
action={
<IconButton size="small" onClick={() => mutate()} disabled>
<RefreshOutlined />
</IconButton>
}
>
let mainElement: React.ReactElement;
switch (true) {
case isLoading:
mainElement = (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Skeleton variant="text" width="60%" height={30} />
<Skeleton variant="text" width="80%" height={24} />
<Skeleton variant="text" width="70%" height={24} />
<Skeleton variant="text" width="50%" height={24} />
</Box>
</EnhancedCard>
);
}
// Error
if (error) {
return (
<EnhancedCard
title={t("home.components.ipInfo.title")}
icon={<LocationOnOutlined />}
iconColor="info"
action={
<IconButton size="small" onClick={() => mutate()}>
<RefreshOutlined />
</IconButton>
}
>
break;
case !!error:
mainElement = (
<Box
sx={{
display: "flex",
@ -168,22 +260,10 @@ export const IpInfoCard = () => {
{t("shared.actions.retry")}
</Button>
</Box>
</EnhancedCard>
);
}
// Normal render
return (
<EnhancedCard
title={t("home.components.ipInfo.title")}
icon={<LocationOnOutlined />}
iconColor="info"
action={
<IconButton size="small" onClick={() => mutate()}>
<RefreshOutlined />
</IconButton>
}
>
break;
default: // Normal render
mainElement = (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box
sx={{
@ -227,7 +307,8 @@ export const IpInfoCard = () => {
maxWidth: "100%",
}}
>
{ipInfo?.country || t("home.components.ipInfo.labels.unknown")}
{ipInfo?.country ||
t("home.components.ipInfo.labels.unknown")}
</Typography>
</Box>
@ -288,7 +369,9 @@ export const IpInfoCard = () => {
/>
<InfoItem
label={t("home.components.ipInfo.labels.location")}
value={[ipInfo?.city, ipInfo?.region].filter(Boolean).join(", ")}
value={[ipInfo?.city, ipInfo?.region]
.filter(Boolean)
.join(", ")}
/>
<InfoItem
label={t("home.components.ipInfo.labels.timezone")}
@ -311,7 +394,10 @@ export const IpInfoCard = () => {
}}
>
<Typography variant="caption">
{t("home.components.ipInfo.labels.autoRefresh")}: {countdown}s
{t("home.components.ipInfo.labels.autoRefresh")}
{countdown.type === "countdown"
? `: ${countdown.remainingSeconds}s`
: "..."}
</Typography>
<Typography
variant="caption"
@ -325,6 +411,16 @@ export const IpInfoCard = () => {
</Typography>
</Box>
</Box>
</EnhancedCard>
);
}
return (
<IPInfoCardContainer ref={containerRef}>{mainElement}</IPInfoCardContainer>
);
};
function useIPInfo() {
return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, {
shouldRetryOnError: true,
});
}

View File

@ -186,10 +186,8 @@ function createPrng(seed: number): () => number {
export const getIpInfo = async (): Promise<
IpInfo & { lastFetchTs: number }
> => {
const lastFetchTs = Date.now();
// 配置参数
const maxRetries = 3;
const maxRetries = 2;
const serviceTimeout = 5000;
const shuffledServices = shuffleServices();
@ -206,7 +204,7 @@ export const getIpInfo = async (): Promise<
try {
return await asyncRetry(
async (bail) => {
console.debug("Fetching IP information...");
console.debug("Fetching IP information:", service.url);
const response = await fetch(service.url, {
method: "GET",
@ -226,15 +224,18 @@ export const getIpInfo = async (): Promise<
if (data && data.ip) {
debugLog(`IP检测成功使用服务: ${service.url}`);
return Object.assign(service.mapping(data), { lastFetchTs });
return Object.assign(service.mapping(data), {
// use last fetch success timestamp
lastFetchTs: Date.now(),
});
} else {
throw new Error(`无效的响应格式 from ${service.url}`);
}
},
{
retries: maxRetries,
minTimeout: 500,
maxTimeout: 2000,
minTimeout: 1000,
maxTimeout: 4000,
randomize: true,
},
);