Skip to content

Routing

Paulo Gomes da Cruz Junior edited this page Oct 23, 2026 · 8 revisions

Routing

The frontend uses @tanstack/react-router with file-based routing: every URL is a file under frontend/src/routes/, and the Vite plugin generates the route tree (src/routeTree.gen.ts) from the folder structure. The routing layer does four things together:

  • defines routes as files, with side-nav metadata attached to each route
  • enforces authentication, status, and role requirements through pathless layout routes
  • gates feature-flagged routes in beforeLoad by throwing a router-level 404
  • preserves deep-link redirects through the FAM login flow

Source map

File Purpose
frontend/src/routes/ Route files — one file per route, folder layout is the URL layout
frontend/src/routeTree.gen.ts Generated route tree, committed to the repo (see Vite code generation)
frontend/src/router.tsx createAppRouter(context, options) factory, the browser-history router singleton, and the single Register module augmentation
frontend/src/routerContext.ts AppRouterContext = { queryClient } — the router context type
frontend/src/routes/__root.tsx Root route: RootLayout (title sync, devtools), AuthAwareNotFound, Layout-wrapped error component
frontend/src/routes/_authenticated.tsx Pathless layout: authentication + status gate for every protected URL
frontend/src/routes/_authenticated/_admin.tsx Pathless layout: ADMIN role tier
frontend/src/routes/_authenticated/_client-access.tsx Pathless layout: VIEWER/SUBMITTER role tier
frontend/src/routes/_authenticated/$.tsx Catch-all: one-shell 404 for unknown authenticated URLs
frontend/src/routes/dashboard.tsx Post-login redirect resolver (DashboardRedirect)
frontend/src/routing/auth.ts Pure decision logic: computeAuthRedirect, hasAnyRole
frontend/src/routing/redirectStorage.ts Single sessionStorage key (redirectAfterLogin) for redirect persistence
frontend/src/routing/safeRedirect.ts getSafeRedirectTarget (open-redirect protection) and isOAuthCallback
frontend/src/routing/searchParams.ts Zod schema + validateWasteSearch for /search URL state; router-level searchParamCodec
frontend/src/routing/staticData.ts StaticDataRouteOption module augmentation and the NavTarget union
frontend/src/routing/nav/useNavEntries.ts Projects the side nav from route staticData
frontend/src/routing/nav/routeMetadata.ts collectRouteGates (gate inheritance) and resolveRouteTitle

src/routes/ holds route files only. Shared routing helpers live in src/routing/ so the route folder stays a pure mapping of files to URLs and the code generator never picks up helper modules as routes.

Request flow

flowchart TD
  Entry["main.tsx<br/>providers + <App />"] --> App["App.tsx<br/><RouterProvider router={router} />"]
  App --> Match["Router matches the URL<br/>against routeTree.gen.ts"]
  Match --> BL["beforeLoad hooks run first<br/>(feature-flag gates)"]
  BL -- "flag off → throw notFound()" --> NF["__root notFoundComponent<br/>AuthAwareNotFound"]
  BL -- "flag on (or no gate)" --> Render["React render: __root → _authenticated"]
  Render --> Auth{"_authenticated layout<br/>computeAuthRedirect"}
  Auth -- "no user, known URL" --> Login["persist pathname+search<br/>navigate to /login"]
  Auth -- "no user, catch-all match" --> LandingR["navigate to /<br/>(nothing persisted)"]
  Auth -- "no-role / role-error status" --> Status["navigate to /no-role<br/>or /unauthorized?reason=CODE"]
  Auth -- "granted" --> Tier{"Role tier for the URL"}
  Tier -- "/clients" --> CA["_client-access layout<br/>any-of VIEWER / SUBMITTER"]
  Tier -- "/configuration subtree" --> ADM["_admin layout<br/>ADMIN only"]
  Tier -- "everything else" --> Leaf
  CA -- "role denied → /unauthorized" --> Leaf
  CA -- "role granted" --> Leaf["Leaf component inside<br/><Layout><Outlet /></Layout>"]
  ADM -- "role denied → /unauthorized" --> Leaf
  ADM -- "role granted" --> Leaf
Loading

Two properties fall out of this flow:

  • beforeLoad runs before any React gate renders. A disabled, flag-gated route 404s before authentication is even checked. See Feature-flag gates for why that ordering is deliberate.
  • The shell renders once. _authenticated wraps its outlet in a single <Layout>; role tiers render a bare <Outlet/>. Page components never wrap themselves in <Layout>.

Router bootstrap

src/router.tsx exposes a factory and a singleton:

export const createAppRouter = (context: AppRouterContext, options?: { history?: RouterHistory }) =>
  createRouter({
    routeTree,
    context,
    ...routerDefaults,
    ...searchParamCodec,
    ...options,
  });

export const router = createAppRouter({ queryClient }, { history: createBrowserHistory() });
  • The factory exists for tests: unit tests pass createMemoryHistory({ initialEntries }) through options.history to exercise the real generated tree without a browser.
  • The singleton is created once at module load with browser history and the shared TanStack Query client. App.tsx mounts it with <RouterProvider router={router} />. There is no runtime route-tree switching; all conditional logic lives in layouts and beforeLoad.

Router-wide defaults

Option Value Why
defaultPendingComponent Carbon Loading overlay One global pending indicator; no per-route <Suspense> wiring
defaultPendingMs 250 Avoids a loading flash for fast transitions
defaultPendingMinMs 300 When the overlay does show, it stays up long enough to be readable
defaultPreload 'intent' Preload on hover/focus so clicks feel instant
defaultPreloadStaleTime 30_000 Preloaded data stays fresh for 30 seconds; repeated hovers do not refetch
defaultErrorComponent bare GlobalErrorPage The root and _authenticated routes supply their own Layout-wrapped error components; keeping the router-level fallback bare avoids rendering a second application shell around an error
...searchParamCodec see Search state Human-readable URL search params everywhere

Type registration

Two module augmentations make navigation and route metadata fully typed:

1. Single Register (in src/router.tsx):

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }
}

This registers the generated tree so useNavigate, Link to, and useRouterState accept only real route paths and real param names — no casts, no string typos. There must be exactly one Register in the app: the generated tree already augments the router types through its FileRoutesByPath interface, and a second Register declaration makes the two augmentations compete — the navigation to union collapses to never and every typed navigation fails to compile. The old router's separate Register was deleted at cutover for this reason; the augmentation now lives next to the router that owns it.

2. staticData typing (in src/routing/staticData.ts):

declare module '@tanstack/router-core' {
  interface StaticDataRouteOption {
    nav?: NavMeta;
    roles?: readonly Role[];
    featureFlag?: keyof FeatureFlags;
    offlineOnly?: boolean;
    offlineReady?: boolean;
    title?: string;
  }
}

The augmentation targets @tanstack/router-core, not @tanstack/react-router — the react package only re-exports the core types, so augmenting it has no effect on the route staticData property.

AppRouterContext lives in its own module (src/routerContext.ts) so route files and loaders can import the context type without creating a runtime import cycle through the generated route tree.

Root route

src/routes/__root.tsx uses createRootRouteWithContext<AppRouterContext>():

  • RootLayout keeps the document title in sync with the active route's staticData (leaf title first, then nav.label, then the nearest ancestor — via resolveRouteTitle). When nothing declares a title, the document title is left untouched. It also mounts devtools in DEV builds only.
  • notFoundComponent: AuthAwareNotFound serves the explicit throw notFound() markers (feature-flag gates, loader 404s): unauthenticated visitors are redirected to / so they can sign in; authenticated users see NotFoundPage ("Content Not Found") inside the application Layout. Unknown URLs are not handled here — the _authenticated/$ catch-all claims them first (see 404 handling).
  • errorComponent is Layout-wrapped, matching the _authenticated error component: exactly one application shell around an error page.

Authentication gate

src/routes/_authenticated.tsx is a pathless layout route: it has no URL of its own, but wraps every URL beneath it (/search, /clients, /reporting-units/*, /configuration/*). Folders and files prefixed with _ never contribute a URL segment.

The gate is a React layout, not a beforeLoad hook, because authentication state lives in React context (useAuth). A beforeLoad hook has no access to that context and would need a second, non-React snapshot of the auth state to make decisions — two sources of truth that can disagree. The layout reads the same context the rest of the app uses.

The decision logic is pure and testable in src/routing/auth.ts (computeAuthRedirect):

Condition Result
isLoading null — layout renders the Loading overlay
No user, not on /login { href: '/login' } — external Cognito login
No user, already on /login null (loop guard; login is external, re-entering would bounce)
Status no-role { href: '/no-role' }
Status role-error, not on /unauthorized { href: '/unauthorized?reason=<CODE>' }
Status role-error, already on /unauthorized null (loop guard)
Otherwise null — render the outlet

Redirects fire from useLayoutEffect (not useEffect) so the hop commits before the browser paints — no flash of gated content, matching the guard timing the previous router generation used.

Redirect persistence

When an unauthenticated user hits a known protected URL, the layout persists pathname + search to sessionStorage under redirectAfterLogin (via persistRedirectUrl in src/routing/redirectStorage.ts) and navigates to /login. The /login path itself is never persisted: the navigate fires before the layout unmounts, and the skip preserves the real intended URL saved on the first pass. After login, /dashboard restores the target (see Redirect after login).

Effect dependency discipline

The redirect effect depends on primitives only (redirectHref, pathname, searchStr, booleans). A fresh object in the dependency array — for example the whole { href } decision result, recreated every render — re-fires the effect on every transition, and each new navigate() cancels the pending one, producing an infinite navigate/cancel loop that never commits the hop. This bug was caught by the routing access-matrix tests before it shipped; the primitive-deps rule is now enforced in review.

Role tiers

Role restrictions are pathless layouts too, one per tier:

Layout Route file Required roles Covers
_client-access src/routes/_authenticated/_client-access.tsx any-of VIEWER, SUBMITTER /clients
_admin src/routes/_authenticated/_admin.tsx ADMIN /configuration subtree
flowchart TD
  Root["__root.tsx — all URLs"] --> Top["Top-level routes<br/>/ (landing) · /dashboard · /no-role · /unauthorized"]
  Root --> Auth["_authenticated (pathless)<br/>auth + status gate"]
  Auth --> CA["_client-access (pathless)<br/>any-of VIEWER / SUBMITTER"]
  Auth --> ADM["_admin (pathless)<br/>ADMIN"]
  Auth --> Search["/search<br/>any authenticated user"]
  Auth --> RU["/reporting-units/create · /reporting-units/$ruId · /reporting-units/$ruId/$blockId<br/>any authenticated user (flag-gated)"]
  Auth --> Catch["_authenticated/$ — catch-all 404"]
  CA --> Clients["/clients"]
  ADM --> Config["/configuration (flag-gated subtree)"]
Loading

Each tier file follows the same pattern:

  • one REQUIRED_ROLES const — the single place the tier's role list is declared;
  • roles are read synchronously from auth context, and hasAnyRole(user.roles, REQUIRED_ROLES) performs an any-of comparison on FamRole.role (user roles are FamRole objects, not bare strings);
  • a useLayoutEffect navigates denied users to /unauthorized before first paint (no-flash, same timing discipline as the auth gate);
  • a Loading overlay covers both the pending and the denied-but-not-yet-redirected render;
  • staticData.roles mirrors the same list as nav metadata only — the layout component is the gate. The nav projection reads staticData to filter menu entries without importing route components.

Adding a new role tier means adding a new _tierName.tsx layout under src/routes/_authenticated/, nesting the covered route files inside a matching _tierName/ folder, and registering the tier's label in nothing else — the file system does the wiring.

Feature-flag gates

Feature-flag gates run in the route's beforeLoad and throw a router-level 404 when the flag is off:

beforeLoad: () => {
  if (!featureFlags['reporting-unit-details-enabled']) throw notFound();
},

Gated routes:

Flag Gate location Covers
reporting-unit-create-enabled reporting-units/create.tsx /reporting-units/create
reporting-unit-details-enabled reporting-units/$ruId/index.tsx /reporting-units/$ruId
reporting-unit-block-details-enabled reporting-units/$ruId/$blockId.tsx /reporting-units/$ruId/$blockId
configuration-enabled _admin/configuration.tsx the whole /configuration subtree (one check at the section layout; children never repeat it)

Why beforeLoad and not a layout: feature flags are synchronous environment values, safe to read anywhere; authentication is React context, which beforeLoad cannot see. Splitting them puts each check where its data lives.

Ordering trade-off, accepted deliberately: beforeLoad runs before the React auth gate, so an unauthenticated user hitting a disabled route gets the auth-aware 404 (redirect to / to sign in) rather than a login prompt for a page that does not exist. The route fails closed — the flag off means the URL is not a route, for anyone. A second consequence: an authenticated non-admin user hitting a disabled /configuration gets a 404, not /unauthorized — the flag check runs before the role tier renders. Both behaviors match "disabled means absent" semantics.

Every gated route also declares staticData.featureFlag, so the side-nav projection (useNavEntries) hides the entry while the flag is off — the menu never advertises a URL that 404s.

404 handling

Two not-found surfaces, deliberately split:

flowchart TD
  URL["URL requested"] --> Match{"Match result"}
  Match -- "no static route matches<br/>under _authenticated" --> CatchAll["_authenticated/$ — catch-all"]
  Match -- "explicit notFound() thrown<br/>flag gate or loader miss" --> RootNF["__root notFoundComponent<br/>AuthAwareNotFound"]
  CatchAll -- "user unauthenticated" --> Hop["_authenticated redirects to /<br/>no redirect persisted"]
  CatchAll -- "user authenticated" --> Shell404["NotFoundPage inside the one<br/>_authenticated Layout shell"]
  RootNF -- "user unauthenticated" --> Hop2["redirect to / (landing)"]
  RootNF -- "user authenticated" --> Shell404
Loading
  • Unknown URLs land on the _authenticated/$ splat route, which renders NotFoundPage inside the shell — authenticated 404s keep the header and side nav. Unauthenticated visitors never see it: the parent _authenticated layout detects the catch-all match (routeId === '/_authenticated/$') and redirects to / without persisting a redirect target, because there is no real destination to restore after login. Static routes always outrank the splat.
  • Explicit notFound() throws (flag gates, loader 404/403 conversions) bubble to the root AuthAwareNotFound.

The catch-all exists so 404s render in exactly one application shell; without it, unknown URLs would fall through to the root fallback and render 404 content with no shell at all.

Redirect after login

src/routes/dashboard.tsx renders DashboardRedirect, the post-login entry point. It renders null — users never see dashboard content, only the redirect.

sequenceDiagram
  participant U as User (no session)
  participant AL as _authenticated layout
  participant SS as sessionStorage
  participant D as /dashboard — DashboardRedirect

  U->>AL: GET /search?district=DFN
  AL->>SS: persistRedirectUrl("/search?district=DFN")
  AL->>U: navigate to /login (external Cognito)
  Note over U: OAuth flow completes
  U->>D: lands on /dashboard
  D->>SS: readPersistedRedirect()
  D->>D: getSafeRedirectTarget() validates the value
  D->>SS: clearPersistedRedirect()
  D->>U: navigate to /search?district=DFN (replace)
Loading

Resolution order, once per mount (guarded by a useRef so the effect runs exactly one time):

  1. Wait for auth to resolve — the login flow needs its session cookies committed before any authenticated navigation makes sense.
  2. Stored safe target → navigate to it and clear storage.
  3. OAuth callback (code + state in the query) → /search (the query params are Cognito artifacts, not filters).
  4. Fallback → /search, preserving any non-OAuth query params as search state.

getSafeRedirectTarget in src/routing/safeRedirect.ts rejects a stored target that is: empty; not path-only (must start with /, must not start with //); an absolute URL from another origin (open-redirect protection); or /dashboard itself (the redirect logic lives there — a self-target would loop).

/dashboard sits at the top level, outside _authenticated, so it never renders the shell or re-triggers the auth/status gate.

Search state

The /search route owns its filter state through validateSearch:

export const Route = createFileRoute('/_authenticated/search')({
  validateSearch: validateWasteSearch,
  component: WasteSearchPage,
  ...
});

The URL is the single source of truth, and the Zod schema in src/routing/searchParams.ts is the only place that decides how each parameter is typed. Consequences:

  • No per-component URL sync hooks are needed; filter components read validated search state and write updates through navigation.
  • Unknown keys are stripped and malformed values degrade to undefined (lenient — a bad URL never fails the route).
  • legacy=false survives encoding: absent means the legacy search source, so false must stay visible in the URL.

The router-level searchParamCodec (spread into createAppRouter) writes plain, human-readable URLs: strings unquoted, arrays comma-joined (district=DCC,DSC), booleans as true/false. The router's default JSON serializer quote-wraps string values, which broke request matching in tests and made URLs ugly; the codec replaces it and still parses legacy JSON-encoded URLs for compatibility.

Loaders

src/pages/ReportingUnitDetails/loader.ts is the canonical example:

export const reportingUnitLoader = async ({ context, params }: {
  context: AppRouterContext;
  params: { ruId: string };
}): Promise<ReportingUnitDto> => {
  const ruIdNum = Number(params.ruId);
  if (Number.isNaN(ruIdNum)) throw notFound();
  const data = await context.queryClient.ensureQueryData({
    queryKey: queryKeys.reportingUnit.details(ruIdNum),
    queryFn: () => service.reportingUnit.getReportingUnit(ruIdNum),
  });
  ...
};
  • Loaders receive { context, params }; the context carries the shared query client so loaders pre-fetch through TanStack Query (ensureQueryData) and the page's useQuery hits the cache.
  • Loaders only resolve data. Access checks live elsewhere: feature flags in beforeLoad, auth and roles in layouts. A loader that also gates access would put one concern in two places.
  • 404/403 ApiError responses become throw notFound() (auth-aware 404); everything else re-throws to the error components.

Navigation conventions

Situation Convention Example
In-app navigation Typed navigate({ to, params }) navigate({ to: '/reporting-units/$ruId', params: { ruId: String(ruId) } })
Numeric route params Wrap with String() — params are typed as strings params: { ruId: String(ruId) }
Runtime-composed target strings navigate({ href }) — the escape hatch for strings the type system cannot prove useListTableRowActions (row action paths templated from table config), PageTitle breadcrumbs (crumb paths built at runtime)

href exists precisely for those two runtime-string boundaries; everywhere else, typed to navigation catches path and param-name typos at compile time.

Side-nav generation

The side nav is data projected from routes, not a hand-maintained list:

  1. Each route that should appear in the nav declares staticData.nav = { label, icon, order, to }. to is a literal NavTarget union member ('/clients' | '/search' | '/reporting-units/create' | '/configuration'), so every side-nav link is a typed TanStack Link with no casts.
  2. useNavEntries walks the router's route table, and for each route with nav metadata collects gate metadata (roles, featureFlag, offlineOnly, offlineReady) from the route and its ancestor chain — staticData is per-route and never merged by the router, so inheritance is an explicit parent walk (collectRouteGates), nearest declaration winning. A child route under _admin inherits the ADMIN requirement without repeating it.
  3. Entries are filtered (connectivity, flag truthiness, any-of role membership) and sorted by nav.order.
  4. LayoutSideNav renders the projected entries as flat SideNavLinks; the "Need Help?" footer link is the only hardcoded item.

Adding a nav-visible page means adding nav metadata to its route — no separate menu registry to keep in sync. The Configuration side-nav entry, for example, is just the staticData on _admin/configuration.tsx.

Vite code generation

// vite.config.ts — plugin order matters
tanstackRouter({
  target: 'react',
  autoCodeSplitting: true,
  routeFileIgnorePattern: '.*\\.(unit|e2e)\\.test\\.[jt]sx?$',
}),
react(),
  • tanstackRouter must run before react() so the generated tree exists when route modules are transformed.
  • autoCodeSplitting keeps each route's component in its own chunk.
  • routeFileIgnorePattern keeps colocated test files out of the route tree.
  • The build script runs tsc -b tsconfig.app.json && vite build — type-check happens first, so a clean checkout needs src/routeTree.gen.ts committed or tsc fails before the generator ever runs. That is why the generated file is checked in.

How to add a route

This walkthrough adds a hypothetical Announcements page (/announcements), visible in the side nav to any authenticated user.

1 — Create the page component

// src/pages/Announcements/index.tsx
import { Column } from '@carbon/react';
import { type FC } from 'react';

import PageTitle from '@/components/core/PageTitle';

const AnnouncementsPage: FC = () => (
  <Column lg={16} md={8} sm={4}>
    <PageTitle title="Announcements" subtitle="System-wide notices and updates." />
    {/* page body goes here */}
  </Column>
);

export default AnnouncementsPage;

No <Layout> wrapper here — _authenticated already provides the shell.

2 — Create the route file

// src/routes/_authenticated/announcements.tsx
import { Notification } from '@carbon/icons-react';
import { createFileRoute } from '@tanstack/react-router';

import AnnouncementsPage from '@/pages/Announcements';

export const Route = createFileRoute('/_authenticated/announcements')({
  component: AnnouncementsPage,
  staticData: {
    title: 'Announcements',
    nav: { label: 'Announcements', icon: Notification, order: 50, to: '/announcements' },
  },
});

File placement picks the gates automatically:

  • src/routes/_authenticated/announcements.tsx — authenticated, any role (sits directly under the auth gate);
  • src/routes/_authenticated/_admin/announcements.tsx — ADMIN only;
  • src/routes/_authenticated/_client-access/announcements.tsx — VIEWER/SUBMITTER;
  • src/routes/announcements.tsx — public (no gate; wrap in your own layout if it needs a shell).

The string inside createFileRoute(...) is kept in sync by the Vite plugin — start the dev server or run the build once and the generator rewrites it (and routeTree.gen.ts) to match the file's real path.

3 — Regenerate and commit the tree

Run npm run dev (or npm run build). The plugin regenerates src/routeTree.gen.ts. Commit both files — tsc runs before vite build, so the tree must exist in clean checkouts.

4 — Test it

// src/pages/Announcements/index.unit.test.tsx
import { RouterProvider } from '@tanstack/react-router';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect } from 'vitest';

import AnnouncementsPage from './index';

import { createTestRouter } from '@/config/tests/routerTestHelper';
import PageTitleProvider from '@/context/pageTitle/PageTitleProvider';

describe('AnnouncementsPage', () => {
  it('shouldRenderTitleAndSubtitle_whenRendered', async () => {
    render(
      <RouterProvider
        router={createTestRouter(() => (
          <PageTitleProvider>
            <AnnouncementsPage />
          </PageTitleProvider>
        ))}
      />,
    );
    await waitFor(() => {
      expect(screen.getByText('Announcements')).toBeDefined();
    });
  });
});

createTestRouter builds a router over the real generated tree with memory history, so the test exercises the same matching and layouts as production.

How to add a feature-flag gate

1 — Declare the flag

Add the key to featureFlagsSchema in src/env.ts (strict Zod schema — unknown keys are rejected) and set its per-environment values (see Feature Flags for the governance table and environment matrix).

2 — Gate the route in beforeLoad

// src/routes/_authenticated/announcements.tsx
import { createFileRoute, notFound } from '@tanstack/react-router';

import { featureFlags } from '@/env';

export const Route = createFileRoute('/_authenticated/announcements')({
  beforeLoad: () => {
    if (!featureFlags['announcements-enabled']) throw notFound();
  },
  component: AnnouncementsPage,
  staticData: {
    featureFlag: 'announcements-enabled',
    nav: { label: 'Announcements', icon: Notification, order: 50, to: '/announcements' },
  },
});

The staticData.featureFlag mirror is what keeps the side-nav entry hidden while the flag is off.

3 — Gate a whole subtree in one place

Put the beforeLoad on the section's layout route and let children inherit: _admin/configuration.tsx checks configuration-enabled once and every /configuration/* child is covered. Children never repeat a parent's flag check.

4 — Test the gate

Cover both directions — flag on renders the page, flag off yields the 404 path (auth-aware: unauthenticated goes to /, authenticated renders NotFoundPage). The routing access-matrix test (src/routes/access-matrix.unit.test.tsx) is the template: it mocks featureFlags and walks the full URL × auth-state × role-state matrix, and it is the test level that caught the effect-dependency loop described under Effect dependency discipline.

Testing coverage

File What is tested
src/routes/__root.unit.test.tsx Root layout title sync, auth-aware not-found behavior
src/routes/access-matrix.unit.test.tsx Full URL × auth × role × flag matrix across the layouts
src/routing/auth.unit.test.ts computeAuthRedirect decision table, hasAnyRole any-of semantics
src/routing/redirectStorage.unit.test.ts Persist/read/clear with storage failures
src/routing/safeRedirect.unit.test.ts Open-redirect rejection rules, OAuth callback detection
src/routing/searchParams.unit.test.ts Zod schema leniency, codec encoding contract
src/routing/nav/useNavEntries.unit.test.tsx Nav projection: inheritance, filtering, ordering
src/pages/ReportingUnitDetails/loader.unit.test.ts Loader data resolution and 404/403 conversion

When gate behavior changes, update the corresponding unit test first; the access-matrix test is the safety net for cross-cutting regressions.

Related

  • Route Guards — the access-control layouts and beforeLoad gates in detail
  • Routes — catalog of every route file and URL
  • Authentication — FAM/Cognito flow and redirect preservation
  • Roles — the access-status rules the auth gate enforces
  • Feature Flags — flag registry, governance, and environment matrices

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally