openedx / openedx/frontend-base

Config-driven common route loaders (apply a loader to every app route)

Open
#278 0 comments 0 reactions 1 assignee View on GitHub

@arbrandes is already working on this.

Since Aug 28, 2026.

Dominant language
TypeScript
Stars
4
Forks
13
Avg merge
3h 21m
Merged PRs (30d)
10

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.