perf(web): разбиение бандла и runtime-оптимизации

- autoCodeSplitting роутов: начальный JS 1012КБ/298гз → 426КБ/131гз (−58%),
  админ-чанки грузятся по требованию
- фильтры грида: убрана искусственная задержка 200мс
- SystemMonitorPopover: поллинг 4 запросов/30с только при открытом поповере
- /me в beforeLoad через ensureQueryData (кэш вместо запроса на каждую навигацию)
- глобальный staleTime 30с (мутации инвалидируют кэш явно)
- режим analyze + rollup-plugin-visualizer (pnpm --filter web analyze)
This commit is contained in:
Denozordec
2026-09-23 14:51:14 +07:00
parent 87f35631b4
commit 15073f53d9
5 changed files with 46 additions and 31 deletions
@@ -1,4 +1,4 @@
import { useMemo, type ReactNode } from 'react' import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Activity, AppWindow, KeyRound, Users } from 'lucide-react' import { Activity, AppWindow, KeyRound, Users } from 'lucide-react'
@@ -76,6 +76,9 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
/** Live system monitor popover — app-shell-7, Auth Portal metrics. @see https://reui.io/preview/base/app-shell-7 */ /** Live system monitor popover — app-shell-7, Auth Portal metrics. @see https://reui.io/preview/base/app-shell-7 */
export function SystemMonitorPopover() { export function SystemMonitorPopover() {
// Поллинг живёт только пока поповер открыт: закрытый монитор не должен
// каждые 30 секунд тянуть /ready, каталог и админские списки.
const [open, setOpen] = useState(false)
const meQ = useQuery(meQueryOptions) const meQ = useQuery(meQueryOptions)
const isAdmin = Boolean(meQ.data?.is_admin) const isAdmin = Boolean(meQ.data?.is_admin)
@@ -86,24 +89,33 @@ export function SystemMonitorPopover() {
if (!res.ok) throw new Error('not ready') if (!res.ok) throw new Error('not ready')
return (await res.json()) as { ok?: boolean } return (await res.json()) as { ok?: boolean }
}, },
enabled: open,
refetchInterval: 30_000, refetchInterval: 30_000,
retry: false, retry: false,
}) })
const catalogQ = useQuery({ ...catalogQueryOptions, refetchInterval: 30_000 }) const catalogQ = useQuery({
...catalogQueryOptions,
enabled: open,
refetchInterval: 30_000,
})
const usersQ = useQuery({ const usersQ = useQuery({
...usersQueryOptions, ...usersQueryOptions,
enabled: isAdmin, enabled: isAdmin && open,
retry: false, retry: false,
refetchInterval: 30_000, refetchInterval: 30_000,
}) })
const oidcQ = useQuery({ const oidcQ = useQuery({
...oidcClientsQueryOptions, ...oidcClientsQueryOptions,
enabled: isAdmin, enabled: isAdmin && open,
retry: false, retry: false,
refetchInterval: 30_000, refetchInterval: 30_000,
}) })
const apiOk = readyQ.data?.ok === true || readyQ.isSuccess const monitorFetched = readyQ.isFetched || catalogQ.isFetched
// До первого открытия данных нет — нейтральное состояние вместо ложной тревоги.
const apiOk = readyQ.isFetched
? readyQ.data?.ok === true || readyQ.isSuccess
: true
const appsCount = catalogQ.data?.apps.length ?? 0 const appsCount = catalogQ.data?.apps.length ?? 0
const usersCount = isAdmin ? (usersQ.data?.length ?? 0) : null const usersCount = isAdmin ? (usersQ.data?.length ?? 0) : null
const oidcCount = isAdmin ? (oidcQ.data?.length ?? 0) : null const oidcCount = isAdmin ? (oidcQ.data?.length ?? 0) : null
@@ -163,10 +175,10 @@ export function SystemMonitorPopover() {
[apiOk, appsCount, disabledUsers, oidcCount, usersCount], [apiOk, appsCount, disabledUsers, oidcCount, usersCount],
) )
const spiking = metrics.some((m) => m.alert) || !apiOk const spiking = monitorFetched && (metrics.some((m) => m.alert) || !apiOk)
return ( return (
<Popover> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger <PopoverTrigger
render={ render={
<button <button
@@ -771,7 +771,6 @@ export function AdminUsersGrid({
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters) const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
const [bulkRole, setBulkRole] = useState<BulkRole>('user') const [bulkRole, setBulkRole] = useState<BulkRole>('user')
const [filteredData, setFilteredData] = useState<AdminUser[]>(users) const [filteredData, setFilteredData] = useState<AdminUser[]>(users)
const [filterLoading, setFilterLoading] = useState(false)
const isInitialLoad = useRef(true) const isInitialLoad = useRef(true)
const lastAppliedActiveKey = useRef<string>( const lastAppliedActiveKey = useRef<string>(
serializeActiveFiltersKey(getActiveFilters(createDefaultFilters())), serializeActiveFiltersKey(getActiveFilters(createDefaultFilters())),
@@ -851,16 +850,6 @@ export function AdminUsersGrid({
[users], [users],
) )
const simulateAsyncFiltering = useCallback(
async (newFilters: Filter[]) => {
setFilterLoading(true)
await new Promise((resolve) => setTimeout(resolve, 200))
setFilteredData(applyFilters(newFilters))
setFilterLoading(false)
},
[applyFilters],
)
const handleFiltersChange = useCallback( const handleFiltersChange = useCallback(
(newFilters: Filter[]) => { (newFilters: Filter[]) => {
setFilters(newFilters) setFilters(newFilters)
@@ -870,9 +859,8 @@ export function AdminUsersGrid({
lastAppliedActiveKey.current = nextKey lastAppliedActiveKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 })) setPagination((prev) => ({ ...prev, pageIndex: 0 }))
setRowSelection({}) setRowSelection({})
void simulateAsyncFiltering(newFilters)
}, },
[simulateAsyncFiltering], [],
) )
useEffect(() => { useEffect(() => {
@@ -933,7 +921,7 @@ export function AdminUsersGrid({
.getSelectedRowModel() .getSelectedRowModel()
.rows.map((row) => row.original.id) .rows.map((row) => row.original.id)
const showClearButton = filters.length > 0 const showClearButton = filters.length > 0
const gridLoading = isLoading || filterLoading const gridLoading = isLoading
const handleClearSelection = useCallback(() => setRowSelection({}), []) const handleClearSelection = useCallback(() => setRowSelection({}), [])
@@ -956,8 +944,7 @@ export function AdminUsersGrid({
) )
setFilters(next) setFilters(next)
setRowSelection({}) setRowSelection({})
void simulateAsyncFiltering(next) }, [])
}, [simulateAsyncFiltering])
return ( return (
<TooltipProvider delay={200}> <TooltipProvider delay={200}>
+7 -1
View File
@@ -9,7 +9,13 @@ import '@authportal/ui/globals.css'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false }, queries: {
retry: 1,
refetchOnWindowFocus: false,
// Данные портала меняются редко; мутации инвалидируют кэш явно.
// Без staleTime каждый маунт компонента рефетчил /me, каталог и т.п.
staleTime: 30_000,
},
}, },
}) })
+6 -4
View File
@@ -6,17 +6,18 @@ import {
jwtClaimsStaleVsMe, jwtClaimsStaleVsMe,
reissueAccessToken, reissueAccessToken,
} from '@/lib/auth' } from '@/lib/auth'
import { api } from '@/lib/api-client' import { meQueryKey, meQueryOptions } from '@/queries/auth'
import type { MeResponse } from '@authportal/shared'
export const Route = createFileRoute('/_auth')({ export const Route = createFileRoute('/_auth')({
beforeLoad: async ({ location }) => { beforeLoad: async ({ context: { queryClient }, location }) => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
throw redirect({ to: '/' }) throw redirect({ to: '/' })
} }
try { try {
const me = await api.get<MeResponse>('/api/v1/auth/me') // Кэшированный /me вместо сырого запроса на каждый вход в auth-дерево;
// сайдбар, NavUser и монитор читают тот же кэш (staleTime из QueryClient).
const me = await queryClient.ensureQueryData(meQueryOptions)
if (jwtClaimsStaleVsMe(token, me)) { if (jwtClaimsStaleVsMe(token, me)) {
await reissueAccessToken() await reissueAccessToken()
} }
@@ -25,6 +26,7 @@ export const Route = createFileRoute('/_auth')({
} }
} catch (err) { } catch (err) {
if (err && typeof err === 'object' && 'to' in err) throw err if (err && typeof err === 'object' && 'to' in err) throw err
queryClient.removeQueries({ queryKey: meQueryKey })
clearToken() clearToken()
throw redirect({ to: '/' }) throw redirect({ to: '/' })
} }
+11 -3
View File
@@ -3,12 +3,20 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { TanStackRouterVite } from '@tanstack/router-vite-plugin' import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({ export default defineConfig(({ mode }) => ({
plugins: [ plugins: [
TanStackRouterVite({ routesDirectory: './src/routes' }), TanStackRouterVite({
routesDirectory: './src/routes',
autoCodeSplitting: true,
}),
react(), react(),
tailwindcss(), tailwindcss(),
// pnpm --filter web analyze → dist/stats.html
...(mode === 'analyze'
? [visualizer({ filename: 'dist/stats.html', gzipSize: true })]
: []),
], ],
resolve: { resolve: {
alias: { alias: {
@@ -32,4 +40,4 @@ export default defineConfig({
'/oauth': 'http://localhost:8080', '/oauth': 'http://localhost:8080',
}, },
}, },
}) }))