Redwood Router with layouts, context providers, etc

I’m continuing the discussion here, because this post is long, and the forum works better for that than chat…

So what I’d want to be able to express is something like this

<Router>
  <MainLayout>
    <AdminContextProvider>
      <AdminLayout>
        <Private unauthenticated="home">
          <Route path="/admin/posts/new" page={NewPostPage} name="newPost" />
          <Route path="/admin/posts/{id:Int}/edit" page={EditPostPage} name="editPost" />
          <Route path="/admin/posts/{id:Int}" page={PostPage} name="post" />
          <Route path="/admin/posts" page={PostsPage} name="posts" />
        </Private>
      </AdminLayout>
    </AdminContextProvider>
 
    <FooBarBazContextProvider>
      <Route path="/foo" page={FooPage} name="foo" />
      <Route path="/bar" page={BarPage} name="bar" />
      <BarWizardContextProvider>
        <WizardLayout>
          <TransitionGroup>
            <CSSTransition>
              <Route path="/bar/wizard/step1" page={BarWizOnePage} name="barWizOne" />
              <Route path="/bar/wizard/step2" page={BarWizTwoPage} name="barWizTwo" />
              <Route path="/bar/wizard/step3" page={BarWizThreePage} name="barWizThree" />
              <Route path="/bar/wizard/step4" page={BarWizFourPage} name="barWizFour" />
            </CSSTransition>
          </TransitionGroup>
        </WizardLayout>
      </BarWizardContextProvider>
      <Route path="/baz" page={BazPage} name="baz" />
      <BazWizardContextProvider>
        <WizardLayout>
          <TransitionGroup>
            <CSSTransition>
              <Route path="/baz/wizard/step1" page={BazWizOnePage} name="bazWizOne" />
              <Route path="/baz/wizard/step2" page={BazWizTwoPage} name="bazWizTwo" />
              <Route path="/baz/wizard/step3" page={BazWizThreePage} name="bazWizThree" />
            </CSSTransition>
          </TransitionGroup>
        </WizardLayout>
      </BazWizardContextProvider>
    </FooBarBazContextProvider>

    <Route path="/" page={HomePage} name="home" />
    <Route path="/about" page={AboutPage} name="about" />
    <Route notfound page={NotFoundPage} />
  </MainLayout>
</Router>

What I don’t like about that though is that it clutters my router and makes it harder to get that nice “site map” thing going that I get with the way the router currently looks. And also, context providers really doesn’t have anything to do with routing, and neither do transition groups, so maybe shouldn’t be here because of that reason. Same thing could maybe be said about the layouts as well.

So, from that example above, what I would really like to look at is this:

<Router>
  <PrivateRoute path="/admin/posts/new" page={NewPostPage} name="newPost" />
  <PrivateRoute path="/admin/posts/{id:Int}/edit" page={EditPostPage} name="editPost" />
  <PrivateRoute path="/admin/posts/{id:Int}" page={PostPage} name="post" />
  <PrivateRoute path="/admin/posts" page={PostsPage} name="posts" />
 
  <Route path="/foo" page={FooPage} name="foo" />
  
  <Route path="/bar" page={BarPage} name="bar" />
  <Route path="/bar/wizard/step1" page={BarWizOnePage} name="barWizOne" />
  <Route path="/bar/wizard/step2" page={BarWizTwoPage} name="barWizTwo" />
  <Route path="/bar/wizard/step3" page={BarWizThreePage} name="barWizThree" />
  <Route path="/bar/wizard/step4" page={BarWizFourPage} name="barWizFour" />
  
  <Route path="/baz" page={BazPage} name="baz" />
  <Route path="/baz/wizard/step1" page={BazWizOnePage} name="bazWizOne" />
  <Route path="/baz/wizard/step2" page={BazWizTwoPage} name="bazWizTwo" />
  <Route path="/baz/wizard/step3" page={BazWizThreePage} name="bazWizThree" />

  <Route path="/" page={HomePage} name="home" />
  <Route path="/about" page={AboutPage} name="about" />
  <NotFoundRoute page={NotFoundPage} />
</Router>

I don’t have a good solution for how to solve this. But I do have some crazy ideas :crazy_face:

  1. Ask @aldonline if it’s possible to make the Redwood VS Code plugin show/hide the extra elements (layout, providers… everything except the routes), so you can choose if you want to see the full thing, or just the routes
  2. Introduce a config object to the router
    {
      unauthenticated: "home",
      layouts: {
        // some way to specify what layouts wrap what pages
      },
      contextProviders: {
        // same idea as layouts pretty much
      }
    }
    
  3. Specify the layouts and context providers separately from the router
    <Layouts>
      <MainLayout pages="foo, bar, home, about, notfoundpage">
        <AdminLayout pages="newPost, editPost, post, posts" />
        <WizardLayout pages="barWizOne, barWizTwo, barWizThree, barWizFour" />
        <WizardLayout pages="bazWizOne, bazWizTwo, bazWizThree" />
      </MainLayout>
    </Layouts>
    
    <Providers>
      <AdminContextProvider pages="newPost, editPost, post, posts" />
      <FooBarBazContextProvider pages="foo, bar, barWizOne, barWizTwo, barWizThree, barWizFour, bazWizOne, bazWizTwo, bazWizThree" />
      <BarWizardContextProvider pages="barWizOne, barWizTwo, barWizThree, barWizFour" />
      <BazWizardContextProvider pages="bazWizOne, bazWizTwo, bazWizThree" />
    </Providers>
    
    This would all just be synthetically. We would have to transpile the file to look like my big <Router> example up top.

I’m not thrilled about any of the options, so very open to other ideas. And I haven’t mentioned transitions. But I think those could go inside a layout.

Basically, I’m asking, “How do we bridge the gap between “needed” and “wanted””?

1 Like

One idea that has been floating around is to add the layout as an attribute to the route

<Route path="/foo" page={FooPage} name="foo" />
<Route path="/bar" page={BarPage} name="bar" />
<Route path="/bar/wizard/step1" page={BarWizOnePage} name="barWizOne" layout="WizardLayout" />
<Route path="/bar/wizard/step2" page={BarWizTwoPage} name="barWizTwo" layout="WizardLayout" />
<Route path="/bar/wizard/step3" page={BarWizThreePage} name="barWizThree" layout="WizardLayout" />
<Route path="/bar/wizard/step4" page={BarWizFourPage} name="barWizFour" layout="WizardLayout" />
<Route path="/baz" page={BazPage} name="baz" />
<Route path="/baz/wizard/step1" page={BazWizOnePage} name="bazWizOne" layout="WizardLayout" />
<Route path="/baz/wizard/step2" page={BazWizTwoPage} name="bazWizTwo" layout="WizardLayout" />
<Route path="/baz/wizard/step3" page={BazWizThreePage} name="bazWizThree" layout="WizardLayout" />

One piece of information that is easily lost here is that going from BarWizThreePage to BarWizOnePage should not re-render WizardLayout, but going from BarWizThreePage to BazWizOnePage should re-render. Looking at my first example in my original post you can easily see why that is the case – the two sub-trees are wrapped in two separate WizardLayouts.

Just saw this in a RW project on GitHub

image

This is a pattern that would be difficult to capture if we just add the layouts to the routes

<Route path="/" page={HomePage} name="home" layout="MainLayout" />

If <MainLayout> always comes together with <HeaderLayout> you could put the header inside the main layout. But it is less flexible.

Ran into another router-related limitation yesterday. I wanted to have a context that wraps my entire app, but that has access to search from const { search } = useLocation().

This is currently not possible because the Location context that has that stuff comes from <Router>, and we can’t put contexts directly inside <Router> to wrap all routes.

Router RFC

I have finally come up with an idea that I think solves all my issues with the current router. And I hope it retains the original ideas behind the router design. I’m happy enough with this proposal that I’d like to try to implement it, if I get the go-ahead.

Basically I want to make Layouts a bit more magic and more central to how Redwood works.

Current issues

  • Layouts are re-rendered every time the page re-renders
  • There is no way to scope a context to a set of pages
  • It’s not clear what the difference is between layouts and regular components. Layouts can be used as components and components can be used as layouts, so why do we even have both?
  • We already have nesting in the router, so people is always going to ask for more nesting (see here for example)

This proposal tries to solve all those issues

Basic example from the tutorial

This is what the routes would look like for the tutorial:

<Router unauthenticated="home">
  <BlogRoute path="/contact" page={ContactPage} name="contact" />
  <BlogRoute path="/blog-post/{id:Int}" page={BlogPostPage} name="blogPost" />
  <PrivateRoute path="/admin/posts/new" page={NewPostPage} name="newPost" />
  <PrivateRoute path="/admin/posts/{id:Int}/edit" page={EditPostPage} name="editPost" />
  <PrivateRoute path="/admin/posts/{id:Int}" page={PostPage} name="post" />
  <PrivateRoute path="/admin/posts" page={PostsPage} name="posts" />
  <BlogRoute path="/about" page={AboutPage} name="about" />
  <BlogRoute path="/" page={HomePage} name="home" />
  <NotFoundRoute page={NotFoundPage} />
</Router>
  • This would give you a totally flat router. No more “Why can I wrap my routes in <Private>, but not in <MyFavoriteWrapper>?” questions.
  • The unauthenticated prop has moved from <Private>, which we don’t have anymore, to <Router> instead
  • Each <*Route> would load the corresponding layout automatically, and wrap the route in that layout. So <BlogRoute path="/about" page={AboutPage}> would automatically load <BlogLayout> and render AboutPage where {children} is in BlogLayout
  • <PrivateRoute> both defines the route as private, and also loads PrivateLayout (if it exists)
  • <NotFoundRoute> both defines this as the 404 route, and also loads NotFoundLayout (if it exists)
  • Except for the new way of specifying private pages and the 404 page this is 100% backwards compatible. If only <Route path="..." ...> is specified, no layout will be automatically loaded. And all layouts can still be imported and rendered in pages as they currently are.

More involved example from the first post in this thread

The tutorial project is pretty small and simple. My first post in this thread had a bigger, more complex example. This is what that example would look like

<Router unauthenticated="home">
  <PrivateRoute path="/admin/posts/new" page={NewPostPage} name="newPost" />
  <PrivateRoute path="/admin/posts/{id:Int}/edit" page={EditPostPage} name="editPost" />
  <PrivateRoute path="/admin/posts/{id:Int}" page={PostPage} name="post" />
  <PrivateRoute path="/admin/posts" page={PostsPage} name="posts" />

  <FooBarBazRoute path="/foo" page={FooPage} name="foo" />
  <FooBarBazRoute path="/bar" page={BarPage} name="bar" />
  <FooBarBazWizardRoute path="/bar/wizard/step1" page={BarWizOnePage} name="barWizOne" />
  <FooBarBazWizardRoute path="/bar/wizard/step2" page={BarWizTwoPage} name="barWizTwo" />
  <FooBarBazWizardRoute path="/bar/wizard/step3" page={BarWizThreePage} name="barWizThree" />
  <FooBarBazWizardRoute path="/bar/wizard/step4" page={BarWizFourPage} name="barWizFour" />
  <FooBarBazRoute path="/baz" page={BazPage} name="baz" />
  <FooBarBazWizardRoute path="/baz/wizard/step1" page={BazWizOnePage} name="bazWizOne" />
  <FooBarBazWizardRoute path="/baz/wizard/step2" page={BazWizTwoPage} name="bazWizTwo" />
  <FooBarBazWizardRoute path="/baz/wizard/step3" page={BazWizThreePage} name="bazWizThree" />

  <Route path="/" page={HomePage} name="home" />
  <Route path="/about" page={AboutPage} name="about" />

  <NotFoundRoute page={NotFoundPage} />
</Router>
  • Compared to the original example this version doesn’t have a <MainLayout> that wraps all the routes. This is solved by the router always loading RouterLayout if it exists (or whatever special name we decide on), and wrapping everything in that.
  • In the original example there was one FooBarBazLayout that had two WizardLayout as children. This is only partly solved in this new example. What I was thinking is that when we look for a layout to wrap the current route in, we don’t only look for an exact match on the whole name, but also for 0-index anchored sub-strings, and then load that layout first, if we find one. So in this case, when looking for FooBarBazWizardLayout we’d find FooBarBazLayout, so we’d load that, and then put WizardLayout inside, as a child.
    • What we don’t solve is that, in the original example, when you navigate from a page inside one group of WizardLayout routes, directly in to a page in the other group we’d re-mount the layout. Here everyone is children of just one instance of WizardLayout. If this was a real problem the user would just have to create two layouts, e.g. WizardOneLayout and WizardTwoLayout, who both probably are just basically reexporting WizardLayout. The user would then have <FooBarBazWizardOneRoute> routes and <FooBarBazWizardTwoRoute> routes
  • Contexts are not shown anywhere. They can be placed inside the layouts if needed.

I think there’s only one more thing I haven’t addressed so far, and that’s the use case I found where someone had two different layouts as siblings in a page. I think this is happening because 1. we’re currently importing and using layouts in pages. 2. we haven’t been super clear on how/where layouts are supposed to be used. I think if the tutorial showed this new way of working with layouts then that’s what people would do. And, as I noted earlier, if someone still wanted to do it like this, they totally could, and it would work just as well/bad as it does today.

I’m hoping this can all be done with babel, but I’ve never written a babel transform plugin, so I can’t be sure. Please tell me if this doesn’t look doable! Is it possible to keep VSCode happy about this? So that it doesn’t complain about using undefined components. I mean, there really isn’t a component called <BlogRoute>. I also have one more idea, that builds on top of this concept I’ve proposed here, but let’s take one thing at a time, eh? :slight_smile:

TL;DR

Layouts are automatically loaded based on route component names and this solves all our problems.

1 Like

Not many seconds passed after I wrote the post above before I realized it’s still too limiting.

One thing it still doesn’t solve is this: Detect subdomain via router, or globally in pages or Cells

Another thing is, what if I want to pass props to my layouts? Can’t do that if they’re automagically wrapped around the pages.

Looks more and more like the easiest option would just be to go with React Router :man_shrugging: Either as-is, or build something on-top of it.

@Tobbe would you be interested in taking time to present and discuss this at the next Contributor’s Meetup?

  • Show your RFC and the issues it resolves
  • Then highlight the limitations

Helpful? And thoughts/suggestions otherwise?

Yes please :slight_smile:

2 Likes

And just a suggestion from someone who has been trying to follow this discussion over the last few months, I think it would also be useful for Tom to give a really brief 1-2 minute explanation for exactly why the router is set up the way it is (flat instead of nested) cause it’s something that gets briefly mentioned every now and then but it isn’t something that I’ve ever been able to fully grok.

2 Likes

My latest idea:

<Router unauthenticated="home" groups="main">
  <Route path="/foo" page={FooPage} name="foo" groups="foobarbaz" />
  <Route path="/bar" page={BarPage} name="bar" groups="foobarbaz" />
  <Route path="/bar/wizard/step1" page={BarWizOnePage} name="barWizOne" groups={['foobarbaz', 'barwizzard']} />
  <Route path="/bar/wizard/step2" page={BarWizTwoPage} name="barWizTwo" groups={['foobarbaz', 'barwizzard']} />
  <Route path="/bar/wizard/step3" page={BarWizThreePage} name="barWizThree" groups={['foobarbaz', 'barwizzard']} />
  <Route path="/bar/wizard/step4" page={BarWizFourPage} name="barWizFour" groups={['foobarbaz', 'barwizzard']} />
  <Route path="/baz" page={BazPage} name="baz" groups="foobarbaz" />
  <Route path="/baz/wizard/step1" page={BazWizOnePage} name="bazWizOne" groups={['foobarbaz', 'bazwizzard']} />
  <Route path="/baz/wizard/step2" page={BazWizTwoPage} name="bazWizTwo" groups={['foobarbaz', 'bazwizzard']} />
  <Route path="/baz/wizard/step3" page={BazWizThreePage} name="bazWizThree" groups={['foobarbaz', 'bazwizzard']} />
</Router>

Here’s the example Router code we ended up with in the 2020-12-15 core meeting:

const DarkLayout = (props) => {
  return <AppLayout {...props} theme="dark" />
}

<Router>
  <Set wrap={[MarketingLayout, HeaderLayout]}>
    <Route path="/home" page={HomePage} />
    <Route path="/pricing" page={PricingPage} />
  </Set>

  <Private unauthenticated="home">
    <Set wrap={[AdminContext, DarkLayout]}>
      <Route path="/admin/users" page={UsersPage} />
      <Route path="/admin/posts" page={PostsPage} />
    </Set>

    <Set wrap={(props) => <AppLayout {...props} theme="light" />}>
      <Route path="/utility" page={UtilityPage} />
    </Set>
  </Private>

  <Route notfound={NotFoundPage} />
</Router>

Slight detour: based on this nested router example, I wonder if behind the scenes there’s really only one tag, <Route> that can take all of these different props, and <Set> and <Private> are just synonyms that let you make the routes easier to read and nest here if you want. But for simplicity in the underlying router code it’s all just calling <Route> and it can, optionally, be nested.

So as far as the router’s “parser” is concerned, the routes could have been defined as:

<Router>
  <Route path="/home" page={HomePage} layout={[MarketingLayout, HeaderLayout]}>
  <Route path="/pricing" page={PricingPage} layout={[MarketingLayout, HeaderLayout]} />
  <Route path="/admin/users" page={UsersPage} context={AdminContext} layout={() => <AppLayout navbar={true} private unauthenticated="home" />
  <Route path="/admin/posts" page={PostsPage} context={AdminContext} layout={() => <AppLayout navbar={true} private unauthenticated="home" />
  <Route path="/utility" page={UtilityPage} layout={() => <AppLayout navbar={false} />} private unauthenticated="home" />
  <Route notfound={NotFoundPage} />
</Router>

Maybe that would simplify the situation in the source instead of maintaining separate functionality in <Route>, <Set> and <Private> tags? Maybe?

1 Like

That’s the way it currently works. The router code loops through all children of <Private> and adds private to them as a prop, and then it removes <Private>. So you just end up with a single list of <Route> components

If I want a context that wraps all my routes, do I add context={MainContext} to <Router> at the top, or do I put a <Set context={MainContext}> just below it that wrapps all other <Set>s and all <Route>s?

There is nothing really special about Layout or Contexts, right? So, instead of

<Set layout={[MarketingLayout, HeaderLayout]} context={MainContext}>

couldn’t we just do

<Set wrapperComponents={[MainContext, MarketingLayout, HeaderLayout]}>

That way we wouldn’t have to add any new props if we wanted to add something like transitions (like this https://reactrouter.com/web/example/animated-transitions )

<Set wrapperComponents={[MainContext, MarketingLayout, HeaderLayout]}>

Ooooo interesting…I’m thinking we’d need a more concise name, but the idea is intriguing… /cc @mojombo

I wonder if behind the scenes there’s really only one tag, <Route> that can take all of these different props, and <Set> and <Private> are just synonyms that let you make the routes easier to read and nest here if you want.

@rob Ooh, I like the idea of being able to set the invidual props on the <Route>s or set them in an enclosing <Set>. That is conceptually very simple and gives some flexibility. What happens if both a Set and a Route define a prop? I guess the Route wins, yes? Maybe it’s a warning too, as that could get pretty confusing?

There is nothing really special about Layout or Contexts, right?

@Tobbe Good point! The only requirement is that they need to render their children. In that case, I suggest:

<Set wrap={MyLayout}>
<Set wrap={[MainContext, MainLayout]}>

We can also automatically send all these components the URL params, just like we do for Pages, so if they wanted to, they could use that stuff directly. Of course, they could also call useParams and friends.

We need to figure out how these are imported. Pages are auto-imported, which is also how they get code-split. Do we require you to manually import your wrappers? In that case, they will all get included in the main bundle.

1 Like

This is a good question. I’d want to auto-import them, but that does require us to know where in the filesystem they live. We can look for components named *Layout in /src/layouts/, but what if it’s <Set wrap={[MyLayout, MyAwesomeWrapper]}>, where do we find MyAwesomeWrapper?

It doesn’t have to be all or nothing. We can auto-import what we can, and require the user to manually import the rest. Like we can say “We autoimport all layouts and all contexts, the rest is up to you”. And even for things we have the users import themselves, they can still get code splitting, right? If they just put this at the top of the router file

const MyAwesomeWrapper = {
  name: 'MyAwesomeWrapper',
  loader: () => import('/src/wherever/MyAwesomeWrapper'),
}

Or they could even use React’s <Suspense> and lazy().

Going through one of my Redwood apps and I’m overwhelmed by scaffolded routes again and thinking of ways to consolidate. And this is only with three scaffolds! VS Code wrapping them doesn’t help, either:

<Router>
  <Route path="/" page={HomePage} name="home" />
  <Private unauthenticated="home">
    <Route path="/contacts" page={ContactsPage} name="contacts" />
    <Route path="/contacts/{id:Int}" page={ContactPage} name="contact" />
    <Route path="/settings" page={SettingsPage} name="settings" />
  </Private>
  <Private unauthenticated="home" role="admin">
    <Route
      path="/admin/contacts/new"
      page={AdminNewContactPage}
      name="adminNewContact"
    />
    <Route
      path="/admin/contacts/{id:Int}/edit"
      page={AdminEditContactPage}
      name="adminEditContact"
    />
    <Route
      path="/admin/contacts/{id:Int}"
      page={AdminContactPage}
      name="adminContact"
    />
    <Route
      path="/admin/contacts"
      page={AdminContactsPage}
      name="adminContacts"
    />

    <Route
      path="/admin/users/new"
      page={AdminNewUserPage}
      name="adminNewUser"
    />
    <Route
      path="/admin/users/{id:Int}/edit"
      page={AdminEditUserPage}
      name="adminEditUser"
    />
    <Route
      path="/admin/users/{id:Int}"
      page={AdminUserPage}
      name="adminUser"
    />
    <Route path="/admin/users" page={AdminUsersPage} name="adminUsers" />
    <Route
      path="/admin/preferences/new"
      page={AdminNewPreferencePage}
      name="adminNewPreference"
    />
    <Route
      path="/admin/preferences/{id:Int}/edit"
      page={AdminEditPreferencePage}
      name="adminEditPreference"
    />
    <Route
      path="/admin/preferences/{id:Int}"
      page={AdminPreferencePage}
      name="adminPreference"
    />
    <Route
      path="/admin/preferences"
      page={AdminPreferencesPage}
      name="adminPreferences"
    />
  </Private>

  <Route notfound page={NotFoundPage} />
</Router>

I haven’t build a Redwood app large enough yet, but my last major Rails app had 38 of them. That’d be 266 routes (!) if each one had to be declared literally! (Redwood equivalent would only be 152 routes.) Rails has the resources shortcut, can we do something similar with <Route>?

<Route path="/admin/users" scaffold="user" />

You’ve still got the path prop so it’s clear what the URL is to get to them and scanning through the routes still works.

You’re always free to use the more verbose route declarations if you want, but for the (probably more common case) of keeping the scaffold as admin pages, it removes a lot of mental overhead.

Look at Routes.js now, so tidy:

<Router>
  <Route path="/" page={HomePage} name="home" />
  <Private unauthenticated="home">
    <Route path="/contacts" page={ContactsPage} name="contacts" />
    <Route path="/contacts/{id:Int}" page={ContactPage} name="contact" />
    <Route path="/settings" page={SettingsPage} name="settings" />
  </Private>
  <Private unauthenticated="home" role="admin">
    <Route path="/admin/users" scaffold="user" />
    <Route path="/admin/contacts" scaffold="contact" />
    <Route path="/admin/preferences" scaffold="preference" />
  </Private>
  <Route notfound page={NotFoundPage} />
</Router>
1 Like

:100: RC. That makes a ton of sense.

With this, I could imagine users more likely to keep the generated scaffold code/routes. Otherwise, per your current Routes.js file, it seems people will be inclined to find their own ways to consolidate.

1 Like