From e9d63aee5e65f1e258e329a5fdb39df44aff2e07 Mon Sep 17 00:00:00 2001 From: Sukka Date: Fri, 6 Feb 2026 17:09:36 +0800 Subject: [PATCH] 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> --- src/components/home/enhanced-card.tsx | 164 ++++----- src/components/home/ip-info-card.tsx | 472 ++++++++++++++++---------- src/services/api.ts | 15 +- 3 files changed, 377 insertions(+), 274 deletions(-) diff --git a/src/components/home/enhanced-card.tsx b/src/components/home/enhanced-card.tsx index 59203c661..262846dd5 100644 --- a/src/components/home/enhanced-card.tsx +++ b/src/components/home/enhanced-card.tsx @@ -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( + ( + { + 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 ( - + return ( - {icon} - - - {typeof title === "string" ? ( - - {title} - - ) : ( - {title} - )} + + {icon} + + + {typeof title === "string" ? ( + + {title} + + ) : ( + {title} + )} + + {action && {action}} + + + {children} - {action && {action}} - - {children} - - - ); -}; + ); + }, +); diff --git a/src/components/home/ip-info-card.tsx b/src/components/home/ip-info-card.tsx index f497da504..249a9f792 100644 --- a/src/components/home/ip-info-card.tsx +++ b/src/components/home/ip-info-card.tsx @@ -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( + ({ children }, ref) => { + const { t } = useTranslation(); + const { mutate } = useIPInfo(); + + return ( + } + iconColor="info" + ref={ref} + action={ + mutate()}> + + + } + > + {children} + + ); + }, +); + // 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({ + 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 ( - } - iconColor="info" - action={ - mutate()} disabled> - - - } - > + let mainElement: React.ReactElement; + + switch (true) { + case isLoading: + mainElement = ( - - ); - } - - // Error - if (error) { - return ( - } - iconColor="info" - action={ - mutate()}> - - - } - > + ); + break; + case !!error: + mainElement = ( { {t("shared.actions.retry")} - - ); - } - - // Normal render - return ( - } - iconColor="info" - action={ - mutate()}> - - - } - > - - - {/* 左侧:国家和IP地址 */} - - - - {getCountryFlag(ipInfo?.country_code)} - - - {ipInfo?.country || t("home.components.ipInfo.labels.unknown")} - - - - - - {t("home.components.ipInfo.labels.ip")}: - + ); + break; + default: // Normal render + mainElement = ( + + + {/* 左侧:国家和IP地址 */} + - - {showIp ? ipInfo?.ip : "••••••••••"} + {getCountryFlag(ipInfo?.country_code)} + + + {ipInfo?.country || + t("home.components.ipInfo.labels.unknown")} - - {showIp ? ( - - ) : ( - - )} - + + + + {t("home.components.ipInfo.labels.ip")}: + + + + {showIp ? ipInfo?.ip : "••••••••••"} + + + {showIp ? ( + + ) : ( + + )} + + + + + - + {/* 右侧:组织、ISP和位置信息 */} + + + + + + - {/* 右侧:组织、ISP和位置信息 */} - - - - - - - - - - - {t("home.components.ipInfo.labels.autoRefresh")}: {countdown}s - - - {`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`} - + + {t("home.components.ipInfo.labels.autoRefresh")} + {countdown.type === "countdown" + ? `: ${countdown.remainingSeconds}s` + : "..."} + + + {`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`} + + - - + ); + } + + return ( + {mainElement} ); }; + +function useIPInfo() { + return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, { + shouldRetryOnError: true, + }); +} diff --git a/src/services/api.ts b/src/services/api.ts index 0b4cf25a8..1cac913d7 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -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, }, );