Route-level code splitting for faster mobile first paint (#106)
The whole app shipped in one 578 KB chunk, so a phone on cell data
downloaded and parsed everything — the canvas editor, gestures, geometry,
every page — before the login screen could paint.
- Lazy-load the heavy/deep routes via React.lazy: the editor (its
GardenCanvas + use-gesture + geometry are the biggest surface), the
public garden view, plants, settings, register. Login and the gardens
list stay eager (entry points — no fallback flash on landing). AppShell
wraps <Outlet> in a Suspense boundary.
- One `vendor` manualChunk for all node_modules so the rarely-changing
libraries cache across app deploys while the tiny app chunk churns.
Kept as a SINGLE chunk deliberately: splitting react-dom/scheduler into
their own chunk reorders module init across chunk boundaries and breaks
React 19 at load ("Cannot set 'Activity' of undefined") — verified that
failure and backed it out.
Result: app entry chunk 578 KB → 39 KB; vendor 421 KB (cached); the editor
(43 KB) + canvas (17 KB) only download when you open a garden. No more
>500 KB chunk warning.
Verified live against the embedded binary: /gardens loads with only
index+vendor; opening a garden lazy-fetches the editor chunk and renders;
console clean; the embed serves the hashed split chunks + SPA fallback fine.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { Toaster } from '@/components/ui/toast'
|
||||
import { useLogout, useMe } from '@/lib/auth'
|
||||
@@ -93,7 +93,10 @@ export function AppShell() {
|
||||
showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6',
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
{/* Boundary for the lazily-loaded routes (see router.tsx). */}
|
||||
<Suspense fallback={<p className="p-6 text-sm text-muted">Loading…</p>}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
{showBottomNav && <BottomNav sections={visibleSections} />}
|
||||
|
||||
+20
-5
@@ -1,3 +1,4 @@
|
||||
import { lazy } from 'react'
|
||||
import {
|
||||
createRootRouteWithContext,
|
||||
createRoute,
|
||||
@@ -9,13 +10,27 @@ import { AppShell } from '@/components/layout/AppShell'
|
||||
import { NotFound } from '@/components/NotFound'
|
||||
import { RouteError } from '@/components/RouteError'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { RegisterPage } from '@/pages/RegisterPage'
|
||||
import { GardensPage } from '@/pages/GardensPage'
|
||||
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
||||
import { PublicGardenPage } from '@/pages/PublicGardenPage'
|
||||
import { PlantsPage } from '@/pages/PlantsPage'
|
||||
import { SettingsPage } from '@/pages/SettingsPage'
|
||||
import { meQueryOptions } from '@/lib/auth'
|
||||
|
||||
// Code-split the heavier / deeper routes so a phone on cell data doesn't download
|
||||
// the whole app (notably the canvas editor with its gesture + geometry deps)
|
||||
// before the first screen paints. Login and the gardens list — the entry points —
|
||||
// stay eager to avoid a fallback flash on landing; AppShell wraps the Outlet in a
|
||||
// Suspense boundary for the rest.
|
||||
const GardenEditorPage = lazy(() =>
|
||||
import('@/pages/GardenEditorPage').then((m) => ({ default: m.GardenEditorPage })),
|
||||
)
|
||||
const PublicGardenPage = lazy(() =>
|
||||
import('@/pages/PublicGardenPage').then((m) => ({ default: m.PublicGardenPage })),
|
||||
)
|
||||
const PlantsPage = lazy(() => import('@/pages/PlantsPage').then((m) => ({ default: m.PlantsPage })))
|
||||
const SettingsPage = lazy(() =>
|
||||
import('@/pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
|
||||
)
|
||||
const RegisterPage = lazy(() =>
|
||||
import('@/pages/RegisterPage').then((m) => ({ default: m.RegisterPage })),
|
||||
)
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { safeRedirectPath } from '@/lib/redirect'
|
||||
import { getLastGardenId } from '@/lib/lastGarden'
|
||||
|
||||
@@ -29,6 +29,20 @@ export default defineConfig(({ mode }) => {
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// One vendor chunk for ALL node_modules: it's rarely-changing, so it
|
||||
// caches across app deploys while the tiny app chunk churns. Kept as a
|
||||
// SINGLE chunk on purpose — splitting react-dom/scheduler into their own
|
||||
// chunk reorders their module init across chunk boundaries and breaks
|
||||
// React 19 at load ("Cannot set 'Activity' of undefined"). The heavy
|
||||
// routes are code-split separately via React.lazy (router.tsx), which is
|
||||
// where the real first-paint win is.
|
||||
manualChunks(id) {
|
||||
return id.includes('node_modules') ? 'vendor' : undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user