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 + }, + }, + }, }, } })