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 { api } from '@shared/api/client'
|
||||||
import type { Me } from '@shared/types'
|
import type { Me } from '@shared/types'
|
||||||
import { fetchMe, login, logout, register } from './api'
|
import { fetchMe, login, logout, register } from './api'
|
||||||
|
import { hasSessionHint, setSessionHint } from './sessionHint'
|
||||||
|
|
||||||
type Session = {
|
type Session = {
|
||||||
status: 'loading' | 'anonymous' | 'authenticated'
|
status: 'loading' | 'anonymous' | 'authenticated'
|
||||||
|
|
@ -17,13 +18,23 @@ const SessionContext = createContext<Session | null>(null)
|
||||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
const client = useQueryClient()
|
const client = useQueryClient()
|
||||||
// Restores the session after a reload: the refresh cookie survives, the
|
// Restores the session after a reload: the refresh cookie survives, the
|
||||||
// in-memory access token does not.
|
// in-memory access token does not. Guests skip the call (see sessionHint).
|
||||||
const restored = useQuery({ queryKey: ['session-restore'], queryFn: () => api.refresh(), staleTime: Infinity })
|
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 meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, enabled: restored.data === true })
|
||||||
|
|
||||||
const signIn = useCallback(
|
const signIn = useCallback(
|
||||||
async (email: string, password: string) => {
|
async (email: string, password: string) => {
|
||||||
await login(email, password)
|
await login(email, password)
|
||||||
|
setSessionHint(true)
|
||||||
client.setQueryData(['session-restore'], true)
|
client.setQueryData(['session-restore'], true)
|
||||||
await client.fetchQuery({ queryKey: ['me'], queryFn: fetchMe })
|
await client.fetchQuery({ queryKey: ['me'], queryFn: fetchMe })
|
||||||
},
|
},
|
||||||
|
|
@ -40,6 +51,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
await logout()
|
await logout()
|
||||||
|
setSessionHint(false)
|
||||||
client.clear()
|
client.clear()
|
||||||
client.setQueryData(['session-restore'], false)
|
client.setQueryData(['session-restore'], false)
|
||||||
}, [client])
|
}, [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 (
|
return (
|
||||||
<section role="listitem" className="lv-gui-panel" data-category={category.id} aria-labelledby={titleId}>
|
<section role="listitem" className="lv-gui-panel" data-category={category.id} aria-labelledby={titleId}>
|
||||||
<header className="lv-gui-panel-head">
|
<header className="lv-gui-panel-head">
|
||||||
<h3 id={titleId} className="lv-gui-panel-title">
|
<h2 id={titleId} className="lv-gui-panel-title">
|
||||||
{category.name}
|
{category.name}
|
||||||
</h3>
|
</h2>
|
||||||
<Icon name={category.id} className="lv-gui-panel-icon" />
|
<Icon name={category.id} className="lv-gui-panel-icon" />
|
||||||
</header>
|
</header>
|
||||||
<ul className="lv-gui-rows">
|
<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 }}
|
style={{ left: pos[w.id].x, top: pos[w.id].y }}
|
||||||
className="lv-hud-widget absolute touch-none select-none"
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { MotionConfig, motion } from 'motion/react'
|
import { MotionConfig, motion } from 'motion/react'
|
||||||
import { useRef } from 'react'
|
import { lazy, Suspense, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
|
import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
|
||||||
|
|
@ -8,9 +8,12 @@ import { parseArgb } from '@features/themes/types'
|
||||||
import { useActiveTheme } from '@features/themes/useActiveTheme'
|
import { useActiveTheme } from '@features/themes/useActiveTheme'
|
||||||
import { ParticleField } from '../../effects/ParticleField'
|
import { ParticleField } from '../../effects/ParticleField'
|
||||||
import { useInView } from '../../effects/useInView'
|
import { useInView } from '../../effects/useInView'
|
||||||
import { VoxelScene } from '../../effects/VoxelScene'
|
import { useIdleReady } from '../../effects/useIdleReady'
|
||||||
import '@features/landing/styles/landing.css'
|
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) => ({
|
const rise = (delay: number) => ({
|
||||||
initial: { opacity: 0, y: 24 },
|
initial: { opacity: 0, y: 24 },
|
||||||
animate: { opacity: 1, y: 0 },
|
animate: { opacity: 1, y: 0 },
|
||||||
|
|
@ -30,6 +33,7 @@ export function Hero() {
|
||||||
const { theme } = useActiveTheme()
|
const { theme } = useActiveTheme()
|
||||||
const sectionRef = useRef<HTMLElement>(null)
|
const sectionRef = useRef<HTMLElement>(null)
|
||||||
const inView = useInView(sectionRef)
|
const inView = useInView(sectionRef)
|
||||||
|
const backdropReady = useIdleReady(300)
|
||||||
const { r, g, b } = parseArgb(theme.theme.accent)
|
const { r, g, b } = parseArgb(theme.theme.accent)
|
||||||
const accent = `rgb(${r}, ${g}, ${b})`
|
const accent = `rgb(${r}, ${g}, ${b})`
|
||||||
const facts = [
|
const facts = [
|
||||||
|
|
@ -45,7 +49,11 @@ export function Hero() {
|
||||||
aria-labelledby="hero-title"
|
aria-labelledby="hero-title"
|
||||||
className="lv-bleed relative isolate -mt-(--topbar-h) flex min-h-svh items-center overflow-hidden pt-(--topbar-h)"
|
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" />
|
<div aria-hidden="true" className="lv-hero-shade absolute inset-0" />
|
||||||
<ParticleField color={accent} />
|
<ParticleField color={accent} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,8 @@
|
||||||
|
|
||||||
/* Atmosphere behind everything below the first screen: accent aurora, faint grid, drifting motes. */
|
/* Atmosphere behind everything below the first screen: accent aurora, faint grid, drifting motes. */
|
||||||
.lv-lower { position: relative; isolation: isolate; }
|
.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 {
|
.lv-lower-bg {
|
||||||
position: absolute; inset: 0 calc(50% - 50vw); z-index: -1; pointer-events: none;
|
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
|
/* Safari repaints this whole (very tall) layered-gradient + mask element on every scroll
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,23 @@
|
||||||
import { CommandHero } from '@features/landing/CommandHero'
|
import { lazy, Suspense } from 'react'
|
||||||
import { HowItWorks } from '@features/landing/HowItWorks'
|
import { useIdleReady } from '@features/landing/effects/useIdleReady'
|
||||||
import { FaqDownload } from '@features/landing/sections/explore/FaqDownload'
|
|
||||||
import { Hero } from '@features/landing/sections/hero/Hero'
|
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'
|
import { ThemeStrip } from '@features/landing/sections/hero/ThemeStrip'
|
||||||
|
|
||||||
|
const LowerSections = lazy(() => import('@features/landing/LowerSections'))
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const ready = useIdleReady()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero />
|
<Hero />
|
||||||
<ThemeStrip />
|
<ThemeStrip />
|
||||||
<div className="lv-lower">
|
<div className="lv-lower" style={ready ? undefined : { minHeight: '2400px' }}>
|
||||||
<div aria-hidden="true" className="lv-lower-bg" />
|
<div aria-hidden="true" className="lv-lower-bg" />
|
||||||
<HudPlayground />
|
{ready && (
|
||||||
<CommandHero />
|
<Suspense fallback={null}>
|
||||||
<HowItWorks />
|
<LowerSections />
|
||||||
<ModuleWall />
|
</Suspense>
|
||||||
<ShowcaseTeaser />
|
)}
|
||||||
<FaqDownload />
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,14 @@ test('anonymous visitor sees nav and a login link', async () => {
|
||||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Визуалы, в которые влюбляешься')
|
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 () => {
|
test('signed-in visitor sees their nick instead of login', async () => {
|
||||||
mockFetch({
|
mockFetch({
|
||||||
'POST /auth/refresh': () => json({ access_token: 't' }),
|
'POST /auth/refresh': () => json({ access_token: 't' }),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import '@testing-library/jest-dom/vitest'
|
import '@testing-library/jest-dom/vitest'
|
||||||
import 'fake-indexeddb/auto'
|
import 'fake-indexeddb/auto'
|
||||||
import { cleanup, configure } from '@testing-library/react'
|
import { cleanup, configure } from '@testing-library/react'
|
||||||
import { afterEach, vi } from 'vitest'
|
import { afterEach, beforeEach, vi } from 'vitest'
|
||||||
import { initI18n } from '@app/i18n'
|
import { initI18n } from '@app/i18n'
|
||||||
|
|
||||||
initI18n('ru')
|
initI18n('ru')
|
||||||
|
|
@ -11,6 +11,9 @@ initI18n('ru')
|
||||||
// than the default 1000ms findBy/waitFor timeout. Give async queries more headroom.
|
// than the default 1000ms findBy/waitFor timeout. Give async queries more headroom.
|
||||||
configure({ asyncUtilTimeout: 5000 })
|
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(() => cleanup())
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue