Skip to content

Define controller ownership with app/actions #11322

@mjackson

Description

@mjackson

Problem

fetch-router has treated controllers as recursive mirrors of a route-map subtree. When an app calls router.map(routes, controller), the controller is expected to define actions for every route leaf in the entire passed route map, including nested route maps.

That creates an ergonomics and ownership ambiguity:

  • A controller sometimes means “actions for this route-map level” and sometimes means “the whole nested controller tree”.
  • Middleware on one controller flows into controllers for nested route maps, even though those controllers do not otherwise inherit things like URL segments or route-map structure at runtime.
  • App file conventions have to distinguish between standalone leaf action files and controller directories.
  • remix doctor has extra logic to decide whether a route should be represented by a standalone action file or a controller entry file, and to report drift when a leaf action grows route-local files.

This is especially awkward at the root route-map level. In demos like bookstore, root leaf routes are standalone action files under app/controllers, while grouped routes are controller directories. We could not cleanly collect those root actions into a root controller because router.map(routes, controller) would also require bringing in the entire nested controller tree.

Desired model

A controller defines actions for the direct leaf routes in the route map passed to router.map(). It does not include nested route-map keys, and it does not compose controllers registered for nested route maps.

For example:

const routes = route({
  home: '/',
  auth: {
    login: form('/login'),
    logout: post('/logout'),
  },
})

Controller<typeof routes> should require home, but should reject auth because auth is a nested route-map key, not a direct leaf route.

Controller<typeof routes.auth> should require login and logout.

Router wiring becomes explicit:

router.map(routes, rootController)
router.map(routes.auth, authController)
router.map(routes.auth.login, loginController)

Controller middleware applies only to the direct actions in that controller. Nested route groups should opt into middleware explicitly in their own controller.

App convention

Rename app-owned route action modules from app/controllers to app/actions.

The owner entry for a route-map level is always a controller.ts(x) file under route-map key segments, not URL path segments:

app/actions/controller.tsx                     # Controller<typeof routes>
app/actions/auth/controller.tsx                # Controller<typeof routes.auth>
app/actions/auth/reset-password/controller.tsx # Controller<typeof routes.auth.resetPassword>

This avoids repetitive root naming like app/controllers/controller.tsx, and removes the distinction between standalone action files and controller files. Everything in app/actions is a controller entry for a route-map level.

Plan

  • Update fetch-router controller types so Controller<routes> includes only direct first-level Route leaves.
  • Update router.map(routeMap, controller) runtime behavior to register only direct leaf routes.
  • Reject missing direct leaf actions, nested route-map keys in actions, and unknown action keys at runtime.
  • Keep controller middleware scoped to direct actions and update tests to verify it does not flow into explicitly mapped controllers for nested route maps.
  • Update fetch-router README, tests, and release notes for the breaking controller semantics change.
  • Refactor CLI route ownership around app/actions and a single controller owner kind.
  • Remove CLI logic for standalone action owners, wrong owner kind, and promotion drift.
  • Add the synthetic root owner app/actions/controller.tsx.
  • Keep route-key based directory naming using the existing disk segment normalization, e.g. resetPassword -> reset-password.
  • Update remix doctor diagnostics, fix generation, fixtures, and tests to use app/actions.
  • Update remix routes owner output to point at app/actions controller files.
  • Migrate demos from app/controllers to app/actions.
  • Move root leaf routes in demos into root controllers where applicable, especially bookstore.
  • Split existing recursive controller objects into explicit router.map(...) calls.
  • Duplicate middleware explicitly in each controller where shared protection is needed.
  • Update demo READMEs, bootstrap templates, AGENTS guidance, and repo skills such as make-demo to describe the app/actions convention.

Expected outcome

The routing model becomes easier to explain:

  • Route maps define URL and key structure.
  • Controllers define direct actions for one route map.
  • Middleware belongs to the controller where it is declared.
  • App action files consistently live under app/actions/<route-key-segments>/controller.tsx.

This should reduce API ambiguity, simplify app organization, and remove a meaningful amount of special-case ownership logic from the CLI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions