From 99798db8f632e8af14451809aa9f49165c97cfcc Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 02:07:50 -0400 Subject: [PATCH] Route-level code splitting for faster mobile first paint (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- web/src/components/layout/AppShell.tsx | 7 +++++-- web/src/router.tsx | 25 ++++++++++++++++++++----- web/vite.config.ts | 14 ++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) 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 + }, + }, + }, }, } })