Skip to content

Commit c56d84c

Browse files
Lazy Loaded Route Modules (#10045)
Co-authored-by: Mark Dalgleish <[email protected]>
1 parent b56f64f commit c56d84c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+8019
-749
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"react-router": minor
3+
"react-router-dom": minor
4+
---
5+
6+
React Router now supports an alternative way to define your route `element` and `errorElement` fields as React Components instead of React Elements. You can instead pass a React Component to the new `Component` and `ErrorBoundary` fields if you choose. There is no functional difference between the two, so use whichever approach you prefer 😀. You shouldn't be defining both, but if you do `Component`/`ErrorBoundary` will "win".
7+
8+
**Example JSON Syntax**
9+
10+
```jsx
11+
// Both of these work the same:
12+
const elementRoutes = [{
13+
path: '/',
14+
element: <Home />,
15+
errorElement: <HomeError />,
16+
}]
17+
18+
const componentRoutes = [{
19+
path: '/',
20+
Component: Home,
21+
ErrorBoundary: HomeError,
22+
}]
23+
24+
function Home() { ... }
25+
function HomeError() { ... }
26+
```
27+
28+
**Example JSX Syntax**
29+
30+
```jsx
31+
// Both of these work the same:
32+
const elementRoutes = createRoutesFromElements(
33+
<Route path='/' element={<Home />} errorElement={<HomeError /> } />
34+
);
35+
36+
const elementRoutes = createRoutesFromElements(
37+
<Route path='/' Component={Home} ErrorBoundary={HomeError} />
38+
);
39+
40+
function Home() { ... }
41+
function HomeError() { ... }
42+
```

.changeset/lazy-route-modules.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"react-router": minor
3+
"react-router-dom": minor
4+
"@remix-run/router": minor
5+
---
6+
7+
**Introducing Lazy Route Modules!**
8+
9+
In order to keep your application bundles small and support code-splitting of your routes, we've introduced a new `lazy()` route property. This is an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `element`/`Component`, `errorElement`/`ErrorBoundary`, `shouldRevalidate`, `handle`).
10+
11+
Lazy routes are resolved on initial load and during the `loading` or `submitting` phase of a navigation or fetcher call. You cannot lazily define route-matching properties (`path`, `index`, `children`) since we only execute your lazy route functions after we've matched known routes.
12+
13+
Your `lazy` functions will typically return the result of a dynamic import.
14+
15+
```jsx
16+
// In this example, we assume most folks land on the homepage so we include that
17+
// in our critical-path bundle, but then we lazily load modules for /a and /b so
18+
// they don't load until the user navigates to those routes
19+
let routes = createRoutesFromElements(
20+
<Route path="/" element={<Layout />}>
21+
<Route index element={<Home />} />
22+
<Route path="a" lazy={() => import("./a")} />
23+
<Route path="b" lazy={() => import("./b")} />
24+
</Route>
25+
);
26+
```
27+
28+
Then in your lazy route modules, export the properties you want defined for the route:
29+
30+
```jsx
31+
export async function loader({ request }) {
32+
let data = await fetchData(request);
33+
return json(data);
34+
}
35+
36+
// Export a `Component` directly instead of needing to create a React Element from it
37+
export function Component() {
38+
let data = useLoaderData();
39+
40+
return (
41+
<>
42+
<h1>You made it!</h1>
43+
<p>{data}</p>
44+
</>
45+
);
46+
}
47+
48+
// Export an `ErrorBoundary` directly instead of needing to create a React Element from it
49+
export function ErrorBoundary() {
50+
let error = useRouteError();
51+
return isRouteErrorResponse(error) ? (
52+
<h1>
53+
{error.status} {error.statusText}
54+
</h1>
55+
) : (
56+
<h1>{error.message || error}</h1>
57+
);
58+
}
59+
```
60+
61+
An example of this in action can be found in the [`examples/lazy-loading-router-provider`](https://github.com/remix-run/react-router/tree/main/examples/lazy-loading-router-provider) directory of the repository.
62+
63+
🙌 Huge thanks to @rossipedia for the [Initial Proposal](https://github.com/remix-run/react-router/discussions/9826) and [POC Implementation](https://github.com/remix-run/react-router/pull/9830).

contributors.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
- Manc
120120
- manzano78
121121
- marc2332
122+
- markdalgleish
122123
- markivancho
123124
- maruffahmed
124125
- marvinruder
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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

docs/route/error-element.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ new: true
77

88
When exceptions are thrown in [loaders][loader], [actions][action], or component rendering, instead of the normal render path for your Routes (`<Route element>`), the error path will be rendered (`<Route errorElement>`) and the error made available with [`useRouteError`][userouteerror].
99

10+
<docs-info>If you do not wish to specify a React element (i.e., `errorElement={<MyErrorBoundary />}`) you may specify an `ErrorBoundary` component instead (i.e., `ErrorBoundary={MyErrorBoundary}`) and React Router will call `createElement` for you internally.</docs-info>
11+
1012
<docs-error>This feature only works if using a data router like [`createBrowserRouter`][createbrowserrouter]</docs-error>
1113

1214
```tsx

0 commit comments

Comments
 (0)