Преглед изворни кода

perf(frontend): replace blank Suspense fallbacks with Spin, switch to matchMedia hook, add virtual table scrolling (#6187)

- routes.tsx, LazyMount.tsx: replace Suspense fallback={null} with Spin
  loader so page transitions and lazy modals never show blank content
- useMediaQuery.ts: switch from resize event to matchMedia change event,
  eliminating state updates on every pixel drag; export MOBILE_BREAKPOINT_PX
- SubPage.tsx: drop duplicate inline isMobile logic (7 lines), use shared
  useMediaQuery(576) (2 lines)
- ClientsPage, InboundList, HostList, NodeList: add virtual + scroll.y to
  Table for viewport-only DOM rendering of large datasets
PathGao пре 6 часа
родитељ
комит
8e7fb144ee

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

@@ -1,4 +1,5 @@
 import { Suspense, useEffect, useState, type ReactNode } from 'react';
+import { Spin } from 'antd';
 
 interface LazyMountProps {
   when: boolean;
@@ -10,7 +11,7 @@ interface LazyMountProps {
 // 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) {
+export default function LazyMount({ when, fallback = <Spin />, children }: LazyMountProps) {
   const [mounted, setMounted] = useState(when);
   useEffect(() => {
     if (when && !mounted) setMounted(true);

+ 18 - 6
frontend/src/hooks/useMediaQuery.ts

@@ -1,15 +1,27 @@
 import { useEffect, useState } from 'react';
 
-const MOBILE_BREAKPOINT_PX = 768;
+export const MOBILE_BREAKPOINT_PX = 768;
 
+/**
+ * Tracks whether the viewport is narrower than `breakpoint`.
+ *
+ * Uses the native `matchMedia` change event instead of the `resize` event so
+ * that state updates fire only when the query actually flips, not on every
+ * pixel change during a window drag.
+ */
 export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
-  const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth <= breakpoint);
+  const query = `(max-width: ${breakpoint}px)`;
+  const [isMobile, setIsMobile] = useState<boolean>(() =>
+    typeof window !== 'undefined' ? window.matchMedia(query).matches : false,
+  );
 
   useEffect(() => {
-    const onResize = () => setIsMobile(window.innerWidth <= breakpoint);
-    window.addEventListener('resize', onResize);
-    return () => window.removeEventListener('resize', onResize);
-  }, [breakpoint]);
+    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]);
 
   return { isMobile };
 }

+ 2 - 1
frontend/src/pages/clients/ClientsPage.tsx

@@ -1350,7 +1350,8 @@ export default function ClientsPage() {
                           rowSelection={rowSelection}
                           pagination={tablePagination}
                           size="small"
-                          scroll={{ x: 1200 }}
+                          scroll={{ x: 1200, y: 'calc(100vh - 380px)' }}
+                          virtual
                           onChange={onTableChange}
                           locale={{
                             emptyText: (

+ 2 - 1
frontend/src/pages/hosts/HostList.tsx

@@ -231,7 +231,8 @@ export default function HostList(props: HostListProps) {
         columns={columns}
         dataSource={sorted}
         pagination={false}
-        scroll={{ x: 'max-content' }}
+        scroll={{ x: 'max-content', y: 'calc(100vh - 280px)' }}
+        virtual
         rowSelection={{
           selectedRowKeys: selectedGroupIds,
           onChange: (keys) => onSelectionChange(keys as string[]),

+ 2 - 1
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -286,7 +286,8 @@ export default function InboundList({
               onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
             }}
             pagination={paginationFor(visibleInbounds)}
-            scroll={{ x: tableScrollX }}
+            scroll={{ x: tableScrollX, y: 'calc(100vh - 320px)' }}
+            virtual
             style={{ marginTop: 10 }}
             size="small"
             locale={{

+ 2 - 1
frontend/src/pages/nodes/NodeList.tsx

@@ -648,7 +648,8 @@ export default function NodeList({
           columns={columns}
           pagination={false}
           loading={loading}
-          scroll={{ x: 'max-content' }}
+          scroll={{ x: 'max-content', y: 'calc(100vh - 320px)' }}
+          virtual
           size="middle"
           rowKey="key"
           rowSelection={dataSource.length > 1 ? {

+ 2 - 8
frontend/src/pages/sub/SubPage.tsx

@@ -38,6 +38,7 @@ import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
 import ConfigBlock from '@/components/clients/ConfigBlock';
 import { setMessageInstance } from '@/utils/messageBus';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
+import { useMediaQuery } from '@/hooks/useMediaQuery';
 import SubUsageSummary from './SubUsageSummary';
 import './SubPage.css';
 
@@ -84,16 +85,9 @@ export default function SubPage() {
   const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
-
-  const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth < 576);
+  const { isMobile } = useMediaQuery(576);
   const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
 
-  useEffect(() => {
-    const onResize = () => setIsMobile(window.innerWidth < 576);
-    window.addEventListener('resize', onResize);
-    return () => window.removeEventListener('resize', onResize);
-  }, []);
-
   const onLangChange = useCallback((next: string) => {
     setLang(next);
     LanguageManager.setLanguage(next);

+ 12 - 1
frontend/src/routes.tsx

@@ -1,5 +1,6 @@
 import { lazy, Suspense } from 'react';
 import { createBrowserRouter, type RouteObject } from 'react-router';
+import { Spin } from 'antd';
 
 import PanelLayout from '@/layouts/PanelLayout';
 
@@ -14,7 +15,17 @@ const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
 const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
 
 function withSuspense(node: React.ReactNode) {
-  return <Suspense fallback={null}>{node}</Suspense>;
+  return (
+    <Suspense
+      fallback={
+        <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
+          <Spin size="large" />
+        </div>
+      }
+    >
+      {node}
+    </Suspense>
+  );
 }
 
 const routes: RouteObject[] = [