The policy loader
Every policy route exports a loader that returns null, and that export is what makes the policy run on a click that enters its area.
- Policy middleware runs on Node. Node only runs when the browser sends it a request.
- On a click inside the app, the browser sends a request to Node only when React Router decides that some matched route's
loadermust run. - A policy's loader must run when the click enters the policy's area, so that click reaches Node and the policy runs.
- A click between two pages already inside the area does not run the policy's loader, so it sends nothing to Node unless the destination page has its own server
loader. - So the
loaderon a policy route is not empty code. Its presence is the check at the door. - This page covers six things, in this order.
- A click inside the app can skip Node. That is the problem.
- The policy loader makes the entering click reach Node. That is the fix.
- When the policy loader is requested, verified by running React Router.
- How React Router decides whether to send the request to Node, in its source.
- Three edits that switch a policy off without an error.
- The page's own fetches can start before the policy answers.
- How to test the policy loader by hand.
Every rule below can be broken silently. Nothing throws, nothing warns, and the check simply stops running. That is why this page exists.
The words this page uses
| Word | Meaning |
|---|---|
| Policy route | A route with no page that decides whether the routes inside it may open. It exports middleware, a loader that returns null, and an Outlet. The policy routes page explains it |
| The policy loader | The loader export on a policy route. It returns null and publishes nothing. It exists so that React Router sends a request to Node on a click inside the app |
| Document request | The browser asks Node for a whole page: a new tab, a refresh, a typed URL, a link from an email. Node always runs every matched middleware and loader |
| Click inside the app | A click on a link or a form inside a page that is already open. React Router handles it in the browser without loading a new document. Also called an in-app click on this page |
.data request | The request the browser sends to Node on an in-app click, when some matched route needs data from Node. Node runs every matched middleware, then the loader exports that were asked for, and answers with data instead of HTML |
clientLoader | A route export that loads the route's data in the browser. A route that has one takes charge of its own loading, and React Router does not ask Node for that route's data unless the clientLoader calls serverLoader() itself |
shouldRevalidate | A route export that tells React Router whether to load that route's data again on a navigation. React Router revalidation in JodApp Web states our rules for it |
| Route manifest | The list of routes and their exports that the build writes into the app. React Router reads it in the browser to know, for each route, whether the file exports a loader, a clientLoader, and so on |
A click inside the app can skip Node
Policy middleware runs on Node, and Node runs only when the browser sends it a request. A click inside the app does not always send one.
Legend: Orange is the policy, this page's subject: its loader, its middleware and its rule. Grey is a request or an outcome. Both kinds of navigation reach the same check, on Node, every time. The top path happens on its own. The bottom path happens only because of the policy loader.
| Navigation | Does the browser ask Node? |
|---|---|
| Document request | Always. That request is the page |
| Click inside the app | Only when some matched route needs data from Node |
The second row is the whole problem. Most private pages in JodApp Web load their data with a clientLoader, which runs in the browser. So a click from one private page to another can ask Node for nothing at all. No request means no middleware. No middleware means no check.
The two cases as a sequence. Mei is signed in and clicks from the employer home page, /employers, into the dashboard. That click enters employer-required-policy.
Legend: The orange box is JodApp Web. In the first case the policy loader makes the browser send a
.datarequest, so the policy runs on Node before the page settles. In the second case nothing asks Node, so the policy never runs, and the person reaches the page unless Rails refuses its data. Rails protects the data either way. The policy loader is what protects the journey.
The policy loader makes the entering click reach Node
React Router's own documentation gives the fix: put a loader on the route that holds the middleware. That is what every policy route does.
Client-side navigations will only run server middleware if a
.datarequest is made to the server for aaction/loader.
However, there may be cases where you want to run certain server middlewares on every client-navigation - even if no
loaderexists. If your middleware meets these criteria, then you can put aloaderon the route that contains the middleware to force it to always call the server for client-side navigations involving that route.
Their example is the design we use. In our names:
// app/routes/policies/employer-required-policy.jsx
export const middleware = [authUserGuard.protect(authEntryRules.employerRequired)]
// By adding a loader, we force the middleware above to run on every
// in-app click involving this route.
export const loader = () => null
Source: React Router, middleware.
When the policy loader is requested
React Router asks Node for a route's loader only when it decides that loader must run. This table was verified by running React Router 8.3.0 with a pathless parent route standing in for a policy.
| Navigation | Is the policy loader requested, so the policy runs on Node? |
|---|---|
| A document request: new tab, reload, typed URL | Yes. Every matched middleware and loader runs |
| A click that enters the policy's area | Yes. The policy is newly matched |
| A click between two pages already inside the area | No, unless the destination page has its own server loader. Nothing reaches Node |
The URL search changes, such as ?page=2 | Yes |
A route action succeeds, or fails with 401 or 403 | Yes. Every loader runs again |
revalidate() after a refused direct call | Yes. Every loader runs again |
| Leave the area and come back | Yes. The policy is newly matched again |
What the third row means for the design:
- The policy is the check at the door. It runs when a person enters an area, and again on every reload.
- Inside the area, a click to a page with only a
clientLoadersends nothing to Node. If the session ended meanwhile, the page's own fetch is refused by Rails, the caller answers withrevalidate(), and that reload runs the policy and redirects. The correction arrives one step later, not one click later. - Rails decides access on every request either way. The page on Rails as the boundary is the reason this is safe.
- The browser cannot know that a route has server middleware. The route manifest carries
hasLoaderandhasClientMiddleware, and nothing about server middleware, so the loader is the only trigger there is.
How React Router decides whether to send the request to Node
The documentation says what to do. The source says when it works, and that matters, because there are three ways to break it.
Here is the idea before the code.
- When you click a link, React Router builds a list of routes it needs data for.
- It walks every route that matches the new URL and tests each one. A route that passes the tests goes on the list.
- At the end, if the list is empty, the browser sends no request to Node at all.
Now the code that does it. This is single-fetch.js from the installed React Router 8.3.0, shortened to the parts that decide.
// For each route matching the new URL:
let { hasLoader, hasClientLoader } = getRouteInfo(m)
// TEST 1 (line 136): may this route be skipped this time?
if (!m.shouldCallHandler(defaultShouldRevalidate)) {
return // stop with this route, move to the next one
}
// TEST 2 (line 140): does this route load its data in the browser?
if (shouldAllowOptOut(m) && hasClientLoader) {
// ...the clientLoader now decides for itself whether to call Node
return // stop with this route, move to the next one
}
// TEST 3 (line 158): does this route export a loader?
if (hasLoader) routesParams.add(routeId) // put it on the list
return here means "stop looking at this route". So a route that stops at test 1 or test 2 never reaches test 3, and never joins the list.
| Test | What it asks | A route that fails it |
|---|---|---|
| 1 | May this route be skipped this time? | Is skipped. It never reaches tests 2 and 3 |
| 2 | Does this route load its data in the browser? | Hands the decision to its own clientLoader, and never reaches test 3 |
| 3 | Does this route export a loader? | Is not put on the list |
After every route has been tested, React Router checks the list.
// line 174: an empty list means no request is sent
if (( ... || routesParams.size === 0) && !window.__reactRouterHdrActive) {
singleFetchDfd.resolve({ routes: {} })
}
routesParams is that list. When it is empty, the browser never calls Node. No request means no server middleware, anywhere in the tree.
One more piece, and this one comes from the build rather than from the running app:
// @react-router/dev, vite.js line 2301
hasLoader: sourceExports.includes("loader"),
hasLoader is decided at build time by reading which names the route file exports. It does not look at what the loader does or what it returns. That is the mechanical reason an empty loader is not dead code. Its presence alone changes the route manifest.
Only routes that pass all three tests are added to the request. But once that request reaches Node, middleware runs for every matched route, not only the ones whose loaders were asked for.
So one policy route passing the tests is enough to run the whole middleware chain above it.
Three edits that switch a policy off without an error
Each of these three edits looks harmless in review. None of them produces an error, and each one makes a policy fail one of the tests above.
1. Deleting the loader
export const middleware = [authUserGuard.protect(authEntryRules.employerRequired)]
// export const loader = ... <- removed as "unused"
The route now fails test 3. In-app clicks stop being checked. A full page load still works, so it looks fine in local testing.
2. Adding shouldRevalidate
export function shouldRevalidate() {
return false
}
The route now fails test 1, which runs before the loader is even considered. The loader is still there, and it no longer helps.
The root routes export a shouldRevalidate on purpose, to reload the session after an action fails with 401 or 403. Those are root routes, not policy routes. Copying that export onto a policy route would turn the policy off.
3. Adding a clientLoader
This one is not in the official documentation, and it is the easiest to add by habit.
export const loader = async ({ context }) => { ... }
export const clientLoader = async () => { ... } // <- breaks it
A route with a clientLoader fails test 2. React Router stops testing it there, so it never reaches test 3 and never joins the list of routes to fetch.
The reason is that a clientLoader is meant to take charge of its own loading. React Router steps back and lets it decide. If that clientLoader wants data from Node, it has to ask by calling serverLoader() itself.
That is fine for a normal page. It is wrong for a policy route, because the policy would then only be checked when its own clientLoader happened to ask.
| Export on a policy route | Allowed? |
|---|---|
middleware | Required |
loader | Required |
shouldRevalidate | Never |
clientLoader | Never |
clientAction, action | No. Session changes belong on a route of their own. The changing-a-session page explains where |
The loader's return value is never read
Test 3 checks whether the loader export exists. It never looks at what the loader returns.
// Every policy loader in JodApp Web looks like this.
export const loader = () => null
An empty loader forces the request exactly as well as a full one. Our policy loaders all return null, because the session already lives on the root loader. A policy has nothing of its own to publish.
The page's own fetches can start before the policy answers
The policy loader makes the check run on every click inside the app. It does not make the check run before everything else. On a click, the destination page's clientLoader can start its own calls to Rails before the policy has answered. This section says exactly what that means, because pages must be designed with it in mind.
On an in-app click, React Router starts work in this order. This was verified in the installed 8.3.0 source, in single-fetch.js.
// single-fetch.js, shortened
}))); // every matched route callback has STARTED
await Promise.all(routeDfds.map((d) => d.promise)) // line 173: wait for them to have started
let data = await fetchAndDecode(args, targetRoutes) // line 178: the policy's request to Node goes out HERE
await resolvePromise // line 184: wait for the child work to finish
A child route's clientLoader is one of those callbacks. So its fetches to Rails can leave the browser before the request that runs the policy middleware. And the redirect cannot call them back:
// router.js, shortened
let { loaderResults } = await callLoadersAndMaybeResolveData(...) // line 721: await ALL loader work
let redirect = findRedirect(loaderResults) // line 725: only NOW find the redirect
By the time React Router knows the person is denied, every child fetch has gone out.
| Navigation | What is ordered |
|---|---|
| Document request | Everything. Middleware runs before any loader, and no child fetch exists yet |
In-app click, child has a server loader | The child's data. It loads inside the same request, after the middleware |
In-app click, child has a clientLoader | Only rendering. The child's fetches race the policy answer, and Rails referees them |
This is not a data leak.
- Rails refuses the raced fetches for a denied person with
401, and the redirect still wins the navigation. - The person lands on the login page and never sees the protected page.
- On every denied in-app navigation, Rails is the only thing refusing those requests. Rails is the boundary treats that as a first-class duty, not an edge case.
Two design rules follow.
| Rule | Why |
|---|---|
A protected page that must not fetch before the check uses a server loader | A server loader runs inside the same request as the middleware, which throws first. This is a per-page choice, and Rails is the boundary says when to make it |
The API client never navigates or clears state on its own 401 | A raced fetch that loses to the policy gets refused while a redirect is in flight. If the client reacted by "logging out", it would fight the policy's navigation. Only policies navigate |
Do not close the gap with clientMiddleware. A browser-side check needs a browser-side copy of the session, and that copy can be stale. JodApp Web D2 — Policies check on the server; no clientMiddleware records the ruling and its cost.
How to test the policy loader by hand
Opening the page in a new tab is not a useful test, because it passes even when the mechanism is broken. The test that matters is the click inside the app.
- Sign in as an employer and open the employer home page,
/employers. - Click into the dashboard. Do not open it in a new tab. This click enters
employer-required-policy. - Watch the network tab. There must be a
.datarequest to Node for that navigation, with_routesnaming the policy. - Revoke the membership, disable the company, or sign out in another tab.
- Click back to
/employers, then click into the dashboard again, without a reload. - The redirect must happen.
A click between two pages inside the dashboard sends no .data request. That is expected, and it is not the thing this test checks.
If step 6 fails, press the browser's reload button on that third page.
- A reload is a document request, so Node runs the policy on it whatever the route exports.
- If the reload redirects and the click did not, the policy works on document requests and not on in-app clicks. The route is failing one of the three tests above.
- If the reload does not redirect either, the problem is not the loader. Check the entry rule and the session first.
All of this depends on server rendering
Everything on this page applies because JodApp Web renders pages on Node. react-router.config.js sets ssr: true. A build that renders only in the browser runs different code in the same file, so these rules would need checking again.