diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx
index e5dd25f..7e525df 100644
--- a/web/src/components/layout/AppShell.tsx
+++ b/web/src/components/layout/AppShell.tsx
@@ -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',
)}
>
-
+ {/* Boundary for the lazily-loaded routes (see router.tsx). */}
+ Loading…
}>
+
+
{showBottomNav && }
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 09429fc..2935fa1 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -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'
diff --git a/web/vite.config.ts b/web/vite.config.ts
index b92a007..88fe190 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -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
+ },
+ },
+ },
},
}
})