Address code-split review: chunk-load recovery + tidier lazy + gesture split
Build image / build-and-push (push) Successful in 10s

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-22 02:17:55 -04:00
co-authored by Claude Opus 4.8
parent 99798db8f6
commit 79df0df53f
2 changed files with 49 additions and 18 deletions
+41 -17
View File
@@ -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<M, K extends keyof M>(load: () => Promise<M>, 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
+8 -1
View File
@@ -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'
},
},
},