Merge pull request 'Route-level code splitting for faster mobile first paint (#106)' (#113) from feat/code-splitting into main
Build image / build-and-push (push) Successful in 17s

This commit was merged in pull request #113.
This commit is contained in:
2026-07-22 06:18:22 +00:00
3 changed files with 70 additions and 7 deletions
+5 -2
View File
@@ -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 { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
import { Toaster } from '@/components/ui/toast' import { Toaster } from '@/components/ui/toast'
import { useLogout, useMe } from '@/lib/auth' 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', showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6',
)} )}
> >
<Outlet /> {/* Boundary for the lazily-loaded routes (see router.tsx). */}
<Suspense fallback={<p className="p-6 text-sm text-muted">Loading</p>}>
<Outlet />
</Suspense>
</main> </main>
{showBottomNav && <BottomNav sections={visibleSections} />} {showBottomNav && <BottomNav sections={visibleSections} />}
+44 -5
View File
@@ -1,3 +1,4 @@
import { lazy, type ComponentType } from 'react'
import { import {
createRootRouteWithContext, createRootRouteWithContext,
createRoute, createRoute,
@@ -9,17 +10,55 @@ import { AppShell } from '@/components/layout/AppShell'
import { NotFound } from '@/components/NotFound' import { NotFound } from '@/components/NotFound'
import { RouteError } from '@/components/RouteError' import { RouteError } from '@/components/RouteError'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { GardensPage } from '@/pages/GardensPage' 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 { meQueryOptions } from '@/lib/auth'
import { queryClient } from '@/lib/queryClient' import { queryClient } from '@/lib/queryClient'
import { safeRedirectPath } from '@/lib/redirect' import { safeRedirectPath } from '@/lib/redirect'
import { getLastGardenId } from '@/lib/lastGarden' 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 = 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 { interface RouterContext {
queryClient: QueryClient queryClient: QueryClient
} }
+21
View File
@@ -29,6 +29,27 @@ export default defineConfig(({ mode }) => {
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: true, 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'
},
},
},
}, },
} }
}) })