rescript-relay-router

1.7.1 • Public • Published

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" } 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:

# 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:

"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:

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:

// Router.res

let preparedAssetsMap = Js.Dict.empty()

// `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:

<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.

// 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:

[
  {
    "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.

The router uses the matching logic from react-router 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 names 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:

// 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:

// OrganizationDashboard.res
module Query = %relay(`
  query OrganizationDashboardQuery($slug: ID!) {
    organizationBySlug(slug: $slug) {
      ...SomeFragment_organization
    }
  }
`)

@react.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 + RelayRouterUtils.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 RelayRouterUtils.childRouteHasContent. Here's an example of using it:

<SlideOver open_={RelayRouterUtils.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:

<RelayRouterLink to_={Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true)}>
  {React.string("See active members")}
</RelayRouterLink>

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 <RelayRouterLink />. 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:

let {push, replace, preload, preloadCode} = RelayRouterUtils.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, <RelayRouterLink /> 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 <RelayRouterScroll.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:

let mainContainerRef = React.useRef(Js.Nullable.null)

<RelayRouterScroll.TargetScrollElement.Provider
  targetElementRef=mainContainerRef
  id="main-scroll-area"
>
  <main ref={ReactDOM.Ref.domRef(mainContainerRef)}>
    {children}
  </main>
</RelayRouterScroll.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 <RelayRouterScroll.ScrollRestoration /> at the bottom inside of <RelayRouterScroll.TargetScrollElement.Provider />, like so:

let mainContainerRef = React.useRef(Js.Nullable.null)

<RelayRouterScroll.TargetScrollElement.Provider
  targetElementRef=mainContainerRef
  id="main-scroll-area"
>
  <main ref={ReactDOM.Ref.domRef(mainContainerRef)}>
    {children}
    <RelayRouterScroll.ScrollRestoration />
  </main>
</RelayRouterScroll.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:

[
  {
    "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:

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:

[
  {
    "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:
// 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:

let {queryParams} = Routes.Organization.Members.Route.useQueryParams()

You can set query params by using setParams:

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:

[
  {
    "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.

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

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:

// 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)})

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

// 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 />)

// There's a generic way to check if a route is active or not, `RelayRouterUtils.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 = {
  @react.component
  let make = (~href, ~routePattern, ~exact=false) => {
    let isRouteActive = RelayRouterUtils.useIsRouteActive(
      ~href=linkHref,
      // 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
    )

    <RelayRouterLink
      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 = RelayRouterUtils.isRouteActive(
  ~pathname="/some/thing/123",
  ~routePattern="/some/thing/:id",
  ~exact=true,
)

Checking whether a direct sub route of a route is active (for tabs, etc)

// 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

Package Sidebar

Install

npm i rescript-relay-router

Weekly Downloads

636

Version

1.7.1

License

MIT

Unpacked Size

301 kB

Total Files

53

Last publish

Collaborators

  • _zth