perf(frontend): defer landing lower sections and voxel backdrop, skip guest refresh 401, fix hud a11y
This commit is contained in:
parent
b170227adf
commit
7172652ac3
11 changed files with 120 additions and 22 deletions
|
|
@ -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])
|
||||
|
|
|
|||
23
frontend/src/features/auth/sessionHint.ts
Normal file
23
frontend/src/features/auth/sessionHint.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
20
frontend/src/features/landing/LowerSections.tsx
Normal file
20
frontend/src/features/landing/LowerSections.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
21
frontend/src/features/landing/effects/useIdleReady.ts
Normal file
21
frontend/src/features/landing/effects/useIdleReady.ts
Normal 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
|
||||
}
|
||||
|
|
@ -110,7 +110,10 @@ export function HudPlayground() {
|
|||
style={{ left: pos[w.id].x, top: pos[w.id].y }}
|
||||
className="lv-hud-widget absolute touch-none select-none"
|
||||
>
|
||||
<WidgetContent id={w.id} unitMs={t('hud.unitMs')} />
|
||||
{/* 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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
>
|
||||
<VoxelScene accent={accent} />
|
||||
{backdropReady && (
|
||||
<Suspense fallback={null}>
|
||||
<VoxelScene accent={accent} />
|
||||
</Suspense>
|
||||
)}
|
||||
<div aria-hidden="true" className="lv-hero-shade absolute inset-0" />
|
||||
<ParticleField color={accent} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue