diff --git a/frontend/src/components/utility/LazyMount.tsx b/frontend/src/components/utility/LazyMount.tsx
index a1ffdd5c8..a5af3da6c 100644
--- a/frontend/src/components/utility/LazyMount.tsx
+++ b/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 = , children }: LazyMountProps) {
const [mounted, setMounted] = useState(when);
useEffect(() => {
if (when && !mounted) setMounted(true);
diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts
index 3d9b846e9..6c07ad6dc 100644
--- a/frontend/src/hooks/useMediaQuery.ts
+++ b/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(() => window.innerWidth <= breakpoint);
+ const query = `(max-width: ${breakpoint}px)`;
+ const [isMobile, setIsMobile] = useState(() =>
+ 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 };
}
diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx
index a13fa0a75..a9497a84b 100644
--- a/frontend/src/pages/clients/ClientsPage.tsx
+++ b/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: (
diff --git a/frontend/src/pages/hosts/HostList.tsx b/frontend/src/pages/hosts/HostList.tsx
index 88d2524e9..d8557e7e7 100644
--- a/frontend/src/pages/hosts/HostList.tsx
+++ b/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[]),
diff --git a/frontend/src/pages/inbounds/list/InboundList.tsx b/frontend/src/pages/inbounds/list/InboundList.tsx
index a196944d5..7d4e8ab18 100644
--- a/frontend/src/pages/inbounds/list/InboundList.tsx
+++ b/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={{
diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx
index aec149054..8e7ed8fe6 100644
--- a/frontend/src/pages/nodes/NodeList.tsx
+++ b/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 ? {
diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx
index 338599960..82f981f0d 100644
--- a/frontend/src/pages/sub/SubPage.tsx
+++ b/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(() => window.innerWidth < 576);
+ const { isMobile } = useMediaQuery(576);
const [lang, setLang] = useState(() => 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);
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx
index f8c4185d1..88e967989 100644
--- a/frontend/src/routes.tsx
+++ b/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 {node};
+ return (
+
+
+
+ }
+ >
+ {node}
+
+ );
}
const routes: RouteObject[] = [