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' }, }, },