perf(frontend): route-level code splitting, main chunk 538 -> 491 KB
Lighthouse (production, default mobile 4x-CPU profile) scored Performance 33/100, dominated by one ~536-538 KB entry chunk that bundled every route's code regardless of which page a visitor landed on -- the "chunks are larger than 500 kB" Vite warning on every build this session. Converted every route except / and the 404 catch-all to React Router 8's native route.lazy (blocks navigation until the chunk is ready -- no separate Suspense fallback needed). AppShell/HomePage/NotFoundPage/ RequireAuth stay eager since they're needed on every load regardless. Result: main chunk 490.84 KB, no >500kB warning, one small chunk per route (LoginPage 1.4 KB, ThemesPage 15.5 KB, AccountPage 11 KB, etc.) loaded only when actually visited. Fixed a real race the lazy boundary exposed: /login and /register share an "Почта" field label, and during the chunk swap between them the old page can stay mounted a beat longer than before -- a test grabbing the field by label could land on the about-to-unmount one. Also bumped RTL's asyncUtilTimeout and vitest's testTimeout, since the route-transition + findBy chain didn't always land inside the old 1000/5000ms defaults under full parallel test-suite load. Claude-Session: https://claude.ai/code/session_01F1M1Jic1wTSn4igUENynmZ
This commit is contained in:
parent
6b0633034d
commit
b170227adf
4 changed files with 34 additions and 23 deletions
|
|
@ -1,38 +1,36 @@
|
||||||
|
import type { ComponentType } from 'react'
|
||||||
import type { RouteObject } from 'react-router'
|
import type { RouteObject } from 'react-router'
|
||||||
import { RequireAuth } from '@features/auth/RequireAuth'
|
import { RequireAuth } from '@features/auth/RequireAuth'
|
||||||
import { AppShell } from '@shared/layout/AppShell'
|
import { AppShell } from '@shared/layout/AppShell'
|
||||||
import HomePage from '@pages/public/HomePage'
|
import HomePage from '@pages/public/HomePage'
|
||||||
import LoginPage from '@pages/public/auth/LoginPage'
|
|
||||||
import RegisterPage from '@pages/public/auth/RegisterPage'
|
|
||||||
import ProfilePage from '@pages/public/ProfilePage'
|
|
||||||
import NotFoundPage from '@pages/public/NotFoundPage'
|
import NotFoundPage from '@pages/public/NotFoundPage'
|
||||||
// Code-split: the download page pulls in react-markdown, so it loads on demand.
|
|
||||||
import DownloadRoute from '@pages/download/DownloadRoute'
|
|
||||||
import AccountPage from '@pages/app/AccountPage'
|
|
||||||
import LinkPage from '@pages/app/LinkPage'
|
|
||||||
import ThemesRoute from '@pages/themes/ThemesRoute'
|
|
||||||
import ConfigsPage from '@pages/app/ConfigsPage'
|
|
||||||
import ShowcaseRoute from '@pages/showcase/ShowcaseRoute'
|
|
||||||
import ListingRoute from '@pages/showcase/ListingRoute'
|
|
||||||
|
|
||||||
|
// Adapts a page module's default export to the shape React Router's `lazy` expects.
|
||||||
|
function toRoute(m: { default: ComponentType }) {
|
||||||
|
return { Component: m.default }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything below is route-level code-split via React Router's `lazy` field:
|
||||||
|
// each page's JS only downloads when a visitor actually navigates to it, so the
|
||||||
|
// initial bundle only carries the app shell + the home page.
|
||||||
export const routes: RouteObject[] = [
|
export const routes: RouteObject[] = [
|
||||||
{
|
{
|
||||||
element: <AppShell />,
|
element: <AppShell />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <HomePage /> },
|
{ path: '/', element: <HomePage /> },
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', lazy: () => import('@pages/public/auth/LoginPage').then(toRoute) },
|
||||||
{ path: '/register', element: <RegisterPage /> },
|
{ path: '/register', lazy: () => import('@pages/public/auth/RegisterPage').then(toRoute) },
|
||||||
{ path: '/download', element: <DownloadRoute /> },
|
{ path: '/download', lazy: () => import('@pages/download/DownloadRoute').then(toRoute) },
|
||||||
{ path: '/themes', element: <ThemesRoute /> },
|
{ path: '/themes', lazy: () => import('@pages/themes/ThemesRoute').then(toRoute) },
|
||||||
{ path: '/u/:id', element: <ProfilePage /> },
|
{ path: '/u/:id', lazy: () => import('@pages/public/ProfilePage').then(toRoute) },
|
||||||
{ path: '/showcase', element: <ShowcaseRoute /> },
|
{ path: '/showcase', lazy: () => import('@pages/showcase/ShowcaseRoute').then(toRoute) },
|
||||||
{ path: '/showcase/:id', element: <ListingRoute /> },
|
{ path: '/showcase/:id', lazy: () => import('@pages/showcase/ListingRoute').then(toRoute) },
|
||||||
{
|
{
|
||||||
element: <RequireAuth />,
|
element: <RequireAuth />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/link', element: <LinkPage /> },
|
{ path: '/link', lazy: () => import('@pages/app/LinkPage').then(toRoute) },
|
||||||
{ path: '/account', element: <AccountPage /> },
|
{ path: '/account', lazy: () => import('@pages/app/AccountPage').then(toRoute) },
|
||||||
{ path: '/configs', element: <ConfigsPage /> },
|
{ path: '/configs', lazy: () => import('@pages/app/ConfigsPage').then(toRoute) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '*', element: <NotFoundPage /> },
|
{ path: '*', element: <NotFoundPage /> },
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,12 @@ test('registering after a redirect lands back where the visitor was going', asyn
|
||||||
})
|
})
|
||||||
const { router } = renderApp(routes, '/link')
|
const { router } = renderApp(routes, '/link')
|
||||||
await user.click(await screen.findByRole('link', { name: 'Регистрация' }))
|
await user.click(await screen.findByRole('link', { name: 'Регистрация' }))
|
||||||
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
|
// /login and /register are lazy-loaded and share the "Почта" email label, so the
|
||||||
|
// previous (login) page can still be mounted for a tick while the register chunk
|
||||||
|
// resolves. Wait for a register-only marker before querying its email field, or a
|
||||||
|
// stale reference to the login field (about to unmount) can end up typed into instead.
|
||||||
|
await screen.findByLabelText('Ник на сайте')
|
||||||
|
await user.type(screen.getByLabelText('Почта'), 'a@b.c')
|
||||||
await user.type(screen.getByLabelText('Ник на сайте'), 'Rider')
|
await user.type(screen.getByLabelText('Ник на сайте'), 'Rider')
|
||||||
await user.type(screen.getByLabelText('Пароль'), 'correct-horse')
|
await user.type(screen.getByLabelText('Пароль'), 'correct-horse')
|
||||||
await user.click(screen.getByRole('button', { name: 'Создать аккаунт' }))
|
await user.click(screen.getByRole('button', { name: 'Создать аккаунт' }))
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import '@testing-library/jest-dom/vitest'
|
import '@testing-library/jest-dom/vitest'
|
||||||
import 'fake-indexeddb/auto'
|
import 'fake-indexeddb/auto'
|
||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup, configure } from '@testing-library/react'
|
||||||
import { afterEach, vi } from 'vitest'
|
import { afterEach, vi } from 'vitest'
|
||||||
import { initI18n } from '@app/i18n'
|
import { initI18n } from '@app/i18n'
|
||||||
|
|
||||||
initI18n('ru')
|
initI18n('ru')
|
||||||
|
|
||||||
|
// Route pages load via dynamic import() (see src/app/routes.tsx), and under a loaded
|
||||||
|
// test run (many jsdom workers in parallel) that first chunk resolution can take longer
|
||||||
|
// than the default 1000ms findBy/waitFor timeout. Give async queries more headroom.
|
||||||
|
configure({ asyncUtilTimeout: 5000 })
|
||||||
|
|
||||||
afterEach(() => cleanup())
|
afterEach(() => cleanup())
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,5 +35,8 @@ export default defineConfig({
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
css: false,
|
css: false,
|
||||||
|
// Route pages now load via dynamic import() (src/app/routes.tsx); the default 5000ms
|
||||||
|
// per-test budget can be tight for a route-transition test under a loaded parallel run.
|
||||||
|
testTimeout: 10000,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue