02 — App.tsx decoded: a hundred routes, four shells
Read this first: you will read the real route table — one 400-line file that maps every URL in
MotorPH to a page, a shell, and a guard, and you will find that the interesting decisions are hiding
in a five-line helper and a handful of comments. By the end you can add a route to the correct shell
and explain, with the incident behind it, why every single route goes through page().
Time: about 25 minutes. Assumes lesson 01.
A hundred pages, each its own chunk
Open frontend/src/App.tsx. The top third is not routing at all — it is about a hundred lazy imports, grouped by module:
// hr & admin
const Dashboard = lazy(() => import('./pages/hr/Dashboard'));
const Employees = lazy(() => import('./pages/hr/Employees'));
const HrAnalytics = lazy(() => import('./pages/hr/HrAnalytics'));
// … one block per module: payroll, recruitment, marketing, crm, inventory, portal
Each lazy(() => import('…')) makes Vite cut that page into its own hashed chunk. The payoff is
who downloads what: an anonymous visitor on /pricing fetches the marketing chunk and never touches
payroll code; an employee opening /my-payslips never downloads the CRM. With this many pages, one
eager bundle would front-load the entire ERP onto the login screen.
One import breaks the pattern: GovernmentForms at the very top is eager, and nothing in the file
says why. Treat it as a gap — lesson 22 collects those — not as a
second convention.
Every route carries its own fallback
Every element= in the tree goes through the same helper. Its comment is the reason the helper
exists:
/**
* Gives each lazy-loaded route its own Suspense boundary. A single Suspense
* wrapping the whole <Routes> tree can leave the previous route's content on
* screen after the URL changes (React Router v7 + React.lazy transition
* quirk) - per-route boundaries keep navigation responsive.
*/
const page = (Component: ComponentType) => (
<RouteErrorBoundary>
<Suspense fallback={<LoadingFallback />}>
<Component />
</Suspense>
</RouteErrorBoundary>
);
Unpack the quirk. A lazy component suspends while its chunk downloads. With one global
<Suspense> around the whole <Routes> tree, React Router v7 treats the navigation as a
transition: instead of showing the fallback, it keeps the committed tree — the page you came from —
on screen until the new chunk resolves. The URL changes, the screen does not, and on a slow
connection the app reads as dead. A boundary per route opts each navigation out of that: the old
page unmounts, LoadingFallback renders exactly where the new page will be, and the shell around it
(header, sidebar) never blinks.
Note what wraps what: RouteErrorBoundary sits outside the Suspense. Suspense handles loading;
it does nothing for failure. That ordering is the next section.
When the chunk itself is gone
Predict: you deploy. The build replaces every hashed chunk file on the server — old hashes are
gone. A user has had the app open since before the deploy, and their loaded index.html still
points at the old hashes. They click a sidebar link to a page they have not visited yet, and the
browser's request for that chunk 404s. What renders with page() in place — and what would render
without it? Write your answer down before reading on.
The answer is in frontend/src/ui/components/route-error-boundary.tsx, whose own comment states the stakes:
const CHUNK_ERROR_PATTERN =
/Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/i;
// Suspense handles the loading state for lazy routes but does not catch
// errors - a stale chunk reference after a rebuild (or any render error)
// would otherwise unmount the whole tree to a blank screen with no fallback.
export class RouteErrorBoundary extends Component<Props, State> {
state: State = { status: 'ok' };
static getDerivedStateFromError(error: Error): State {
if (CHUNK_ERROR_PATTERN.test(error.message ?? '')) {
const key = `motorph.chunkRetry:${window.location.pathname}`;
if (!sessionStorage.getItem(key)) {
sessionStorage.setItem(key, '1');
return { status: 'reloading' };
}
}
return { status: 'error' };
}
/* … componentDidCatch calls window.location.reload() while 'reloading' … */
Without page(): the import failure is a render-phase error, it propagates past Suspense (which
does not catch errors) to the root, and React unmounts everything — a blank white screen, no
message, no recovery. With page(): the boundary recognizes the failure as chunk-shaped, records a
one-shot key in sessionStorage, and reloads the page. The reload fetches the new index.html
with the new hashes, so the user lands on the page they clicked, one flash later. The
sessionStorage key is the safety catch — if the error is not deploy skew and the reload does not
fix it, the second pass falls through to status: 'error' and a "Something went wrong / Reload"
screen, instead of reloading forever.
Four shells, and the pages that get none
A shell is a pathless <Route element={<SomeLayout />}> whose layout renders an <Outlet /> where
child routes appear. The shell mounts once; pages swap inside it. Here is the slot in
frontend/src/ui/layout/app-layout.tsx:
{/* Main content */}
<Box
as="main"
id="main-content"
/* … margin for the sidebar, padding for the header … */
>
<Box maxW="1400px" mx="auto" px={{ base: 4, md: 8 }} py={8}>
<Outlet />
</Box>
</Box>
There are four shells, split by audience, not by module:
AppLayout— the ERP: global header, permission-filtered sidebar, notification badge. Every staff-facing page lives here.PublicLayout— marketing:PublicHeader, footer, the Chatwoot bubble. It also carries a scroll fix with its reason attached:// SPA navigation preserves scroll position; marketing pages should start at the top.PortalLayout— the customer storefront (/portal/*), with its own auth store and its ownPortalProtectedRoute, because storefront customers are not ERP users.SuperAdminLayout— the platform operator. The route tree explains why this is not justAppLayoutwith fewer links:
{/* The platform operator's area. Its own shell, not the ERP one: a platform account
belongs to no tenant, so the app shell's notifications, saved views and navigation
preferences -- all tenant-owned -- have nothing to attach to. */}
<Route element={<RequirePlatform />}>
<Route element={<SuperAdminLayout />}>
<Route path={ROUTES.SUPER_ADMIN_TENANTS} element={page(Tenants)} />
</Route>
</Route>
And three routes get no shell at all — LOGIN, SIGNUP, CHANGE_PASSWORD — each for a stated
reason:
{/* Outside every layout, like LOGIN: on success it navigates straight into the ERP shell,
and a marketing header would flash on the way through. */}
<Route path={ROUTES.SIGNUP} element={page(Signup)} />
The nesting is the security order
The whole tree, as a shape:
<BrowserRouter> → <Routes>
├── /login, /signup ..................... no shell, no auth
├── <ProtectedRoute> .................... login required
│ ├── /change-password ................ still no shell (see below)
│ ├── <RequirePlatform>
│ │ └── <SuperAdminLayout> .......... /admin/tenants
│ └── <AppLayout> ..................... the ERP shell
│ └── <RequirePermission …>
│ └── page(Employees) ......... /employees
├── <PortalLayout> ...................... /portal/*, own auth inside
├── <PublicLayout> ...................... /, /pricing, /careers, …
└── * ................................... page(NotFound)
Auth comes before the shell, the shell before the permission, the permission before the page.
ProtectedRoute is small enough to read whole — and its one comment justifies where the
mustChangePassword check lives:
if (!isAuthenticated) {
return (
<Navigate
to={ROUTES.LOGIN}
replace
state={{ from: location.pathname + location.search }}
/>
);
}
// Checked here rather than only after logging in, because the session survives a reload: the
// stored user carries the flag, so someone who closed the tab on the change-password screen would
// otherwise come back to a full app in which every request is refused.
if (user?.mustChangePassword && location.pathname !== ROUTES.CHANGE_PASSWORD) {
return <Navigate to={ROUTES.CHANGE_PASSWORD} replace />;
}
That is also why /change-password sits inside ProtectedRoute but outside AppLayout: a shell
whose every request the API refuses must never get the chance to mount.
Inside AppLayout, pages are grouped under pathless <RequirePermission> routes — one permission
(or an any-of array) gating a batch of siblings. How permissions themselves work is
lesson 03; here, only notice that exceptions are documented where they
happen:
{/* Deliberately outside RequirePermission: this is where the payment provider drops
the customer back, and a permission miss redirects to /dashboard silently, which
would swallow the checkout return. ProtectedRoute still requires a login. */}
<Route path={ROUTES.BILLING_SUCCESS} element={page(BillingSuccess)} />
Paths live in one file
No route path is an inline string. Every one comes from frontend/src/constants/routes.ts:
export const ROUTES = {
LOGIN: '/login',
/** Public self-serve workspace creation. Unauthenticated, like LOGIN. */
SIGNUP: '/signup',
CHANGE_PASSWORD: '/change-password',
DASHBOARD: '/dashboard',
// …
PAYROLL_RUNS: '/payroll-runs',
PAYROLL_RUN_DETAIL: '/payroll-runs/:id',
// …
} as const;
The reason is the number of consumers. App.tsx matches these paths; the sidebar's
nav-config.ts links to them; guards redirect to them;
pages call navigate(ROUTES.…). With inline strings, renaming a URL means grepping for a
string and hoping; with one constant, the rename is one edit and every consumer follows. A typo
(ROUTES.EMPLOYES) is a compile error instead of a silent 404, and as const keeps each value a
literal type so route params like :id stay visible at the one place they are defined.
Adding a route
The whole capability, in four moves:
- Add the path to
ROUTES— with a doc comment if the path is an exception to anything. - Add a
lazy(() => import('…'))line to the matching module block inApp.tsx. - Pick the shell by audience: staff page → inside
<AppLayout>, under the<RequirePermission>group that matches its permission (or a new group); storefront →PortalLayout; anonymous →PublicLayout. <Route path={ROUTES.YOUR_PAGE} element={page(YourPage)} />— always throughpage().
The full page-building recipe (grid, hooks, backend) is Build a module page; the exhaustive route-tree reference is ../frontend/routing.md.
Where this shows up in MotorPH
- frontend/src/App.tsx — the route table itself
- frontend/src/constants/routes.ts — every path constant
- frontend/src/ui/components/route-error-boundary.tsx — the chunk-skew reload
- frontend/src/ui/components/protected-route.tsx — auth gate + forced password change
- The four shells: app-layout.tsx, public-layout.tsx, portal-layout.tsx, super-admin-layout.tsx
- frontend/src/ui/layout/nav-config.ts — the sidebar,
the second-biggest consumer of
ROUTES - ../frontend/routing.md — the reference version of this lesson
Recap
- Every page is a lazy import wrapped in
page()— its own chunk, its own Suspense, its own error boundary, because one global Suspense leaves the previous route on screen during navigation (React Router v7 +React.lazy, per the comment inApp.tsx). - A stale chunk reloads itself exactly once —
CHUNK_ERROR_PATTERNplus a one-shotsessionStoragekey turns deploy skew into a single page refresh instead of a blank screen or a reload loop. - Pick the shell by audience, not by module — shells carry audience-owned state (tenant
notifications, portal cart, marketing chrome), which is why the platform operator gets
SuperAdminLayoutinstead of a strippedAppLayout. - The nesting order is the security order —
ProtectedRoute→ shell →RequirePermission→page(), and every deliberate exception (/change-password,/billing/success) has its reason in a comment at the route. - Paths live only in
ROUTES— the router, the sidebar, and everynavigate()call share one definition, so a rename is one edit and a typo is a compile error.