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..eb240c5 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,3 +1,4 @@ +import { lazy, type ComponentType } from 'react' import { createRootRouteWithContext, createRoute, @@ -9,17 +10,55 @@ 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' 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 = 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 b92a007..c90cfc7 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -29,6 +29,27 @@ 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) { + 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' + }, + }, + }, }, } })