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,103 +19,109 @@ interface EnhancedCardProps {
}
// 自定义卡片组件
export const EnhancedCard = ({
title,
icon,
action,
children,
iconColor = "primary",
minHeight,
noContentPadding = false,
}: EnhancedCardProps) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
(
{
title,
icon,
action,
children,
iconColor = "primary",
minHeight,
noContentPadding = false,
},
ref,
) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
// 统一的标题截断样式
const titleTruncateStyle = {
minWidth: 0,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
};
// 统一的标题截断样式
const titleTruncateStyle = {
minWidth: 0,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
};
return (
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
borderRadius: 2,
backgroundColor: isDark ? "#282a36" : "#ffffff",
}}
>
return (
<Box
sx={{
px: 2,
py: 1,
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: 1,
borderColor: "divider",
flexDirection: "column",
borderRadius: 2,
backgroundColor: isDark ? "#282a36" : "#ffffff",
}}
ref={ref}
>
<Box
sx={{
px: 2,
py: 1,
display: "flex",
alignItems: "center",
minWidth: 0,
flex: 1,
overflow: "hidden",
justifyContent: "space-between",
borderBottom: 1,
borderColor: "divider",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1.5,
width: 38,
height: 38,
mr: 1.5,
flexShrink: 0,
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
color: theme.palette[iconColor].main,
minWidth: 0,
flex: 1,
overflow: "hidden",
}}
>
{icon}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
{typeof title === "string" ? (
<Typography
variant="h6"
fontWeight="medium"
fontSize={18}
sx={titleTruncateStyle}
title={title}
>
{title}
</Typography>
) : (
<Box sx={titleTruncateStyle}>{title}</Box>
)}
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1.5,
width: 38,
height: 38,
mr: 1.5,
flexShrink: 0,
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
color: theme.palette[iconColor].main,
}}
>
{icon}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
{typeof title === "string" ? (
<Typography
variant="h6"
fontWeight="medium"
fontSize={18}
sx={titleTruncateStyle}
title={title}
>
{title}
</Typography>
) : (
<Box sx={titleTruncateStyle}>{title}</Box>
)}
</Box>
</Box>
{action && <Box sx={{ ml: 2, flexShrink: 0 }}>{action}</Box>}
</Box>
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
p: noContentPadding ? 0 : 2,
...(minHeight && { minHeight }),
}}
>
{children}
</Box>
{action && <Box sx={{ ml: 2, flexShrink: 0 }}>{action}</Box>}
</Box>
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
p: noContentPadding ? 0 : 2,
...(minHeight && { minHeight }),
}}
>
{children}
</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,163 +260,167 @@ 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>
}
>
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
flexDirection: "row",
flex: 1,
overflow: "hidden",
}}
>
{/* 左侧国家和IP地址 */}
<Box sx={{ width: "40%", overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
mb: 1,
overflow: "hidden",
}}
>
<Box
component="span"
sx={{
fontSize: "1.5rem",
mr: 1,
display: "inline-block",
width: 28,
textAlign: "center",
flexShrink: 0,
fontFamily: '"twemoji mozilla", sans-serif',
}}
>
{getCountryFlag(ipInfo?.country_code)}
</Box>
<Typography
variant="subtitle1"
sx={{
fontWeight: "medium",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "100%",
}}
>
{ipInfo?.country || t("home.components.ipInfo.labels.unknown")}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{t("home.components.ipInfo.labels.ip")}:
</Typography>
);
break;
default: // Normal render
mainElement = (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
flexDirection: "row",
flex: 1,
overflow: "hidden",
}}
>
{/* 左侧国家和IP地址 */}
<Box sx={{ width: "40%", overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
ml: 1,
mb: 1,
overflow: "hidden",
maxWidth: "calc(100% - 30px)",
}}
>
<Typography
variant="body2"
<Box
component="span"
sx={{
fontFamily: "monospace",
fontSize: "0.75rem",
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-all",
fontSize: "1.5rem",
mr: 1,
display: "inline-block",
width: 28,
textAlign: "center",
flexShrink: 0,
fontFamily: '"twemoji mozilla", sans-serif',
}}
>
{showIp ? ipInfo?.ip : "••••••••••"}
{getCountryFlag(ipInfo?.country_code)}
</Box>
<Typography
variant="subtitle1"
sx={{
fontWeight: "medium",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "100%",
}}
>
{ipInfo?.country ||
t("home.components.ipInfo.labels.unknown")}
</Typography>
<IconButton size="small" onClick={toggleShowIp}>
{showIp ? (
<VisibilityOffOutlined fontSize="small" />
) : (
<VisibilityOutlined fontSize="small" />
)}
</IconButton>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{t("home.components.ipInfo.labels.ip")}:
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
ml: 1,
overflow: "hidden",
maxWidth: "calc(100% - 30px)",
}}
>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
fontSize: "0.75rem",
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-all",
}}
>
{showIp ? ipInfo?.ip : "••••••••••"}
</Typography>
<IconButton size="small" onClick={toggleShowIp}>
{showIp ? (
<VisibilityOffOutlined fontSize="small" />
) : (
<VisibilityOutlined fontSize="small" />
)}
</IconButton>
</Box>
</Box>
<InfoItem
label={t("home.components.ipInfo.labels.asn")}
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
/>
</Box>
<InfoItem
label={t("home.components.ipInfo.labels.asn")}
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
/>
{/* 右侧组织、ISP和位置信息 */}
<Box sx={{ width: "60%", overflow: "auto" }}>
<InfoItem
label={t("home.components.ipInfo.labels.isp")}
value={ipInfo?.organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.org")}
value={ipInfo?.asn_organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.location")}
value={[ipInfo?.city, ipInfo?.region]
.filter(Boolean)
.join(", ")}
/>
<InfoItem
label={t("home.components.ipInfo.labels.timezone")}
value={ipInfo?.timezone}
/>
</Box>
</Box>
{/* 右侧组织、ISP和位置信息 */}
<Box sx={{ width: "60%", overflow: "auto" }}>
<InfoItem
label={t("home.components.ipInfo.labels.isp")}
value={ipInfo?.organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.org")}
value={ipInfo?.asn_organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.location")}
value={[ipInfo?.city, ipInfo?.region].filter(Boolean).join(", ")}
/>
<InfoItem
label={t("home.components.ipInfo.labels.timezone")}
value={ipInfo?.timezone}
/>
</Box>
</Box>
<Box
sx={{
mt: "auto",
pt: 0.5,
borderTop: 1,
borderColor: "divider",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
opacity: 0.7,
fontSize: "0.7rem",
}}
>
<Typography variant="caption">
{t("home.components.ipInfo.labels.autoRefresh")}: {countdown}s
</Typography>
<Typography
variant="caption"
<Box
sx={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
mt: "auto",
pt: 0.5,
borderTop: 1,
borderColor: "divider",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
opacity: 0.7,
fontSize: "0.7rem",
}}
>
{`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`}
</Typography>
<Typography variant="caption">
{t("home.components.ipInfo.labels.autoRefresh")}
{countdown.type === "countdown"
? `: ${countdown.remainingSeconds}s`
: "..."}
</Typography>
<Typography
variant="caption"
sx={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`}
</Typography>
</Box>
</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,
},
);