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

View File

@ -7,9 +7,18 @@ import {
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material"; import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useEffect } from "foxact/use-abortable-effect"; 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 { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWRImmutable from "swr/immutable";
import { getIpInfo } from "@/services/api"; import { getIpInfo } from "@/services/api";
@ -54,26 +63,60 @@ const getCountryFlag = (countryCode: string | undefined) => {
return String.fromCodePoint(...codePoints); 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信息卡片组件 // IP信息卡片组件
export const IpInfoCard = () => { export const IpInfoCard = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showIp, setShowIp] = useState(false); const [showIp, setShowIp] = useState(false);
const appWindow = useMemo(() => getCurrentWebviewWindow(), []); const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS); // track ip info card has been in viewport or not
// hasIntersected default to false, and will be true once the card is in viewport
const { // and will never be false again afterwards (unless resetIntersected is called or
data: ipInfo, // the component is unmounted)
error, const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
isLoading, rootMargin: "0px",
mutate,
} = useSWR(IP_INFO_CACHE_KEY, getIpInfo, {
refreshInterval: 0,
refreshWhenOffline: false,
revalidateOnFocus: true,
shouldRetryOnError: true,
}); });
const [countdown, setCountdown] = useState<CountDownState>({
type: "countdown",
remainingSeconds: IP_REFRESH_SECONDS,
});
const { data: ipInfo, error, isLoading, mutate } = useIPInfo();
// function useEffectEvent // function useEffectEvent
const onCountdownTick = useEffectEvent(async () => { const onCountdownTick = useEffectEvent(async () => {
const now = Date.now(); const now = Date.now();
@ -86,9 +129,30 @@ export const IpInfoCard = () => {
const remaining = IP_REFRESH_SECONDS - elapsed; const remaining = IP_REFRESH_SECONDS - elapsed;
if (remaining <= 0) { if (remaining <= 0) {
if (navigator.onLine && (await appWindow.isVisible())) { if (
mutate(); // has intersected at least once
setCountdown(IP_REFRESH_SECONDS); // 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 { } else {
// do nothing. we even skip "setCountdown" to reduce re-renders // 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. // or network online again, we mutate() immediately in the following tick.
} }
} else { } else {
setCountdown(remaining); setCountdown({
type: "countdown",
remainingSeconds: remaining,
});
} }
}); });
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired // Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
useEffect(() => { 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 () => { return () => {
if (timer != null) clearInterval(timer); if (timer != null) clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
}; };
}, []); }, [hasIntersected]);
const toggleShowIp = useCallback(() => { const toggleShowIp = useCallback(() => {
setShowIp((prev) => !prev); setShowIp((prev) => !prev);
}, []); }, []);
// Loading let mainElement: React.ReactElement;
if (isLoading) {
return ( switch (true) {
<EnhancedCard case isLoading:
title={t("home.components.ipInfo.title")} mainElement = (
icon={<LocationOnOutlined />}
iconColor="info"
action={
<IconButton size="small" onClick={() => mutate()} disabled>
<RefreshOutlined />
</IconButton>
}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Skeleton variant="text" width="60%" height={30} /> <Skeleton variant="text" width="60%" height={30} />
<Skeleton variant="text" width="80%" height={24} /> <Skeleton variant="text" width="80%" height={24} />
<Skeleton variant="text" width="70%" height={24} /> <Skeleton variant="text" width="70%" height={24} />
<Skeleton variant="text" width="50%" height={24} /> <Skeleton variant="text" width="50%" height={24} />
</Box> </Box>
</EnhancedCard>
); );
} break;
case !!error:
// Error mainElement = (
if (error) {
return (
<EnhancedCard
title={t("home.components.ipInfo.title")}
icon={<LocationOnOutlined />}
iconColor="info"
action={
<IconButton size="small" onClick={() => mutate()}>
<RefreshOutlined />
</IconButton>
}
>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -168,22 +260,10 @@ export const IpInfoCard = () => {
{t("shared.actions.retry")} {t("shared.actions.retry")}
</Button> </Button>
</Box> </Box>
</EnhancedCard>
); );
} break;
default: // Normal render
// Normal render mainElement = (
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={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box <Box
sx={{ sx={{
@ -227,7 +307,8 @@ export const IpInfoCard = () => {
maxWidth: "100%", maxWidth: "100%",
}} }}
> >
{ipInfo?.country || t("home.components.ipInfo.labels.unknown")} {ipInfo?.country ||
t("home.components.ipInfo.labels.unknown")}
</Typography> </Typography>
</Box> </Box>
@ -288,7 +369,9 @@ export const IpInfoCard = () => {
/> />
<InfoItem <InfoItem
label={t("home.components.ipInfo.labels.location")} label={t("home.components.ipInfo.labels.location")}
value={[ipInfo?.city, ipInfo?.region].filter(Boolean).join(", ")} value={[ipInfo?.city, ipInfo?.region]
.filter(Boolean)
.join(", ")}
/> />
<InfoItem <InfoItem
label={t("home.components.ipInfo.labels.timezone")} label={t("home.components.ipInfo.labels.timezone")}
@ -311,7 +394,10 @@ export const IpInfoCard = () => {
}} }}
> >
<Typography variant="caption"> <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>
<Typography <Typography
variant="caption" variant="caption"
@ -325,6 +411,16 @@ export const IpInfoCard = () => {
</Typography> </Typography>
</Box> </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< export const getIpInfo = async (): Promise<
IpInfo & { lastFetchTs: number } IpInfo & { lastFetchTs: number }
> => { > => {
const lastFetchTs = Date.now();
// 配置参数 // 配置参数
const maxRetries = 3; const maxRetries = 2;
const serviceTimeout = 5000; const serviceTimeout = 5000;
const shuffledServices = shuffleServices(); const shuffledServices = shuffleServices();
@ -206,7 +204,7 @@ export const getIpInfo = async (): Promise<
try { try {
return await asyncRetry( return await asyncRetry(
async (bail) => { async (bail) => {
console.debug("Fetching IP information..."); console.debug("Fetching IP information:", service.url);
const response = await fetch(service.url, { const response = await fetch(service.url, {
method: "GET", method: "GET",
@ -226,15 +224,18 @@ export const getIpInfo = async (): Promise<
if (data && data.ip) { if (data && data.ip) {
debugLog(`IP检测成功使用服务: ${service.url}`); 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 { } else {
throw new Error(`无效的响应格式 from ${service.url}`); throw new Error(`无效的响应格式 from ${service.url}`);
} }
}, },
{ {
retries: maxRetries, retries: maxRetries,
minTimeout: 500, minTimeout: 1000,
maxTimeout: 2000, maxTimeout: 4000,
randomize: true, randomize: true,
}, },
); );