瀏覽代碼

chore(frontend): update dependencies and adapt to oxlint 1.79

npm install was failing with ERESOLVE: the lockfile pinned storybook 10.5.7
and vitest 4.1.10 as peers while package.json asked for ^10.5.9 and ^4.1.11,
and npm would not move either. Neither npm update, a targeted install, nor
--package-lock-only broke the cycle, so node_modules and package-lock.json
were regenerated from scratch (601 packages, 0 vulnerabilities).

oxlint 1.79.0 then promoted five React Compiler rules into the correctness
category, flagging 101 pre-existing sites. 1.78.0 exits 0 on the same tree,
so nothing in our code changed - the rule set grew. They are fixed rather
than suppressed:

- refs (31): latest-value ref writes moved out of render into an effect.
  onlineClientsRef turned out to be write-only and is gone; expireDiffRef
  and trafficDiffRef were replaced by reading the values directly.
- set-state-in-effect (55): reset-on-open modals now adjust state during
  render; where an effect mixed a synchronous reset with an async fetch, the
  reset moved to render and the effect kept only the request. useMediaQuery
  became useSyncExternalStore.
- preserve-manual-memoization (11): optional-chained deps the compiler cannot
  match, hoisted to locals or dropped where the memo wrapped a string concat.
- purity (3): Date.now() in render replaced by a state-backed clock, which
  also refreshes the expiry tag every 60s instead of freezing it until the
  next unrelated re-render.
- immutability (1): applyClientStatsEvent merged websocket traffic into
  DBInbound rows in place; it now rebuilds only the rows it touches.

Two things fell out of that. clientCount is derived with useMemo instead of
an imperative rebuildClientCount() called from five sites, which also fixes a
staleness bug where changing the expiry or traffic threshold left the counts
alone until some later rebuild. statsVersion existed only to force a
re-render after an in-place mutation, is meaningless now that rows are
replaced, and nothing read it, so it is removed.

Also adds a lint:fix script - oxlint --fix was previously only reachable
through the lint-staged hook.
Sanaei 8 小時之前
父節點
當前提交
b9eda09da9
共有 54 個文件被更改,包括 1210 次插入1049 次删除
  1. 253 243
      frontend/package-lock.json
  2. 15 14
      frontend/package.json
  3. 14 8
      frontend/src/components/feedback/PromptModal.tsx
  4. 9 6
      frontend/src/components/feedback/TextModal.tsx
  5. 6 2
      frontend/src/components/geodata/GeoBrowserModal.stories.tsx
  6. 29 27
      frontend/src/components/geodata/GeoBrowserModal.tsx
  7. 5 1
      frontend/src/components/geodata/GeoTokenInput.stories.tsx
  8. 12 4
      frontend/src/components/geodata/GeoTokenInput.tsx
  9. 2 4
      frontend/src/components/utility/LazyMount.tsx
  10. 4 2
      frontend/src/components/viz/Sparkline.tsx
  11. 3 1
      frontend/src/hooks/useClients.ts
  12. 14 10
      frontend/src/hooks/useMediaQuery.ts
  13. 17 24
      frontend/src/hooks/useServerDraft.ts
  14. 33 29
      frontend/src/hooks/useXraySetting.ts
  15. 3 5
      frontend/src/layouts/AppSidebar.tsx
  16. 3 1
      frontend/src/lib/xray/forms/fields/FinalMaskField.tsx
  17. 3 1
      frontend/src/lib/xray/forms/fields/SniffingField.tsx
  18. 3 1
      frontend/src/lib/xray/forms/fields/SockoptCustomField.tsx
  19. 7 3
      frontend/src/pages/clients/BulkAttachInboundsModal.tsx
  20. 7 3
      frontend/src/pages/clients/BulkDetachInboundsModal.tsx
  21. 8 6
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  22. 20 20
      frontend/src/pages/clients/ClientInfoModal.tsx
  23. 24 22
      frontend/src/pages/clients/ClientQrModal.tsx
  24. 11 6
      frontend/src/pages/groups/GroupAddClientsModal.tsx
  25. 11 6
      frontend/src/pages/groups/GroupRemoveClientsModal.tsx
  26. 8 3
      frontend/src/pages/hosts/HostFormModal.tsx
  27. 3 1
      frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx
  28. 15 9
      frontend/src/pages/inbounds/clients/AttachClientsModal.tsx
  29. 12 3
      frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx
  30. 12 8
      frontend/src/pages/inbounds/clients/DetachClientsModal.tsx
  31. 5 3
      frontend/src/pages/inbounds/form/protocols/vless.tsx
  32. 26 7
      frontend/src/pages/inbounds/form/security/RealityTargetScannerModal.tsx
  33. 39 12
      frontend/src/pages/inbounds/info/InboundInfoModal.tsx
  34. 11 1
      frontend/src/pages/inbounds/list/InboundStatsModal.tsx
  35. 26 11
      frontend/src/pages/inbounds/qr/QrCodeModal.tsx
  36. 1 1
      frontend/src/pages/inbounds/qr/QrPanel.tsx
  37. 199 246
      frontend/src/pages/inbounds/useInbounds.ts
  38. 13 9
      frontend/src/pages/index/GeodataSection.tsx
  39. 17 10
      frontend/src/pages/index/LogModal.tsx
  40. 79 64
      frontend/src/pages/index/SystemHistoryModal.tsx
  41. 7 2
      frontend/src/pages/index/VersionModal.tsx
  42. 19 10
      frontend/src/pages/index/XrayLogModal.tsx
  43. 106 142
      frontend/src/pages/index/XrayMetricsModal.tsx
  44. 6 3
      frontend/src/pages/index/useOverviewHistory.ts
  45. 8 4
      frontend/src/pages/nodes/NodeFormModal.tsx
  46. 9 5
      frontend/src/pages/settings/SecurityTab.tsx
  47. 3 10
      frontend/src/pages/settings/SettingsPage.tsx
  48. 3 1
      frontend/src/pages/settings/SubJsonFinalMaskForm.tsx
  49. 20 23
      frontend/src/pages/settings/TwoFactorModal.tsx
  50. 6 3
      frontend/src/pages/xray/balancers/BalancerFormModal.tsx
  51. 8 1
      frontend/src/pages/xray/balancers/BalancersTab.tsx
  52. 9 1
      frontend/src/pages/xray/overrides/NordModal.tsx
  53. 18 4
      frontend/src/pages/xray/overrides/WarpModal.tsx
  54. 6 3
      frontend/src/pages/xray/routing/RoutingTab.tsx

File diff suppressed because it is too large
+ 253 - 243
frontend/package-lock.json


+ 15 - 14
frontend/package.json

@@ -13,6 +13,7 @@
     "build": "npm run gen:api && vite build",
     "preview": "vite preview",
     "lint": "oxlint src tools",
+    "lint:fix": "oxlint --fix src tools",
     "lint:deprecated": "oxlint --type-aware -A all -D typescript/no-deprecated src",
     "format": "oxfmt src tools",
     "format:check": "oxfmt --check src tools",
@@ -36,13 +37,13 @@
     "@ant-design/icons": "^6.3.2",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/theme-one-dark": "^6.1.3",
-    "@hookform/resolvers": "^5.7.1",
+    "@hookform/resolvers": "^5.9.1",
     "@noble/hashes": "^2.3.0",
     "@tanstack/react-query": "^5.101.4",
     "@tanstack/react-query-devtools": "^5.101.4",
-    "antd": "^6.6.0",
+    "antd": "^6.6.1",
     "codemirror": "^6.0.2",
-    "dayjs": "^1.11.21",
+    "dayjs": "^1.11.23",
     "i18next": "^26.3.6",
     "otpauth": "^9.5.1",
     "persian-calendar-suite": "^1.5.6",
@@ -51,35 +52,35 @@
     "react-hook-form": "^7.85.0",
     "react-i18next": "^17.0.11",
     "react-router": "^8.3.0",
-    "swagger-ui-react": "^5.32.13",
+    "swagger-ui-react": "^5.32.14",
     "uplot": "^1.6.32",
     "zod": "^4.4.3"
   },
   "devDependencies": {
-    "@storybook/addon-a11y": "^10.5.7",
-    "@storybook/addon-docs": "^10.5.7",
-    "@storybook/addon-vitest": "^10.5.7",
-    "@storybook/react-vite": "^10.5.7",
+    "@storybook/addon-a11y": "^10.5.9",
+    "@storybook/addon-docs": "^10.5.9",
+    "@storybook/addon-vitest": "^10.5.9",
+    "@storybook/react-vite": "^10.5.9",
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.2",
     "@types/react": "^19.2.18",
     "@types/react-dom": "^19.2.4",
     "@types/swagger-ui-react": "^5.18.0",
     "@vitejs/plugin-react": "^6.0.5",
-    "@vitest/browser-playwright": "4.1.10",
-    "@vitest/coverage-v8": "^4.1.10",
+    "@vitest/browser-playwright": "4.1.11",
+    "@vitest/coverage-v8": "^4.1.11",
     "husky": "^9.1.7",
     "jsdom": "^30.0.1",
     "lint-staged": "^17.3.0",
     "msw": "^2.15.0",
-    "oxfmt": "0.63.0",
-    "oxlint": "1.78.0",
+    "oxfmt": "0.64.0",
+    "oxlint": "1.79.0",
     "oxlint-tsgolint": "^7.0.2001",
     "playwright": "^1.62.1",
-    "storybook": "^10.5.7",
+    "storybook": "^10.5.9",
     "typescript": "7.0.2",
     "vite": "8.2.1",
-    "vitest": "^4.1.10"
+    "vitest": "^4.1.11"
   },
   "overrides": {
     "dompurify": "^3.4.11",

+ 14 - 8
frontend/src/components/feedback/PromptModal.tsx

@@ -33,15 +33,21 @@ export default function PromptModal({
   const textareaRef = useRef<HTMLTextAreaElement | null>(null);
   const inputRef = useRef<InputRef | null>(null);
 
+  const [openedWith, setOpenedWith] = useState<string | null>(null);
+  const openKey = open ? `${type}\u0000${initialValue}` : null;
+  if (openKey !== openedWith) {
+    setOpenedWith(openKey);
+    if (open) setValue(initialValue);
+  }
+
   useEffect(() => {
-    if (open) {
-      setValue(initialValue);
-      setTimeout(() => {
-        if (type === 'textarea') textareaRef.current?.focus();
-        else inputRef.current?.focus();
-      }, 50);
-    }
-  }, [open, initialValue, type]);
+    if (!open) return;
+    const id = setTimeout(() => {
+      if (type === 'textarea') textareaRef.current?.focus();
+      else inputRef.current?.focus();
+    }, 50);
+    return () => clearTimeout(id);
+  }, [open, type]);
 
   function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
     if (type !== 'textarea' && e.key === 'Enter') {

+ 9 - 6
frontend/src/components/feedback/TextModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useState } from 'react';
 import { Button, Input, Modal, Tabs, message } from 'antd';
 import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
@@ -35,9 +35,12 @@ export default function TextModal({
   const [messageApi, messageContextHolder] = message.useMessage();
   const [activeKey, setActiveKey] = useState('');
 
-  useEffect(() => {
-    if (open && tabs && tabs.length > 0) setActiveKey(tabs[0].key);
-  }, [open, tabs]);
+  // Reset on the way out so the next open starts on the first tab; activeTab
+  // falls back to tabs[0] whenever activeKey no longer matches.
+  const close = useCallback(() => {
+    setActiveKey('');
+    onClose();
+  }, [onClose]);
 
   const activeTab = tabs?.find((tab) => tab.key === activeKey) ?? tabs?.[0];
   const activeContent = activeTab ? activeTab.content : content;
@@ -46,7 +49,7 @@ export default function TextModal({
     const ok = await ClipboardManager.copyText(activeContent || '');
     if (ok) {
       messageApi.success(t('copied'));
-      onClose();
+      close();
     }
   }
 
@@ -61,7 +64,7 @@ export default function TextModal({
       <Modal
         open={open}
         title={title}
-        onCancel={onClose}
+        onCancel={close}
         destroyOnHidden
         footer={
           <>

+ 6 - 2
frontend/src/components/geodata/GeoBrowserModal.stories.tsx

@@ -749,8 +749,12 @@ const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
 function BrowserDemo(props: GeoBrowserModalProps) {
   const [open, setOpen] = useState(props.open);
   const [value, setValue] = useState(props.value);
-  useEffect(() => setOpen(props.open), [props.open]);
-  useEffect(() => setValue(props.value), [props.value]);
+  const [synced, setSynced] = useState({ open: props.open, value: props.value });
+  if (synced.open !== props.open || synced.value !== props.value) {
+    setSynced({ open: props.open, value: props.value });
+    setOpen(props.open);
+    setValue(props.value);
+  }
   return (
     <Space orientation="vertical" size={12}>
       <Space size={8}>

+ 29 - 27
frontend/src/components/geodata/GeoBrowserModal.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Alert,
@@ -69,13 +69,16 @@ export default function GeoBrowserModal({
   const [entryPage, setEntryPage] = useState(1);
   const [selected, setSelected] = useState<string[]>([]);
 
-  const knownRef = useRef<Set<string>>(new Set());
-  const seededFilesRef = useRef<Set<string>>(new Set());
+  const [known, setKnown] = useState<Set<string>>(() => new Set());
+  const [seededFiles, setSeededFiles] = useState<Set<string>>(() => new Set());
 
   const filesQuery = useGeodataFiles(open);
   const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
-  const activeFile = files.find((candidate) => candidate.name === file);
-  const fileKind: GeoKind = activeFile?.kind ?? kind;
+  const activeFile = useMemo(
+    () => files.find((candidate) => candidate.name === file),
+    [files, file],
+  );
+  const fileKind: GeoKind = useMemo(() => activeFile?.kind ?? kind, [activeFile, kind]);
 
   const categoriesQuery = useGeodataCategories(file, '', open && !!file);
   // While a newly picked database loads, the query still serves the previous
@@ -117,28 +120,27 @@ export default function GeoBrowserModal({
     return () => window.clearTimeout(handle);
   }, [entryQuery, entryFilter]);
 
-  useEffect(() => {
-    if (!open) return;
-    knownRef.current = new Set();
-    seededFilesRef.current = new Set();
-    setCategoryQuery('');
-    setEntryQuery('');
-    setEntryFilter('');
-    setActiveCode(undefined);
-    setEntryPage(1);
-    setSelected([]);
-  }, [open]);
-
-  useEffect(() => {
-    if (!open || file || files.length === 0) return;
+  // Opening, picking the default database and seeding the selection are all
+  // render-time adjustments — an effect would paint the previous state first.
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
+    if (open) {
+      setKnown(new Set());
+      setSeededFiles(new Set());
+      setCategoryQuery('');
+      setEntryQuery('');
+      setEntryFilter('');
+      setActiveCode(undefined);
+      setEntryPage(1);
+      setSelected([]);
+    }
+  } else if (open && !file && files.length > 0) {
     setFile(preferredFile(files, kind));
-  }, [open, file, files, kind]);
-
-  useEffect(() => {
-    if (!open || !file || categories.length === 0 || seededFilesRef.current.has(file)) return;
+  } else if (open && file && categories.length > 0 && !seededFiles.has(file)) {
     const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
-    for (const token of tokens) knownRef.current.add(token);
-    seededFilesRef.current.add(file);
+    setKnown(new Set([...known, ...tokens]));
+    setSeededFiles(new Set(seededFiles).add(file));
     const fromValue = selectionFromValue(value, new Set(tokens));
     if (fromValue.length > 0) {
       setSelected((previous) => [
@@ -146,7 +148,7 @@ export default function GeoBrowserModal({
         ...fromValue.filter((token) => !previous.includes(token)),
       ]);
     }
-  }, [open, file, categories, fileKind, value]);
+  }
 
   const visibleCategories = useMemo(() => {
     const query = categoryQuery.trim().toLowerCase();
@@ -276,7 +278,7 @@ export default function GeoBrowserModal({
       title={t('pages.xray.geoBrowser.title')}
       width={880}
       onCancel={onClose}
-      onOk={() => onApply(mergeSelection(value, selected, knownRef.current))}
+      onOk={() => onApply(mergeSelection(value, selected, known))}
       okText={t('pages.xray.geoBrowser.apply')}
       cancelText={t('close')}
       className="geo-browser-modal"

+ 5 - 1
frontend/src/components/geodata/GeoTokenInput.stories.tsx

@@ -216,7 +216,11 @@ const withGeodata: Decorator = function GeodataBackend(Story) {
 
 function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
   const [current, setCurrent] = useState(value);
-  useEffect(() => setCurrent(value), [value]);
+  const [synced, setSynced] = useState(value);
+  if (synced !== value) {
+    setSynced(value);
+    setCurrent(value);
+  }
   return (
     <Space orientation="vertical" size={4} style={{ width: 460 }}>
       <label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>

+ 12 - 4
frontend/src/components/geodata/GeoTokenInput.tsx

@@ -49,13 +49,21 @@ export default function GeoTokenInput({
   const validate = useValidateGeoTokens();
   const { mutateAsync } = validate;
 
-  useEffect(() => {
-    const tokens = parseTokens(value);
-    if (tokens.length === 0) {
+  // An empty field has nothing to validate, so it clears during render rather
+  // than waiting a commit for the effect to catch up.
+  const isEmpty = parseTokens(value).length === 0;
+  const [wasEmpty, setWasEmpty] = useState(isEmpty);
+  if (isEmpty !== wasEmpty) {
+    setWasEmpty(isEmpty);
+    if (isEmpty) {
       setIssues([]);
       setCheckFailed(false);
-      return;
     }
+  }
+
+  useEffect(() => {
+    const tokens = parseTokens(value);
+    if (tokens.length === 0) return;
     let cancelled = false;
     const timer = setTimeout(() => {
       mutateAsync({ tokens, kind })

+ 2 - 4
frontend/src/components/utility/LazyMount.tsx

@@ -1,4 +1,4 @@
-import { Suspense, useEffect, useState, type ReactNode } from 'react';
+import { Suspense, useState, type ReactNode } from 'react';
 import { Spin } from 'antd';
 
 interface LazyMountProps {
@@ -13,9 +13,7 @@ interface LazyMountProps {
 // on heavy list pages to keep the initial bundle small.
 export default function LazyMount({ when, fallback = <Spin />, children }: LazyMountProps) {
   const [mounted, setMounted] = useState(when);
-  useEffect(() => {
-    if (when && !mounted) setMounted(true);
-  }, [when, mounted]);
+  if (when && !mounted) setMounted(true);
   if (!mounted) return null;
   return <Suspense fallback={fallback}>{children}</Suspense>;
 }

+ 4 - 2
frontend/src/components/viz/Sparkline.tsx

@@ -268,9 +268,11 @@ export default function Sparkline(props: SparklineProps) {
     extrema,
   };
   const cfgRef = useRef(cfg);
-  cfgRef.current = cfg;
   const viewRef = useRef<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints });
-  viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
+  useEffect(() => {
+    cfgRef.current = cfg;
+    viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
+  });
 
   const containerRef = useRef<HTMLDivElement>(null);
   const plotRef = useRef<uPlot | null>(null);

+ 3 - 1
frontend/src/hooks/useClients.ts

@@ -700,7 +700,9 @@ export function useClients(options: UseClientsOptions = {}) {
   // WS-driven in-place merges. Page wires these via useWebSocket; the bridge
   // covers coarse 'invalidate' and 'inbounds' events centrally.
   const queryRef = useRef(query);
-  queryRef.current = query;
+  useEffect(() => {
+    queryRef.current = query;
+  });
 
   const applyTrafficEvent = useCallback(
     (payload: unknown) => {

+ 14 - 10
frontend/src/hooks/useMediaQuery.ts

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useSyncExternalStore } from 'react';
 
 export const MOBILE_BREAKPOINT_PX = 768;
 
@@ -11,17 +11,21 @@ export const MOBILE_BREAKPOINT_PX = 768;
  */
 export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
   const query = `(max-width: ${breakpoint}px)`;
-  const [isMobile, setIsMobile] = useState<boolean>(() =>
-    typeof window !== 'undefined' ? window.matchMedia(query).matches : false,
+
+  const subscribe = useCallback(
+    (onStoreChange: () => void) => {
+      const mql = window.matchMedia(query);
+      mql.addEventListener('change', onStoreChange);
+      return () => mql.removeEventListener('change', onStoreChange);
+    },
+    [query],
   );
 
-  useEffect(() => {
-    const mql = window.matchMedia(query);
-    const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
-    mql.addEventListener('change', onChange);
-    setIsMobile(mql.matches);
-    return () => mql.removeEventListener('change', onChange);
-  }, [query]);
+  const isMobile = useSyncExternalStore(
+    subscribe,
+    () => window.matchMedia(query).matches,
+    () => false,
+  );
 
   return { isMobile };
 }

+ 17 - 24
frontend/src/hooks/useServerDraft.ts

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 
 export function useServerDraft<T>(
   server: T | undefined,
@@ -6,37 +6,30 @@ export function useServerDraft<T>(
   equals: (left: T, right: T) => boolean,
 ) {
   const cloneRef = useRef(clone);
-  const equalsRef = useRef(equals);
-  cloneRef.current = clone;
-  equalsRef.current = equals;
+  useEffect(() => {
+    cloneRef.current = clone;
+  });
 
   const [draft, setDraft] = useState<T | undefined>();
   const [baseline, setBaseline] = useState<T | undefined>();
-  const draftRef = useRef(draft);
-  const baselineRef = useRef(baseline);
-  draftRef.current = draft;
-  baselineRef.current = baseline;
+  const [syncedServer, setSyncedServer] = useState<T | undefined>();
 
-  useEffect(() => {
-    if (server === undefined) return;
-    const currentDraft = draftRef.current;
-    const currentBaseline = baselineRef.current;
-    const isDirty =
-      currentDraft !== undefined &&
-      (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline));
-    setBaseline(server);
-    if (isDirty && !equalsRef.current(currentDraft, server)) return;
-    setDraft(cloneRef.current(server));
-  }, [server]);
+  const isDirty = draft !== undefined && (baseline === undefined || !equals(draft, baseline));
+
+  // Adopting the server value during render (not in an effect) keeps the
+  // returned draft and isDirty consistent within the very first render.
+  if (server !== syncedServer) {
+    setSyncedServer(server);
+    if (server !== undefined) {
+      setBaseline(server);
+      const keepLocalEdits = isDirty && !equals(draft as T, server);
+      if (!keepLocalEdits) setDraft(clone(server));
+    }
+  }
 
   const markSaved = useCallback((value: T) => {
     setBaseline(cloneRef.current(value));
   }, []);
 
-  const isDirty = useMemo(
-    () => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)),
-    [baseline, draft],
-  );
-
   return { draft, setDraft, isDirty, markSaved };
 }

+ 33 - 29
frontend/src/hooks/useXraySetting.ts

@@ -139,10 +139,14 @@ export function useXraySetting(): UseXraySettingResult {
   const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
   const [savedXraySetting, setSavedXraySetting] = useState('');
   const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
-  const [inboundTags, setInboundTags] = useState<string[]>([]);
-  const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
-  const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
-  const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
+  const config = configQuery.data;
+  const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
+  const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
+  const subscriptionOutbounds = useMemo<unknown[]>(
+    () => config?.subscriptionOutbounds || [],
+    [config],
+  );
+  const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
   const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>(
     {},
   );
@@ -161,34 +165,34 @@ export function useXraySetting(): UseXraySettingResult {
   const templateSettingsRef = useRef<XraySettingsValue | null>(null);
   const subscriptionOutboundsRef = useRef<unknown[]>([]);
 
-  xraySettingRef.current = xraySetting;
-  outboundTestUrlRef.current = outboundTestUrl;
-  savedXraySettingRef.current = savedXraySetting;
-  savedOutboundTestUrlRef.current = savedOutboundTestUrl;
-  templateSettingsRef.current = templateSettings;
-  subscriptionOutboundsRef.current = subscriptionOutbounds;
+  const [syncedConfig, setSyncedConfig] = useState<XrayConfigPayload | undefined>();
 
   useEffect(() => {
-    if (!configQuery.data) return;
-    const obj = configQuery.data;
-    const pretty = JSON.stringify(obj.xraySetting, null, 2);
-    const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || '');
-    setInboundTags(obj.inboundTags || []);
-    setClientReverseTags(obj.clientReverseTags || []);
-    setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
-    setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
+    xraySettingRef.current = xraySetting;
+    outboundTestUrlRef.current = outboundTestUrl;
+    savedXraySettingRef.current = savedXraySetting;
+    savedOutboundTestUrlRef.current = savedOutboundTestUrl;
+    templateSettingsRef.current = templateSettings;
+    subscriptionOutboundsRef.current = subscriptionOutbounds;
+  });
+
+  // Adopt a fetched config during render, so the editor never paints one frame
+  // of the previous config after a refetch. Local edits win over the refetch.
+  if (config && config !== syncedConfig) {
+    setSyncedConfig(config);
     const isDirty =
-      savedXraySettingRef.current !== xraySettingRef.current ||
-      savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current);
-    if (isDirty) return;
-    syncingRef.current = true;
-    setXraySettingState(pretty);
-    setTemplateSettingsState(obj.xraySetting);
-    setSavedXraySetting(pretty);
-    syncingRef.current = false;
-    setOutboundTestUrlState(nextUrl);
-    setSavedOutboundTestUrl(nextUrl);
-  }, [configQuery.data]);
+      savedXraySetting !== xraySetting ||
+      savedOutboundTestUrl !== normalizeOutboundTestUrl(outboundTestUrl);
+    if (!isDirty) {
+      const pretty = JSON.stringify(config.xraySetting, null, 2);
+      const nextUrl = normalizeOutboundTestUrl(config.outboundTestUrl || '');
+      setXraySettingState(pretty);
+      setTemplateSettingsState(config.xraySetting);
+      setSavedXraySetting(pretty);
+      setOutboundTestUrlState(nextUrl);
+      setSavedOutboundTestUrl(nextUrl);
+    }
+  }
 
   const fetched = configQuery.data !== undefined || configQuery.isError;
   const fetchError = configQuery.error ? (configQuery.error as Error).message : '';

+ 3 - 5
frontend/src/layouts/AppSidebar.tsx

@@ -287,11 +287,9 @@ export default function AppSidebar() {
 
   const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
   const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
-  useEffect(() => {
-    if (openSubmenu) {
-      setOpenKeys((keys) => (keys.includes(openSubmenu) ? keys : [...keys, openSubmenu]));
-    }
-  }, [openSubmenu]);
+  if (openSubmenu && !openKeys.includes(openSubmenu)) {
+    setOpenKeys([...openKeys, openSubmenu]);
+  }
 
   const toMenuItems = useCallback(
     (items: typeof tabs): MenuProps['items'] =>

+ 3 - 1
frontend/src/lib/xray/forms/fields/FinalMaskField.tsx

@@ -24,7 +24,9 @@ export default function FinalMaskField({
   const [form] = Form.useForm();
   const [initial] = useState(() => value ?? EMPTY);
   const onChangeRef = useRef(onChange);
-  onChangeRef.current = onChange;
+  useEffect(() => {
+    onChangeRef.current = onChange;
+  });
   const lastEmitted = useRef(JSON.stringify(initial));
 
   const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;

+ 3 - 1
frontend/src/lib/xray/forms/fields/SniffingField.tsx

@@ -14,7 +14,9 @@ export default function SniffingField({ value, onChange, enableLabel }: Sniffing
   const [form] = Form.useForm();
   const [initial] = useState(() => value ?? SniffingSchema.parse({}));
   const onChangeRef = useRef(onChange);
-  onChangeRef.current = onChange;
+  useEffect(() => {
+    onChangeRef.current = onChange;
+  });
   const lastEmitted = useRef(JSON.stringify(initial));
 
   const sniffing = Form.useWatch('sniffing', { form, preserve: true }) as Sniffing | undefined;

+ 3 - 1
frontend/src/lib/xray/forms/fields/SockoptCustomField.tsx

@@ -13,7 +13,9 @@ export default function SockoptCustomField({ value, onChange }: SockoptCustomFie
   const [form] = Form.useForm();
   const [initial] = useState(() => value ?? []);
   const onChangeRef = useRef(onChange);
-  onChangeRef.current = onChange;
+  useEffect(() => {
+    onChangeRef.current = onChange;
+  });
   const lastEmitted = useRef(JSON.stringify(initial));
 
   const list = Form.useWatch('customSockopt', form) as CustomSockopt[] | undefined;

+ 7 - 3
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Typography, message } from 'antd';
 
@@ -37,9 +37,13 @@ export default function BulkAttachInboundsModal({
   const [targetIds, setTargetIds] = useState<number[]>([]);
   const [submitting, setSubmitting] = useState(false);
 
-  useEffect(() => {
+  // React resets this during render rather than in an effect so the modal's
+  // first open frame already shows cleared fields.
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
     if (open) setTargetIds([]);
-  }, [open]);
+  }
 
   const targetOptions = useMemo(() => {
     return (inbounds || [])

+ 7 - 3
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Typography, message } from 'antd';
 
@@ -37,9 +37,13 @@ export default function BulkDetachInboundsModal({
   const [targetIds, setTargetIds] = useState<number[]>([]);
   const [submitting, setSubmitting] = useState(false);
 
-  useEffect(() => {
+  // React resets this during render rather than in an effect so the modal's
+  // first open frame already shows cleared fields.
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
     if (open) setTargetIds([]);
-  }, [open]);
+  }
 
   const targetOptions = useMemo(() => {
     return (inbounds || [])

+ 8 - 6
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -95,12 +95,14 @@ export default function ClientBulkAddModal({
   const limitIpDisabled = !fail2ban.usable;
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
-  useEffect(() => {
-    if (!open) return;
-
-    methods.reset(EMPTY);
-    setDelayedStart(false);
-  }, [open, methods]);
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
+    if (open) {
+      methods.reset(EMPTY);
+      setDelayedStart(false);
+    }
+  }
 
   const flowCapableIds = useMemo(() => {
     const ids = new Set<number>();

+ 20 - 20
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -109,14 +109,20 @@ export default function ClientInfoModal({
     keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null
   >(null);
 
-  useEffect(() => {
-    if (!open) {
+  // Clearing on close happens during render; the effect owns only the fetch.
+  const openSubId = open ? (client?.subId ?? '') : null;
+  const [syncedSubId, setSyncedSubId] = useState(openSubId);
+  if (openSubId !== syncedSubId) {
+    setSyncedSubId(openSubId);
+    if (openSubId === null) {
       setLinks([]);
       setClientIps([]);
       setIpsModalOpen(false);
-      return;
     }
-    if (!client?.subId) return;
+  }
+
+  useEffect(() => {
+    if (!open || !client?.subId) return;
     let cancelled = false;
     (async () => {
       const msg = (await HttpUtil.get(
@@ -139,22 +145,16 @@ export default function ClientInfoModal({
     return r > 0 ? r : 0;
   }, [totalBytes, used]);
 
-  const subLink = useMemo(() => {
-    if (!client?.subId || !subSettings?.subURI) return '';
-    return subSettings.subURI + client.subId;
-  }, [client?.subId, subSettings?.subURI]);
-
-  const subJsonLink = useMemo(() => {
-    if (!client?.subId) return '';
-    if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return '';
-    return subSettings.subJsonURI + client.subId;
-  }, [client?.subId, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
-
-  const subClashLink = useMemo(() => {
-    if (!client?.subId) return '';
-    if (!subSettings?.subClashEnable || !subSettings?.subClashURI) return '';
-    return subSettings.subClashURI + client.subId;
-  }, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
+  const subId = client?.subId;
+  const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : '';
+  const subJsonLink =
+    subId && subSettings?.subJsonEnable && subSettings?.subJsonURI
+      ? subSettings.subJsonURI + subId
+      : '';
+  const subClashLink =
+    subId && subSettings?.subClashEnable && subSettings?.subClashURI
+      ? subSettings.subClashURI + subId
+      : '';
 
   const showSubscription = !!(subSettings?.enable && client?.subId);
   const wgInbound = useMemo(

+ 24 - 22
frontend/src/pages/clients/ClientQrModal.tsx

@@ -52,16 +52,13 @@ export default function ClientQrModal({
   const [links, setLinks] = useState<string[]>([]);
   const [loading, setLoading] = useState(false);
 
-  const subLink = useMemo(() => {
-    if (!client?.subId || !subSettings?.enable || !subSettings?.subURI) return '';
-    return subSettings.subURI + client.subId;
-  }, [client?.subId, subSettings?.enable, subSettings?.subURI]);
-
-  const subJsonLink = useMemo(() => {
-    if (!client?.subId || !subSettings?.enable) return '';
-    if (!subSettings?.subJsonEnable || !subSettings?.subJsonURI) return '';
-    return subSettings.subJsonURI + client.subId;
-  }, [client?.subId, subSettings?.enable, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
+  const subId = client?.subId;
+  const subEnabled = !!subSettings?.enable;
+  const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : '';
+  const subJsonLink =
+    subId && subEnabled && subSettings?.subJsonEnable && subSettings?.subJsonURI
+      ? subSettings.subJsonURI + subId
+      : '';
 
   const wgInbound = useMemo(
     () => findWireguardInbound(client, inboundsById),
@@ -79,13 +76,18 @@ export default function ClientQrModal({
 
   const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0;
 
+  // The reset runs during render so the effect only carries the request.
+  const openSubId = open ? (client?.subId ?? '') : '';
+  const [syncedSubId, setSyncedSubId] = useState(openSubId);
+  if (openSubId !== syncedSubId) {
+    setSyncedSubId(openSubId);
+    setLinks([]);
+    setLoading(!!openSubId);
+  }
+
   useEffect(() => {
-    if (!open || !client?.subId) {
-      setLinks([]);
-      return;
-    }
+    if (!open || !client?.subId) return;
     let cancelled = false;
-    setLoading(true);
     (async () => {
       try {
         const msg = (await HttpUtil.get(
@@ -166,13 +168,13 @@ export default function ClientQrModal({
     return out;
   }, [subLink, subJsonLink, wgConfigText, links, client?.email, t]);
 
-  useEffect(() => {
-    if (!open) {
-      setActiveKey([]);
-      return;
-    }
-    setActiveKey(items.length > 0 ? [items[0].key] : []);
-  }, [open, items]);
+  // Expanding the first panel is a render-time adjustment, not a side effect.
+  const firstKey = open && items.length > 0 ? items[0].key : null;
+  const [syncedFirstKey, setSyncedFirstKey] = useState<string | null>(null);
+  if (firstKey !== syncedFirstKey) {
+    setSyncedFirstKey(firstKey);
+    setActiveKey(firstKey ? [firstKey] : []);
+  }
 
   return (
     <Modal

+ 11 - 6
frontend/src/pages/groups/GroupAddClientsModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
@@ -46,11 +46,16 @@ export default function GroupAddClientsModal({
     [candidates],
   );
 
-  useEffect(() => {
-    if (!open) return;
-    setSelectedEmails([]);
-    setSearch('');
-  }, [open]);
+  // React resets this during render rather than in an effect so the modal's
+  // first open frame already shows cleared fields.
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
+    if (open) {
+      setSelectedEmails([]);
+      setSearch('');
+    }
+  }
 
   const filteredRows = useMemo(() => {
     const q = search.trim().toLowerCase();

+ 11 - 6
frontend/src/pages/groups/GroupRemoveClientsModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
@@ -44,11 +44,16 @@ export default function GroupRemoveClientsModal({
     [members],
   );
 
-  useEffect(() => {
-    if (!open) return;
-    setSelectedEmails([]);
-    setSearch('');
-  }, [open]);
+  // React resets this during render rather than in an effect so the modal's
+  // first open frame already shows cleared fields.
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
+    if (open) {
+      setSelectedEmails([]);
+      setSearch('');
+    }
+  }
 
   const filteredRows = useMemo(() => {
     const q = search.trim().toLowerCase();

+ 8 - 3
frontend/src/pages/hosts/HostFormModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
 import {
@@ -94,12 +94,17 @@ export default function HostFormModal({
   const showTls = security === 'tls' || security === 'reality';
   const showTlsExtras = security === 'tls';
 
-  useEffect(() => {
+  // React resets this during render rather than in an effect so the modal's
+  // first open frame already shows cleared fields.
+  const openHost = open ? host : null;
+  const [syncedHost, setSyncedHost] = useState(openHost);
+  if (openHost !== syncedHost) {
+    setSyncedHost(openHost);
     if (open) {
       methods.reset(defaultsFor(host));
       setLoading(false);
     }
-  }, [open, host, methods]);
+  }
 
   const { nodes } = useNodesQuery();
 

+ 3 - 1
frontend/src/pages/hosts/json-forms/HostFinalMaskForm.tsx

@@ -35,7 +35,9 @@ export default function HostFinalMaskForm({
   const [form] = Form.useForm();
   const [initial] = useState(() => parseFinalMask(value));
   const onChangeRef = useRef(onChange);
-  onChangeRef.current = onChange;
+  useEffect(() => {
+    onChangeRef.current = onChange;
+  });
 
   const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
 

+ 15 - 9
frontend/src/pages/inbounds/clients/AttachClientsModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Input, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
@@ -57,14 +57,20 @@ export default function AttachClientsModal({
   const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
   const [search, setSearch] = useState('');
 
-  useEffect(() => {
-    if (!open) return;
-    const rows = source ? readClientRows(source.settings) : [];
-    setClientRows(rows);
-    setSelectedEmails(rows.map((r) => r.email));
-    setTargetIds([]);
-    setSearch('');
-  }, [open, source]);
+  // React resets this during render rather than in an effect so the modal's
+  // first open frame already shows cleared fields.
+  const openSource = open ? source : null;
+  const [syncedSource, setSyncedSource] = useState(openSource);
+  if (openSource !== syncedSource) {
+    setSyncedSource(openSource);
+    if (openSource) {
+      const rows = readClientRows(openSource.settings);
+      setClientRows(rows);
+      setSelectedEmails(rows.map((r) => r.email));
+      setTargetIds([]);
+      setSearch('');
+    }
+  }
 
   const targetOptions = useMemo(() => {
     if (!source) return [];

+ 12 - 3
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -49,12 +49,21 @@ export default function AttachExistingClientsModal({
   const [search, setSearch] = useState('');
   const [groupFilter, setGroupFilter] = useState<string | undefined>(undefined);
 
+  // Reset during render, not in an effect, so the first frame is already clean.
+  const openTarget = open ? target : null;
+  const [syncedTarget, setSyncedTarget] = useState(openTarget);
+  if (openTarget !== syncedTarget) {
+    setSyncedTarget(openTarget);
+    if (openTarget) {
+      setLoading(true);
+      setSearch('');
+      setGroupFilter(undefined);
+    }
+  }
+
   useEffect(() => {
     if (!open || !target) return;
     let cancelled = false;
-    setLoading(true);
-    setSearch('');
-    setGroupFilter(undefined);
     HttpUtil.get('/panel/api/clients/list', undefined, { silent: true })
       .then((msg) => {
         if (cancelled) return;

+ 12 - 8
frontend/src/pages/inbounds/clients/DetachClientsModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
@@ -52,13 +52,17 @@ export default function DetachClientsModal({
   const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
   const [search, setSearch] = useState('');
 
-  useEffect(() => {
-    if (!open) return;
-    const rows = source ? readClientRows(source.settings) : [];
-    setClientRows(rows);
-    setSelectedEmails([]);
-    setSearch('');
-  }, [open, source]);
+  // Reset during render, not in an effect, so the first frame is already clean.
+  const openSource = open ? source : null;
+  const [syncedSource, setSyncedSource] = useState(openSource);
+  if (openSource !== syncedSource) {
+    setSyncedSource(openSource);
+    if (openSource) {
+      setClientRows(readClientRows(openSource.settings));
+      setSelectedEmails([]);
+      setSearch('');
+    }
+  }
 
   const filteredRows = useMemo(() => {
     const q = search.trim().toLowerCase();

+ 5 - 3
frontend/src/pages/inbounds/form/protocols/vless.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
 import { Controller, useFormContext } from 'react-hook-form';
@@ -29,9 +29,11 @@ export default function VlessFields({
   const { control } = useFormContext();
   const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
 
-  useEffect(() => {
+  const [syncedAuthKind, setSyncedAuthKind] = useState(vlessAuthKind);
+  if (vlessAuthKind !== syncedAuthKind) {
+    setSyncedAuthKind(vlessAuthKind);
     setAuthKind(vlessAuthKind ?? 'x25519');
-  }, [vlessAuthKind]);
+  }
 
   const authOptions = (Object.entries(VLESS_AUTH_LABEL_KEYS) as [VlessAuthKind, string][]).map(
     ([value, labelKey]) => ({ value, label: t(labelKey) }),

+ 26 - 7
frontend/src/pages/inbounds/form/security/RealityTargetScannerModal.tsx

@@ -23,10 +23,11 @@ export default function RealityTargetScannerModal({
   const [query, setQuery] = useState('');
   const [results, setResults] = useState<RealityScanResult[]>([]);
   const scanRef = useRef(scanRealityCandidates);
-  scanRef.current = scanRealityCandidates;
+  useEffect(() => {
+    scanRef.current = scanRealityCandidates;
+  });
 
-  const runScan = useCallback(async (targets?: string) => {
-    setLoading(true);
+  const applyScan = useCallback(async (targets?: string) => {
     try {
       setResults(await scanRef.current(targets));
     } finally {
@@ -34,11 +35,29 @@ export default function RealityTargetScannerModal({
     }
   }, []);
 
+  const runScan = useCallback(
+    (targets?: string) => {
+      setLoading(true);
+      setResults([]);
+      void applyScan(targets);
+    },
+    [applyScan],
+  );
+
+  // Clearing the previous results is done during render so the auto-scan effect
+  // carries only the request itself.
+  const [scannedOpen, setScannedOpen] = useState(false);
+  if (open !== scannedOpen) {
+    setScannedOpen(open);
+    if (open) {
+      setResults([]);
+      setLoading(true);
+    }
+  }
+
   useEffect(() => {
-    if (!open) return;
-    setResults([]);
-    runScan();
-  }, [open, runScan]);
+    if (open) void applyScan();
+  }, [open, applyScan]);
 
   const columns: ColumnsType<RealityScanResult> = [
     {

+ 39 - 12
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -99,8 +99,26 @@ export default function InboundInfoModal({
     }
   }, [clientStats, t]);
 
-  useEffect(() => {
-    if (!open || !dbInbound) return;
+  // The panel's contents are a pure function of the props, so they are adopted
+  // during render; only the IP lookup below stays asynchronous.
+  const [syncedProps, setSyncedProps] = useState<{
+    dbInbound: typeof dbInbound;
+    clientIndex: typeof clientIndex;
+    nodeAddress: typeof nodeAddress;
+    subSettings: typeof subSettings;
+    ipLimitEnable: typeof ipLimitEnable;
+  } | null>(null);
+  if (
+    open &&
+    dbInbound &&
+    (syncedProps === null ||
+      syncedProps.dbInbound !== dbInbound ||
+      syncedProps.clientIndex !== clientIndex ||
+      syncedProps.nodeAddress !== nodeAddress ||
+      syncedProps.subSettings !== subSettings ||
+      syncedProps.ipLimitEnable !== ipLimitEnable)
+  ) {
+    setSyncedProps({ dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable });
     const info = buildInboundInfo(dbInbound);
     setInbound(info);
     setActiveTab(info.clients.length > 0 ? 'client' : 'inbound');
@@ -189,7 +207,16 @@ export default function InboundInfoModal({
         }
       });
     }
-  }, [open, dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable, t]);
+  }
+
+  // The expiry tag colours against the current time; a state-backed clock keeps
+  // render pure and still refreshes the tag while the modal stays open.
+  const [now, setNow] = useState(() => Date.now());
+  useEffect(() => {
+    if (!open) return;
+    const id = window.setInterval(() => setNow(Date.now()), 60_000);
+    return () => window.clearInterval(id);
+  }, [open]);
 
   const isEnable = useMemo(() => {
     if (clientSettings) return !!clientSettings.enable;
@@ -202,9 +229,9 @@ export default function InboundInfoModal({
     const used = (clientStats.up ?? 0) + (clientStats.down ?? 0);
     if (total > 0 && used >= total) return true;
     const expiry = clientSettings.expiryTime ?? 0;
-    if (expiry > 0 && Date.now() >= expiry) return true;
+    if (expiry > 0 && now >= expiry) return true;
     return false;
-  }, [clientStats, clientSettings]);
+  }, [clientStats, clientSettings, now]);
 
   const remainingStats = useMemo(() => {
     if (!clientStats || !clientSettings) return '-';
@@ -212,10 +239,12 @@ export default function InboundInfoModal({
     return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
   }, [clientStats, clientSettings]);
 
-  const wgPubKey = useMemo(() => {
-    if (!dbInbound?.isWireguard || !inbound?.settings?.secretKey) return '';
-    return Wireguard.generateKeypair(inbound.settings.secretKey as string).publicKey;
-  }, [dbInbound?.isWireguard, inbound?.settings?.secretKey]);
+  const isWireguard = !!dbInbound?.isWireguard;
+  const wgSecretKey = inbound?.settings?.secretKey as string | undefined;
+  const wgPubKey = useMemo(
+    () => (isWireguard && wgSecretKey ? Wireguard.generateKeypair(wgSecretKey).publicKey : ''),
+    [isWireguard, wgSecretKey],
+  );
 
   const formatLastOnline = useCallback(
     (email: string) => {
@@ -438,9 +467,7 @@ export default function InboundInfoModal({
             </td>
             <td>
               {(clientSettings?.expiryTime ?? 0) > 0 ? (
-                <Tag
-                  color={ColorUtils.usageColor(Date.now(), expireDiff, clientSettings!.expiryTime!)}
-                >
+                <Tag color={ColorUtils.usageColor(now, expireDiff, clientSettings!.expiryTime!)}>
                   {IntlUtil.formatDate(clientSettings!.expiryTime!, datepicker)}
                 </Tag>
               ) : (clientSettings?.expiryTime ?? 0) < 0 ? (

+ 11 - 1
frontend/src/pages/inbounds/list/InboundStatsModal.tsx

@@ -1,3 +1,4 @@
+import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Modal, Tag } from 'antd';
 
@@ -40,6 +41,15 @@ export default function InboundStatsModal({
   onClose,
 }: InboundStatsModalProps) {
   const { t } = useTranslation();
+  // The expiry tag colours against the current time; a state-backed clock keeps
+  // render pure and still refreshes the tag while the modal stays open.
+  const [now, setNow] = useState(() => Date.now());
+  useEffect(() => {
+    if (!open) return;
+    const id = window.setInterval(() => setNow(Date.now()), 60_000);
+    return () => window.clearInterval(id);
+  }, [open]);
+
   return (
     <Modal
       open={open}
@@ -143,7 +153,7 @@ export default function InboundStatsModal({
           <div className="stat-row">
             <span className="stat-label">{t('pages.inbounds.expireDate')}</span>
             {record.expiryTime > 0 ? (
-              <Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)}>
+              <Tag color={ColorUtils.usageColor(now, expireDiff, record._expiryTime)}>
                 {IntlUtil.formatRelativeTime(record.expiryTime)}
               </Tag>
             ) : (

+ 26 - 11
frontend/src/pages/inbounds/qr/QrCodeModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Collapse, Modal } from 'antd';
 import type { CollapseProps } from 'antd';
@@ -54,8 +54,24 @@ export default function QrCodeModal({
   const [subJsonLink, setSubJsonLink] = useState('');
   const [activeKey, setActiveKey] = useState<string[]>([]);
 
-  useEffect(() => {
-    if (!open || !dbInbound) return;
+  // Building the links is a pure function of the props, so it runs during
+  // render; an effect would paint the previous inbound's QR first.
+  const [syncedProps, setSyncedProps] = useState<{
+    dbInbound: typeof dbInbound;
+    client: typeof client;
+    nodeAddress: typeof nodeAddress;
+    subSettings: typeof subSettings;
+  } | null>(null);
+  if (
+    open &&
+    dbInbound &&
+    (syncedProps === null ||
+      syncedProps.dbInbound !== dbInbound ||
+      syncedProps.client !== client ||
+      syncedProps.nodeAddress !== nodeAddress ||
+      syncedProps.subSettings !== subSettings)
+  ) {
+    setSyncedProps({ dbInbound, client, nodeAddress, subSettings });
     const inbound = inboundFromDb(dbInbound);
     const fallbackHostname = preferPublicHost(
       window.location.hostname,
@@ -105,7 +121,7 @@ export default function QrCodeModal({
     }
     setSubLink(nextSub);
     setSubJsonLink(nextSubJson);
-  }, [open, dbInbound, client, nodeAddress, subSettings]);
+  }
 
   const qrItems = useMemo<QrItem[]>(() => {
     const items: QrItem[] = [];
@@ -158,13 +174,12 @@ export default function QrCodeModal({
     [qrItems],
   );
 
-  useEffect(() => {
-    if (!open) {
-      setActiveKey([]);
-      return;
-    }
-    setActiveKey(qrItems.length > 0 ? [qrItems[0].key] : []);
-  }, [open, qrItems]);
+  const firstKey = open && qrItems.length > 0 ? qrItems[0].key : null;
+  const [syncedFirstKey, setSyncedFirstKey] = useState<string | null>(null);
+  if (firstKey !== syncedFirstKey) {
+    setSyncedFirstKey(firstKey);
+    setActiveKey(firstKey ? [firstKey] : []);
+  }
 
   return (
     <Modal

+ 1 - 1
frontend/src/pages/inbounds/qr/QrPanel.tsx

@@ -133,7 +133,7 @@ export default function QrPanel({
           tabIndex={0}
           aria-label={t('copy')}
           onClick={copyImage}
-          onKeyDown={activateOnKey(copyImage)}
+          onKeyDown={(event) => activateOnKey(copyImage)(event)}
         >
           <Tooltip title={t('copy')}>
             <QRCode

+ 199 - 246
frontend/src/pages/inbounds/useInbounds.ts

@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { HttpUtil } from '@/utils';
 import { parseMsg } from '@/utils/zodValidate';
 import { DBInbound, coerceInboundJsonField } from '@/models/dbinbound';
+import type { ClientStats, DBInboundInit } from '@/models/dbinbound';
 import { Protocols } from '@/schemas/primitives';
 import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
 import { setDatepicker } from '@/hooks/useDatepicker';
@@ -203,20 +204,13 @@ export function useInbounds() {
     if (defaults.datepicker) setDatepicker(datepicker);
   }, [datepicker, defaults.datepicker]);
 
-  const expireDiffRef = useRef(expireDiff);
-  expireDiffRef.current = expireDiff;
-  const trafficDiffRef = useRef(trafficDiff);
-  trafficDiffRef.current = trafficDiff;
-
-  // dbInbounds mirrors the slim query data wrapped as DBInbound instances, but
-  // stays mutable so the WS-driven applyClientStatsEvent / applyTrafficEvent
-  // can merge per-row updates without invalidating the entire query.
+  // dbInbounds mirrors the slim query data wrapped as DBInbound instances. The
+  // WS handlers rebuild only the rows they touch, so no refetch is needed.
   const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
   const dbInboundsRef = useRef<DBInboundInstance[]>([]);
-  dbInboundsRef.current = dbInbounds;
-
-  const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
-  const [statsVersion, setStatsVersion] = useState(0);
+  useEffect(() => {
+    dbInboundsRef.current = dbInbounds;
+  });
 
   const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>(() =>
     Date.now() - inboundSpeedCache.at < SPEED_CACHE_TTL_MS ? inboundSpeedCache.data : {},
@@ -226,20 +220,18 @@ export function useInbounds() {
   }, [inboundSpeed]);
 
   const [onlineClients, setOnlineClients] = useState<string[]>([]);
-  const onlineClientsRef = useRef<string[]>([]);
-  onlineClientsRef.current = onlineClients;
 
   // Online emails keyed by the hosting node's panelGuid. The rollup reads this
   // so each inbound only counts clients online on the node that physically
   // hosts it, attributing a sub-node's clients to that sub-node (#4983).
-  const onlineByGuidRef = useRef<Map<string, Set<string>>>(new Map());
+  const [onlineByGuid, setOnlineByGuid] = useState<Map<string, Set<string>>>(() => new Map());
 
   // Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID
   // missing from this map means "no per-inbound activity reported" (e.g. remote
   // nodes), so the rollup leaves that node's inbounds ungated and falls back to
   // the email signal. A present GUID gates: a client only counts online on an
   // inbound whose tag carried traffic this window.
-  const activeByGuidRef = useRef<Map<string, Set<string>>>(new Map());
+  const [activeByGuid, setActiveByGuid] = useState<Map<string, Set<string>>>(() => new Map());
 
   const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
 
@@ -276,12 +268,12 @@ export function useInbounds() {
       // the master-local synthetic id for an old-build node without one (#4983).
       const guid =
         dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
-      const nodeOnline = onlineByGuidRef.current.get(guid);
+      const nodeOnline = onlineByGuid.get(guid);
       // A node absent from the active map reports no per-inbound activity, so
       // leave its inbounds ungated. When present, only mark a client online on
       // this inbound if its tag actually carried traffic — that's what stops a
       // multi-inbound client lighting up every inbound it's attached to.
-      const activeForNode = activeByGuidRef.current.get(guid);
+      const activeForNode = activeByGuid.get(guid);
       const inboundActive =
         activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
 
@@ -312,8 +304,8 @@ export function useInbounds() {
           if (inboundActive && nodeOnline?.has(client.email)) online.push(client.email);
           if (stats) {
             const expiringSoon =
-              (stats.expiryTime > 0 && stats.expiryTime - now < expireDiffRef.current) ||
-              (stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiffRef.current);
+              (stats.expiryTime > 0 && stats.expiryTime - now < expireDiff) ||
+              (stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff);
             if (expiringSoon) expiring.push(client.email);
           }
         }
@@ -333,12 +325,14 @@ export function useInbounds() {
         comments,
       };
     },
-    [],
+    [onlineByGuid, activeByGuid, expireDiff, trafficDiff],
   );
 
-  const rebuildClientCount = useCallback(() => {
+  // Every write to a DBInbound row also replaces the dbInbounds array, so this
+  // recomputes on both a refetch and a WS-merged stats update.
+  const clientCount = useMemo(() => {
     const counts: Record<number, ClientRollup> = {};
-    for (const dbInbound of dbInboundsRef.current) {
+    for (const dbInbound of dbInbounds) {
       const protocol = dbInbound.protocol;
       if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
       const settings = coerceInboundJsonField(dbInbound.settings) as {
@@ -348,60 +342,44 @@ export function useInbounds() {
       if (protocol === Protocols.SHADOWSOCKS && !isSSMultiUser({ protocol, settings })) continue;
       counts[dbInbound.id] = rollupClients(dbInbound, { clients: settings.clients });
     }
-    setClientCount(counts);
-  }, [rollupClients]);
-
-  // Seed dbInbounds + clientCount from the slim query. Runs on first fetch and
-  // again every time the query refetches (e.g. invalidate from WS bridge).
-  useEffect(() => {
-    if (!slimQuery.data) return;
-    const next: DBInboundInstance[] = [];
-    const counts: Record<number, ClientRollup> = {};
-    for (const row of slimQuery.data as { protocol: string; id: number }[]) {
-      const dbInbound = new DBInbound(row) as DBInboundInstance;
-      next.push(dbInbound);
-      if (TRACKED_PROTOCOLS.includes(row.protocol)) {
-        const settings = coerceInboundJsonField(dbInbound.settings) as {
-          method?: string;
-          clients?: Array<{ email?: string; enable?: boolean; comment?: string }>;
-        };
-        if (
-          row.protocol === Protocols.SHADOWSOCKS &&
-          !isSSMultiUser({ protocol: row.protocol, settings })
-        )
-          continue;
-        counts[row.id] = rollupClients(dbInbound, { clients: settings.clients });
-      }
-    }
-    dbInboundsRef.current = next;
-    setDbInbounds(next);
-    setClientCount(counts);
-  }, [slimQuery.data, rollupClients]);
+    return counts;
+  }, [dbInbounds, rollupClients]);
+
+  // Adopting fetched data during render (rather than in an effect) keeps the
+  // list from painting one frame of the previous data after a refetch.
+  const [syncedSlim, setSyncedSlim] = useState<unknown>();
+  if (slimQuery.data && slimQuery.data !== syncedSlim) {
+    setSyncedSlim(slimQuery.data);
+    setDbInbounds(
+      (slimQuery.data as { protocol: string; id: number }[]).map(
+        (row) => new DBInbound(row) as DBInboundInstance,
+      ),
+    );
+  }
 
-  useEffect(() => {
-    if (onlinesQuery.data) {
-      onlineClientsRef.current = onlinesQuery.data;
-      setOnlineClients(onlinesQuery.data);
-    }
-  }, [onlinesQuery.data]);
+  const [syncedOnlines, setSyncedOnlines] = useState<unknown>();
+  if (onlinesQuery.data && onlinesQuery.data !== syncedOnlines) {
+    setSyncedOnlines(onlinesQuery.data);
+    setOnlineClients(onlinesQuery.data);
+  }
 
-  useEffect(() => {
-    if (onlinesByGuidQuery.data) {
-      onlineByGuidRef.current = toGuidOnlineMap(onlinesByGuidQuery.data);
-      rebuildClientCount();
-    }
-  }, [onlinesByGuidQuery.data, rebuildClientCount]);
+  const [syncedOnlinesByGuid, setSyncedOnlinesByGuid] = useState<unknown>();
+  if (onlinesByGuidQuery.data && onlinesByGuidQuery.data !== syncedOnlinesByGuid) {
+    setSyncedOnlinesByGuid(onlinesByGuidQuery.data);
+    setOnlineByGuid(toGuidOnlineMap(onlinesByGuidQuery.data));
+  }
 
-  useEffect(() => {
-    if (activeInboundsQuery.data) {
-      activeByGuidRef.current = toGuidOnlineMap(activeInboundsQuery.data);
-      rebuildClientCount();
-    }
-  }, [activeInboundsQuery.data, rebuildClientCount]);
+  const [syncedActiveInbounds, setSyncedActiveInbounds] = useState<unknown>();
+  if (activeInboundsQuery.data && activeInboundsQuery.data !== syncedActiveInbounds) {
+    setSyncedActiveInbounds(activeInboundsQuery.data);
+    setActiveByGuid(toGuidOnlineMap(activeInboundsQuery.data));
+  }
 
-  useEffect(() => {
-    if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
-  }, [lastOnlineQuery.data]);
+  const [syncedLastOnline, setSyncedLastOnline] = useState<unknown>();
+  if (lastOnlineQuery.data && lastOnlineQuery.data !== syncedLastOnline) {
+    setSyncedLastOnline(lastOnlineQuery.data);
+    setLastOnlineMap(lastOnlineQuery.data);
+  }
 
   const fetched =
     (slimQuery.data !== undefined || slimQuery.isError) &&
@@ -430,185 +408,161 @@ export function useInbounds() {
   // uuid/password/flow/etc.) and swaps it into the cached list. Use this
   // before opening edit / info / qr / export / clone flows — refresh() loads
   // the slim list which doesn't carry per-client secrets.
-  const hydrateInbound = useCallback(
-    async (id: number) => {
-      const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
-      if (!msg?.success || !msg.obj) return null;
-      const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
-      if (!validated.obj) return null;
-      const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
-      setDbInbounds((prev) => {
-        const next = prev.map((row) =>
-          (row as unknown as { id: number }).id === id ? dbInbound : row,
-        );
-        dbInboundsRef.current = next;
+  const hydrateInbound = useCallback(async (id: number) => {
+    const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`);
+    if (!msg?.success || !msg.obj) return null;
+    const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`);
+    if (!validated.obj) return null;
+    const dbInbound = new DBInbound(validated.obj) as DBInboundInstance;
+    setDbInbounds((prev) => {
+      const next = prev.map((row) =>
+        (row as unknown as { id: number }).id === id ? dbInbound : row,
+      );
+      dbInboundsRef.current = next;
+      return next;
+    });
+    return dbInbound;
+  }, []);
+
+  const applyTrafficEvent = useCallback((payload: unknown) => {
+    if (!payload || typeof payload !== 'object') return;
+    const p = payload as {
+      traffics?: TrafficDelta[];
+      nodeTraffics?: TrafficDelta[];
+      onlineClients?: string[];
+      onlineByGuid?: Record<string, string[]>;
+      activeInbounds?: Record<string, string[]>;
+      lastOnlineMap?: Record<string, number>;
+    };
+    if (Array.isArray(p.onlineClients)) {
+      setOnlineClients(p.onlineClients);
+    }
+    if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
+      setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid));
+    }
+    if (p.activeInbounds && typeof p.activeInbounds === 'object') {
+      setActiveByGuid(toGuidOnlineMap(p.activeInbounds));
+    }
+    if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
+      setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
+    }
+    // Speed arrives from two independent 5s polls: the local Xray poll sends
+    // `traffics` (local inbounds) and the node sync sends `nodeTraffics` (node
+    // inbounds). Each replaces speed only within its own scope so the two don't
+    // clobber each other; an idle in-scope inbound — absent from its payload —
+    // clears instead of showing a stale value.
+    const applyTraffics = (
+      traffics: TrafficDelta[],
+      inScope: (ib: DBInboundInstance) => boolean,
+    ) => {
+      const byTag = new Map<string, TrafficDelta>();
+      for (const tr of traffics) {
+        if (!tr || typeof tr.Tag !== 'string') continue;
+        if (tr.IsInbound === false) continue;
+        byTag.set(tr.Tag, tr);
+      }
+      setInboundSpeed((prev) => {
+        const next = { ...prev };
+        for (const ib of dbInboundsRef.current) {
+          if (!inScope(ib)) continue;
+          const delta = byTag.get(ib.tag);
+          if (delta) {
+            next[ib.id] = {
+              up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
+              down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
+            };
+          } else {
+            delete next[ib.id];
+          }
+        }
         return next;
       });
-      rebuildClientCount();
-      return dbInbound;
-    },
-    [rebuildClientCount],
-  );
-
-  const applyTrafficEvent = useCallback(
-    (payload: unknown) => {
-      if (!payload || typeof payload !== 'object') return;
-      const p = payload as {
-        traffics?: TrafficDelta[];
-        nodeTraffics?: TrafficDelta[];
-        onlineClients?: string[];
-        onlineByGuid?: Record<string, string[]>;
-        activeInbounds?: Record<string, string[]>;
-        lastOnlineMap?: Record<string, number>;
-      };
-      if (Array.isArray(p.onlineClients)) {
-        onlineClientsRef.current = p.onlineClients;
-        setOnlineClients(p.onlineClients);
-      }
-      if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
-        onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
-      }
-      if (p.activeInbounds && typeof p.activeInbounds === 'object') {
-        activeByGuidRef.current = toGuidOnlineMap(p.activeInbounds);
+    };
+    if (Array.isArray(p.traffics)) applyTraffics(p.traffics, (ib) => ib.nodeId == null);
+    if (Array.isArray(p.nodeTraffics)) applyTraffics(p.nodeTraffics, (ib) => ib.nodeId != null);
+  }, []);
+
+  const applyClientStatsEvent = useCallback((payload: unknown) => {
+    if (!payload || typeof payload !== 'object') return;
+    const p = payload as {
+      inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[];
+      clients?: {
+        email: string;
+        up?: number;
+        down?: number;
+        total?: number;
+        expiryTime?: number;
+        enable?: boolean;
+      }[];
+    };
+
+    const byId = new Map<
+      number,
+      { id: number; up?: number; down?: number; total?: number; enable?: boolean }
+    >();
+    if (Array.isArray(p.inbounds)) {
+      for (const row of p.inbounds) {
+        if (row && row.id != null) byId.set(row.id, row);
       }
-      if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
-        setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
+    }
+    const byEmail = new Map<
+      string,
+      {
+        email: string;
+        up?: number;
+        down?: number;
+        total?: number;
+        expiryTime?: number;
+        enable?: boolean;
       }
-      // Speed arrives from two independent 5s polls: the local Xray poll sends
-      // `traffics` (local inbounds) and the node sync sends `nodeTraffics` (node
-      // inbounds). Each replaces speed only within its own scope so the two don't
-      // clobber each other; an idle in-scope inbound — absent from its payload —
-      // clears instead of showing a stale value.
-      const applyTraffics = (
-        traffics: TrafficDelta[],
-        inScope: (ib: DBInboundInstance) => boolean,
-      ) => {
-        const byTag = new Map<string, TrafficDelta>();
-        for (const tr of traffics) {
-          if (!tr || typeof tr.Tag !== 'string') continue;
-          if (tr.IsInbound === false) continue;
-          byTag.set(tr.Tag, tr);
-        }
-        setInboundSpeed((prev) => {
-          const next = { ...prev };
-          for (const ib of dbInboundsRef.current) {
-            if (!inScope(ib)) continue;
-            const delta = byTag.get(ib.tag);
-            if (delta) {
-              next[ib.id] = {
-                up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
-                down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
-              };
-            } else {
-              delete next[ib.id];
-            }
-          }
-          return next;
-        });
-      };
-      if (Array.isArray(p.traffics)) applyTraffics(p.traffics, (ib) => ib.nodeId == null);
-      if (Array.isArray(p.nodeTraffics)) applyTraffics(p.nodeTraffics, (ib) => ib.nodeId != null);
-      rebuildClientCount();
-    },
-    [rebuildClientCount],
-  );
-
-  const applyClientStatsEvent = useCallback(
-    (payload: unknown) => {
-      if (!payload || typeof payload !== 'object') return;
-      const p = payload as {
-        inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[];
-        clients?: {
-          email: string;
-          up?: number;
-          down?: number;
-          total?: number;
-          expiryTime?: number;
-          enable?: boolean;
-        }[];
-      };
-      let touched = false;
-
-      if (Array.isArray(p.inbounds) && p.inbounds.length > 0) {
-        const byId = new Map<
-          number,
-          { id: number; up?: number; down?: number; total?: number; enable?: boolean }
-        >();
-        for (const row of p.inbounds) {
-          if (row && row.id != null) byId.set(row.id, row);
-        }
-        for (const ib of dbInboundsRef.current) {
-          const upd = byId.get((ib as unknown as { id: number }).id);
-          if (!upd) continue;
-          const ibRec = ib as unknown as {
-            up: number;
-            down: number;
-            total: number;
-            enable: boolean;
-          };
-          if (typeof upd.up === 'number') ibRec.up = upd.up;
-          if (typeof upd.down === 'number') ibRec.down = upd.down;
-          if (typeof upd.total === 'number') ibRec.total = upd.total;
-          if (typeof upd.enable === 'boolean') ibRec.enable = upd.enable;
-          touched = true;
-        }
+    >();
+    if (Array.isArray(p.clients)) {
+      for (const row of p.clients) {
+        if (row && row.email) byEmail.set(row.email, row);
       }
-
-      if (Array.isArray(p.clients) && p.clients.length > 0) {
-        const byEmail = new Map<
-          string,
-          {
-            email: string;
-            up?: number;
-            down?: number;
-            total?: number;
-            expiryTime?: number;
-            enable?: boolean;
-          }
-        >();
-        for (const row of p.clients) {
-          if (row && row.email) byEmail.set(row.email, row);
-        }
-        for (const ib of dbInboundsRef.current) {
-          const stats = (
-            ib as unknown as {
-              clientStats: {
-                email: string;
-                up: number;
-                down: number;
-                total: number;
-                expiryTime: number;
-                enable: boolean;
-              }[];
-            }
-          ).clientStats;
-          if (!Array.isArray(stats)) continue;
-          for (let i = 0; i < stats.length; i++) {
-            const stat = stats[i];
-            const upd = byEmail.get(stat.email);
-            if (!upd) continue;
-            if (typeof upd.up === 'number') stat.up = upd.up;
-            if (typeof upd.down === 'number') stat.down = upd.down;
-            if (typeof upd.total === 'number') stat.total = upd.total;
-            if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
-            if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
-            touched = true;
-          }
-        }
+    }
+    if (byId.size === 0 && byEmail.size === 0) return;
+
+    // Rows carrying an update are rebuilt rather than patched in place: the
+    // derived clientCount only recomputes when a row's identity changes.
+    let touched = false;
+    const next = dbInboundsRef.current.map((ib) => {
+      const upd = byId.get(ib.id);
+      const stats = Array.isArray(ib.clientStats) ? ib.clientStats : null;
+      let statsTouched = false;
+      const nextStats =
+        stats && byEmail.size > 0
+          ? stats.map((stat) => {
+              const su = byEmail.get(stat.email);
+              if (!su) return stat;
+              statsTouched = true;
+              return {
+                ...stat,
+                up: typeof su.up === 'number' ? su.up : stat.up,
+                down: typeof su.down === 'number' ? su.down : stat.down,
+                total: typeof su.total === 'number' ? su.total : stat.total,
+                expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
+                enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
+              } as ClientStats;
+            })
+          : null;
+      if (!upd && !statsTouched) return ib;
+      touched = true;
+      const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
+      if (upd) {
+        if (typeof upd.up === 'number') row.up = upd.up;
+        if (typeof upd.down === 'number') row.down = upd.down;
+        if (typeof upd.total === 'number') row.total = upd.total;
+        if (typeof upd.enable === 'boolean') row.enable = upd.enable;
       }
+      if (statsTouched && nextStats) row.clientStats = nextStats;
+      return row;
+    });
 
-      if (touched) {
-        setStatsVersion((v) => v + 1);
-        setDbInbounds((prev) => {
-          const next = [...prev];
-          dbInboundsRef.current = next;
-          return next;
-        });
-        rebuildClientCount();
-      }
-    },
-    [rebuildClientCount],
-  );
+    if (!touched) return;
+    dbInboundsRef.current = next;
+    setDbInbounds(next);
+  }, []);
 
   const totals = useMemo(() => {
     let up = 0;
@@ -629,7 +583,6 @@ export function useInbounds() {
     onlineClients,
     lastOnlineMap,
     inboundSpeed,
-    statsVersion,
     totals,
     expireDiff,
     trafficDiff,

+ 13 - 9
frontend/src/pages/index/GeodataSection.tsx

@@ -38,21 +38,20 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
   const [outbound, setOutbound] = useState<string | undefined>(undefined);
   const [rows, setRows] = useState<GeodataAssetRow[]>([]);
   const [outboundTags, setOutboundTags] = useState<string[]>([]);
-  const templateRef = useRef<Record<string, unknown> | null>(null);
+  const [template, setTemplate] = useState<Record<string, unknown> | null>(null);
   const outboundTestUrlRef = useRef('');
 
   const load = useCallback(async () => {
-    setLoading(true);
     try {
       const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
       if (!msg?.success || typeof msg.obj !== 'string') return;
       const payload = JSON.parse(msg.obj) as Record<string, unknown>;
-      const template = (payload.xraySetting || {}) as Record<string, unknown>;
-      templateRef.current = template;
+      const next = (payload.xraySetting || {}) as Record<string, unknown>;
+      setTemplate(next);
       outboundTestUrlRef.current =
         typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
 
-      const geodata = (template.geodata || {}) as Record<string, unknown>;
+      const geodata = (next.geodata || {}) as Record<string, unknown>;
       const assets = Array.isArray(geodata.assets) ? geodata.assets : [];
       setRows(
         assets
@@ -67,7 +66,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
       // Download outbound candidates: template outbounds + subscription outbounds.
       // Skip blackhole outbounds — routing a download through one just drops it.
       const tags = new Set<string>();
-      const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
+      const outbounds = Array.isArray(next.outbounds) ? next.outbounds : [];
       for (const o of outbounds) {
         if (!o || typeof o !== 'object') continue;
         const rec = o as Record<string, unknown>;
@@ -87,8 +86,14 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
     }
   }, []);
 
+  const [wasActive, setWasActive] = useState(false);
+  if (active !== wasActive) {
+    setWasActive(active);
+    if (active) setLoading(true);
+  }
+
   useEffect(() => {
-    if (active) load();
+    if (active) void load();
   }, [active, load]);
 
   function setRow(index: number, patch: Partial<GeodataAssetRow>) {
@@ -102,7 +107,6 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
   }
 
   function save() {
-    const template = templateRef.current;
     if (!template) return;
     const assets = rows
       .map((r) => ({ url: r.url.trim(), file: r.file.trim() }))
@@ -213,7 +217,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
             >
               {t('pages.index.geodataAddFile')}
             </Button>
-            <Button type="primary" onClick={save} disabled={loading || !templateRef.current}>
+            <Button type="primary" onClick={save} disabled={loading || !template}>
               {t('pages.index.geodataSaveRestart')}
             </Button>
           </div>

+ 17 - 10
frontend/src/pages/index/LogModal.tsx

@@ -25,10 +25,8 @@ export default function LogModal({ open, onClose }: LogModalProps) {
   const [autoUpdate, setAutoUpdate] = useState(false);
   const [loading, setLoading] = useState(false);
   const [logs, setLogs] = useState<string[]>([]);
-  const openRef = useRef(open);
 
-  const refresh = useCallback(async () => {
-    setLoading(true);
+  const runRefresh = useCallback(async () => {
     try {
       const msg = await HttpUtil.post<string[]>(`/panel/api/server/logs/${rows}`, {
         level,
@@ -43,19 +41,28 @@ export default function LogModal({ open, onClose }: LogModalProps) {
     }
   }, [rows, level, syslog]);
 
+  const refresh = useCallback(() => {
+    setLoading(true);
+    void runRefresh();
+  }, [runRefresh]);
+
   const refreshRef = useRef(refresh);
   useEffect(() => {
     refreshRef.current = refresh;
-  }, [refresh]);
+  });
 
-  useEffect(() => {
-    openRef.current = open;
-    if (open) refresh();
-  }, [open, refresh]);
+  // The spinner is raised during render so the fetch effect stays side-effect
+  // free until its response lands.
+  const refreshKey = open ? `${rows}\u0000${level}\u0000${syslog}` : null;
+  const [loadingKey, setLoadingKey] = useState<string | null>(null);
+  if (refreshKey !== loadingKey) {
+    setLoadingKey(refreshKey);
+    if (refreshKey) setLoading(true);
+  }
 
   useEffect(() => {
-    if (openRef.current) refresh();
-  }, [rows, level, syslog, refresh]);
+    if (open) void runRefresh();
+  }, [open, runRefresh]);
 
   useEffect(() => {
     if (!open || !autoUpdate) return;

+ 79 - 64
frontend/src/pages/index/SystemHistoryModal.tsx

@@ -200,16 +200,74 @@ function formatFullTimestamp(unixSec: number): string {
   return `${MM}-${DD} ${time}`;
 }
 
+interface HistoryChart {
+  points: number[];
+  points2: number[];
+  points3: number[];
+  labels: string[];
+  timestamps: number[];
+}
+
+const EMPTY_CHART: HistoryChart = {
+  points: [],
+  points2: [],
+  points3: [],
+  labels: [],
+  timestamps: [],
+};
+
+async function loadBucket(metric: (typeof METRICS)[number], bucket: number): Promise<HistoryChart> {
+  try {
+    const msg = await HttpUtil.get(`/panel/api/server/history/${metric.key}/${bucket}`);
+    if (!msg?.success || !Array.isArray(msg.obj)) return EMPTY_CHART;
+    const points: number[] = [];
+    const labels: string[] = [];
+    const timestamps: number[] = [];
+    for (const p of msg.obj) {
+      const d = new Date(p.t * 1000);
+      const MM = String(d.getMonth() + 1).padStart(2, '0');
+      const DD = String(d.getDate()).padStart(2, '0');
+      const hh = String(d.getHours()).padStart(2, '0');
+      const mm = String(d.getMinutes()).padStart(2, '0');
+      const ss = String(d.getSeconds()).padStart(2, '0');
+      labels.push(
+        bucket >= 2880
+          ? `${MM}-${DD} ${hh}:${mm}`
+          : bucket >= 60
+            ? `${hh}:${mm}`
+            : `${hh}:${mm}:${ss}`,
+      );
+      points.push(Number(p.v) || 0);
+      timestamps.push(Number(p.t) || 0);
+    }
+    const fetchAligned = async (key?: string): Promise<number[]> => {
+      if (!key) return [];
+      const m = await HttpUtil.get(`/panel/api/server/history/${key}/${bucket}`);
+      if (!m?.success || !Array.isArray(m.obj)) return [];
+      const byTs = new Map<number, number>();
+      for (const p of m.obj) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
+      return timestamps.map((ts) => byTs.get(ts) ?? 0);
+    };
+    return {
+      labels,
+      points,
+      timestamps,
+      points2: await fetchAligned(metric.key2),
+      points3: await fetchAligned(metric.key3),
+    };
+  } catch (e) {
+    console.error('Failed to fetch history bucket', e);
+    return EMPTY_CHART;
+  }
+}
+
 export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
   const [activeKey, setActiveKey] = useState('cpu');
   const [bucket, setBucket] = useState(2);
-  const [points, setPoints] = useState<number[]>([]);
-  const [points2, setPoints2] = useState<number[]>([]);
-  const [points3, setPoints3] = useState<number[]>([]);
-  const [labels, setLabels] = useState<string[]>([]);
-  const [timestamps, setTimestamps] = useState<number[]>([]);
+  const [{ points, points2, points3, labels, timestamps }, setChart] =
+    useState<HistoryChart>(EMPTY_CHART);
 
   const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
   const trName = (n?: string) => (n && n.startsWith('pages.') ? t(n) : n);
@@ -237,70 +295,27 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
 
   const fetchBucket = useCallback(async () => {
     if (!activeMetric) return;
-    try {
-      const url = `/panel/api/server/history/${activeMetric.key}/${bucket}`;
-      const msg = await HttpUtil.get(url);
-      if (msg?.success && Array.isArray(msg.obj)) {
-        const vals: number[] = [];
-        const labs: string[] = [];
-        const tss: number[] = [];
-        for (const p of msg.obj) {
-          const d = new Date(p.t * 1000);
-          const MM = String(d.getMonth() + 1).padStart(2, '0');
-          const DD = String(d.getDate()).padStart(2, '0');
-          const hh = String(d.getHours()).padStart(2, '0');
-          const mm = String(d.getMinutes()).padStart(2, '0');
-          const ss = String(d.getSeconds()).padStart(2, '0');
-          const lab =
-            bucket >= 2880
-              ? `${MM}-${DD} ${hh}:${mm}`
-              : bucket >= 60
-                ? `${hh}:${mm}`
-                : `${hh}:${mm}:${ss}`;
-          labs.push(lab);
-          vals.push(Number(p.v) || 0);
-          tss.push(Number(p.t) || 0);
-        }
-        setLabels(labs);
-        setPoints(vals);
-        setTimestamps(tss);
-
-        const fetchAligned = async (key?: string): Promise<number[]> => {
-          if (!key) return [];
-          const m = await HttpUtil.get(`/panel/api/server/history/${key}/${bucket}`);
-          if (m?.success && Array.isArray(m.obj)) {
-            const byTs = new Map<number, number>();
-            for (const p of m.obj) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
-            return tss.map((ts) => byTs.get(ts) ?? 0);
-          }
-          return [];
-        };
-        setPoints2(await fetchAligned(activeMetric.key2));
-        setPoints3(await fetchAligned(activeMetric.key3));
-      } else {
-        setLabels([]);
-        setPoints([]);
-        setPoints2([]);
-        setPoints3([]);
-        setTimestamps([]);
-      }
-    } catch (e) {
-      console.error('Failed to fetch history bucket', e);
-      setLabels([]);
-      setPoints([]);
-      setPoints2([]);
-      setPoints3([]);
-      setTimestamps([]);
-    }
+    const next = await loadBucket(activeMetric, bucket);
+    setChart(next);
   }, [activeMetric, bucket]);
 
-  useEffect(() => {
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
     if (open) setActiveKey('cpu');
-  }, [open]);
+  }
 
   useEffect(() => {
-    if (open) fetchBucket();
-  }, [open, activeKey, bucket, fetchBucket]);
+    if (!open || !activeMetric) return;
+    let cancelled = false;
+    void (async () => {
+      const next = await loadBucket(activeMetric, bucket);
+      if (!cancelled) setChart(next);
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, [open, activeMetric, bucket]);
 
   useEffect(() => {
     if (!open) return undefined;

+ 7 - 2
frontend/src/pages/index/VersionModal.tsx

@@ -38,7 +38,6 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
   const [loading, setLoading] = useState(false);
 
   const fetchVersions = useCallback(async () => {
-    setLoading(true);
     try {
       const msg = await HttpUtil.get<string[]>('/panel/api/server/getXrayVersion');
       if (msg?.success) setVersions(msg.obj || []);
@@ -47,8 +46,14 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
     }
   }, []);
 
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
+    if (open) setLoading(true);
+  }
+
   useEffect(() => {
-    if (open) fetchVersions();
+    if (open) void fetchVersions();
   }, [open, fetchVersions]);
 
   function switchXrayVersion(version: string) {

+ 19 - 10
frontend/src/pages/index/XrayLogModal.tsx

@@ -73,12 +73,10 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
   const [autoUpdate, setAutoUpdate] = useState(false);
   const [loading, setLoading] = useState(false);
   const [logs, setLogs] = useState<XrayLogEntry[]>([]);
-  const openRef = useRef(open);
 
   const orderedLogs = useMemo(() => [...logs].reverse(), [logs]);
 
-  const refresh = useCallback(async () => {
-    setLoading(true);
+  const runRefresh = useCallback(async () => {
     try {
       const msg = await HttpUtil.post<XrayLogEntry[]>(`/panel/api/server/xraylogs/${rows}`, {
         filter,
@@ -93,19 +91,30 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
     }
   }, [rows, filter, showDirect, showBlocked, showProxy]);
 
+  const refresh = useCallback(() => {
+    setLoading(true);
+    void runRefresh();
+  }, [runRefresh]);
+
   const refreshRef = useRef(refresh);
   useEffect(() => {
     refreshRef.current = refresh;
-  }, [refresh]);
+  });
 
-  useEffect(() => {
-    openRef.current = open;
-    if (open) refresh();
-  }, [open, refresh]);
+  // The spinner is raised during render so the fetch effect stays side-effect
+  // free until its response lands.
+  const refreshKey = open
+    ? `${rows}\u0000${showDirect}\u0000${showBlocked}\u0000${showProxy}`
+    : null;
+  const [loadingKey, setLoadingKey] = useState<string | null>(null);
+  if (refreshKey !== loadingKey) {
+    setLoadingKey(refreshKey);
+    if (refreshKey) setLoading(true);
+  }
 
   useEffect(() => {
-    if (openRef.current) refresh();
-  }, [rows, showDirect, showBlocked, showProxy, refresh]);
+    if (open) void runRefresh();
+  }, [open, rows, showDirect, showBlocked, showProxy, runRefresh]);
 
   useEffect(() => {
     if (!open || !autoUpdate) return;

+ 106 - 142
frontend/src/pages/index/XrayMetricsModal.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import type { ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Tabs, Tag } from 'antd';
@@ -147,19 +147,71 @@ function formatFullTimestamp(unixSec: number): string {
   return `${MM}-${DD} ${time}`;
 }
 
+interface MetricsChart {
+  points: number[];
+  labels: string[];
+  timestamps: number[];
+}
+
+const EMPTY_CHART: MetricsChart = { points: [], labels: [], timestamps: [] };
+
+function toChart(msg: Msg<{ t: number; v: number }[]> | null | undefined, bucket: number) {
+  if (!msg?.success || !Array.isArray(msg.obj)) return EMPTY_CHART;
+  const points: number[] = [];
+  const labels: string[] = [];
+  const timestamps: number[] = [];
+  for (const p of msg.obj) {
+    const d = new Date(p.t * 1000);
+    const hh = String(d.getHours()).padStart(2, '0');
+    const mm = String(d.getMinutes()).padStart(2, '0');
+    const ss = String(d.getSeconds()).padStart(2, '0');
+    labels.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
+    points.push(Number(p.v) || 0);
+    timestamps.push(Number(p.t) || 0);
+  }
+  return { points, labels, timestamps };
+}
+
+async function loadHistory(url: string | null, bucket: number): Promise<MetricsChart> {
+  if (!url) return EMPTY_CHART;
+  try {
+    return toChart(await HttpUtil.get<{ t: number; v: number }[]>(url), bucket);
+  } catch (e) {
+    console.error('Failed to fetch xray metrics bucket', e);
+    return EMPTY_CHART;
+  }
+}
+
+async function loadState(): Promise<XrayState | null> {
+  try {
+    const msg = await HttpUtil.get<XrayState>('/panel/api/server/xrayMetricsState');
+    return msg?.success && msg.obj ? msg.obj : null;
+  } catch (e) {
+    console.error('Failed to fetch xray metrics state', e);
+    return null;
+  }
+}
+
+async function loadObservatory(): Promise<ObservatoryTag[]> {
+  try {
+    const msg = await HttpUtil.get<ObservatoryTag[]>('/panel/api/server/xrayObservatory');
+    return msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
+  } catch (e) {
+    console.error('Failed to fetch observatory snapshot', e);
+    return [];
+  }
+}
+
 export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
   const [activeKey, setActiveKey] = useState('xrAlloc');
   const [bucket, setBucket] = useState(2);
-  const [points, setPoints] = useState<number[]>([]);
-  const [labels, setLabels] = useState<string[]>([]);
-  const [timestamps, setTimestamps] = useState<number[]>([]);
+  const [{ points, labels, timestamps }, setChart] = useState<MetricsChart>(EMPTY_CHART);
   const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' });
   const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]);
   const [obsActiveTag, setObsActiveTag] = useState('');
-  const obsTimerRef = useRef<number | null>(null);
-  const openRef = useRef(open);
+  const [obsTick, setObsTick] = useState(0);
 
   const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
   const isObservatory = activeKey === OBS_KEY;
@@ -184,151 +236,63 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
     [tsLookup],
   );
 
-  const applyHistory = useCallback(
-    (msg: Msg<{ t: number; v: number }[]> | null | undefined, currentBucket: number) => {
-      if (msg?.success && Array.isArray(msg.obj)) {
-        const vals: number[] = [];
-        const labs: string[] = [];
-        const tss: number[] = [];
-        for (const p of msg.obj) {
-          const d = new Date(p.t * 1000);
-          const hh = String(d.getHours()).padStart(2, '0');
-          const mm = String(d.getMinutes()).padStart(2, '0');
-          const ss = String(d.getSeconds()).padStart(2, '0');
-          labs.push(currentBucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
-          vals.push(Number(p.v) || 0);
-          tss.push(Number(p.t) || 0);
-        }
-        setLabels(labs);
-        setPoints(vals);
-        setTimestamps(tss);
-      } else {
-        setLabels([]);
-        setPoints([]);
-        setTimestamps([]);
-      }
-    },
-    [],
-  );
-
-  const fetchState = useCallback(async () => {
-    try {
-      const msg = await HttpUtil.get<XrayState>('/panel/api/server/xrayMetricsState');
-      if (msg?.success && msg.obj) setState(msg.obj);
-    } catch (e) {
-      console.error('Failed to fetch xray metrics state', e);
-    }
-  }, []);
-
-  const fetchObservatory = useCallback(async () => {
-    try {
-      const msg = await HttpUtil.get<ObservatoryTag[]>('/panel/api/server/xrayObservatory');
-      if (msg?.success && Array.isArray(msg.obj)) {
-        const tags = msg.obj;
-        setObsTags(tags);
-        setObsActiveTag((prev) => {
-          if (tags.find((tg) => tg.tag === prev)) return prev;
-          return tags[0]?.tag || '';
-        });
-      } else {
-        setObsTags([]);
-      }
-    } catch (e) {
-      console.error('Failed to fetch observatory snapshot', e);
-      setObsTags([]);
-    }
-  }, []);
-
-  const fetchMetricBucket = useCallback(async () => {
-    if (!activeMetric) return;
-    try {
-      const url = `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`;
-      const msg = await HttpUtil.get<{ t: number; v: number }[]>(url);
-      applyHistory(msg, bucket);
-    } catch (e) {
-      console.error('Failed to fetch xray metrics bucket', e);
-      setLabels([]);
-      setPoints([]);
-      setTimestamps([]);
-    }
-  }, [activeMetric, bucket, applyHistory]);
-
-  const fetchObsBucket = useCallback(async () => {
-    if (!obsActiveTag) {
-      setLabels([]);
-      setPoints([]);
-      setTimestamps([]);
-      return;
-    }
-    try {
-      const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`;
-      const msg = await HttpUtil.get<{ t: number; v: number }[]>(url);
-      applyHistory(msg, bucket);
-    } catch (e) {
-      console.error('Failed to fetch observatory bucket', e);
-      setLabels([]);
-      setPoints([]);
-      setTimestamps([]);
-    }
-  }, [obsActiveTag, bucket, applyHistory]);
-
-  const stopObsPolling = useCallback(() => {
-    if (obsTimerRef.current != null) {
-      window.clearInterval(obsTimerRef.current);
-      obsTimerRef.current = null;
-    }
-  }, []);
-
-  useEffect(() => {
-    openRef.current = open;
-    if (open) {
-      setActiveKey('xrAlloc');
-      fetchState();
-    } else {
-      stopObsPolling();
-    }
-  }, [open, fetchState, stopObsPolling]);
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
+    if (open) setActiveKey('xrAlloc');
+  }
 
   useEffect(() => {
     if (!open) return;
-    if (isObservatory) {
-      fetchObservatory();
-      fetchObsBucket();
-      stopObsPolling();
-      obsTimerRef.current = window.setInterval(async () => {
-        if (!openRef.current || !isObservatory) return;
-        await fetchObservatory();
-        fetchObsBucket();
-      }, 2000);
-    } else {
-      stopObsPolling();
-      fetchMetricBucket();
-    }
+    let cancelled = false;
+    void (async () => {
+      const next = await loadState();
+      if (!cancelled && next) setState(next);
+    })();
     return () => {
-      stopObsPolling();
+      cancelled = true;
     };
-  }, [
-    open,
-    activeKey,
-    isObservatory,
-    fetchObservatory,
-    fetchObsBucket,
-    fetchMetricBucket,
-    stopObsPolling,
-  ]);
+  }, [open]);
 
+  // The observatory snapshot is a live view, so it re-polls; obsTick then pulls
+  // the chart along with it.
   useEffect(() => {
-    if (!open) return;
-    if (isObservatory) {
-      fetchObsBucket();
-    } else {
-      fetchMetricBucket();
-    }
-  }, [open, bucket, isObservatory, fetchObsBucket, fetchMetricBucket]);
+    if (!open || !isObservatory) return;
+    let cancelled = false;
+    const tick = async () => {
+      const tags = await loadObservatory();
+      if (cancelled) return;
+      setObsTags(tags);
+      setObsActiveTag((prev) => (tags.find((tg) => tg.tag === prev) ? prev : tags[0]?.tag || ''));
+      setObsTick((n) => n + 1);
+    };
+    void tick();
+    const id = window.setInterval(() => void tick(), 2000);
+    return () => {
+      cancelled = true;
+      window.clearInterval(id);
+    };
+  }, [open, isObservatory]);
+
+  const historyUrl = isObservatory
+    ? obsActiveTag
+      ? `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`
+      : null
+    : activeMetric
+      ? `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`
+      : null;
 
   useEffect(() => {
-    if (open && isObservatory) fetchObsBucket();
-  }, [open, obsActiveTag, isObservatory, fetchObsBucket]);
+    if (!open) return;
+    let cancelled = false;
+    void (async () => {
+      const next = await loadHistory(historyUrl, bucket);
+      if (!cancelled) setChart(next);
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, [open, historyUrl, bucket, obsTick]);
 
   return (
     <Modal

+ 6 - 3
frontend/src/pages/index/useOverviewHistory.ts

@@ -128,8 +128,11 @@ export function useOverviewHistory(status: Status, hasData: boolean): OverviewHi
     };
   }, []);
 
-  useEffect(() => {
-    if (!hasData) return;
+  // Each polled status is appended during render; an effect would show the
+  // chart one sample behind the numbers beside it.
+  const [sampledStatus, setSampledStatus] = useState<Status | null>(null);
+  if (hasData && status !== sampledStatus) {
+    setSampledStatus(status);
     setTrend((prev) => {
       const point = sampleOf(status);
       const next = emptyWindow();
@@ -139,7 +142,7 @@ export function useOverviewHistory(status: Status, hasData: boolean): OverviewHi
       }
       return next;
     });
-  }, [status, hasData]);
+  }
 
   const labels = useMemo(() => trend.times.map(TimeFormatter.formatClock), [trend.times]);
 

+ 8 - 4
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Alert,
@@ -103,8 +103,12 @@ export default function NodeFormModal({
     ];
   }, [outboundGroups, t]);
 
-  useEffect(() => {
-    if (!open) return;
+  // Reset during render, not in an effect, so the first frame is already clean.
+  const [synced, setSynced] = useState<{ mode: string; node: NodeRecord | null } | null>(null);
+  if (!open) {
+    if (synced) setSynced(null);
+  } else if (!synced || synced.mode !== mode || synced.node !== (node ?? null)) {
+    setSynced({ mode, node: node ?? null });
     const base = defaultValues();
     const next: NodeFormValues =
       mode === 'edit' && node
@@ -123,7 +127,7 @@ export default function NodeFormModal({
     methods.reset(next);
     setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
     setTestResult(null);
-  }, [open, mode, node, methods]);
+  }
 
   const title = useMemo(
     () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),

+ 9 - 5
frontend/src/pages/settings/SecurityTab.tsx

@@ -73,7 +73,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
   const [updating, setUpdating] = useState(false);
 
   const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]);
-  const [apiTokensLoading, setApiTokensLoading] = useState(false);
+  const [apiTokensLoading, setApiTokensLoading] = useState(true);
   const [createOpen, setCreateOpen] = useState(false);
   const [createName, setCreateName] = useState('');
   const [creating, setCreating] = useState(false);
@@ -130,8 +130,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
     }
   }
 
-  const loadApiTokens = useCallback(async () => {
-    setApiTokensLoading(true);
+  const fetchApiTokens = useCallback(async () => {
     try {
       const msg = (await HttpUtil.get('/panel/api/setting/apiTokens')) as ApiMsg<ApiTokenRow[]>;
       if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
@@ -140,9 +139,14 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
     }
   }, []);
 
+  const loadApiTokens = useCallback(async () => {
+    setApiTokensLoading(true);
+    await fetchApiTokens();
+  }, [fetchApiTokens]);
+
   useEffect(() => {
-    loadApiTokens();
-  }, [loadApiTokens]);
+    void fetchApiTokens();
+  }, [fetchApiTokens]);
 
   async function copyToken(token: string) {
     if (!token) return;

+ 3 - 10
frontend/src/pages/settings/SettingsPage.tsx

@@ -86,16 +86,9 @@ export default function SettingsPage() {
     savePayload,
   } = useAllSettings();
 
-  const [entryHost, setEntryHost] = useState('');
-  const [entryPort, setEntryPort] = useState('');
-  const [entryIsIP, setEntryIsIP] = useState(false);
-
-  useEffect(() => {
-    const host = window.location.hostname;
-    setEntryHost(host);
-    setEntryPort(window.location.port);
-    setEntryIsIP(isIp(host));
-  }, []);
+  const [entryHost] = useState(() => window.location.hostname);
+  const [entryPort] = useState(() => window.location.port);
+  const [entryIsIP] = useState(() => isIp(window.location.hostname));
 
   const [alertVisible, setAlertVisible] = useState(true);
   const location = useLocation();

+ 3 - 1
frontend/src/pages/settings/SubJsonFinalMaskForm.tsx

@@ -30,7 +30,9 @@ export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMa
   const [form] = Form.useForm();
   const [initial] = useState(() => parseFinalMask(value));
   const onChangeRef = useRef(onChange);
-  onChangeRef.current = onChange;
+  useEffect(() => {
+    onChangeRef.current = onChange;
+  });
 
   const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
 

+ 20 - 23
frontend/src/pages/settings/TwoFactorModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
 import * as OTPAuth from 'otpauth';
@@ -32,28 +32,25 @@ export default function TwoFactorModal({
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
   const [enteredCode, setEnteredCode] = useState('');
-  const [qrValue, setQrValue] = useState('');
-  const totpRef = useRef<OTPAuth.TOTP | null>(null);
 
-  useEffect(() => {
-    if (!open) return;
-
-    setEnteredCode('');
-    totpRef.current = null;
-    setQrValue('');
-    if (token) {
-      const totp = new OTPAuth.TOTP({
-        issuer: '3x-ui',
-        label: 'Administrator',
-        algorithm: 'SHA1',
-        digits: 6,
-        period: 30,
-        secret: token,
-      });
-      totpRef.current = totp;
-      setQrValue(totp.toString());
-    }
+  const totp = useMemo(() => {
+    if (!open || !token) return null;
+    return new OTPAuth.TOTP({
+      issuer: '3x-ui',
+      label: 'Administrator',
+      algorithm: 'SHA1',
+      digits: 6,
+      period: 30,
+      secret: token,
+    });
   }, [open, token]);
+  const qrValue = totp ? totp.toString() : '';
+
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
+    if (open) setEnteredCode('');
+  }
 
   function close(success: boolean, code = '') {
     onConfirm(success, code);
@@ -73,8 +70,8 @@ export default function TwoFactorModal({
       close(true, codeOk.data);
       return;
     }
-    if (!totpRef.current) return;
-    if (totpRef.current.generate() === codeOk.data) {
+    if (!totp) return;
+    if (totp.generate() === codeOk.data) {
       close(true);
     } else {
       messageApi.error(t('pages.settings.security.twoFactorModalError'));

+ 6 - 3
frontend/src/pages/xray/balancers/BalancerFormModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
 import type { ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd';
@@ -72,12 +72,15 @@ export default function BalancerFormModal({
   const [submitAttempted, setSubmitAttempted] = useState(false);
   const isEdit = balancer != null;
 
-  useEffect(() => {
+  const openBalancer = open ? (balancer ?? null) : undefined;
+  const [syncedBalancer, setSyncedBalancer] = useState<typeof openBalancer>(undefined);
+  if (openBalancer !== syncedBalancer) {
+    setSyncedBalancer(openBalancer);
     if (open) {
       methods.reset(initialState(balancer));
       setSubmitAttempted(false);
     }
-  }, [open, balancer, methods]);
+  }
 
   const strategy = useWatch({ control: methods.control, name: 'strategy' });
   const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];

+ 8 - 1
frontend/src/pages/xray/balancers/BalancersTab.tsx

@@ -190,7 +190,14 @@ export default function BalancersTab({
   }, [liveTags]);
 
   useEffect(() => {
-    refreshLive();
+    let cancelled = false;
+    void (async () => {
+      await refreshLive();
+      if (cancelled) return;
+    })();
+    return () => {
+      cancelled = true;
+    };
   }, [refreshLive]);
 
   async function setOverride(tag: string, target: string) {

+ 9 - 1
frontend/src/pages/xray/overrides/NordModal.tsx

@@ -128,7 +128,15 @@ export default function NordModal({
   }, [fetchCountries]);
 
   useEffect(() => {
-    if (open) fetchData();
+    if (!open) return;
+    let cancelled = false;
+    void (async () => {
+      await fetchData();
+      if (cancelled) return;
+    })();
+    return () => {
+      cancelled = true;
+    };
   }, [open, fetchData]);
 
   async function login() {

+ 18 - 4
frontend/src/pages/xray/overrides/WarpModal.tsx

@@ -174,12 +174,26 @@ export default function WarpModal({
     }
   }, [methods]);
 
+  const [wasOpen, setWasOpen] = useState(false);
+  if (open !== wasOpen) {
+    setWasOpen(open);
+    if (open) {
+      setWarpConfig(null);
+      setStagedOutbound(null);
+      setLicenseError('');
+    }
+  }
+
   useEffect(() => {
     if (!open) return;
-    setWarpConfig(null);
-    setStagedOutbound(null);
-    setLicenseError('');
-    fetchData();
+    let cancelled = false;
+    void (async () => {
+      await fetchData();
+      if (cancelled) return;
+    })();
+    return () => {
+      cancelled = true;
+    };
   }, [open, fetchData]);
 
   async function register() {

+ 6 - 3
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
 import {
@@ -68,7 +68,6 @@ export default function RoutingTab({
     [templateSettings?.routing?.rules],
   );
   const rulesRef = useRef(rules);
-  rulesRef.current = rules;
   const rowsRef = useRef<RuleRow[]>([]);
 
   const rows: RuleRow[] = useMemo(
@@ -100,7 +99,11 @@ export default function RoutingTab({
         }),
     [rules],
   );
-  rowsRef.current = rows;
+
+  useEffect(() => {
+    rulesRef.current = rules;
+    rowsRef.current = rows;
+  });
 
   const mutate = useCallback(
     (mutator: (next: XraySettingsValue) => void) => {

Some files were not shown because too many files changed in this diff