Skip to main content

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 own PortalProtectedRoute, because storefront customers are not ERP users.
  • SuperAdminLayout — the platform operator. The route tree explains why this is not just AppLayout with 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:

  1. Add the path to ROUTES — with a doc comment if the path is an exception to anything.
  2. Add a lazy(() => import('…')) line to the matching module block in App.tsx.
  3. 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.
  4. <Route path={ROUTES.YOUR_PAGE} element={page(YourPage)} /> — always through page().

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​

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 in App.tsx).
  • A stale chunk reloads itself exactly once — CHUNK_ERROR_PATTERN plus a one-shot sessionStorage key 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 SuperAdminLayout instead of a stripped AppLayout.
  • 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 every navigate() call share one definition, so a rename is one edit and a typo is a compile error.

Next: 03 — Who can see what: the permission system.