Skip to content

Guarded route chunks are downloaded even when the loader redirects #101

Description

@georgekaran

Context

Found while reviewing #100, which moved route gating from wrapper components to loaders. That PR's first description claimed a signed-out visitor to /dashboard no longer downloads the dashboard chunk. That is false, and the claim has been corrected.

A route with a static loader and a lazy callback runs both concurrently. From react-router@8.3.0, dist/development/lib/router/router.js:2460:

let [value] = await Promise.all([
  runHandler(handler),   // the guard
  lazyHandlerPromise,
  lazyRoutePromise,      // the dynamic import
]);

The redirect stops the route rendering. It cannot cancel an import already in flight.

Measured with a probe — a lazy callback that records whether it ran, driven by the real guards:

requireSession:   importer ran 1 time(s)   (signed-out visitor to /dashboard)
requireAnonymous: importer ran 1 time(s)   (signed-in visitor to /login)

So a signed-out visitor deep-linking into a guarded page still pays for its chunk, and a signed-in visitor bounced off /login still pays for the login screen.

The trade, which is why this is an issue and not a fix

The obvious remedy is to sequence the import behind the check: drop lazy, and give the route an element holding a React.lazy screen. The import then fires on first render, which never happens when the loader redirects.

That costs something, and the cost lands on the commoner case. Today the session request and the chunk download run in parallel. Serialising them makes the redirect path cheaper and the successful path slower by one full round trip — and a signed-in visitor is who a guarded route is normally for.

Current waste on a redirect, from pnpm build:

chunk raw
Dashboard 252 B
PageDetail 321 B
Login 1.7 kB
Signup 1.8 kB

A few hundred bytes to ~2 kB, against a serialised round trip on every successful guarded navigation. On these numbers the trade is bad.

It is bad because the screens are placeholders. Dashboard is essentially a heading. The moment these carry real UI — a table, a chart library, a date picker — the redirect waste grows while the round trip stays the same size, and the trade flips.

What to do

Nothing yet. Revisit when any guarded screen's chunk passes roughly 20 kB, or when a real deployment gives latency numbers to weigh the round trip against.

If it is taken up:

  • Prefer React.lazy in the element over an eager guard component. The point of Gate routes with loaders instead of wrapper components #100 was to stop mounting a screen in order to decide about it, and a guard component would walk that back.
  • /login and /signup are the larger chunks and the cheaper fix — an anonymous visitor is the common case there, so sequencing costs the rare path. These four routes do not have to be decided together.

Definition of done

Whichever way it goes, a test that observes whether the importer ran. #100 has no such test: every assertion there observes where the visitor lands, which is why the wrong claim survived review.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:webFrontend under web/type:decisionRequires a technical choice, to be logged in DECISIONS.md

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions