perf: load pages on demand, split vendor chunks, and replace the 888 KB favicon

App.tsx imported all 24 pages statically, so a first-time visitor
downloaded the admin panel, three multiplayer modes and the preset-pair
table before the landing page could render - one 682 KB chunk. Every
page except Home is now React.lazy behind a Suspense boundary: 56
chunks, pages at 2-11 KB. Verified in a browser against the production
build - the landing page fetches only the app and vendor chunks, a
navigation fetches that route's chunks and renders, and a deep link into
an authenticated route lazy-loads it, redirects to sign-in, and
lazy-loads that too.

Vendor code is split into vendor-react, vendor-router, vendor-ui and
vendor-net via manualChunks. The main chunk was 95% dependencies, so the
first load is not much smaller - the dependency set is the load - but
the chunk that changes on every deploy is now 50 KB (14 KB gzipped)
rather than 682 KB. A returning visitor after a deploy re-downloads the
app, not React.

The favicon was 888 KB: PNG data under a .jpg name, 768x768, with a
fully opaque alpha channel, fetched on every page load. It is a 2.6 KB
32px PNG now, with a 180px apple-touch-icon, and manifest.json points at
real 192px and 512px PNGs with the correct type instead of declaring the
same mislabelled file at both sizes. The service worker's notification
icon pointed at a /favicon.ico that never existed.

The useGameStore selector item from M26 was already done in WP-10.

Addresses M26.
This commit is contained in:
TehRiehlDeal committed 2026-09-11 18:19:36 -07:00
1 parent 2bdbf5fbc3
commit e67692006f
13 files changed
+184 -63

No files matched your search

+46
View File
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.0.1] - 2026-09-11
Bundle and asset budget (WP-15: M26).
### Added
- A test suite, where there was none: vitest with `@testing-library/react` and
@@ -25,6 +29,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
vitest - measured, and it reached the development backend once.
- A `test` job in CI.
### Changed
- **Every page except Home loads on demand.** `App.tsx` imported all 24 pages
statically, so a first-time visitor downloaded the admin panel, three
multiplayer modes and the preset-pair table before the landing page could
render: one 682 KB chunk. Routes are now `React.lazy` behind a `Suspense`
boundary, giving 56 chunks with pages at 2-11 KB each. Verified in a browser
against the production build: the landing page fetches only the app chunk and
vendor chunks; navigating to a route fetches that route's chunks and renders;
a deep link into an authenticated route lazy-loads it, redirects to sign-in,
and lazy-loads that too.
- **Vendor code is split into separately cached chunks** (`vendor-react`,
`vendor-router`, `vendor-ui`, `vendor-net`) via `manualChunks`. The first
load is not much smaller - the main chunk was 95% dependencies, and the
dependency set is the load - but the chunk that changes on every deploy is now
**50 KB (14 KB gzipped)** rather than 682 KB, so a returning visitor after a
deploy re-downloads the app, not React. Vite's 500 KB chunk warning no longer
fires.
### Fixed
- The favicon was an 888 KB image - PNG data under a `.jpg` name, 768x768 with
a fully opaque alpha channel - fetched on every page load. It is now a 2.6 KB
32px PNG, with a 180px `apple-touch-icon` for iOS home screens.
- `manifest.json` declared that same file as both its 192x192 and 512x512 icon,
typed `image/jpeg`. It now points at real 192px and 512px PNGs with the
correct type, so installs get a correctly sized icon instead of a mislabelled
one the browser has to rescale.
- The service worker's push notifications referenced `/favicon.ico`, which never
existed; a push with no icon in its payload rendered blank. They use the 192px
icon.
### Known issues
- The `useGameStore()` selector item from M26 was already resolved in WP-10;
the chain-validation hook subscribes per field.
- Not done, recorded for later: `socket.io-client` (~90 KB of `vendor-net`)
ships to anonymous visitors because the page chrome's notification bell
reaches the notification store, which imports it statically. Loading the
socket client on demand would mean an async `connect()` in four stores - a
change in its own right, not a bundle setting.
## [2.0.0] - 2026-09-10
Container and response-header hardening (WP-13: M31, M34).
+2 -1
View File
@@ -2,7 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/jpeg" href="/icon.jpg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>You Know Who Else Was In That Movie?</title>
<meta name="description" content="A trivia-puzzle game inspired by Six Degrees of Kevin Bacon. Build a chain of actor-movie connections forming a complete loop between two movies." />
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend-temp",
"version": "2.0.0",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend-temp",
"version": "2.0.0",
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"@base-ui/react": "^1.2.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend-temp",
"private": true,
"version": "2.0.0",
"version": "2.0.1",
"type": "module",
"license": "MIT",
"author": "Kevin Riehl",
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 867 KiB

+4 -4
View File
@@ -7,14 +7,14 @@
"theme_color": "#1e1b30",
"icons": [
{
"src": "/icon.jpg",
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/jpeg"
"type": "image/png"
},
{
"src": "/icon.jpg",
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/jpeg"
"type": "image/png"
}
]
}
+2 -2
View File
@@ -6,8 +6,8 @@ self.addEventListener('push', (event) => {
const payload = event.data.json();
const options = {
body: payload.body,
icon: payload.icon || '/favicon.ico',
badge: '/favicon.ico',
icon: payload.icon || '/icon-192.png',
badge: '/icon-192.png',
data: { url: payload.url || '/' },
};
+90 -53
View File
@@ -1,31 +1,54 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router'
import { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router';
import { Toaster } from 'sonner';
import { Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import Home from '@/pages/Home'
import Game from '@/pages/Game'
import DailyChallenge from '@/pages/DailyChallenge'
import Leaderboard from '@/pages/Leaderboard'
import Profile from '@/pages/Profile'
import Login from '@/pages/Login'
import Versus from '@/pages/Versus'
import VersusLobby from '@/pages/VersusLobby'
import VersusGame from '@/pages/VersusGame'
import AsyncVersusGame from '@/pages/AsyncVersusGame'
import AsyncMatchLeaderboard from '@/pages/AsyncMatchLeaderboard'
import Achievements from '@/pages/Achievements'
import Endless from '@/pages/Endless'
import CustomGame from '@/pages/CustomGame'
import GameNight from '@/pages/GameNight'
import GameNightLobby from '@/pages/GameNightLobby'
import GameNightPlay from '@/pages/GameNightPlay'
import GameNightResults from '@/pages/GameNightResults'
import GameReview from '@/pages/GameReview'
import Friends from '@/pages/Friends'
import AddFriend from '@/pages/AddFriend'
import Chat from '@/pages/Chat'
import Admin from '@/pages/Admin'
import NotFound from '@/pages/NotFound'
import Home from '@/pages/Home';
// Every page used to be imported statically, so a first-time visitor downloaded
// the admin panel, three multiplayer modes and the preset-pair table before the
// landing page could render - one 680 KB chunk. Home stays eager because it is
// where visitors land and it is light; everything else is fetched on the first
// navigation to it and cached by the browser from then on.
const Game = lazy(() => import('@/pages/Game'));
const DailyChallenge = lazy(() => import('@/pages/DailyChallenge'));
const Leaderboard = lazy(() => import('@/pages/Leaderboard'));
const Profile = lazy(() => import('@/pages/Profile'));
const Login = lazy(() => import('@/pages/Login'));
const Versus = lazy(() => import('@/pages/Versus'));
const VersusLobby = lazy(() => import('@/pages/VersusLobby'));
const VersusGame = lazy(() => import('@/pages/VersusGame'));
const AsyncVersusGame = lazy(() => import('@/pages/AsyncVersusGame'));
const AsyncMatchLeaderboard = lazy(
() => import('@/pages/AsyncMatchLeaderboard'),
);
const Achievements = lazy(() => import('@/pages/Achievements'));
const Endless = lazy(() => import('@/pages/Endless'));
const CustomGame = lazy(() => import('@/pages/CustomGame'));
const GameNight = lazy(() => import('@/pages/GameNight'));
const GameNightLobby = lazy(() => import('@/pages/GameNightLobby'));
const GameNightPlay = lazy(() => import('@/pages/GameNightPlay'));
const GameNightResults = lazy(() => import('@/pages/GameNightResults'));
const GameReview = lazy(() => import('@/pages/GameReview'));
const Friends = lazy(() => import('@/pages/Friends'));
const AddFriend = lazy(() => import('@/pages/AddFriend'));
const Chat = lazy(() => import('@/pages/Chat'));
const Admin = lazy(() => import('@/pages/Admin'));
const NotFound = lazy(() => import('@/pages/NotFound'));
/** Shown while a route's chunk is in flight - once per route per session. */
function RouteFallback() {
return (
<div
className="flex min-h-screen items-center justify-center"
role="status"
aria-live="polite"
aria-label="Loading"
>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
export default function App() {
const loadUser = useAuthStore((s) => s.loadUser);
@@ -37,32 +60,46 @@ export default function App() {
return (
<BrowserRouter>
<Toaster position="top-right" richColors closeButton />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/game" element={<Game />} />
<Route path="/daily" element={<DailyChallenge />} />
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/login" element={<Login />} />
<Route path="/versus" element={<Versus />} />
<Route path="/versus/lobby/:matchId" element={<VersusLobby />} />
<Route path="/versus/game" element={<VersusGame />} />
<Route path="/versus/async-game/:matchId" element={<AsyncVersusGame />} />
<Route path="/versus/async/:matchId/leaderboard" element={<AsyncMatchLeaderboard />} />
<Route path="/achievements" element={<Achievements />} />
<Route path="/endless" element={<Endless />} />
<Route path="/custom" element={<CustomGame />} />
<Route path="/game-night" element={<GameNight />} />
<Route path="/game-night/lobby/:roomId" element={<GameNightLobby />} />
<Route path="/game-night/play" element={<GameNightPlay />} />
<Route path="/game-night/:roomId/results" element={<GameNightResults />} />
<Route path="/game/:id/review" element={<GameReview />} />
<Route path="/friends" element={<Friends />} />
<Route path="/friends/add" element={<AddFriend />} />
<Route path="/friends/chat/:friendId" element={<Chat />} />
<Route path="/admin" element={<Admin />} />
<Route path="*" element={<NotFound />} />
</Routes>
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/game" element={<Game />} />
<Route path="/daily" element={<DailyChallenge />} />
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/login" element={<Login />} />
<Route path="/versus" element={<Versus />} />
<Route path="/versus/lobby/:matchId" element={<VersusLobby />} />
<Route path="/versus/game" element={<VersusGame />} />
<Route
path="/versus/async-game/:matchId"
element={<AsyncVersusGame />}
/>
<Route
path="/versus/async/:matchId/leaderboard"
element={<AsyncMatchLeaderboard />}
/>
<Route path="/achievements" element={<Achievements />} />
<Route path="/endless" element={<Endless />} />
<Route path="/custom" element={<CustomGame />} />
<Route path="/game-night" element={<GameNight />} />
<Route
path="/game-night/lobby/:roomId"
element={<GameNightLobby />}
/>
<Route path="/game-night/play" element={<GameNightPlay />} />
<Route
path="/game-night/:roomId/results"
element={<GameNightResults />}
/>
<Route path="/game/:id/review" element={<GameReview />} />
<Route path="/friends" element={<Friends />} />
<Route path="/friends/add" element={<AddFriend />} />
<Route path="/friends/chat/:friendId" element={<Chat />} />
<Route path="/admin" element={<Admin />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</BrowserRouter>
)
);
}
+37
View File
@@ -4,6 +4,18 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// Vendor groups for manualChunks. Each changes on its own cadence, so each
// gets its own cache entry.
const REACT = new Set(['react', 'react-dom', 'scheduler', 'use-sync-external-store']);
const NET = new Set([
'axios',
'socket.io-client',
'socket.io-parser',
'engine.io-client',
'engine.io-parser',
]);
const UI = new Set(['lucide-react', 'sonner', 'tailwind-merge', 'clsx', 'class-variance-authority']);
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
@@ -11,6 +23,31 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
build: {
rollupOptions: {
output: {
// Vendor code is split into chunks that change only when a dependency
// does. Without this, every deploy of app code changed the hash of the
// one chunk that also held React, the router and the UI kit, and every
// returning visitor re-downloaded ~170 KB gzipped of libraries that had
// not changed. The first load is not smaller - the dependency set is
// the load - but the second visit after a deploy fetches only the app
// chunk. Route code is split separately via React.lazy in App.tsx.
manualChunks(id) {
// Vite normalises module ids to forward slashes on every platform.
const pkg = /\/node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(id)?.[1];
if (!pkg) return undefined;
if (REACT.has(pkg)) return 'vendor-react';
if (pkg === 'react-router') return 'vendor-router';
if (NET.has(pkg)) return 'vendor-net';
if (UI.has(pkg) || pkg.startsWith('@base-ui/') || pkg.startsWith('@floating-ui/')) {
return 'vendor-ui';
}
return 'vendor';
},
},
},
},
server: {
// Bind-mounted source on Docker Desktop (Windows/macOS) does not deliver
// inotify events, so Vite never sees host edits and silently serves stale