Skip to content

Commit 93ee96b

Browse files
perf: lazy-load /app routes to shrink marketing bundle (#19)
Marketing visitors landing on the homepage shouldn't pay the JS cost of the auth-gated dashboard. Converts the 12 /app/* page components and the AppShell layout to React.lazy, splitting them into per-page chunks that only load after AuthGate passes. Public marketing routes, auth routes, and the prerender route list are unchanged. Bundle: main entry 764 KB → 682 KB, with 14 new chunks (AppShell 9.6 KB, 12 pages 2.5-18 KB each, shared ctx 1.5 KB) loaded on demand. SSR prerender still emits 115 HTML files since /app/* was never in the prerender list. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 99ece28 commit 93ee96b

1 file changed

Lines changed: 91 additions & 17 deletions

File tree

src/App.tsx

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { lazy, Suspense } from 'react'
12
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
2-
import { AppShell } from './layout/AppShell'
33

4-
// Public marketing surfaces
4+
// Public marketing surfaces — eagerly imported. A marketing visitor might
5+
// click any of these from the homepage nav, so keeping them in the main
6+
// chunk avoids a network round-trip on first interaction. These are also
7+
// the routes that get statically pre-rendered by scripts/prerender.mjs.
58
import { MarketingPage } from './pages/MarketingPage'
69
import { PricingPage } from './pages/PricingPage'
710
import { ForAgentsPage } from './pages/ForAgentsPage'
@@ -12,24 +15,65 @@ import { DocsPage } from './pages/DocsPage'
1215
import { UseCasesPage } from './pages/UseCasesPage'
1316
import { UseCaseDetailPage } from './pages/UseCaseDetailPage'
1417

15-
// Auth surfaces
18+
// Auth surfaces — eagerly imported. A marketing visitor can land on /login
19+
// directly (deep link, "Sign in" button), and the login form is small.
1620
import { LoginPage } from './pages/LoginPage'
1721
import { LoginCallbackPage } from './pages/LoginCallbackPage'
1822
import { ClaimPage } from './pages/ClaimPage'
1923

20-
// Authenticated dashboard surfaces
21-
import { OverviewPage } from './pages/OverviewPage'
22-
import { ResourcesPage } from './pages/ResourcesPage'
23-
import { ResourceDetailPage } from './pages/ResourceDetailPage'
24-
import { DeploymentsPage } from './pages/DeploymentsPage'
25-
import { DeployDetailPage } from './pages/DeployDetailPage'
26-
import { StacksPage } from './pages/StacksPage'
27-
import { VaultPage } from './pages/VaultPage'
28-
import { TeamPage } from './pages/TeamPage'
29-
import { BillingPage } from './pages/BillingPage'
30-
import { SettingsPage } from './pages/SettingsPage'
31-
import { AgentPage } from './pages/AgentPage'
32-
import { ContractsPage } from './pages/ContractsPage'
24+
// Authenticated dashboard surfaces — lazy-loaded. These pages only render
25+
// behind AuthGate (token must be present), so a marketing visitor never
26+
// needs the bytes. Each React.lazy() call ends up in its own chunk; Rollup
27+
// splits these out of the main bundle and the browser fetches them on
28+
// demand when the user navigates into /app/*.
29+
//
30+
// AppShell is also lazy-loaded because it's exclusively the chrome for
31+
// /app/* — the nav rail, breadcrumbs, scope pills. A marketing visitor on
32+
// the homepage never sees it, so its ~10 KB of JSX + the useDashboardCtx
33+
// hook tree it pulls in don't need to ship in the entry bundle.
34+
//
35+
// All page components use named exports, so we adapt them to React.lazy's
36+
// default-export contract inline. The chunkName comment is a hint for
37+
// rollup so the emitted file has a recognizable name in dist/assets/.
38+
const AppShell = lazy(() =>
39+
import('./layout/AppShell').then((m) => ({ default: m.AppShell })),
40+
)
41+
const OverviewPage = lazy(() =>
42+
import(/* webpackChunkName: "app-overview" */ './pages/OverviewPage').then((m) => ({ default: m.OverviewPage })),
43+
)
44+
const ResourcesPage = lazy(() =>
45+
import('./pages/ResourcesPage').then((m) => ({ default: m.ResourcesPage })),
46+
)
47+
const ResourceDetailPage = lazy(() =>
48+
import('./pages/ResourceDetailPage').then((m) => ({ default: m.ResourceDetailPage })),
49+
)
50+
const DeploymentsPage = lazy(() =>
51+
import('./pages/DeploymentsPage').then((m) => ({ default: m.DeploymentsPage })),
52+
)
53+
const DeployDetailPage = lazy(() =>
54+
import('./pages/DeployDetailPage').then((m) => ({ default: m.DeployDetailPage })),
55+
)
56+
const StacksPage = lazy(() =>
57+
import('./pages/StacksPage').then((m) => ({ default: m.StacksPage })),
58+
)
59+
const VaultPage = lazy(() =>
60+
import('./pages/VaultPage').then((m) => ({ default: m.VaultPage })),
61+
)
62+
const TeamPage = lazy(() =>
63+
import('./pages/TeamPage').then((m) => ({ default: m.TeamPage })),
64+
)
65+
const BillingPage = lazy(() =>
66+
import('./pages/BillingPage').then((m) => ({ default: m.BillingPage })),
67+
)
68+
const SettingsPage = lazy(() =>
69+
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
70+
)
71+
const AgentPage = lazy(() =>
72+
import('./pages/AgentPage').then((m) => ({ default: m.AgentPage })),
73+
)
74+
const ContractsPage = lazy(() =>
75+
import('./pages/ContractsPage').then((m) => ({ default: m.ContractsPage })),
76+
)
3377

3478
import { getToken } from './api'
3579

@@ -42,6 +86,24 @@ function AuthGate({ children }: { children: JSX.Element }) {
4286
return children
4387
}
4488

89+
// AppLoadingFallback — shown while a lazy-loaded /app/* chunk is in flight.
90+
// Tiny inline style so it renders even before the page's own CSS resolves.
91+
// In practice this fallback is on screen for ~50-150ms on a warm cache.
92+
function AppLoadingFallback() {
93+
return (
94+
<div
95+
style={{
96+
padding: '2rem',
97+
fontFamily: 'system-ui, sans-serif',
98+
color: 'var(--text-muted, #888)',
99+
fontSize: '0.875rem',
100+
}}
101+
>
102+
Loading…
103+
</div>
104+
)
105+
}
106+
45107
// PricingPage and ForAgentsPage both wrap themselves in <PublicShell>, and
46108
// MarketingPage inlines its own nav. So routes mount the page directly —
47109
// no extra shell wrapper needed (would cause double nav rendering).
@@ -50,6 +112,12 @@ function AuthGate({ children }: { children: JSX.Element }) {
50112
// the SSR entry (src/entry-server.tsx) can mount it under <StaticRouter>
51113
// for build-time pre-rendering. The browser-side wrapper below stays the
52114
// same — this is just an extraction, no route changes.
115+
//
116+
// SSR note: scripts/prerender.mjs only renders public routes (see its
117+
// PRERENDER_ROUTES list — no /app/* paths). React.lazy resolves to a
118+
// Suspense fallback during SSR for unrendered chunks, but since SSG never
119+
// visits an /app route, the lazy components are never invoked server-side
120+
// and the build still emits 115 HTML files.
53121
export function AppRoutes() {
54122
return (
55123
<Routes>
@@ -70,11 +138,17 @@ export function AppRoutes() {
70138
<Route path="/claim" element={<ClaimPage />} />
71139

72140
{/* ─── authenticated dashboard at /app/* ─────────────────── */}
141+
{/* Suspense wraps the whole /app subtree so any lazy page below
142+
shows the same fallback while its chunk loads. We place it
143+
inside AuthGate so unauthenticated users redirect to /login
144+
without ever triggering a chunk fetch. */}
73145
<Route
74146
path="/app"
75147
element={
76148
<AuthGate>
77-
<AppShell />
149+
<Suspense fallback={<AppLoadingFallback />}>
150+
<AppShell />
151+
</Suspense>
78152
</AuthGate>
79153
}
80154
>

0 commit comments

Comments
 (0)