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:
1 parent
2bdbf5fbc3
commit
e67692006f
13 files changed
+184
-63
No files matched your search
@@ -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
@@ -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." />
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 867 KiB |
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user