Description
(This was suggested by @MaxFrank13 here).
There is currently no way to apply a cross-cutting React Router loader to every app route from site config. Any loader a site operator wants to run site-wide, whether an authentication gate, a feature-flag check, telemetry, a tenant or entitlement lookup, or shared data prefetch, has to be attached by hand to each route in each app's routes.tsx. An operator who does not own those apps has no declarative lever: they must rewrite the whole apps[].routes tree in site.config.tsx, composing with each route's existing loader and recursing into children themselves. That is fragile, easy to get wrong, and has to be redone whenever an app is added.
This proposes a config-driven way to declare loaders that run across app routes, with a per-loader scope so each loader can be narrowed to the subset of routes it should touch.
Proposed approach
Add an optional commonLoaders field to SiteConfig, analogous to the existing commonAppConfig. Each entry pairs a LoaderFunction with an optional scope. The framework stays agnostic about what any given loader does; scoping is expressed entirely by the config author, who is the one who knows each loader's purpose.
Scope is expressed with two optional predicates over the route, include and exclude. A loader with neither predicate applies to every route. When include is present, the loader applies only to routes for which it returns true. When exclude is present, the loader skips routes for which it returns true. When both are present, a route is in scope when it is included and not excluded, so exclude takes precedence within the included set. Predicates receive the RouteObject, so they can test path, handle.roles, or anything else already on the route, which keeps role-based selection available as one option among many rather than the mechanism.
commonLoaders: [
{ loader: authenticatedLoader, exclude: (route) => hasRole(route, PUBLIC_ROLES) },
{ loader: featureFlagLoader, include: (route) => route.path?.startsWith('beta') },
{ loader: telemetryLoader }, // no scope: every route
]
Application happens centrally in getAppRoutes() (shell/router/getAppRoutes.ts), the single point where all app routes are already flattened. For each route, the in-scope common loaders are composed in front of the route's own loader: they run in declared order, and if one returns a Response/redirect that result short-circuits, otherwise the route's original loader runs. Composition recurses into children, and scope is evaluated per route node. A small hasRole helper can ship alongside as a convenience for role-based predicates, without the core feature depending on roles or on authentication.
Implementation sketch
// shell/router/getAppRoutes.ts
interface CommonLoader {
loader: LoaderFunction;
include?: (route: RouteObject) => boolean;
exclude?: (route: RouteObject) => boolean;
}
function inScope(entry: CommonLoader, route: RouteObject): boolean {
const included = entry.include ? entry.include(route) : true;
const excluded = entry.exclude ? entry.exclude(route) : false;
return included && !excluded;
}
function composeLoaders(route: RouteObject, commonLoaders: CommonLoader[]): RouteObject {
const applicable = commonLoaders.filter((entry) => inScope(entry, route));
const own = route.loader;
return {
...route,
loader: applicable.length === 0 ? own : async (args) => {
for (const { loader } of applicable) {
const result = await loader(args);
if (result) {
return result; // redirect/Response short-circuits
}
}
return typeof own === 'function' ? own(args) : null;
},
children: route.children?.map((child) => composeLoaders(child, commonLoaders)),
};
}
getAppRoutes() maps the flattened routes through composeLoaders when getSiteConfig().commonLoaders is set, leaving current behavior unchanged when it is not.
LLM usage notice
Built with assistance from Claude.
Description
(This was suggested by @MaxFrank13 here).
There is currently no way to apply a cross-cutting React Router loader to every app route from site config. Any loader a site operator wants to run site-wide, whether an authentication gate, a feature-flag check, telemetry, a tenant or entitlement lookup, or shared data prefetch, has to be attached by hand to each route in each app's
routes.tsx. An operator who does not own those apps has no declarative lever: they must rewrite the wholeapps[].routestree insite.config.tsx, composing with each route's existing loader and recursing intochildrenthemselves. That is fragile, easy to get wrong, and has to be redone whenever an app is added.This proposes a config-driven way to declare loaders that run across app routes, with a per-loader scope so each loader can be narrowed to the subset of routes it should touch.
Proposed approach
Add an optional
commonLoadersfield toSiteConfig, analogous to the existingcommonAppConfig. Each entry pairs aLoaderFunctionwith an optional scope. The framework stays agnostic about what any given loader does; scoping is expressed entirely by the config author, who is the one who knows each loader's purpose.Scope is expressed with two optional predicates over the route,
includeandexclude. A loader with neither predicate applies to every route. Whenincludeis present, the loader applies only to routes for which it returns true. Whenexcludeis present, the loader skips routes for which it returns true. When both are present, a route is in scope when it is included and not excluded, soexcludetakes precedence within the included set. Predicates receive theRouteObject, so they can testpath,handle.roles, or anything else already on the route, which keeps role-based selection available as one option among many rather than the mechanism.Application happens centrally in
getAppRoutes()(shell/router/getAppRoutes.ts), the single point where all app routes are already flattened. For each route, the in-scope common loaders are composed in front of the route's own loader: they run in declared order, and if one returns aResponse/redirect that result short-circuits, otherwise the route's original loader runs. Composition recurses intochildren, and scope is evaluated per route node. A smallhasRolehelper can ship alongside as a convenience for role-based predicates, without the core feature depending on roles or on authentication.Implementation sketch
getAppRoutes()maps the flattened routes throughcomposeLoaderswhengetSiteConfig().commonLoadersis set, leaving current behavior unchanged when it is not.LLM usage notice
Built with assistance from Claude.