Skip to content

Code generation and delegation to nested routes#1887

Draft
parsonsmatt wants to merge 140 commits intomasterfrom
mattp/nested-route-discovery
Draft

Code generation and delegation to nested routes#1887
parsonsmatt wants to merge 140 commits intomasterfrom
mattp/nested-route-discovery

Conversation

@parsonsmatt
Copy link
Copy Markdown
Collaborator

@parsonsmatt parsonsmatt commented Jul 30, 2025

Nested Route Separation

We have almost 2,000 total routes in our codebase now, with 1,100 of them on the top-level.
We have been leveraging nested routes to organize the routes and make certain things easier - namely, reducing the overall size of the Route WebApp which makes pattern matching more efficient in compilation.

However, compiling mkYesodData for our application is a large bottleneck.
Compilation takes a significant amount of time and much work must be redone, even when most of the routing information could have been saved.
One thing that would significantly help is the ability to separate out the sub-route datatypes, generate the instance for these separately, and refer to them elsewhere.

Another thing that would help is having a finer grained YesodDispatch facility - allowing us to, in tests, specify more precisely which parts of the route structure we actually need to deal with.
Currently, we need almost 7k modules in order to compile a test that refers to YesodDispatch, since this brings in the transitive dependencies of every web handler.
With a finer grained YesodDispatch, able to refer to route fragments, we'd be able to avoid depending on more of the website than we really need to.
We expect this to yield significant benefits when we move more testing infrastructure to buck2 - right now, changing anything used by any part of the web app would require running all tests that exercise anything in the webapp!
With more granular YesodDispatch, we gain the ability to only run tests on parts of the app that actually use the route structure in question.


This PR is a breaking change, and so will be yesod-core-0.2.0.0. When I tried this branch on our package, it required 0 modifications to upgrade - most of the breaking changes here are breaking "advanced power user" type functions, like the ability to customize the MkDispatchSettings to have highly custom route behaviors. We don't use any of those, and I have no idea how widespread their use is. I will admit to finding them very difficult to use and adapt, which is part of why I ended up scrapping their use in the ParseRoute generation (the class isn't even used).

For the most part, though, all prior behavior remains unchanged. The performance of routing may be slightly worse - YesodDispatchNested returns a Maybe Application (but with the Request pulled out - only the "send response" side is in the Maybe) to support fallthrough. And if you enable fallthrough, then the app will do more checking (proportional to the number of routes and nested routes) rather than early-404ing. But IMO for perf, we're better off coming up with a trie-based router than trying to optimize this one much more.

For an example of the testing facilities this provides, see parsonsmatt/hspec-yesod#7 - particularly, this file has an introduction to how the tests are separated and defined.


Fix for #1880

Before submitting your PR, check that you've:

After submitting your PR:

  • Update the Changelog.md file with a link to your PR
  • Check that CI passes (or if it fails, for reasons unrelated to your change, like CI timeouts)

dtpowl and others added 30 commits March 20, 2025 14:18
Co-authored-by: Matt Parsons <[email protected]>
…ontent

Show truncated content in handler content
…s-generation

Mattp/control resources generation
@L0neGamer
Copy link
Copy Markdown
Contributor

Hi Matt! @jezen asked me to test this out on our codebase, and from what I can tell there is some kind of error when we use mkYesodSubData.

Here's an example excerpt:

mkYesodSubData "(SubsiteClass subsite master) => SubsiteData subsite"
  $(makeRelativeToProject "config/routes.yesodroutes" >>= parseRoutesFile)

results in many errors of the format:

src/Subsite/Data.hs:158:1: error: [GHC-53634]
    Out of scope type variable ‘subsite’ in the RHS of a family instance.
      All such variables must be bound on the LHS.
    |
158 | mkYesodSubData "(SubsiteClass subsite master) => SubsiteData subsite"
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...

I can't immediately spot why in your PR.

@parsonsmatt
Copy link
Copy Markdown
Collaborator Author

Great, thank you for the report! I don't think we have a test case for that in the repository yet. I'll make one and see if I can get it fixed.

@parsonsmatt
Copy link
Copy Markdown
Collaborator Author

@L0neGamer I added a test case and fixed, mind trying again?

@L0neGamer
Copy link
Copy Markdown
Contributor

Getting another compile time issue, this time with mkYesodSubDispatch.

Here are some more snippets:

-- routes.yesodroutes
!/#{AssociatedType subsite}/route RouteR':
  / RouteR GET POST DELETE
-- Data.hs
mkYesodSubData "(SubsiteClass subsite master) => Subsite subsite"
  $(makeRelativeToProject "routes.yesodroutes" >>= parseRoutesFile)
-- Subsite.hs
instance
  ( SubsiteClass subsite master
  , OtherSubsiteClass master
  ) => YesodSubDispatch (Subsite subsite) master where
  yesodSubDispatch = $(mkYesodSubDispatch resourcesSubsite)

results in the error:

src/Subsite.hs:127:23: error: [GHC-83865]
    • Expecting one more argument to ‘RouteR'’
      Expected a type, but ‘RouteR'’ has kind ‘k0 -> *’
    • In the first argument of ‘YesodDispatchNested’, namely
        ‘RouteR'’
      In the type ‘YesodDispatchNested RouteR'’
      In the argument of reifyInstances: Yesod.Core.Class.Dispatch.YesodDispatchNested Data.RouteR'
    |
127 |   yesodSubDispatch = $(mkYesodSubDispatch resourcesSubsite)

It seems that you're not passing the subsite variable when doing the dispatch?

@parsonsmatt
Copy link
Copy Markdown
Collaborator Author

Great! Thanks. The more of a reproducer you can give me the faster I can fix it up 😄

@L0neGamer
Copy link
Copy Markdown
Contributor

I'll have a look shortly if this is insufficient.

When nested route types have type parameters (e.g. from parameterized
subsites), isInstance requires fully-applied types. Added fullyApplyType
helper that looks up type arity and applies fresh type variables. Fixed
all isInstance checks across Dispatch, RenderRoute, ParseRoute, and
RouteAttrs TH modules.

This fixes the "Expecting one more argument to 'RouteR'" error when
using mkYesodSubDispatch with parameterized subsites that have nested
routes.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@L0neGamer
Copy link
Copy Markdown
Contributor

Please observe PR here for failing test example: #1910

Tests that mkYesodSubDispatch compiles when the subsite has
constrained type parameters (e.g. ParamSubDispatchClass subsite master
=> ParamSubDispatch subsite). This covers the isInstance / fullyApplyType
fix from the previous commit.

Note: mkYesodSubDispatch + nested route delegation (`:` syntax) is not
yet supported for subsites because yesodDispatchNested expects
YesodRunnerEnv but subsite dispatch uses YesodSubRunnerEnv. The
ParameterizedSubData module already covers mkYesodSubData with nested
routes.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@L0neGamer
Copy link
Copy Markdown
Contributor

I believe Claude's suggested test does not demonstrate the error while my commit does; feel free to cherry pick and rejig mine as you see fit.

parsonsmatt and others added 4 commits March 3, 2026 12:16
…ed routes

When a subsite has nested routes (`:` syntax), mkYesodSubDispatch now
handles them via two mechanisms:

1. Inline dispatch (backward-compatible): When no nested dispatch instance
   exists, mkDispatchClause's `go` for ResourceParent recursively generates
   child dispatch clauses inline. Existing user code using mkYesodSubDispatch
   works with nested routes without changes.

2. Class-based delegation: New YesodSubDispatchNested class (parallel to
   YesodDispatchNested) enables module separation for subsite nested routes.
   mkYesodSubDispatchInstance generates both YesodSubDispatch and
   YesodSubDispatchNested instances in one splice.

Also incorporates l0neGamer's PR #1910 test case with associated types,
ConstraintKinds, and dynamic pieces in nested route parents.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Replace [t| ParentSite $(pure x) |] TH splices with manual type
  construction (ConT ''ParentSite `AppT` x) to avoid $ being parsed
  as a type operator on GHC < 9.4 (lts-16, lts-18)
- Add data-default to tests suite build-depends since hs-source-dirs
  includes src/ and Dispatch.hs conditionally imports Data.Default
  when wai-extra < 3.1.14 (lts-20)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace foldMap with mapM + mconcat since Monoid (Q (a, b)) isn't
available on older GHCs that lack Monoid a => Monoid (Q a).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Replace Quote-based polymorphism with explicit liftQ in
  generateParseRouteClause (Quote class doesn't exist pre-GHC 9.0)
- Add CPP guards for ConP arity change (template-haskell 2.18/GHC 9.4)
  and TupE Maybe wrapping (template-haskell 2.16/GHC 8.10) in tests

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@parsonsmatt
Copy link
Copy Markdown
Collaborator Author

OK, I think we have incorporated your test case- mind giving it another go? Thanks for your patience 🙏🏻

@L0neGamer
Copy link
Copy Markdown
Contributor

L0neGamer commented Mar 3, 2026

getting this incredibly unhelpful error when making an example app:

example/ExampleApp.hs:47:1: error: [GHC-22385]
    Not in scope: record field ‘pathInfo’
   |
47 | mkYesodDispatchOpts siteOpts "App" routes
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

the splices look like this

image

I intuit that there's something wrong with how the TH is building the the splice somehow. I can't do any more investigation right now (way past when I should be up), but might be able to help or explore in ~12 hours. Let me know if that's something you want me to try or if you're gonna get up to it.

EDIT: There are many cases of pathInfo being used as a record field.

parsonsmatt and others added 3 commits March 3, 2026 17:31
Use TH quoted name 'W.pathInfo instead of mkName "pathInfo" in
record update expression within genNestedDispatchClauses. The
mkName version creates an unqualified name that requires
Network.Wai to be imported at the splice site.

Fixes error reported by l0negamer: "Not in scope: record field
'pathInfo'" when using mkYesodDispatchOpts with nested routes.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Replace mkName "renderRoute" with 'renderRoute in RenderRoute.hs
  for consistency with 'renderRouteNested usage
- Replace mkName lambda-bound variables (sroute, p, r) with newName
  in Dispatch.hs to avoid potential name capture

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Introduces mkLambda in Internal.hs that generates a single-argument
lambda with a fresh name, replacing manual newName + LamE patterns
across Dispatch.hs for better name hygiene and less boilerplate.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@L0neGamer
Copy link
Copy Markdown
Contributor

Good news and bad news.

Good news is I can get the project to a compiling state from this.

The bad news is that adding the type parameter is a breaking change. I encountered this when working on #1866; it seems that #1887 would want to permanently turn that option on if it needs this.

@parsonsmatt
Copy link
Copy Markdown
Collaborator Author

Can you clarify what you mean? ie that each use of mkSomeTh (ro :: RouteOpts) needs to enable the parameterized subsite flag? If you can show an example of some code that is breaking then I'd be happy to try and make the backwards compatibility work out for y'all.

@L0neGamer
Copy link
Copy Markdown
Contributor

So, the changes you make add the type arguments to the type of the subroutes, but that is a breaking change if any of the subroutes are used in a type signature.

For example if we take the following example from before:

-- routes.yesodroutes
!/#{AssociatedType subsite}/route RouteR':
  / RouteR GET POST DELETE

Then we will get the following types and constructors:
Route (SomethingThatUses subsite), with constructor RouteR' (AssociatedType subsite) RouteR'
RouteR', with constructor RouteR
But after your changes (and my flag elsewhere), the result will be
Route (SomethingThatUses subsite), with constructor RouteR' (AssociatedType subsite) (RouteR' subsite)
RouteR' subsite, with constructor RouteR

I hope you can see the change; the type RouteR' goes from having no type arguments to having one.

chiroptical and others added 4 commits March 17, 2026 15:36
* Initial commit

* Add basic tests for parent attr inclusion

* Minimize language extensions

* Add since annotation based on directions

* Add Lift instance for Set from th-lift-instances

* Add th-lift-instances

* Add th-lift-instances to test targets

* Use record to represent parent details over tuple

* Add changelog entry

* Update ChangeLog
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants