mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 08:10:58 +00:00
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
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { Suspense, useEffect, useState, type ReactNode } from 'react';
|
import { Suspense, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
interface LazyMountProps {
|
interface LazyMountProps {
|
||||||
when: boolean;
|
when: boolean;
|
||||||
@@ -10,7 +11,7 @@ interface LazyMountProps {
|
|||||||
// thereafter, so React.lazy modals get loaded on demand but their close
|
// thereafter, so React.lazy modals get loaded on demand but their close
|
||||||
// animations still play out. Pair with `lazy(() => import(...))` modal imports
|
// animations still play out. Pair with `lazy(() => import(...))` modal imports
|
||||||
// on heavy list pages to keep the initial bundle small.
|
// 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);
|
const [mounted, setMounted] = useState(when);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (when && !mounted) setMounted(true);
|
if (when && !mounted) setMounted(true);
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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) {
|
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(() => {
|
useEffect(() => {
|
||||||
const onResize = () => setIsMobile(window.innerWidth <= breakpoint);
|
const mql = window.matchMedia(query);
|
||||||
window.addEventListener('resize', onResize);
|
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
return () => window.removeEventListener('resize', onResize);
|
mql.addEventListener('change', onChange);
|
||||||
}, [breakpoint]);
|
setIsMobile(mql.matches);
|
||||||
|
return () => mql.removeEventListener('change', onChange);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
return { isMobile };
|
return { isMobile };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1350,7 +1350,8 @@ export default function ClientsPage() {
|
|||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
pagination={tablePagination}
|
pagination={tablePagination}
|
||||||
size="small"
|
size="small"
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1200, y: 'calc(100vh - 380px)' }}
|
||||||
|
virtual
|
||||||
onChange={onTableChange}
|
onChange={onTableChange}
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: (
|
emptyText: (
|
||||||
|
|||||||
@@ -231,7 +231,8 @@ export default function HostList(props: HostListProps) {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={sorted}
|
dataSource={sorted}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content', y: 'calc(100vh - 280px)' }}
|
||||||
|
virtual
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedGroupIds,
|
selectedRowKeys: selectedGroupIds,
|
||||||
onChange: (keys) => onSelectionChange(keys as string[]),
|
onChange: (keys) => onSelectionChange(keys as string[]),
|
||||||
|
|||||||
@@ -286,7 +286,8 @@ export default function InboundList({
|
|||||||
onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
|
onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
|
||||||
}}
|
}}
|
||||||
pagination={paginationFor(visibleInbounds)}
|
pagination={paginationFor(visibleInbounds)}
|
||||||
scroll={{ x: tableScrollX }}
|
scroll={{ x: tableScrollX, y: 'calc(100vh - 320px)' }}
|
||||||
|
virtual
|
||||||
style={{ marginTop: 10 }}
|
style={{ marginTop: 10 }}
|
||||||
size="small"
|
size="small"
|
||||||
locale={{
|
locale={{
|
||||||
|
|||||||
@@ -648,7 +648,8 @@ export default function NodeList({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 'max-content' }}
|
scroll={{ x: 'max-content', y: 'calc(100vh - 320px)' }}
|
||||||
|
virtual
|
||||||
size="middle"
|
size="middle"
|
||||||
rowKey="key"
|
rowKey="key"
|
||||||
rowSelection={dataSource.length > 1 ? {
|
rowSelection={dataSource.length > 1 ? {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
|||||||
import ConfigBlock from '@/components/clients/ConfigBlock';
|
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import SubUsageSummary from './SubUsageSummary';
|
import SubUsageSummary from './SubUsageSummary';
|
||||||
import './SubPage.css';
|
import './SubPage.css';
|
||||||
|
|
||||||
@@ -84,16 +85,9 @@ export default function SubPage() {
|
|||||||
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
|
||||||
|
const { isMobile } = useMediaQuery(576);
|
||||||
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth < 576);
|
|
||||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
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) => {
|
const onLangChange = useCallback((next: string) => {
|
||||||
setLang(next);
|
setLang(next);
|
||||||
LanguageManager.setLanguage(next);
|
LanguageManager.setLanguage(next);
|
||||||
|
|||||||
+12
-1
@@ -1,5 +1,6 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import { createBrowserRouter, type RouteObject } from 'react-router';
|
import { createBrowserRouter, type RouteObject } from 'react-router';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
import PanelLayout from '@/layouts/PanelLayout';
|
import PanelLayout from '@/layouts/PanelLayout';
|
||||||
|
|
||||||
@@ -14,7 +15,17 @@ const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
|
|||||||
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
||||||
|
|
||||||
function withSuspense(node: React.ReactNode) {
|
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[] = [
|
const routes: RouteObject[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user