This document describes how code-splitting works in the Callora frontend, the
contract every page route must satisfy, and the exact steps to add a new lazy
route. It exists because a page added with only a lazy() import still works,
but silently misses prefetching — which is the whole point of the strategy.
| Concern | File |
|---|---|
Lazy page imports, prefetch map, Suspense boundary |
src/App.tsx |
Manual entry router (skeletons + startRouteLoading) |
src/main.tsx |
Loading state + RouteProgressBar |
src/hooks/useRouteLoading.ts, src/components/RouteProgressBar.tsx |
| Regression coverage | src/RouteSplitting.test.tsx |
Two routers exist on purpose:
src/App.tsxis the React Router app.main.tsxrenders it for the default case and for any path outside the four it handles itself.src/main.tsxowns a small manual router for/publish,/marketplace,/details/*, and/latency-chart. Those paths render a skeleton,await import(...), render the real page, and bracket the swap withstartRouteLoading()/stopRouteLoading().
If a route is served by the manual router, adding it to the App.tsx prefetch
map alone is not enough — main.tsx must know about the path too.
Every heavy page is a named lazy() binding at the top of App.tsx:
const MarketplacePage = lazy(() => import("./pages/MarketplacePage"));
const DashboardPage = lazy(() => import("./pages/DashboardPage"));
// ...one binding per pageAll of them are rendered from a single Suspense boundary that wraps the
<Routes> element:
<Suspense fallback={<div className="route-loading-fallback" aria-busy="true" aria-label="Loading page" style={{ minHeight: "300px" }} />}>
<Routes>{/* ... */}</Routes>
</Suspense>Because the boundary is shared, the chunk for a route is only requested when that route first renders. Hovering or focusing a nav link is what removes that first-render delay.
prefetchRoute is fed by routePrefetchers, a plain map from exact path to
dynamic import:
const routePrefetchers: Record<string, () => Promise<any>> = {
"/marketplace": () => import("./pages/MarketplacePage"),
"/dashboard": () => import("./pages/DashboardPage"),
// ...
};
export function prefetchRoute(path: string) {
const prefetcher = routePrefetchers[path];
if (prefetcher) {
prefetcher().catch(() => {
// Ignore prefetch failures gracefully
});
}
}Rules that follow from the implementation:
- Keys are exact paths, not route patterns.
prefetchRoute("/dashboard")hits;prefetchRoute("/dashboard/")andprefetchRoute("/dashboard?tab=1")do not. Keep keys identical to theAPP_ROUTESstring they mirror. - Unknown paths are a silent no-op. This is deliberate: callers may pass
routes that are not prefetchable (for example
/non-existent-routeinRouteSplitting.test.tsx) without needing a guard. - Failures are swallowed. A rejected chunk request must never surface as an unhandled rejection; the real navigation still loads the chunk normally.
- The map and the
lazy()bindings are separate. Adding alazy()import does not register a prefetcher. Both must be updated. - Not every lazy page is prefetchable.
InvoiceCardis lazily imported but has no nav link of its own, so it is intentionally absent from the map.
The map is consumed by six nav links, all of which prefetch on both pointer and keyboard intent:
<NavLink
to={APP_ROUTES.dashboard}
onMouseEnter={() => prefetchRoute(APP_ROUTES.dashboard)}
onFocus={() => prefetchRoute(APP_ROUTES.dashboard)}
>
Dashboard
</NavLink>Both handlers are required: onMouseEnter covers pointer users and onFocus
covers keyboard users, so the optimisation does not create a keyboard-only
penalty.
- There is exactly one
Suspenseboundary around the router, not one per route. Adding a second boundary changes the perceived transition and should be avoided. - The fallback is intentionally non-visual: a
route-loading-fallbackelement witharia-busy="true",aria-label="Loading page", and a 300 px minimum height so the footer does not jump. - Do not put user-facing copy in the fallback. Route transitions are announced through the progress bar instead, so screen readers do not read placeholder text twice.
- Rendering the fallback always flips
aria-busy;RouteProgressBaris the visible indicator.
src/hooks/useRouteLoading.ts owns a counter and two custom events
(rl-start / rl-end):
startRouteLoading()/stopRouteLoading()dispatch the events.useRouteLoading()subscribes and returnstruewhile at least one load is in flight (the counter handles overlapping loads).RouteProgressBarrenders arole="progressbar"bar while loading, honoursprefers-reduced-motion(0 ms vs. 240 ms exit delay), and is hidden from print viano-print.
The manual router in main.tsx is the only caller of
startRouteLoading/stopRouteLoading. React Router routes do not emit these
events: their transition is Suspense-driven, which is why the progress bar is
rendered in App.tsx as well and stays idle for pure client-side navigation.
-
Create the page under
src/pages/. -
Add a
lazy()binding at the top ofsrc/App.tsx, next to the others:const MyPage = lazy(() => import("./pages/MyPage"));. -
Add the path to
APP_ROUTESso the string has a single source of truth. -
Add a prefetcher entry whose key is that exact
APP_ROUTESvalue:[APP_ROUTES.myPage]: () => import("./pages/MyPage"),. -
Render it inside the existing
Suspenseboundary with a<Route path={APP_ROUTES.myPage} element={<MyPage />} />. Do not add a new boundary. -
Add a
NavLinkwithonMouseEnterandonFocusif the page is reachable from the primary navigation, so it is prefetched like its neighbours. -
If the path needs a skeleton or is served by the manual router, add the branch to
src/main.tsxand bracket theawait import(...)withstartRouteLoading()/stopRouteLoading(). -
Run the regression suite:
npm test -- --run src/RouteSplitting.test.tsx
src/RouteSplitting.test.tsx renders the real App inside its providers and
asserts the strategy keeps working:
- the landing route renders without a loading fallback delay;
Plan Badge,Webhook Deliveries,Rate Limit Card, andTheme Playgroundresolve inside theSuspenseboundary;- hovering and focusing nav links triggers prefetching without throwing;
prefetchRouteis a no-op for an unknown path;- rapid route transitions settle on the final route with no error state.
Add a case there when you add a new prefetched route — it is the guard against a page that loads but never prefetches.