From c12d34a2629528fdfbdffd851abeef23b15beb6c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 23 May 2026 18:22:24 +0200 Subject: [PATCH] perf(frontend): lazy-load modals on inbounds / clients / index pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modals on the three list pages were imported statically, so the JS + CSS for every form, info, qr, log, backup, metrics, system-history, version, and config-text modal sat in the initial bundle even though they're only needed after a click. Converted those imports to React.lazy() and gated each modal with a new LazyMount helper that mounts on first open and keeps the component mounted thereafter so AntD close animations still play. Build now emits a dedicated chunk per modal — InboundFormModal at 66 kB (13 kB gzipped) and InboundInfoModal at 23 kB (4 kB gzipped) are the largest, totalling roughly 150 kB of code that no longer parses on first paint. Profiler measured the inbounds-page React render tree drop from ~444 ms to ~254 ms on a prod build. --- frontend/src/components/LazyMount.tsx | 20 +++ frontend/src/pages/clients/ClientsPage.tsx | 113 ++++++++------ frontend/src/pages/inbounds/InboundsPage.tsx | 121 ++++++++------- frontend/src/pages/index/IndexPage.tsx | 155 ++++++++++--------- 4 files changed, 234 insertions(+), 175 deletions(-) create mode 100644 frontend/src/components/LazyMount.tsx diff --git a/frontend/src/components/LazyMount.tsx b/frontend/src/components/LazyMount.tsx new file mode 100644 index 00000000..a1ffdd5c --- /dev/null +++ b/frontend/src/components/LazyMount.tsx @@ -0,0 +1,20 @@ +import { Suspense, useEffect, useState, type ReactNode } from 'react'; + +interface LazyMountProps { + when: boolean; + fallback?: ReactNode; + children: ReactNode; +} + +// Mounts children only after `when` first becomes true and keeps them mounted +// thereafter, so React.lazy modals get loaded on demand but their close +// animations still play out. Pair with `lazy(() => import(...))` modal imports +// on heavy list pages to keep the initial bundle small. +export default function LazyMount({ when, fallback = null, children }: LazyMountProps) { + const [mounted, setMounted] = useState(when); + useEffect(() => { + if (when && !mounted) setMounted(true); + }, [when, mounted]); + if (!mounted) return null; + return {children}; +} diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 06d015f7..6657aa79 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge, @@ -51,11 +51,12 @@ import AppSidebar from '@/components/AppSidebar'; import CustomStatistic from '@/components/CustomStatistic'; import { IntlUtil, SizeFormatter } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; -import ClientFormModal from './ClientFormModal'; -import ClientInfoModal from './ClientInfoModal'; -import ClientQrModal from './ClientQrModal'; -import ClientBulkAddModal from './ClientBulkAddModal'; -import ClientBulkAdjustModal from './ClientBulkAdjustModal'; +import LazyMount from '@/components/LazyMount'; +const ClientFormModal = lazy(() => import('./ClientFormModal')); +const ClientInfoModal = lazy(() => import('./ClientInfoModal')); +const ClientQrModal = lazy(() => import('./ClientQrModal')); +const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal')); +const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal')); import '@/styles/page-cards.css'; import './ClientsPage.css'; @@ -853,51 +854,61 @@ export default function ClientsPage() { - - - - setBulkAddOpen(false)} - /> - { - const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes); - if (msg?.success) { - setSelectedRowKeys([]); - return msg.obj ?? { adjusted: 0 }; - } - return null; - }} - /> + + + + + + + + + + + setBulkAddOpen(false)} + /> + + + { + const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes); + if (msg?.success) { + setSelectedRowKeys([]); + return msg.obj ?? { adjusted: 0 }; + } + return null; + }} + /> + ); diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 27323fe4..ce3b12d1 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, @@ -28,14 +28,15 @@ import { useWebSocket } from '@/hooks/useWebSocket'; import { useNodes } from '@/hooks/useNodes'; import AppSidebar from '@/components/AppSidebar'; import CustomStatistic from '@/components/CustomStatistic'; -import TextModal from '@/components/TextModal'; -import PromptModal from '@/components/PromptModal'; +const TextModal = lazy(() => import('@/components/TextModal')); +const PromptModal = lazy(() => import('@/components/PromptModal')); import { useInbounds } from './useInbounds'; import InboundList from './InboundList'; -import InboundFormModal from './InboundFormModal'; -import InboundInfoModal from './InboundInfoModal'; -import QrCodeModal from './QrCodeModal'; +import LazyMount from '@/components/LazyMount'; +const InboundFormModal = lazy(() => import('./InboundFormModal')); +const InboundInfoModal = lazy(() => import('./InboundInfoModal')); +const QrCodeModal = lazy(() => import('./QrCodeModal')); import '@/styles/page-cards.css'; import './InboundsPage.css'; @@ -517,56 +518,66 @@ export default function InboundsPage() { - setFormOpen(false)} - onSaved={refresh} - mode={formMode} - dbInbound={formDbInbound} - dbInbounds={dbInbounds as any[]} - availableNodes={nodesList} - /> - setInfoOpen(false)} - dbInbound={infoDbInbound} - clientIndex={infoClientIndex} - remarkModel={remarkModel} - expireDiff={expireDiff} - trafficDiff={trafficDiff} - ipLimitEnable={ipLimitEnable} - tgBotEnable={tgBotEnable} - subSettings={subSettings} - lastOnlineMap={lastOnlineMap} - nodeAddress={infoNodeAddress} - /> - setQrOpen(false)} - dbInbound={qrDbInbound} - client={null} - remarkModel={remarkModel} - nodeAddress={qrNodeAddress} - subSettings={subSettings} - /> + + setFormOpen(false)} + onSaved={refresh} + mode={formMode} + dbInbound={formDbInbound} + dbInbounds={dbInbounds as any[]} + availableNodes={nodesList} + /> + + + setInfoOpen(false)} + dbInbound={infoDbInbound} + clientIndex={infoClientIndex} + remarkModel={remarkModel} + expireDiff={expireDiff} + trafficDiff={trafficDiff} + ipLimitEnable={ipLimitEnable} + tgBotEnable={tgBotEnable} + subSettings={subSettings} + lastOnlineMap={lastOnlineMap} + nodeAddress={infoNodeAddress} + /> + + + setQrOpen(false)} + dbInbound={qrDbInbound} + client={null} + remarkModel={remarkModel} + nodeAddress={qrNodeAddress} + subSettings={subSettings} + /> + - setTextOpen(false)} - title={textTitle} - content={textContent} - fileName={textFileName} - /> - setPromptOpen(false)} - title={promptTitle} - okText={promptOkText} - type={promptType} - initialValue={promptInitial} - loading={promptLoading} - onConfirm={onPromptConfirm} - /> + + setTextOpen(false)} + title={textTitle} + content={textContent} + fileName={textFileName} + /> + + + setPromptOpen(false)} + title={promptTitle} + okText={promptOkText} + type={promptType} + initialValue={promptInitial} + loading={promptLoading} + onConfirm={onPromptConfirm} + /> + ); diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx index 9d1ef77e..5334c171 100644 --- a/frontend/src/pages/index/IndexPage.tsx +++ b/frontend/src/pages/index/IndexPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, @@ -40,18 +40,19 @@ import { useStatus } from '@/hooks/useStatus'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import AppSidebar from '@/components/AppSidebar'; import CustomStatistic from '@/components/CustomStatistic'; -import JsonEditor from '@/components/JsonEditor'; +import LazyMount from '@/components/LazyMount'; import { setMessageInstance } from '@/utils/messageBus'; import StatusCard from './StatusCard'; import XrayStatusCard from './XrayStatusCard'; -import PanelUpdateModal from './PanelUpdateModal'; import type { PanelUpdateInfo } from './PanelUpdateModal'; -import LogModal from './LogModal'; -import BackupModal from './BackupModal'; -import SystemHistoryModal from './SystemHistoryModal'; -import XrayMetricsModal from './XrayMetricsModal'; -import XrayLogModal from './XrayLogModal'; -import VersionModal from './VersionModal'; +const JsonEditor = lazy(() => import('@/components/JsonEditor')); +const PanelUpdateModal = lazy(() => import('./PanelUpdateModal')); +const LogModal = lazy(() => import('./LogModal')); +const BackupModal = lazy(() => import('./BackupModal')); +const SystemHistoryModal = lazy(() => import('./SystemHistoryModal')); +const XrayMetricsModal = lazy(() => import('./XrayMetricsModal')); +const XrayLogModal = lazy(() => import('./XrayLogModal')); +const VersionModal = lazy(() => import('./VersionModal')); import '@/styles/page-cards.css'; import './IndexPage.css'; @@ -435,67 +436,83 @@ export default function IndexPage() { - setPanelUpdateOpen(false)} - onBusy={setBusy} - /> - setLogsOpen(false)} /> - setBackupOpen(false)} - onBusy={setBusy} - /> - setSysHistoryOpen(false)} - /> - setXrayMetricsOpen(false)} /> - setXrayLogsOpen(false)} /> - setVersionOpen(false)} - onBusy={setBusy} - /> - - setConfigTextOpen(false)} - footer={[ - , - , - ]} - > - + setPanelUpdateOpen(false)} + onBusy={setBusy} /> - + + + setLogsOpen(false)} /> + + + setBackupOpen(false)} + onBusy={setBusy} + /> + + + setSysHistoryOpen(false)} + /> + + + setXrayMetricsOpen(false)} /> + + + setXrayLogsOpen(false)} /> + + + setVersionOpen(false)} + onBusy={setBusy} + /> + + + + setConfigTextOpen(false)} + footer={[ + , + , + ]} + > + + + );