rescript-relay-router
Version:
A ReScript web router for RescriptRelay.
616 lines (450 loc) • 28.2 kB
Markdown
# rescript-relay-router
A router designed for scale, performance and ergonomics. Tailored for usage with `rescript-relay`. A _modern_ router, targeting modern browsers and developer workflows using `vite`.
## Features
- Nested layouts
- Render-as-you-fetch
- Preload data and code with full, zero effort type safety
- Granular preloading strategies and priorities - when in view, on intent, etc
- Fine grained control over rendering, suspense and error boundaries
- Full type safety
- First class query param support
- Scroll restoration
- Automatic code splitting
- Automatic release/cleanup of Relay queries no longer in use
## Setting up
The router requires the following:
- `vite` for your project setup, `>2.8.0`.
- [`build: { target: "esnext" }`](https://vitejs.dev/config/#build-target) in your `vite.config.js`.
- `"type": "module"` in your `package.json`, meaning you need to run in es modules mode.
- Your Relay config named `relay.config.cjs`.
- Preferably `yarn` for everything to work smoothly.
- `rescript-relay@>=1.1.0`
Install the router and initialize it:
```bash
# Install the package
yarn add rescript-relay-router
# Initiate the router itself
yarn rescript-relay-router init
# Run the first generate command manually. This will run automatically when everything is setup
yarn rescript-relay-router generate -scaffold-renderers
```
This will create all necessary assets to get started. Now, add the router to your `bsconfig.json`:
```json
"bs-dependencies": [
"@rescript/react",
"rescript-relay",
"rescript-relay-router"
]
```
Worth noting:
- The router relies on having a _single folder_ where router assets are defined. This is `./src/routes` by default, but can be customized. Route JSON files and route renderes _must_ be placed inside of this folder.
- The router will generate code as you define your routes. By default, this code ends up in `./src/routes/__generated__`, but the location can be customized. This generated code is safe to check in to source control.
Now, add the router Vite plugin to your `vite.config.js`:
```js
import { rescriptRelayVitePlugin } from "rescript-relay-router";
export default defineConfig({
plugins: [rescriptRelayVitePlugin()],
});
```
Restart Vite. Vite will now watch and autogenerate the router from your route definitions (more on that below).
Let's set up the actual ReScript code. First, let's initiate our router:
```rescript
// Router.res
let preparedAssetsMap = Dict.make()
// `cleanup` does not need to run on the client, but would clean up the router after you're done using it, like when doing SSR.
let (_cleanup, routerContext) = RelayRouter.Router.make(
// RouteDeclarations.res is autogenerated by the router
~routes=RouteDeclarations.make(
// prepareDisposeTimeout - How long is prepared data allowed to live without being used before it's
// potentially cleaned up? Default is 5 minutes.
~prepareDisposeTimeout=5 * 60 * 1000
),
// This is your Relay environment
~environment=RelayEnv.environment,
// SSR coming soon. For now, initiate a browser environment for the router
~routerEnvironment=RelayRouter.RouterEnvironment.makeBrowserEnvironment(),
~preloadAsset=RelayRouter.AssetPreloader.makeClientAssetPreloader(preparedAssetsMap),
)
```
Now we can take `routerContext` and wrap our application with the router context provider:
```rescript
<RelayRouter.Provider value={Router.routerContext}>
<React.Suspense fallback={React.string("Loading...")}>
<RescriptReactErrorBoundary fallback={_ => React.string("Error!")}>
<App />
</RescriptReactErrorBoundary>
</React.Suspense>
</RelayRouter.Provider>
```
Finally, we'll need to render `<RelayRouter.RouteRenderer />`. You can render that wherever you want to render your routes. It's typically somewhere around the top level, although you might have shared things unaffected by the router that you want to wrap the route renderer with.
```rescript
// App.res or similar
<RelayRouter.RouteRenderer
// This renders all the time, and when there's a pending navigation (pending via React concurrent mode), pending will be `true`
renderPending={pending => <div>{pending ? React.string("Loading...") : React.null}</div>}
/>
```
There, we're all set! Let's go into how routes are defined and rendered.
## Defining and rendering routes
### Route JSON files
Routes are defined in JSON files. Route JSON files can include other route JSON files. This makes it easy to organize route definitions. Each route has a name, a path (including path params), query parameters if wanted, and child routes that are to be rendered inside of the route.
> Route files are interpreted JSONC, which means you can add comments in them. Check the example below.
`routes.json` is the entry file for all routes. Example `routes.json`:
```json
[
{
"name": "Organization",
"path": "/organization/:slug",
"children": [
// Look, a comment! This works fine because the underlying format is jsonc rather than plain JSON.
// Good to provide contextual information about the routes.
{ "name": "Dashboard", "path": "" },
{ "name": "Members", "path": "members?showActive=bool" }
]
},
{ "include": "adminRoutes.json" }
]
```
Route names must:
1. Start with a capitalized letter
2. Contain only letters, digits or underscores
Any routes put in another route's `children` is _nested_. In the example above, this means that the `Organization` route controls rendering of all of its child routes. This enables nested layouts, where layouts can stay rendered and untouched as URL changes in ways that does not affect them.
To create a "catch all" route, use the `*`, character as the route path. Typically used for the "not found" route. Example:
```json
[
{
"name": "NotFound",
"path": "*"
}
]
```
> The router uses the matching logic from [`react-router`](https://reactrouter.com/docs/en/v6) under the hood.
### Route renderers
Each defined route expects to have a _route renderer_ defined, that instructs the router how to render the route. The router will automatically generate route renderer files for any route, that you can then just "fill in".
The route renderers needs to live inside of the defined routes folder, and the naming of them follow the pattern of `<uniqueRouteName>_route_renderer.res`. `<uniqueRouteName>` is the fully joined `name`s from the route definition files that leads to the route. Each route renderer is also _automatically code split_ without you needing to do anything in particular.
In the example above, the route renderer for `Organization` would be `Organization_route_renderer.res`. And for the `Dashboard` route, it'd be `Organization__Dashboard_route_renderer.res`.
A route renderer looks like this:
```rescript
// This creates a lazy loaded version of the <OrganizationDashboard /> component, that we can code split and preload. Very handy!
// You're encouraged to always code split like this for performance, even if the route renderer itself is automatically code split.
// The router will intelligently load the route code as it's likely to be needed.
module OrganizationDashboard = %relay.deferredComponent(OrganizationDashboard.make)
// Don't worry about the names/paths here, it will be autogenerated for you
let renderer = Routes.Organization.Dashboard.Route.makeRenderer(
// prepareCode lets you preload any _assets_ you want to preload. Here we preload the code of our codesplit component.
// It receives the same props as `prepare` below.
~prepareCode=_ => [OrganizationDashboard.preload()],
// prepare let's your preload your data. It's fed a bunch of things (more on that later). In the example below, we're using the Relay environment, as well as the slug, that's a path parameter defined in the route definition, like `/campaigns/:slug`.
~prepare=({environment, slug}) => {
// HINT: This returns a single query ref, but remember you can return _anything_ from here - objects, arrays, tuples, whatever. A hot tip is to return an object that doesn't require a type definition, to leverage type inference.
OrganizationDashboardQuery_graphql.load(
~environment,
~variables={slug: slug},
~fetchPolicy=StoreOrNetwork,
(),
)
},
// Render receives all the config `prepare` receives, and whatever `prepare` returns itself. It also receives `childRoutes`, which is any rendered route nested inside of it. So, if the route definition of this route has `children` and they match, the rendered output is in `childRoutes`. Each route with children is responsible for rendering its children. This makes layouting easy.
~render=props => {
<OrganizationDashboard queryRef=props.prepared> {props.childRoutes} </OrganizationDashboard>
},
)
```
And, just for clarity, `OrganizationDashboard` being rendered looks something like:
```rescript
// OrganizationDashboard.res
module Query = %relay(`
query OrganizationDashboardQuery($slug: ID!) {
organizationBySlug(slug: $slug) {
...SomeFragment_organization
}
}
`)
.component
let make = (~queryRef) => {
let data = Query.usePreloaded(~queryRef)
....
}
```
Now, let's look at what props each part of the route renderer receives:
`prepare` will receive:
- `environment` - The Relay environment in use.
- `location` - the full location object, including pathname/search/hash etc.
- Any path params. For a path like `/some/route/:thing/:otherThing`, `prepare` would receive: `thing: string, otherThing: string`.
- Any query params. For a path like `/some/route/:thing?someParam=bool&anotherParam=array<string>`, `prepare` would receive `someParam: option<bool>, anotherParam: option<array<string>>`. More on query params later.
- `childParams?` - _If_ the route's child routes has path params, they'll be available here.
`prepareCode` will receive the same props as `prepare` above.
`render` will receive the same things as `prepare`, and in addition to that it'll also receive:
- `childRoutes: React.element` - if there are rendered child routes, its rendered content will be here.
- `prepared` - whatever it is that `prepare` returns above.
### Child routes + `RelayRouter.Utils.childRouteHasContent`
As you can see, both child route params and content is passed along to your parent route.
The child route _content_ (that you render to show the actual route contents) is passed along as a prop `childRoutes`. The child route _params_ (any path params for child routes) are passed along as `childParams`, if there are any child params. This means that `childParams` will only exist if there are actual child params.
Sometimes it's useful to know whether that child route content is actually rendered or not. For example, maybe you want to control whether a slideover or modal shows based on whether there's actual content to show in it. For that purpose, there's a helper called `RelayRouter.Utils.childRouteHasContent`. Here's an example of using it:
```rescript
<SlideOver open_={RelayRouter.Utils.childRouteHasContent(childRoutes)} title=None>
{childRoutes}
</SlideOver>
```
There, excellent! We've now covered how we define and render routes. Let's move on to how we use the router itself - link to routes, interact with query params, prepare route data and code in advance, and so on.
## Linking and query params
### Linking
Linking to routes is fully type safe, and also quite ergonomic. A type safe `makeLink` function is generated for every defined route. Using it looks like this:
```rescript
<RelayRouter.Link to_={Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true)}>
{React.string("See active members")}
</RelayRouter.Link>
```
`makeLink` will take any parameters defined for the route as non-optional (`slug` here), and any query param defined for the route (or any parent route that renders it) as an optional. `makeLink` will then produce the correct URL for you.
> This is really nice because it means you don't have to actively think about your route structure when doing day-to-day work. Just about what the route is called and what parameters it takes.
`Routes` is the main file you'll be interacting with. It lets you find all route assets, regardless of how they're organized. It's designed to be autocomplete friendly, making it easy to autocomplete your way to whatever route you're after.
In `Routes.res`, any route will have all its generated assets at the route name itself + `Route`, like `Routes.Organization.Members.Route.makeLink`. Any children of that route would be located inside of that same module, like `Routes.Organization.Members.SomeChildRoute.Route`.
> Tip: Create a helper module and alias the link component, so you use something link `<U.Link />` day to day instead of `<RelayRouter.Link />`. This helps if you need to add your own things to the Link component at a later stage.
### Programatic navigation and preloading
The router lets you navigate and preload/prepare routes programatically if needed. It works like this:
```rescript
let {push, replace, preload, preloadCode} = RelayRouter.Utils.useRouter()
// This will push a new route
push(Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true))
// This will replace
replace(Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true))
// This will prepare the *code* for a specific route, as in download the code for the route. Notice `priority` - it goes from Low | Default | High, and lets you control how fast you need this to be prepared.
preloadCode(
~priority=High,
Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true)
)
// This will preload the code _and_ data for a route.
preload(
~priority=High,
Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true)
)
```
Just as with the imperative functions above, `<RelayRouter.Link />` can help you preload both code and data. It's configured via these props:
- `preloadCode` - controls when code will be preloaded. Default is `OnInView`, and variants are:
- `OnRender` as soon as the link renders. Suitable for things like important menu entries etc.
- `OnInView` as soon as the link is in the viewport.
- `OnIntent` as soon as the link is hovered, or focused.
- `NoPreloading` do not preload.
- `preloadData` - controls when code _and_ data is preloaded. Same variants as above, default is `OnIntent`.
- `preloadPriority` - At what priority to preload code and data. Same priority variant as described above.
A few notes on preloading:
- The router will automatically release any Relay data fetched in `prepare` after 5 minutes if that route hasn't also been rendered.
- If the route is rendered, the router will release the Relay data when the route is unmounted.
- Releasing the Relay data means that that data _could_ be evicted from the store, if the Relay store needs the storage for newer data.
- It's good practice to always release data so the Relay store does not grow indefinitively with data not necessarily in use anymore. The router solves that for you.
### Scrolling and scroll restoration
If your only scrolling area is the document itself, you can enable scroll restoration via the router (if you don't prefer the browser's built in scroll restoration) by simply rendering `<RelayRouter.Scroll.ScrollRestoration />` inside of your app, close to the router provider.
> Remember to turn off the built in browser scroll restoration if you do this: ``%%raw(`window.history.scrollRestoration = "manual"`)``
If you have scrolling content areas that isn't scrolling on the main document itself, you'll need to tell the router about it so it can correctly help you with scroll restoration, and look at the intersection of the correct elements when detecting if links are in view yet. You tell the router about your scrolling areas this way:
```rescript
let mainContainerRef = React.useRef(Nullable.null)
<RelayRouter.Scroll.TargetScrollElement.Provider
targetElementRef=mainContainerRef
id="main-scroll-area"
>
<main ref={ReactDOM.Ref.domRef(mainContainerRef)}>
{children}
</main>
</RelayRouter.Scroll.TargetScrollElement.Provider>
```
This lets the router know that `<main />` is the element that will be scrolling. If you also want the router to do scroll restoration, you can render `<RelayRouter.Scroll.ScrollRestoration />` at the bottom inside of `<RelayRouter.Scroll.TargetScrollElement.Provider />`, like so:
```rescript
let mainContainerRef = React.useRef(Nullable.null)
<RelayRouter.Scroll.TargetScrollElement.Provider
targetElementRef=mainContainerRef
id="main-scroll-area"
>
<main ref={ReactDOM.Ref.domRef(mainContainerRef)}>
{children}
<RelayRouter.Scroll.ScrollRestoration />
</main>
</RelayRouter.Scroll.TargetScrollElement.Provider>
```
This will have the router restore scroll as you navigate back and forth through your app. Repeat this for as many content areas as you'd like.
> Scroll restoration is currently only on the y-axis, but implementing it also for the x-axis (so things like carousels etc can easily restore scroll) is on the roadmap.
## Query parameters
Full, first class support for query parameters is one of the main features of the router. Working with query parameters is designed to be as _ergonomic_ as possible. This is how it works:
### Defining query parameters
Query parameters are defined inline inside of your route definitions JSON:
```json
[
{
"name": "Organization",
"path": "/organization/:slug?displayMode=DisplayMode.t&expandDetails=bool",
"children": [
{ "name": "Dashboard", "path": "" },
{ "name": "Members", "path": "members?first=int&after=string" }
]
}
]
```
A few things to distill here:
- Notice how we're defining query parameters inline, just like you'd write them in a URL.
- Query parameters are _inherited_ down. In the example above, that means that `Organization` has access to `displayMode` and `expandDetails`, which it defines itself. But, its nested routes `Dashboard` and `Members` (and any routes nested under them) will have access to those parameters too, and in addition to that, their own parameters. So `Members` has access to `displayMode`, `expandDetails`, `first` _and_ `after`. So, all routes have access to the query parameters they define, and whatever parameters their parents have defined.
Query parameters can be defined as `string`, `int`, `float`, `boolean` or the type of a _custom module_. A custom module query parameter is defined by pointing at the module's type `t`: `someParam=SomeModule.t`. Doing this, the router expects `SomeModule` to _at least_ look like this:
```rescript
type t
let parse: string => option<t>
let serialize: t => string
```
The router will automatically convert back and forth between the custom module value as needed. You will only even need to interact with `t`, not the raw string.
All query parameters can also be defined as `array<param>`. So, for example: `someParam=array<string>`.
More notes:
- The router will automatically take care of encoding and decoding values so it can be put in the URL.
- A change in query params only will _not_ trigger a full route re-render (meaning the route renderers gets updated query params). With Relay, you're encouraged to refetch only the data you need to refetch as query params change, not the full route query if you can avoid it.
### Query parameters with default values
While there's no way to guarantee that a query param always has a value in the URL, you can set default values for query params so that the value _you_ interact with in the code will always exist. This can be quite convenient at times.
Currently, this is only possible to do with custom modules. Here's a full example to illustrate how it's done:
```json
[
{
"name": "Organization",
"path": "/organization/:slug?displayMode=DisplayMode.t!&expandDetails=bool",
"children": [
{ "name": "Dashboard", "path": "" },
{ "name": "Members", "path": "members?first=int&after=string" }
]
}
]
```
- Notice the `!` after `displayMode=DisplayMode.t`. This means that this particular query parameter will always have a value. But, where's the default value defined? The router expects you to have a `defaultValue` value inside of `DisplayMode`, so it can point to `DisplayMode.defaultValue` as needed. Here's an example:
```rescript
// DisplayMode.res
type t = Full | Partial
let parse = (str): option<t> => {
switch str {
| "full" => Some(Full)
| "partial" => Some(Partial)
| _ => None
}
}
let serialize = (t: t) => {
switch t {
| Full => "full"
| Partial => "partial"
}
}
let defaultValue = Full
```
There, anytime `displayMode` is not set in the URL, you'll get the `defaultValue` of `Full` instead.
### Accessing and setting query parameters
You can access the current value of a route's query parameter like this:
```rescript
let {queryParams} = Routes.Organization.Members.Route.useQueryParams()
```
You can set query params by using `setParams`:
```rescript
let {setParams} = Routes.Organization.Members.Route.useQueryParams()
setParams(
~setter=currentParameters => {...currentParameters, expandDetails: Some(true)},
~onAfterParamsSet=newParams => {
// ...do whatever refetching based on the new params you'd like here.
}
)
```
Let's have a look at what config `setParams` take, and how it works:
- `setter: oldParams => newParams` - A function that returns the new parameters you want to set. For convenience, it receives the currently set query parameters, so it's easy to just set a single or a few new values without keeping track of the currently set ones.
- `onAfterParamsSet: newParams => unit` - This runs as soon as the new params has been returned, and receives whatever new params the `setter` returns. Here's where you'll trigger any refetching or similar using the new parameters.
- `navigationMode_: Push | Replace` - Push or replace the current route? Defaults to replace.
- `removeNotControlledParams: bool` - Setting this to `false` will preserve any query parameters in the URL not controlled by this route. Defaults to `true`.
> Please note that `setParams` will do a _shallow_ navigation by default. A shallow navigation means that no route data loaders will trigger. This lets you run your own specialized query, like a refetch or pagination query, driven by `setParams`, without trigger additional potentially redundant data fetching. If you for some reason _don't_ want that behavior, there's a "hidden" `shallow: bool` prop you can pass to `setParams`.
## Path params
Path params are typically modelled as strings. But, if you only want a route to match if a path param is in a known set of values, you can encode that into the path param definition. It looks like this:
```json
[
{
"name": "Organization",
"path": "/organization/:slug/members/:memberStatus(active|inactive|deleted)"
}
]
```
This would do 2 things:
- This route will only match if `memberStatus` is one of the values in the provided list (`active`, `inactive` or `deleted`).
- The type of `memberStatus` will be a polyvariant `[#active | #inactive | #deleted]`.
### Accessing path params via a hook
You can access the path params for a route via the `usePathParams` hook. It'll return the path params if you're currently on that route.
```rescript
switch Routes.Organization.Members.Route.usePathParams() {
| Some({slug}) => Console.log("Organization slug: " ++ slug)
| None => Console.log("Woops, not on the expected route.")
}
```
## Advanced
Here's a few more advanced things you can utilize the router for.
### Linking when you just want to change one or a few query params, preserving the rest
#### With `makeLinkFromQueryParams`
In addition to `makeLink`, there's also a `makeLinkFromQueryParams` function generated to simplify the use case of changing just one or a few of a large set of query params. `makeLinkFromQueryParams` lets you create a link by supplying your new query params as a record rather than each individual query param as a distinct labelled argument. It enables a few neat things:
```rescript
// Imagine this is quite a large object of various query params related to the current view.
let {queryParams} = Routes.Organization.Members.Route.useQueryParams()
// Scenario 1: Linking to the same view, with the same filters, but for another organization
let otherOrgSameViewLink = Routes.Organization.Members.Route.makeLinkFromQueryParams(~orgSlug=someOtherOrgSlug, queryParams)
// Scenario 2: Changing a single query parameter without caring about the rest
let changingASingleQueryParam = Routes.Organization.Members.Route.makeLinkFromQueryParams(~orgSlug=currentOrgSlug, {...queryParams, showDetails: Some(true)})
```
#### With `useMakeLinkWithPreservedPath`
In case you don't already have the current value of the path and query parameters and only want to update the query params, you can use `useMakeLinkWithPreservedPath` to generate a new link:
```rescript
let makeNewLink = Routes.Organization.Members.Route.useMakeLinkWithPreservedPath()
// Changing a single query parameter without caring about the rest
let changingASingleQueryParam = makeNewLink(queryParams => {...queryParams, showDetails: Some(true)})
```
### Checking if a route or a sub route is active
The router emits helpers to both check whether a route is active or not, as well as check whether what, if any, of a route's immediate children is active. The latter is particularly useful for tabs where each tab is a separate path in the URL. Examples:
#### Checking whether a specific route is active
```rescript
// Tells us whether this specific route is active or not. Every route exports one of this.
// ~exact: Whether to check whether _exactly_ this route is active. `false` means subroutes of the route will also say it's active.
let routeActive = Routes.Organization.Members.Route.useIsRouteActive(~exact=false)
```
#### Checking whether a route is active in a generic way (`<NavLink />`)
```rescript
// There's a generic way to check if a route is active or not, `RelayRouter.Utils.useIsRouteActive`.
// Useful for making your own <NavLink /> component that highlights itself in some way when it's active.
// A very crude example below:
module NavLink = {
.component
let make = (~href, ~routePattern, ~exact=false) => {
let isRouteActive = RelayRouter.Utils.useIsRouteActive(
// Every route has a `routePattern` you can use
~routePattern=Routes.Organization.Members.Route.routePattern,
// Whether to check whether _exactly_ this route is active. `false` means subroutes of the route will also say it's active.
~exact
)
<RelayRouter.Link
to_=href
className={className ++ " " ++ isRouteActive ? "css-classes-for-active-styling" : "css-classes-for-not-active-styling"}
>
....
}
}
// Use like this:
<NavLink
to_={Routes.Organization.Members.Route.makeLink()}
routePattern={Routes.Organization.Members.Route.routePattern}
>
// You can also check a pathname directly, without using the hook:
let routeActive = RelayRouter.Utils.isRouteActive(
~pathname="/some/thing/123",
~routePattern="/some/thing/:id",
~exact=true,
)
```
#### Extracting the parameters of a route is active in a generic way (`parseRoute`)
If you want to check if a link matches a given route (that is not the active one) and want to extract its parameters, you can use `parseRoute`:
```rescript
switch Routes.Organization.Members.Route.parseRoute(link){
| Some((_pathParams, {showDetails: true})) => // do something here
| Some(_) => // do something else
| None => // the link is not matched by the given route
}
```
#### Checking whether a direct sub route of a route is active (for tabs, etc)
```rescript
// This will be option<[#Dashboard | #Members]>, meaning it will return if and what immediate sub route is active for the Organization route. You can use this information to for example highlight tabs.
let activeSubRoute = Routes.Organization.Route.useActiveSubRoute()
```
## FAQ
- Check in or don't check in generated assets?
- Cleaning up?
- CI