From 9a2f4ff8fbde335615bd104a2235be3fb6aa1d0d Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:34:32 +0800 Subject: [PATCH] fix: warm lazy navigation routes after startup --- src/components/layout/layout-item.tsx | 30 ++++++-- src/pages/_layout.tsx | 32 ++++++-- src/pages/_routers.tsx | 106 ++++++++++++++++++++++---- 3 files changed, 140 insertions(+), 28 deletions(-) diff --git a/src/components/layout/layout-item.tsx b/src/components/layout/layout-item.tsx index 532d22fa3..411468dc1 100644 --- a/src/components/layout/layout-item.tsx +++ b/src/components/layout/layout-item.tsx @@ -9,7 +9,8 @@ import { ListItemIcon, ListItemText, } from '@mui/material' -import type { CSSProperties, ReactNode } from 'react' +import type { CSSProperties, PointerEvent, ReactNode } from 'react' +import { useCallback } from 'react' import { useMatch, useNavigate, useResolvedPath } from 'react-router' import { useVerge } from '@/hooks/use-verge' @@ -28,9 +29,10 @@ interface Props { children: string icon: ReactNode[] sortable?: SortableProps + onPreload?: () => Promise } export const LayoutItem = (props: Props) => { - const { to, children, icon, sortable } = props + const { to, children, icon, sortable, onPreload } = props const { verge } = useVerge() const { menu_icon } = verge ?? {} const navCollapsed = verge?.collapse_navbar ?? false @@ -45,9 +47,21 @@ export const LayoutItem = (props: Props) => { sortable ?? {} const draggable = Boolean(sortable) && !disabled - const dragHandleProps = draggable - ? { ...(attributes ?? {}), ...(listeners ?? {}) } - : undefined + const { onPointerDown, ...otherListeners } = draggable + ? (listeners ?? {}) + : {} + + const handlePreload = useCallback(() => { + void onPreload?.().catch(() => {}) + }, [onPreload]) + + const handlePointerDown = useCallback( + (event: PointerEvent) => { + handlePreload() + onPointerDown?.(event) + }, + [handlePreload, onPointerDown], + ) return ( { > { ]} title={navCollapsed ? children : undefined} aria-label={navCollapsed ? children : undefined} + onFocus={handlePreload} + onMouseEnter={handlePreload} + onPointerDown={handlePointerDown} onClick={() => navigate(to)} > {(effectiveMenuIcon === 'monochrome' || !effectiveMenuIcon) && ( diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 78d254dea..57a48c32d 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -48,7 +48,6 @@ import { WindowControls } from '@/components/layout/window-controller' import { useI18n } from '@/hooks/use-i18n' import { useVerge } from '@/hooks/use-verge' import { useWindowDecorations } from '@/hooks/use-window' -import { ensureLanguageSections } from '@/services/i18n' import { useThemeMode } from '@/services/states' import getSystem from '@/utils/get-system' @@ -59,17 +58,14 @@ import { useNavMenuOrder, } from './_layout/hooks' import { handleNoticeMessage } from './_layout/utils' -import { navItems } from './_routers' +import { navItems, preloadLogsPage, preloadNavigationRoutes } from './_routers' import 'dayjs/locale/ru' import 'dayjs/locale/zh-cn' export const portableFlag = false -const LogsPage = lazy(async () => { - await ensureLanguageSections('logs') - return import('./logs') -}) +const LogsPage = lazy(() => preloadLogsPage()) type NavItem = (typeof navItems)[number] @@ -105,6 +101,7 @@ const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => { { useLoadingOverlay(themeReady) + useEffect(() => { + if (!themeReady) { + return + } + + const controller = new AbortController() + const timerId = window.setTimeout(() => { + void preloadNavigationRoutes(controller.signal) + }, 2000) + + return () => { + controller.abort() + window.clearTimeout(timerId) + } + }, [themeReady]) + const handleNotice = useCallback( (payload: [string, string]) => { const [status, msg] = payload @@ -406,7 +419,12 @@ const Layout = () => { return null } return ( - + {t(item.label)} ) diff --git a/src/pages/_routers.tsx b/src/pages/_routers.tsx index 98d82b806..27508f3ea 100644 --- a/src/pages/_routers.tsx +++ b/src/pages/_routers.tsx @@ -22,27 +22,83 @@ import { ensureLanguageSections } from '@/services/i18n' import Layout from './_layout' import HomePage from './home' +const waitForWarmupIdle = (signal: AbortSignal) => + new Promise((resolve) => { + let idleId: number | undefined + let timeoutId: number | undefined + + const cleanup = () => { + signal.removeEventListener('abort', finish) + if (idleId !== undefined) { + window.cancelIdleCallback(idleId) + } + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId) + } + } + + const finish = () => { + cleanup() + resolve() + } + + if (signal.aborted) { + resolve() + return + } + + signal.addEventListener('abort', finish, { once: true }) + + if (window.requestIdleCallback) { + idleId = window.requestIdleCallback(finish, { timeout: 500 }) + } else { + timeoutId = window.setTimeout(finish, 120) + } + }) + +const createRoutePreload = ( + load: () => Promise<{ default: ComponentType }>, + sections?: string | readonly string[], +) => { + let preloadPromise: Promise<{ default: ComponentType }> | undefined + + const startPreload = sections + ? async () => { + await ensureLanguageSections(sections) + return load() + } + : load + + return () => { + preloadPromise ??= startPreload().catch((error) => { + preloadPromise = undefined + throw error + }) + + return preloadPromise + } +} + const createLazyRoute = ( load: () => Promise<{ default: ComponentType }>, sections?: string | readonly string[], ) => { - const Component = lazy( - sections - ? async () => { - await ensureLanguageSections(sections) - return load() - } - : load, - ) + const preload = createRoutePreload(load, sections) + const Component = lazy(preload) const LazyRoute = () => ( ) - return LazyRoute + return { Component: LazyRoute, preload } } +export const preloadLogsPage = createRoutePreload( + () => import('./logs'), + 'logs', +) + export const navItems = [ { label: 'layout.components.navigation.tabs.home', @@ -54,46 +110,66 @@ export const navItems = [ label: 'layout.components.navigation.tabs.proxies', path: '/proxies', icon: [, ], - Component: createLazyRoute(() => import('./proxies')), + ...createLazyRoute(() => import('./proxies')), }, { label: 'layout.components.navigation.tabs.profiles', path: '/profile', icon: [, ], - Component: createLazyRoute(() => import('./profiles'), 'rules'), + ...createLazyRoute(() => import('./profiles'), 'rules'), }, { label: 'layout.components.navigation.tabs.connections', path: '/connections', icon: [, ], - Component: createLazyRoute(() => import('./connections'), 'connections'), + ...createLazyRoute(() => import('./connections'), 'connections'), }, { label: 'layout.components.navigation.tabs.rules', path: '/rules', icon: [, ], - Component: createLazyRoute(() => import('./rules'), 'rules'), + ...createLazyRoute(() => import('./rules'), 'rules'), }, { label: 'layout.components.navigation.tabs.logs', path: '/logs', icon: [, ], Component: () => null /* LogsPage rendered in Layout only on /logs route */, + preload: preloadLogsPage, }, { label: 'layout.components.navigation.tabs.unlock', path: '/unlock', icon: [, ], - Component: createLazyRoute(() => import('./unlock')), + ...createLazyRoute(() => import('./unlock')), }, { label: 'layout.components.navigation.tabs.settings', path: '/settings', icon: [, ], - Component: createLazyRoute(() => import('./settings')), + ...createLazyRoute(() => import('./settings')), }, ] +export const preloadNavigationRoutes = async (signal: AbortSignal) => { + for (const item of navItems) { + if (signal.aborted) { + return + } + const preload = 'preload' in item ? item.preload : undefined + if (!preload) { + continue + } + + await waitForWarmupIdle(signal) + if (signal.aborted) { + return + } + + await preload().catch(() => {}) + } +} + export const router = createBrowserRouter([ { path: '/',