Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parallel Routes #426

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open

Parallel Routes #426

wants to merge 16 commits into from

Conversation

Brendonovich
Copy link
Contributor

@Brendonovich Brendonovich commented May 22, 2024

This PR adds basic support for Parallel Routes, a feature I've only seen in Next.js.

The following is my reasoning for why Solid Router should support them, but I think Next and this Remix Proposal do a much better job of explaining them.

Proposal

Parallel routes allow layouts to have multiple children, aka slots. Each of these slots define their own routes, generate their own set of matches, and can be placed anywhere in the layout just like regular children (children in fact are just a fancy slot).

This is useful for situations like the following from Mattrax (simplified for this example).

image

At a high level, there's a root layout that renders a header including breadcrumbs, and then the actual page is rendered below that.
Currently, the header would need to use something like useCurrentMatches, deciding which breadcrumbs should be rendered manually.

With parallel routes, the breadcrumbs become their own slot. The router calculates a separate branch of matches specifically for the breadcrumbs' routes, and provides the rendered components inside props.slots.breadcrumbs instead of props.children. It really is just allowing multiple sets of children.

The config for this would look something like this:

const routes = {
  component: (props) => {
    return (
      <>
        {props.slots.breadcrumbs}
        <main>{props.children}</main>
      </>
    )
  },
  children: [
    {
      path: "/o/:orgSlug",
      children: [
        { path: "/", component: () => "Org Information" },
        { path: "/t/:tenantSlug", component: () => "Tenant Information" }
      ]
    }
  ],
  slots: {
    breadcrumbs: {
      component: (props) => <header>{props.children}</header>,
      children: [{ 
        path: "/o/:orgSlug",
        component: (props) => (
          <>
            <span>Org Breadcrumb</span>
            {props.children}
          </>
        ),
        children: [
          { 
            path: "/t/:tenantSlug", 
            component: () => <span>Tenant Breadcrumb</span>,
            // necessary to render on subpaths
            children: [{ path: "/*rest" }] 
          },
          // necessary to render on subpaths
          { path: "/*rest" }
        ]
      }]
    }
  }
}

Another thing that's great about this is that since slots get their own matches, they also get their own loaders, so our breadcrumbs can define the exact data they need to be preloaded at a route level, rather than having to cram it all into one loader for the root layout.

Unresolved Questions

Default

In Next.js, when a slot mounts but doesn't have any matches it will render a default.js view if one is available.
I'm not sure if solid router needs an equivalent default: ... field for slots. Catchall routes in the slot + its nested children do the same job (as they would in nextjs), but having a single default option to fall back on would be kinda nice.
It would also need to be used in the case that only slots have matches and no children do, in which case the layout route itself would need default rather than its slots.
To see this in action, go to this playground and notice that while the audience slot has a match, the other slots do not and are fallback back to their default page. It's basically a catchall that applies to nested routes too.
Also notice that loading the home route and the navigating to a slot-specific route doesn't display the default page, because of the below behaviour.

Match persistence

In Next.js, when slot A and B both have a match, but then a navigation happens that causes only slot A to have a match, slot B will continue display the match it had previously. I haven't implemented this but it seems pretty cool to have.
To see this in action, navigate between routes in the audience slot of this playground and notice that the views slot remains on 'Home'. If you navigate between the routes in the views slot, the last selected route in the audience slot remains selected, unless 'Home' is picked in either slot, at which point both slots match and 'Home' ends up being selected in both slots.

TODO before merge

  • Ensure that disposal is handled 100% correctly for slot branches
    • Initial navigations between breadcrumb layers in Mattrax currently cause the navItems slot to remount Was being caused by not accounting for props.children being undefined while lazy loading nav items
    • Need to make sure there's 0 memory leaks - Looks like everything's being disposed of properly from working on Mattrax

Copy link

changeset-bot bot commented May 22, 2024

🦋 Changeset detected

Latest commit: fffdf1c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@solidjs/router Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Brendonovich Brendonovich marked this pull request as ready for review June 11, 2024 10:18
@ryansolid
Copy link
Member

Ok looking at this now. First couple notes on the PR. I've done some updates so this will need to be rebased. Should be minor but putting that out there. Also you might notice I tend to avoid optional chaining in code. Mostly it is because some CDNs have issues with it. I got into that habit with Solid core and have continued with the other libraries. That's why there are so many chained &&s

As for the feature itself. Yeah I think it is pretty compelling and doesn't look like it adds too much extra code . I do think we need to have opinions on the unresolved things. Can you show me the syntax for the default for a slot. Is it another field at the same level as component/children directly inside the slot.. like:

breadcrumbs: {
      component: (props) => <header>{props.children}</header>,
      default: {
        component: //...
      }
      children: //....

And you are saying that is the equivalent of adding a children with path * inside the slot? If so I'm tempted not to bother with default here. Maybe that can be a file system routing thing.

More generally slots can have slots right?.. Basically anywhere there is a children field there could be a slot field beside it. Slots get injected at the same level as children. I think default also being at the same level as children starts getting ambiguous. Like routes not using slots could also set default as well and the only difference is not having access to the named wildcard in the path I think? I guess every slot resolves out on the matches on its own as well so they would have their own path params. Like you could name :id and name it :userId in a slot and as long as the pattern matched for both they'd have that respective name in each location.

Match Persistence seems like it is one of the reasons people would go for such an approach in the first place. It is interesting that on refresh you need default slots because not all navigation information is encoded in the URL. But I have to admit I'm having a hard time picturing any UI that looked like that Next example that I've ever used that had any connection to routing. Honestly I'm having a hard time looking past breadcrumbs or maybe like more info pangels for the use of this feature. That's enough mind you. But I feel things like Match Persistence are the things this can do that would be hard otherwise.

In any case I've seen a number of thumbs up on the issue and there is obviously interest on my part. I'd love to understand the use cases or how people would use the feature a bit more because while it looks like the code doesn't get much trickier this is a big concept to teach and explain.

@Brendonovich
Copy link
Contributor Author

Brendonovich commented Jul 13, 2024

Also you might notice I tend to avoid optional chaining in code. Mostly it is because some CDNs have issues with it

Ah ok, if you want I can configure rollup and typescript to transpile away the optional chains so we can keep the nicer dx

Can you show me the syntax for the default for a slot. Is it another field at the same level as component/children directly inside the slot.. like:

It'd be similar to what you did except the component wouldn't be necessary, since default itself just expects a component.

breadcrumbs: {
      component: (props) => <header>{props.children}</header>,
      default: (props) => "Default",
      children: //....

And you are saying that is the equivalent of adding a children with path * inside the slot?

I think so, but i'm not 100% certain on the behaviour of * catchalls. I'll try and get some insight from the Next team for why they didn't just use catchalls.

More generally slots can have slots right?

Yes, as an example Mattrax uses a few nested slots. We have the @topbar slot of the main layout, and then that has both @breadcrumbs and @navItems slots so that the shared topbar layout can have top and bottom sections that route independently.

Basically anywhere there is a children field there could be a slot field beside it. Slots get injected at the same level as children

Basically yeah, since children itself is just a slot with a dedicated label.

I think default also being at the same level as children starts getting ambiguous. Like routes not using slots could also set default as well and the only difference is not having access to the named wildcard in the path I think?

This isn't the case, default could only be applied to route configs directly inside a slots object, not just adjacent to any children field. RouteDefinition would look something like this:

export type RouteDefinition = {
  path?: string;
  children?: RouteDefinition | RouteDefinition[];
  slots?: Record<string, Omit<RouteDefinition, 'path'> & { default?: Component }>
}

I guess every slot resolves out on the matches on its own as well so they would have their own path params

The slots themselves wouldn't have path params, but the slot's children could. Note that path is being omitted from RouteDefinition in the type above.

But I feel things like Match Persistence are the things this can do that would be hard otherwise.

Yeah I'm not sure when I'd actually use match persistence, but having it seems nice since it's a feature that has to be implemented at router-level.

@Brendonovich
Copy link
Contributor Author

After playing with the Next example more, I've realised this implementation doesn't act the same way as Next.
This implementation requires that there be a match for children for a slot to render as well, but with Next you can have just a slot be matched like this and the main children will show default.js. Next treats children literally as an implicit slot and does everything in parallel, I'll change this to do the same.

@Brendonovich Brendonovich deleted the branch solidjs:main August 5, 2024 13:24
@Brendonovich Brendonovich deleted the main branch August 5, 2024 13:24
@Brendonovich Brendonovich restored the main branch August 5, 2024 13:27
@Brendonovich Brendonovich reopened this Aug 5, 2024
@Brendonovich Brendonovich deleted the branch solidjs:main August 5, 2024 13:28
@Brendonovich Brendonovich deleted the main branch August 5, 2024 13:28
@Brendonovich Brendonovich restored the main branch August 5, 2024 13:29
@Brendonovich
Copy link
Contributor Author

turns out 'rename branch' just means 'delete and recreate with a new name' 🤦

@paularmstrong
Copy link

Is there any help currently needed with this PR to get consideration continued? I'm increasingly finding that our designs have needs that could be much easier accomplished with this parallel routes support instead of needing to wrap every route file in the generic layout and passing props individually. The ability to have more generic layouts higher up and avoid re-rendering large parts of them, while still changing others would save a lot of hassle.

Happy to beta test or help out if we think this can keep moving forward!

@Brendonovich
Copy link
Contributor Author

I haven't had time to make this implementation more like Next's, and at some point I'd like to chat with @tannerlinsley about his ideas for parallel routes. Some help with the former would be appreciated, though the router's internals are quite something to navigate 😅

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.

3 participants