Route-level code splitting for faster mobile first paint (#106) #113
+41
-17
@@ -1,4 +1,4 @@
|
|||||||
import { lazy } from 'react'
|
import { lazy, type ComponentType } from 'react'
|
||||||
import {
|
import {
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
createRoute,
|
createRoute,
|
||||||
@@ -12,28 +12,52 @@ import { RouteError } from '@/components/RouteError'
|
|||||||
import { LoginPage } from '@/pages/LoginPage'
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
import { GardensPage } from '@/pages/GardensPage'
|
import { GardensPage } from '@/pages/GardensPage'
|
||||||
import { meQueryOptions } from '@/lib/auth'
|
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
|
// 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)
|
// 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 —
|
// 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
|
// stay eager to avoid a fallback flash on landing; AppShell wraps the Outlet in a
|
||||||
// Suspense boundary for the rest.
|
// Suspense boundary for the rest.
|
||||||
const GardenEditorPage = lazy(() =>
|
const GardenEditorPage = lazyPage(() => import('@/pages/GardenEditorPage'), 'GardenEditorPage')
|
||||||
import('@/pages/GardenEditorPage').then((m) => ({ default: m.GardenEditorPage })),
|
const PublicGardenPage = lazyPage(() => import('@/pages/PublicGardenPage'), 'PublicGardenPage')
|
||||||
)
|
const PlantsPage = lazyPage(() => import('@/pages/PlantsPage'), 'PlantsPage')
|
||||||
const PublicGardenPage = lazy(() =>
|
const SettingsPage = lazyPage(() => import('@/pages/SettingsPage'), 'SettingsPage')
|
||||||
import('@/pages/PublicGardenPage').then((m) => ({ default: m.PublicGardenPage })),
|
const RegisterPage = lazyPage(() => import('@/pages/RegisterPage'), 'RegisterPage')
|
||||||
)
|
|
||||||
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'
|
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
|
|||||||
+8
-1
@@ -39,7 +39,14 @@ export default defineConfig(({ mode }) => {
|
|||||||
// routes are code-split separately via React.lazy (router.tsx), which is
|
// routes are code-split separately via React.lazy (router.tsx), which is
|
||||||
// where the real first-paint win is.
|
// where the real first-paint win is.
|
||||||
manualChunks(id) {
|
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'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user
🟠 manualChunks forces editor-only deps (@use-gesture, geometry) into the eager vendor chunk, defeating the first-paint split for those libraries
performance · flagged by 1 model
web/vite.config.ts:41-43— the gesture/geometry deps the PR wants off the first-paint path are actually pulled into it. ThemanualChunks(id)function unconditionally returns'vendor'for everynode_modulesmodule, and that vendor chunk is on the critical path for every route (including the eager/loginand/gardens). I verified that@use-gesture/react(the dep the PR description names as the biggest surface to keep lazy) is only referenced from `web/src/editor/useView…🪰 Gadfly · advisory