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:
loki5512344 2026-09-30 16:06:45 +02:00
parent 6b0633034d
commit b170227adf
Signed by: boba
GPG key ID: 253067914055423B
4 changed files with 34 additions and 23 deletions

View file

@ -1,38 +1,36 @@
import type { ComponentType } from 'react'
import type { RouteObject } from 'react-router'
import { RequireAuth } from '@features/auth/RequireAuth'
import { AppShell } from '@shared/layout/AppShell'
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'
// 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[] = [
{
element: <AppShell />,
children: [
{ path: '/', element: <HomePage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{ path: '/download', element: <DownloadRoute /> },
{ path: '/themes', element: <ThemesRoute /> },
{ path: '/u/:id', element: <ProfilePage /> },
{ path: '/showcase', element: <ShowcaseRoute /> },
{ path: '/showcase/:id', element: <ListingRoute /> },
{ path: '/login', lazy: () => import('@pages/public/auth/LoginPage').then(toRoute) },
{ path: '/register', lazy: () => import('@pages/public/auth/RegisterPage').then(toRoute) },
{ path: '/download', lazy: () => import('@pages/download/DownloadRoute').then(toRoute) },
{ path: '/themes', lazy: () => import('@pages/themes/ThemesRoute').then(toRoute) },
{ path: '/u/:id', lazy: () => import('@pages/public/ProfilePage').then(toRoute) },
{ path: '/showcase', lazy: () => import('@pages/showcase/ShowcaseRoute').then(toRoute) },
{ path: '/showcase/:id', lazy: () => import('@pages/showcase/ListingRoute').then(toRoute) },
{
element: <RequireAuth />,
children: [
{ path: '/link', element: <LinkPage /> },
{ path: '/account', element: <AccountPage /> },
{ path: '/configs', element: <ConfigsPage /> },
{ path: '/link', lazy: () => import('@pages/app/LinkPage').then(toRoute) },
{ path: '/account', lazy: () => import('@pages/app/AccountPage').then(toRoute) },
{ path: '/configs', lazy: () => import('@pages/app/ConfigsPage').then(toRoute) },
],
},
{ path: '*', element: <NotFoundPage /> },

View file

@ -42,7 +42,12 @@ test('registering after a redirect lands back where the visitor was going', asyn
})
const { router } = renderApp(routes, '/link')
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('Пароль'), 'correct-horse')
await user.click(screen.getByRole('button', { name: 'Создать аккаунт' }))

View file

@ -1,11 +1,16 @@
import '@testing-library/jest-dom/vitest'
import 'fake-indexeddb/auto'
import { cleanup } from '@testing-library/react'
import { cleanup, configure } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
import { initI18n } from '@app/i18n'
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(() => vi.unstubAllGlobals())

View file

@ -35,5 +35,8 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
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,
},
})