fix: warm lazy navigation routes after startup

This commit is contained in:
Tunglies
2026-06-30 21:34:32 +08:00
parent e176e4a81e
commit 9a2f4ff8fb
3 changed files with 140 additions and 28 deletions
+24 -6
View File
@@ -9,7 +9,8 @@ import {
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
} from '@mui/material' } 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 { useMatch, useNavigate, useResolvedPath } from 'react-router'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
@@ -28,9 +29,10 @@ interface Props {
children: string children: string
icon: ReactNode[] icon: ReactNode[]
sortable?: SortableProps sortable?: SortableProps
onPreload?: () => Promise<unknown>
} }
export const LayoutItem = (props: Props) => { export const LayoutItem = (props: Props) => {
const { to, children, icon, sortable } = props const { to, children, icon, sortable, onPreload } = props
const { verge } = useVerge() const { verge } = useVerge()
const { menu_icon } = verge ?? {} const { menu_icon } = verge ?? {}
const navCollapsed = verge?.collapse_navbar ?? false const navCollapsed = verge?.collapse_navbar ?? false
@@ -45,9 +47,21 @@ export const LayoutItem = (props: Props) => {
sortable ?? {} sortable ?? {}
const draggable = Boolean(sortable) && !disabled const draggable = Boolean(sortable) && !disabled
const dragHandleProps = draggable const { onPointerDown, ...otherListeners } = draggable
? { ...(attributes ?? {}), ...(listeners ?? {}) } ? (listeners ?? {})
: undefined : {}
const handlePreload = useCallback(() => {
void onPreload?.().catch(() => {})
}, [onPreload])
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
handlePreload()
onPointerDown?.(event)
},
[handlePreload, onPointerDown],
)
return ( return (
<ListItem <ListItem
@@ -60,7 +74,8 @@ export const LayoutItem = (props: Props) => {
> >
<ListItemButton <ListItemButton
selected={!!match} selected={!!match}
{...(dragHandleProps ?? {})} {...(draggable ? (attributes ?? {}) : {})}
{...(draggable ? otherListeners : {})}
sx={[ sx={[
{ {
borderRadius: 2, borderRadius: 2,
@@ -90,6 +105,9 @@ export const LayoutItem = (props: Props) => {
]} ]}
title={navCollapsed ? children : undefined} title={navCollapsed ? children : undefined}
aria-label={navCollapsed ? children : undefined} aria-label={navCollapsed ? children : undefined}
onFocus={handlePreload}
onMouseEnter={handlePreload}
onPointerDown={handlePointerDown}
onClick={() => navigate(to)} onClick={() => navigate(to)}
> >
{(effectiveMenuIcon === 'monochrome' || !effectiveMenuIcon) && ( {(effectiveMenuIcon === 'monochrome' || !effectiveMenuIcon) && (
+25 -7
View File
@@ -48,7 +48,6 @@ import { WindowControls } from '@/components/layout/window-controller'
import { useI18n } from '@/hooks/use-i18n' import { useI18n } from '@/hooks/use-i18n'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { useWindowDecorations } from '@/hooks/use-window' import { useWindowDecorations } from '@/hooks/use-window'
import { ensureLanguageSections } from '@/services/i18n'
import { useThemeMode } from '@/services/states' import { useThemeMode } from '@/services/states'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
@@ -59,17 +58,14 @@ import {
useNavMenuOrder, useNavMenuOrder,
} from './_layout/hooks' } from './_layout/hooks'
import { handleNoticeMessage } from './_layout/utils' import { handleNoticeMessage } from './_layout/utils'
import { navItems } from './_routers' import { navItems, preloadLogsPage, preloadNavigationRoutes } from './_routers'
import 'dayjs/locale/ru' import 'dayjs/locale/ru'
import 'dayjs/locale/zh-cn' import 'dayjs/locale/zh-cn'
export const portableFlag = false export const portableFlag = false
const LogsPage = lazy(async () => { const LogsPage = lazy(() => preloadLogsPage())
await ensureLanguageSections('logs')
return import('./logs')
})
type NavItem = (typeof navItems)[number] type NavItem = (typeof navItems)[number]
@@ -105,6 +101,7 @@ const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => {
<LayoutItem <LayoutItem
to={item.path} to={item.path}
icon={item.icon} icon={item.icon}
onPreload={item.preload}
sortable={{ sortable={{
setNodeRef, setNodeRef,
attributes, attributes,
@@ -232,6 +229,22 @@ const Layout = () => {
useLoadingOverlay(themeReady) 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( const handleNotice = useCallback(
(payload: [string, string]) => { (payload: [string, string]) => {
const [status, msg] = payload const [status, msg] = payload
@@ -406,7 +419,12 @@ const Layout = () => {
return null return null
} }
return ( return (
<LayoutItem key={item.path} to={item.path} icon={item.icon}> <LayoutItem
key={item.path}
to={item.path}
icon={item.icon}
onPreload={item.preload}
>
{t(item.label)} {t(item.label)}
</LayoutItem> </LayoutItem>
) )
+91 -15
View File
@@ -22,27 +22,83 @@ import { ensureLanguageSections } from '@/services/i18n'
import Layout from './_layout' import Layout from './_layout'
import HomePage from './home' import HomePage from './home'
const waitForWarmupIdle = (signal: AbortSignal) =>
new Promise<void>((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 = ( const createLazyRoute = (
load: () => Promise<{ default: ComponentType }>, load: () => Promise<{ default: ComponentType }>,
sections?: string | readonly string[], sections?: string | readonly string[],
) => { ) => {
const Component = lazy( const preload = createRoutePreload(load, sections)
sections const Component = lazy(preload)
? async () => {
await ensureLanguageSections(sections)
return load()
}
: load,
)
const LazyRoute = () => ( const LazyRoute = () => (
<Suspense fallback={null}> <Suspense fallback={null}>
<Component /> <Component />
</Suspense> </Suspense>
) )
return LazyRoute return { Component: LazyRoute, preload }
} }
export const preloadLogsPage = createRoutePreload(
() => import('./logs'),
'logs',
)
export const navItems = [ export const navItems = [
{ {
label: 'layout.components.navigation.tabs.home', label: 'layout.components.navigation.tabs.home',
@@ -54,46 +110,66 @@ export const navItems = [
label: 'layout.components.navigation.tabs.proxies', label: 'layout.components.navigation.tabs.proxies',
path: '/proxies', path: '/proxies',
icon: [<WifiRoundedIcon key="mui" />, <ProxiesSvg key="svg" />], icon: [<WifiRoundedIcon key="mui" />, <ProxiesSvg key="svg" />],
Component: createLazyRoute(() => import('./proxies')), ...createLazyRoute(() => import('./proxies')),
}, },
{ {
label: 'layout.components.navigation.tabs.profiles', label: 'layout.components.navigation.tabs.profiles',
path: '/profile', path: '/profile',
icon: [<DnsRoundedIcon key="mui" />, <ProfilesSvg key="svg" />], icon: [<DnsRoundedIcon key="mui" />, <ProfilesSvg key="svg" />],
Component: createLazyRoute(() => import('./profiles'), 'rules'), ...createLazyRoute(() => import('./profiles'), 'rules'),
}, },
{ {
label: 'layout.components.navigation.tabs.connections', label: 'layout.components.navigation.tabs.connections',
path: '/connections', path: '/connections',
icon: [<LanguageRoundedIcon key="mui" />, <ConnectionsSvg key="svg" />], icon: [<LanguageRoundedIcon key="mui" />, <ConnectionsSvg key="svg" />],
Component: createLazyRoute(() => import('./connections'), 'connections'), ...createLazyRoute(() => import('./connections'), 'connections'),
}, },
{ {
label: 'layout.components.navigation.tabs.rules', label: 'layout.components.navigation.tabs.rules',
path: '/rules', path: '/rules',
icon: [<ForkRightRoundedIcon key="mui" />, <RulesSvg key="svg" />], icon: [<ForkRightRoundedIcon key="mui" />, <RulesSvg key="svg" />],
Component: createLazyRoute(() => import('./rules'), 'rules'), ...createLazyRoute(() => import('./rules'), 'rules'),
}, },
{ {
label: 'layout.components.navigation.tabs.logs', label: 'layout.components.navigation.tabs.logs',
path: '/logs', path: '/logs',
icon: [<SubjectRoundedIcon key="mui" />, <LogsSvg key="svg" />], icon: [<SubjectRoundedIcon key="mui" />, <LogsSvg key="svg" />],
Component: () => null /* LogsPage rendered in Layout only on /logs route */, Component: () => null /* LogsPage rendered in Layout only on /logs route */,
preload: preloadLogsPage,
}, },
{ {
label: 'layout.components.navigation.tabs.unlock', label: 'layout.components.navigation.tabs.unlock',
path: '/unlock', path: '/unlock',
icon: [<LockOpenRoundedIcon key="mui" />, <UnlockSvg key="svg" />], icon: [<LockOpenRoundedIcon key="mui" />, <UnlockSvg key="svg" />],
Component: createLazyRoute(() => import('./unlock')), ...createLazyRoute(() => import('./unlock')),
}, },
{ {
label: 'layout.components.navigation.tabs.settings', label: 'layout.components.navigation.tabs.settings',
path: '/settings', path: '/settings',
icon: [<SettingsRoundedIcon key="mui" />, <SettingsSvg key="svg" />], icon: [<SettingsRoundedIcon key="mui" />, <SettingsSvg key="svg" />],
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([ export const router = createBrowserRouter([
{ {
path: '/', path: '/',