From 7172652ac37ca6fe0185e9d25a350c1cc0f086d3 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Wed, 30 Sep 2026 16:24:28 +0200 Subject: [PATCH] perf(frontend): defer landing lower sections and voxel backdrop, skip guest refresh 401, fix hud a11y --- frontend/src/features/auth/session.tsx | 16 +++++++++++-- frontend/src/features/auth/sessionHint.ts | 23 ++++++++++++++++++ .../clickgui/screens/ModulesScreen.tsx | 4 ++-- .../src/features/landing/LowerSections.tsx | 20 ++++++++++++++++ .../features/landing/effects/useIdleReady.ts | 21 ++++++++++++++++ .../sections/explore/HudPlayground.tsx | 5 +++- .../features/landing/sections/hero/Hero.tsx | 14 ++++++++--- .../src/features/landing/styles/landing.css | 2 ++ frontend/src/pages/public/HomePage.tsx | 24 +++++++++---------- .../src/shared/layout/tests/layout.test.tsx | 8 +++++++ frontend/src/test/setup.ts | 5 +++- 11 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 frontend/src/features/auth/sessionHint.ts create mode 100644 frontend/src/features/landing/LowerSections.tsx create mode 100644 frontend/src/features/landing/effects/useIdleReady.ts diff --git a/frontend/src/features/auth/session.tsx b/frontend/src/features/auth/session.tsx index b8fc813..2d15ccf 100644 --- a/frontend/src/features/auth/session.tsx +++ b/frontend/src/features/auth/session.tsx @@ -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(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]) diff --git a/frontend/src/features/auth/sessionHint.ts b/frontend/src/features/auth/sessionHint.ts new file mode 100644 index 0000000..936672b --- /dev/null +++ b/frontend/src/features/auth/sessionHint.ts @@ -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. + } +} diff --git a/frontend/src/features/clickgui/screens/ModulesScreen.tsx b/frontend/src/features/clickgui/screens/ModulesScreen.tsx index ca18947..b46d493 100644 --- a/frontend/src/features/clickgui/screens/ModulesScreen.tsx +++ b/frontend/src/features/clickgui/screens/ModulesScreen.tsx @@ -24,9 +24,9 @@ function Panel({ category, state, dispatch }: { category: Category } & Props) { return (
-

+

{category.name} -

+
    diff --git a/frontend/src/features/landing/LowerSections.tsx b/frontend/src/features/landing/LowerSections.tsx new file mode 100644 index 0000000..0754545 --- /dev/null +++ b/frontend/src/features/landing/LowerSections.tsx @@ -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 ( + <> + + + + + + + + ) +} diff --git a/frontend/src/features/landing/effects/useIdleReady.ts b/frontend/src/features/landing/effects/useIdleReady.ts new file mode 100644 index 0000000..2fc78d7 --- /dev/null +++ b/frontend/src/features/landing/effects/useIdleReady.ts @@ -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 +} diff --git a/frontend/src/features/landing/sections/explore/HudPlayground.tsx b/frontend/src/features/landing/sections/explore/HudPlayground.tsx index 7f3be7f..0d3de41 100644 --- a/frontend/src/features/landing/sections/explore/HudPlayground.tsx +++ b/frontend/src/features/landing/sections/explore/HudPlayground.tsx @@ -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" > - + {/* Purely decorative readout: the button's aria-label is its accessible name. */} + ))} diff --git a/frontend/src/features/landing/sections/hero/Hero.tsx b/frontend/src/features/landing/sections/hero/Hero.tsx index 83671d7..01508e9 100644 --- a/frontend/src/features/landing/sections/hero/Hero.tsx +++ b/frontend/src/features/landing/sections/hero/Hero.tsx @@ -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(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 && ( + + + + )}