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