|
| 1 | +# Lazy Route Modules |
| 2 | + |
| 3 | +Date: 2023-02-21 |
| 4 | + |
| 5 | +Status: accepted |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +In a data-aware React Router application (`<RouterProvider>`), the router needs to be aware of the route tree ahead of time so it can match routes and execute loaders/actions _prior_ to rendering the destination route. This is different than in non-data-aware React Router applications (`<BrowserRouter>`) where you could nest `<Routes>` sub-tree anywhere in your application, and compose together `<React.Suspense>` and `React.lazy()` to dynamically load "new" portions of your routing tree as the user navigated through the application. The downside of this approach in `BrowserRouter` is that it's a render-then-fetch cycle which produces network waterfalls and nested spinners, two things that we're aiming to eliminate in `RouterProvider` applications. |
| 10 | + |
| 11 | +There were ways to [manually code-split][manually-code-split] in a `RouterProvider` application but they can be a bit verbose and tedious to do manually. As a result of this DX, we received a [Remix Route Modules Proposal][proposal] from the community along with a [POC implementation][poc] (thanks `@rossipedia` 🙌). |
| 12 | + |
| 13 | +## Original POC |
| 14 | + |
| 15 | +The original POC idea was to implement this in user-land where `element`/`errorElement` would be transformed into `React.lazy()` calls and `loader`/`action` would load the module and then execute the `loader`/`action`: |
| 16 | + |
| 17 | +```js |
| 18 | +// Assuming route.module is a function returning a Remix-style route module |
| 19 | +let Component = React.lazy(route.module); |
| 20 | +route.element = <Component />; |
| 21 | +route.loader = async (args) => { |
| 22 | + const { loader } = await route.module(); |
| 23 | + return typeof loader === "function" ? loader(args) : null; |
| 24 | +}; |
| 25 | +``` |
| 26 | + |
| 27 | +This approach got us pretty far but suffered from some limitations being done in user-land since it did not have access to some router internals to make for a more seamless integration. Namely, it _had_ to put every possible property onto a route since it couldn't know ahead of time whether the route module would resolve with the matching property. For example, will `import('./route')` return an `errorElement`? Who knows! |
| 28 | + |
| 29 | +To combat this, a `route.use` property was considered which would allow the user to define the exports of the module: |
| 30 | + |
| 31 | +```js |
| 32 | +const route = { |
| 33 | + path: "/", |
| 34 | + module: () => import("./route"), |
| 35 | + use: ["loader", "element"], |
| 36 | +}; |
| 37 | +``` |
| 38 | + |
| 39 | +This wasn't ideal since it introduced a tight coupling of the file contents and the route definitions. |
| 40 | + |
| 41 | +Furthermore, since the goal of `RouterProvider` is to reduce spinners, it felt incorrect to automatically introduce `React.lazy` and thus expect Suspense boundaries for elements that we expected to be fully fetched _prior_ to rendering the destination route. |
| 42 | + |
| 43 | +## Decision |
| 44 | + |
| 45 | +Given what we learned from the original POC, we felt we could do this a bit leaner with an implementation inside the router. Data router apps already have an asynchronous pre-render flow where we could hook in and run this logic. A few advantages of doing this inside of the router include: |
| 46 | + |
| 47 | +- We can load at a more specific spot internal to the router |
| 48 | +- We can access the navigation `AbortSignal` in case the `lazy()` call gets interrupted |
| 49 | +- We can also load once and update the internal route definition so subsequent navigations don't have a repeated `lazy()` call |
| 50 | +- We don't have issue with knowing whether or not an `errorElement` exists since we will have updated the route prior to updating any UI state |
| 51 | + |
| 52 | +This proved to work out quite well as we did our own POC so we went with this approach in the end. Now, any time we enter a `submitting`/`loading` state we first check for a `route.lazy` definition and resolve that promise first and update the internal route definition with the result. |
| 53 | + |
| 54 | +The resulting API looks like this, assuming you want to load your homepage in the main bundle, but lazily load the code for the `/about` route. Note we're using the new `Component` API introduced along with this work. |
| 55 | + |
| 56 | +```jsx |
| 57 | +// app.jsx |
| 58 | +const router = createBrowserRouter([ |
| 59 | + { |
| 60 | + path: "/", |
| 61 | + Component: Layout, |
| 62 | + children: [ |
| 63 | + { |
| 64 | + index: true, |
| 65 | + Component: Home, |
| 66 | + }, |
| 67 | + { |
| 68 | + path: "about", |
| 69 | + lazy: () => import("./about"), |
| 70 | + }, |
| 71 | + ], |
| 72 | + }, |
| 73 | +]); |
| 74 | +``` |
| 75 | + |
| 76 | +And then your `about.jsx` file would export the properties to be lazily defined on the route: |
| 77 | + |
| 78 | +```jsx |
| 79 | +// about.jsx |
| 80 | +export function loader() { ... } |
| 81 | + |
| 82 | +export function Component() { ... } |
| 83 | +``` |
| 84 | + |
| 85 | +## Choices |
| 86 | + |
| 87 | +Here's a few choices we made along the way: |
| 88 | + |
| 89 | +### Immutable Route Properties |
| 90 | + |
| 91 | +A route has 3 types of fields defined on it: |
| 92 | + |
| 93 | +- Path matching properties: `path`, `index`, `caseSensitive` and `children` |
| 94 | + - While not strictly used for matching, `id` is also considered static since it is needed up-front to uniquely identify all defined routes |
| 95 | +- Data loading properties: `loader`, `action`, `hasErrorBoundary`, `shouldRevalidate` |
| 96 | +- Rendering properties: `handle` and the framework-aware `element`/`errorElement`/`Component`/`ErrorBoundary` |
| 97 | + |
| 98 | +The `route.lazy()` method is focused on lazy-loading the data loading and rendering properties, but cannot update the path matching properties because we have to path match _first_ before we can even identify which matched routes include a `lazy()` function. Therefore, we do not allow path matching route keys to be updated by `lazy()`, and will log a warning if you return one of those properties from your lazy() method. |
| 99 | + |
| 100 | +## Static Route Properties |
| 101 | + |
| 102 | +Similar to how you cannot override any immutable path-matching properties, you also cannot override any statically defined data-loading or rendering properties (and will log the a console warning if you attempt to). This allows you to statically define aspects that you don't need (or wish) to lazy load. Two potential use-cases her might be: |
| 103 | + |
| 104 | +1. Using a small statically-defined `loader`/`action` which just hits an API endpoint to load/submit data. |
| 105 | + - In fact this is an interesting option we've optimized React Router to detect this and call any statically defined loader/action handlers in parallel with `lazy` (since `lazy` will be unable to update the `loader`/`action` anyway!). This will provide the ability to obtain the most-optimal parallelization of loading your component in parallel with your data fetches. |
| 106 | +2. Re-using a common statically-defined `ErrorBoundary` across multiple routes |
| 107 | + |
| 108 | +### Addition of route `Component` and `ErrorBoundary` fields |
| 109 | + |
| 110 | +In React Router v6, routes define `element` properties because it allows static prop passing as well as fitting nicely in the JSX render-tree-defined route trees: |
| 111 | + |
| 112 | +```jsx |
| 113 | +<BrowserRouter> |
| 114 | + <Routes> |
| 115 | + <Route path="/" element={<Homepage prop="value" />} /> |
| 116 | + </Routes> |
| 117 | +</BrowserRouter> |
| 118 | +``` |
| 119 | + |
| 120 | +However, in a React Router 6.4+ landscape when using `RouterProvider`, routes are defined statically up-front to enable data-loading, so using element feels arguably a bit awkward outside of a JSX tree: |
| 121 | + |
| 122 | +```js |
| 123 | +const routes = [ |
| 124 | + { |
| 125 | + path: "/", |
| 126 | + element: <Homepage prop="value" />, |
| 127 | + }, |
| 128 | +]; |
| 129 | +``` |
| 130 | + |
| 131 | +It also means that you cannot easily use hooks inline, and have to add a level of indirection to access hooks. |
| 132 | + |
| 133 | +This gets a bit more awkward with the introduction of `lazy()` since your file now has to export a root-level JSX element: |
| 134 | + |
| 135 | +```jsx |
| 136 | +// home.jsx |
| 137 | +export const element = <Homepage /> |
| 138 | + |
| 139 | +function Homepage() { ... } |
| 140 | +``` |
| 141 | + |
| 142 | +In reality, what we want in this "static route definition" landscape is just the component for the Route: |
| 143 | + |
| 144 | +```js |
| 145 | +const routes = [ |
| 146 | + { |
| 147 | + path: "/", |
| 148 | + Component: Homepage, |
| 149 | + }, |
| 150 | +]; |
| 151 | +``` |
| 152 | + |
| 153 | +This has a number of advantages in that we can now use inline component functions to access hooks, provide props, etc. And we also simplify the exports of a `lazy()` route module: |
| 154 | + |
| 155 | +```jsx |
| 156 | +const routes = [ |
| 157 | + { |
| 158 | + path: "/", |
| 159 | + // You can include just the component |
| 160 | + Component: Homepage, |
| 161 | + }, |
| 162 | + { |
| 163 | + path: "/a", |
| 164 | + // Or you can inline your component and pass props |
| 165 | + Component: () => <Homepage prop="value" />, |
| 166 | + }, |
| 167 | + { |
| 168 | + path: "/b", |
| 169 | + // And even use use hooks without indirection 💥 |
| 170 | + Component: () => { |
| 171 | + let data = useLoaderData(); |
| 172 | + return <Homepage data={data} />; |
| 173 | + }, |
| 174 | + }, |
| 175 | +]; |
| 176 | +``` |
| 177 | + |
| 178 | +So in the end, the work for `lazy()` introduced support for `route.Component` and `route.ErrorBoundary`, which can be statically or lazily defined. They will take precedence over `element`/`errorElement` if both happen to be defined, but for now both are acceptable ways to define routes. We think we'll be expanding the `Component` API in the future for stronger type-safety since we can pass it inferred-type `loaderData` etc. so in the future that _may_ become the preferred API. |
| 179 | + |
| 180 | +### Interruptions |
| 181 | + |
| 182 | +Previously when a link was clicked or a form was submitted, since we had the `action`/`loader` defined statically up-front, they were immediately executed and there was no chance for an interruption _before calling the handler_. Now that we've introduced the concept of `lazy()` there is a period of time prior to executing the handler where the user could interrupt the navigation by clicking to a new location. In order to keep behavior consistent with lazily-loaded routes and statically defined routes, if a `lazy()` function is interrupted React Router _will still call the returned handler_. As always, the user can leverage `request.signal.aborted` inside the handler to short-circuit on interruption if desired. |
| 183 | + |
| 184 | +This is important because `lazy()` is only ever run once in an application session. Once lazy has completed it updates the route in place, and all subsequent navigations to that route use the now-statically-defined properties. Without this behavior, routes would behave differently on the _first_ navigation versus _subsequent_ navigations which could introduce subtle and hard-to-track-down bugs. |
| 185 | + |
| 186 | +Additionally, since `lazy()` functions are intended to return a static definition of route `loader`/`element`/etc. - if multiple navigations happen to the same route in parallel, the first `lazy()` call to resolve will "win" and update the route, and the returned values from any other `lazy()` executions will be ignored. This should not be much of an issue in practice though as modern bundlers latch onto the same promise for repeated calls to `import()` so in those cases the first call will still "win". |
| 187 | + |
| 188 | +### Error Handling |
| 189 | + |
| 190 | +If an error is thrown by `lazy()` we catch that in the same logic as if the error was thrown by the `action`/`loader` and bubble it to the nearest `errorElement`. |
| 191 | + |
| 192 | +## Consequences |
| 193 | + |
| 194 | +Not so much as a consequence, but more of limitation - we still require the routing tree up-front for the most efficient data-loading. This means that we can't _yet_ support quite the same nested `<Routes>` use-cases as before (particularly with respect to microfrontends), but we have ideas for how to solve that as an extension of this concept in the future. |
| 195 | + |
| 196 | +Another slightly edge-case concept we discovered is that in DIY SSR applications using `createStaticHandler` and `StaticRouterProvider`, it's possible to server-render a lazy route and send up its hydration data. But then we may _not_ have those routes loaded in our client-side hydration: |
| 197 | + |
| 198 | +```jsx |
| 199 | +const routes = [{ |
| 200 | + path: '/', |
| 201 | + lazy: () => import("./route"), |
| 202 | +}] |
| 203 | +let router = createBrowserRouter(routes, { |
| 204 | + hydrationData: window.__hydrationData, |
| 205 | +}); |
| 206 | + |
| 207 | +// ⚠️ At this point, the router has the data but not the route definition! |
| 208 | + |
| 209 | +ReactDOM.hydrateRoot( |
| 210 | + document.getElementById("app")!, |
| 211 | + <RouterProvider router={router} fallbackElement={null} /> |
| 212 | +); |
| 213 | +``` |
| 214 | + |
| 215 | +In the above example, we've server-rendered our `/` route and therefore we _don't_ want to render a `fallbackElement` since we already have the SSR'd content, and the router doesn't need to "initialize" because we've provided the data in `hydrationData`. However, if we're hydrating into a route that includes `lazy`, then we _do_ need to initialize that lazy route. |
| 216 | + |
| 217 | +The real solution for this is to do what Remix does and know your matched routes and preload their modules ahead of time and hydrate with synchronous route definitions. This is a non-trivial process through so it's not expected that every DIY SSR use-case will handle it. Instead, the router will not be initialized until any initially matched lazy routes are loaded, and therefore we need to delay the hydration or our `RouterProvider`. |
| 218 | + |
| 219 | +The recommended way to do this is to manually match routes against the initial location and load/update any lazy routes before creating your router: |
| 220 | + |
| 221 | +```jsx |
| 222 | +// Determine if any of the initial routes are lazy |
| 223 | +let lazyMatches = matchRoutes(routes, window.location)?.filter( |
| 224 | + (m) => m.route.lazy |
| 225 | +); |
| 226 | + |
| 227 | +// Load the lazy matches and update the routes before creating your router |
| 228 | +// so we can hydrate the SSR-rendered content synchronously |
| 229 | +if (lazyMatches && lazyMatches.length > 0) { |
| 230 | + await Promise.all( |
| 231 | + lazyMatches.map(async (m) => { |
| 232 | + let routeModule = await m.route.lazy!(); |
| 233 | + Object.assign(m.route, { ...routeModule, lazy: undefined }); |
| 234 | + }) |
| 235 | + ); |
| 236 | +} |
| 237 | + |
| 238 | +// Create router and hydrate |
| 239 | +let router = createBrowserRouter(routes) |
| 240 | +ReactDOM.hydrateRoot( |
| 241 | + document.getElementById("app")!, |
| 242 | + <RouterProvider router={router} fallbackElement={null} /> |
| 243 | +); |
| 244 | +``` |
| 245 | +
|
| 246 | +[manually-code-split]: https://www.infoxicator.com/en/react-router-6-4-code-splitting |
| 247 | +[proposal]: https://github.com/remix-run/react-router/discussions/9826 |
| 248 | +[poc]: https://github.com/remix-run/react-router/pull/9830 |
0 commit comments