From 99798db8f632e8af14451809aa9f49165c97cfcc Mon Sep 17 00:00:00 2001
From: Steve Dudenhoeffer
Date: Wed, 22 Jul 2026 02:07:50 -0400
Subject: [PATCH 1/2] 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
+ },
+ },
+ },
},
}
})
From 79df0df53fdb1cbac961ba3c537f5be4bd0f14f1 Mon Sep 17 00:00:00 2001
From: Steve Dudenhoeffer
Date: Wed, 22 Jul 2026 02:17:55 -0400
Subject: [PATCH 2/2] Address code-split review: chunk-load recovery + tidier
lazy + gesture split
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Gadfly on #106:
- (2 models) React.lazy memoizes a REJECTED import, so after a deploy (every
main push) a still-open app references chunk hashes the server just
replaced — the import 404s and RouteError's "Try again" can never recover.
New lazyPage() helper reloads once on a chunk-load failure to fetch fresh
hashes (session-flag guarded against a reload loop; cleared on success).
- (perf) @use-gesture is editor-only, but the single vendor chunk pulled it
into the eager first paint. Exclude it from vendor so it rides with the
lazy editor chunk (vendor 421→392 KB; the gesture code moved into the
editor's own chunk). react/react-dom/tanstack still share one vendor chunk
— splitting react-dom out is what broke React 19 at load.
- (nits) lazyPage also unwraps the named export, so the five route lazies are
uniform one-liners; moved the lazy block below the import group.
Re-verified live: app mounts, /gardens/1 lazy-loads + renders the editor,
console clean. tsc + build green, no >500 KB warning.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
---
web/src/router.tsx | 58 ++++++++++++++++++++++++++++++++--------------
web/vite.config.ts | 9 ++++++-
2 files changed, 49 insertions(+), 18 deletions(-)
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 2935fa1..eb240c5 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -1,4 +1,4 @@
-import { lazy } from 'react'
+import { lazy, type ComponentType } from 'react'
import {
createRootRouteWithContext,
createRoute,
@@ -12,28 +12,52 @@ import { RouteError } from '@/components/RouteError'
import { LoginPage } from '@/pages/LoginPage'
import { GardensPage } from '@/pages/GardensPage'
import { meQueryOptions } from '@/lib/auth'
+import { queryClient } from '@/lib/queryClient'
+import { safeRedirectPath } from '@/lib/redirect'
+import { getLastGardenId } from '@/lib/lastGarden'
+
+// Lazily load a page by its named export, with recovery for the stale-chunk
+// problem. A push to main redeploys, so a still-open app references chunk hashes
+// the server has just replaced; that import 404s and React.lazy MEMOIZES the
+// rejection, so RouteError's "Try again" (router.invalidate) can never recover —
+// the user is stuck until a manual hard reload. On the first such failure we
+// reload once (fetching the fresh index + hashes); a session flag stops a reload
+// loop, and a success clears it so a later genuine failure can reload again.
+function lazyPage(load: () => Promise, name: K) {
+ return lazy(async () => {
+ try {
+ const mod = await load()
+ try {
+ sessionStorage.removeItem('pansy:chunk-reload')
+ } catch {
+ /* storage unavailable — fine */
+ }
+ return { default: mod[name] as ComponentType }
+ } catch (err) {
+ try {
+ if (!sessionStorage.getItem('pansy:chunk-reload')) {
+ sessionStorage.setItem('pansy:chunk-reload', '1')
+ window.location.reload()
+ return await new Promise<{ default: ComponentType }>(() => {}) // hold for the reload
+ }
+ } catch {
+ /* storage unavailable — fall through to surface the error */
+ }
+ throw err // already reloaded once (or can't); let the error boundary show it
+ }
+ })
+}
// 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'
+const GardenEditorPage = lazyPage(() => import('@/pages/GardenEditorPage'), 'GardenEditorPage')
+const PublicGardenPage = lazyPage(() => import('@/pages/PublicGardenPage'), 'PublicGardenPage')
+const PlantsPage = lazyPage(() => import('@/pages/PlantsPage'), 'PlantsPage')
+const SettingsPage = lazyPage(() => import('@/pages/SettingsPage'), 'SettingsPage')
+const RegisterPage = lazyPage(() => import('@/pages/RegisterPage'), 'RegisterPage')
interface RouterContext {
queryClient: QueryClient
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 88fe190..c90cfc7 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -39,7 +39,14 @@ export default defineConfig(({ mode }) => {
// 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
+ if (!id.includes('node_modules')) return undefined
+ // Editor-only libs (the gesture engine) ride with the lazy editor
+ // chunk instead of the eager vendor one, so the first paint doesn't
+ // pay for code only the canvas needs. Everything else — react, tanstack
+ // and the rest — stays in ONE vendor chunk; splitting react-dom out
+ // reorders its init across chunks and breaks React 19 at load.
+ if (id.includes('@use-gesture')) return undefined
+ return 'vendor'
},
},
},