perf(frontend): defer landing lower sections and voxel backdrop, skip guest refresh 401, fix hud a11y

This commit is contained in:
loki5512344 2026-09-30 16:24:28 +02:00
parent b170227adf
commit 7172652ac3
Signed by: boba
GPG key ID: 253067914055423B
11 changed files with 120 additions and 22 deletions

View file

@ -3,6 +3,7 @@ import { createContext, useCallback, useContext, useMemo, type ReactNode } from
import { api } from '@shared/api/client'
import type { Me } from '@shared/types'
import { fetchMe, login, logout, register } from './api'
import { hasSessionHint, setSessionHint } from './sessionHint'
type Session = {
status: 'loading' | 'anonymous' | 'authenticated'
@ -17,13 +18,23 @@ const SessionContext = createContext<Session | null>(null)
export function SessionProvider({ children }: { children: ReactNode }) {
const client = useQueryClient()
// Restores the session after a reload: the refresh cookie survives, the
// in-memory access token does not.
const restored = useQuery({ queryKey: ['session-restore'], queryFn: () => api.refresh(), staleTime: Infinity })
// in-memory access token does not. Guests skip the call (see sessionHint).
const restored = useQuery({
queryKey: ['session-restore'],
queryFn: async () => {
if (!hasSessionHint()) return false
const ok = await api.refresh()
setSessionHint(ok)
return ok
},
staleTime: Infinity,
})
const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, enabled: restored.data === true })
const signIn = useCallback(
async (email: string, password: string) => {
await login(email, password)
setSessionHint(true)
client.setQueryData(['session-restore'], true)
await client.fetchQuery({ queryKey: ['me'], queryFn: fetchMe })
},
@ -40,6 +51,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const signOut = useCallback(async () => {
await logout()
setSessionHint(false)
client.clear()
client.setQueryData(['session-restore'], false)
}, [client])

View file

@ -0,0 +1,23 @@
const KEY = 'lv_session'
/**
* A guest has no refresh cookie, so asking /auth/refresh only produces a 401 (a console error
* and a wasted round trip on every first visit). This flag records "this browser signed in";
* without it the restore step is skipped. Storage may be unavailable, so it never throws.
*/
export function hasSessionHint(): boolean {
try {
return localStorage.getItem(KEY) === '1'
} catch {
return false
}
}
export function setSessionHint(on: boolean): void {
try {
if (on) localStorage.setItem(KEY, '1')
else localStorage.removeItem(KEY)
} catch {
// Private mode or blocked storage: the hint is only an optimisation.
}
}

View file

@ -24,9 +24,9 @@ function Panel({ category, state, dispatch }: { category: Category } & Props) {
return (
<section role="listitem" className="lv-gui-panel" data-category={category.id} aria-labelledby={titleId}>
<header className="lv-gui-panel-head">
<h3 id={titleId} className="lv-gui-panel-title">
<h2 id={titleId} className="lv-gui-panel-title">
{category.name}
</h3>
</h2>
<Icon name={category.id} className="lv-gui-panel-icon" />
</header>
<ul className="lv-gui-rows">

View file

@ -0,0 +1,20 @@
import { CommandHero } from '@features/landing/CommandHero'
import { HowItWorks } from '@features/landing/HowItWorks'
import { FaqDownload } from '@features/landing/sections/explore/FaqDownload'
import { HudPlayground } from '@features/landing/sections/explore/HudPlayground'
import { ModuleWall } from '@features/landing/sections/explore/ModuleWall'
import { ShowcaseTeaser } from '@features/landing/sections/explore/ShowcaseTeaser'
/** Everything below the first screen; loaded as its own chunk after the hero has painted. */
export default function LowerSections() {
return (
<>
<HudPlayground />
<CommandHero />
<HowItWorks />
<ModuleWall />
<ShowcaseTeaser />
<FaqDownload />
</>
)
}

View file

@ -0,0 +1,21 @@
import { useEffect, useState } from 'react'
/**
* Flips to true once the browser is idle (or after `timeout` ms at the latest), so heavy
* decorative work stays out of the first paint's main-thread budget. Without
* requestIdleCallback (Safari) a short timeout stands in.
*/
export function useIdleReady(timeout = 600): boolean {
const [ready, setReady] = useState(false)
useEffect(() => {
if (typeof requestIdleCallback === 'function') {
const id = requestIdleCallback(() => setReady(true), { timeout })
return () => cancelIdleCallback(id)
}
const id = setTimeout(() => setReady(true), 200)
return () => clearTimeout(id)
}, [timeout])
return ready
}

View file

@ -110,8 +110,11 @@ export function HudPlayground() {
style={{ left: pos[w.id].x, top: pos[w.id].y }}
className="lv-hud-widget absolute touch-none select-none"
>
{/* Purely decorative readout: the button's aria-label is its accessible name. */}
<div aria-hidden="true" className="contents">
<WidgetContent id={w.id} unitMs={t('hud.unitMs')} />
</div>
</div>
))}
</div>

View file

@ -1,5 +1,5 @@
import { MotionConfig, motion } from 'motion/react'
import { useRef } from 'react'
import { lazy, Suspense, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
@ -8,9 +8,12 @@ import { parseArgb } from '@features/themes/types'
import { useActiveTheme } from '@features/themes/useActiveTheme'
import { ParticleField } from '../../effects/ParticleField'
import { useInView } from '../../effects/useInView'
import { VoxelScene } from '../../effects/VoxelScene'
import { useIdleReady } from '../../effects/useIdleReady'
import '@features/landing/styles/landing.css'
// The backdrop is decorative and its first draw is heavy, so it loads and paints after the headline.
const VoxelScene = lazy(() => import('../../effects/VoxelScene').then((m) => ({ default: m.VoxelScene })))
const rise = (delay: number) => ({
initial: { opacity: 0, y: 24 },
animate: { opacity: 1, y: 0 },
@ -30,6 +33,7 @@ export function Hero() {
const { theme } = useActiveTheme()
const sectionRef = useRef<HTMLElement>(null)
const inView = useInView(sectionRef)
const backdropReady = useIdleReady(300)
const { r, g, b } = parseArgb(theme.theme.accent)
const accent = `rgb(${r}, ${g}, ${b})`
const facts = [
@ -45,7 +49,11 @@ export function Hero() {
aria-labelledby="hero-title"
className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh items-center overflow-hidden pt-(--topbar-h)"
>
{backdropReady && (
<Suspense fallback={null}>
<VoxelScene accent={accent} />
</Suspense>
)}
<div aria-hidden="true" className="lv-hero-shade absolute inset-0" />
<ParticleField color={accent} />

View file

@ -176,6 +176,8 @@
/* Atmosphere behind everything below the first screen: accent aurora, faint grid, drifting motes. */
.lv-lower { position: relative; isolation: isolate; }
/* Off-screen sections skip style/layout/paint until scrolled near; the intrinsic size keeps the scrollbar stable. */
.lv-lower > section { content-visibility: auto; contain-intrinsic-size: auto 800px; }
.lv-lower-bg {
position: absolute; inset: 0 calc(50% - 50vw); z-index: -1; pointer-events: none;
/* Safari repaints this whole (very tall) layered-gradient + mask element on every scroll

View file

@ -1,25 +1,23 @@
import { CommandHero } from '@features/landing/CommandHero'
import { HowItWorks } from '@features/landing/HowItWorks'
import { FaqDownload } from '@features/landing/sections/explore/FaqDownload'
import { lazy, Suspense } from 'react'
import { useIdleReady } from '@features/landing/effects/useIdleReady'
import { Hero } from '@features/landing/sections/hero/Hero'
import { HudPlayground } from '@features/landing/sections/explore/HudPlayground'
import { ModuleWall } from '@features/landing/sections/explore/ModuleWall'
import { ShowcaseTeaser } from '@features/landing/sections/explore/ShowcaseTeaser'
import { ThemeStrip } from '@features/landing/sections/hero/ThemeStrip'
const LowerSections = lazy(() => import('@features/landing/LowerSections'))
export default function HomePage() {
const ready = useIdleReady()
return (
<>
<Hero />
<ThemeStrip />
<div className="lv-lower">
<div className="lv-lower" style={ready ? undefined : { minHeight: '2400px' }}>
<div aria-hidden="true" className="lv-lower-bg" />
<HudPlayground />
<CommandHero />
<HowItWorks />
<ModuleWall />
<ShowcaseTeaser />
<FaqDownload />
{ready && (
<Suspense fallback={null}>
<LowerSections />
</Suspense>
)}
</div>
</>
)

View file

@ -19,6 +19,14 @@ test('anonymous visitor sees nav and a login link', async () => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Визуалы, в которые влюбляешься')
})
test('a guest never calls /auth/refresh (no 401 in the console)', async () => {
localStorage.removeItem('lv_session')
const fetch = mockFetch({})
renderApp(routes, '/')
expect(await screen.findByRole('link', { name: 'Войти' })).toBeInTheDocument()
expect(fetch.mock.calls.some(([url]) => String(url).includes('/auth/refresh'))).toBe(false)
})
test('signed-in visitor sees their nick instead of login', async () => {
mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),

View file

@ -1,7 +1,7 @@
import '@testing-library/jest-dom/vitest'
import 'fake-indexeddb/auto'
import { cleanup, configure } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
import { afterEach, beforeEach, vi } from 'vitest'
import { initI18n } from '@app/i18n'
initI18n('ru')
@ -11,6 +11,9 @@ initI18n('ru')
// than the default 1000ms findBy/waitFor timeout. Give async queries more headroom.
configure({ asyncUtilTimeout: 5000 })
// Sessions are restored only for browsers that signed in before (see features/auth/sessionHint);
// tests assume a returning visitor unless they clear the flag themselves.
beforeEach(() => localStorage.setItem('lv_session', '1'))
afterEach(() => cleanup())
afterEach(() => vi.unstubAllGlobals())