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

Lazy Loaded Route Modules #10045

Merged
merged 68 commits into from
Mar 8, 2023
Merged
Show file tree
Hide file tree
Changes from 65 commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
566672b
POC of lazily loaded route modules
brophdawg11 Feb 2, 2023
36975ff
Update logic and add Marks examples
brophdawg11 Feb 3, 2023
1d4439d
Update createRoutesFromChildren tests to reflect new lazy property
brophdawg11 Feb 3, 2023
971172f
1kb bundle bump
brophdawg11 Feb 3, 2023
c15b7cb
Add route mapper
markdalgleish Feb 7, 2023
47d09a8
Replace routeMapper with hasErrorBoundary function
markdalgleish Feb 7, 2023
77060e4
Add markdalgleish to contributors
markdalgleish Feb 7, 2023
af16b4b
Minor updates
brophdawg11 Feb 7, 2023
6284984
Add onInitialize callback
markdalgleish Feb 8, 2023
3e6039a
Unit test harness updates
brophdawg11 Feb 8, 2023
68a143b
Add lazy route error handling tests
markdalgleish Feb 8, 2023
661eb4c
Fix lazy actions after navigation, add more tests
markdalgleish Feb 9, 2023
f86e58e
Remove unused type import
markdalgleish Feb 9, 2023
e7ad8f8
Minor cleanups
brophdawg11 Feb 9, 2023
a6b49c6
DRY up typings with LazyRouteFunction<R>
brophdawg11 Feb 9, 2023
54af8c0
Fix lazy() handling on aborted requests
brophdawg11 Feb 9, 2023
e8daf54
Update changeset
brophdawg11 Feb 9, 2023
f94adc5
Merge branch 'dev' into brophdawg11/lazy-route-modules
brophdawg11 Feb 9, 2023
7c8f3a4
Bump bundle
brophdawg11 Feb 9, 2023
29a01f6
Fix typos in changeset
markdalgleish Feb 9, 2023
4fdc3cc
Keep resolved lazy routes even after cancellation
markdalgleish Feb 10, 2023
84369ad
Ensure immutable route key references are coupled
markdalgleish Feb 10, 2023
a708858
Clean up stray space character in example JSX
markdalgleish Feb 10, 2023
fcb59cd
Move comment
markdalgleish Feb 10, 2023
63d49ec
Add docs, tweak changeset
markdalgleish Feb 10, 2023
68c67c8
update changeset with proposal and POC implementation
brophdawg11 Feb 10, 2023
5d3b172
Ensure static route props take priority over lazy
markdalgleish Feb 13, 2023
0ea4435
Exclude hasErrorBoundary from static route props
markdalgleish Feb 13, 2023
112c4fd
Bump router bundle size
markdalgleish Feb 13, 2023
5049aea
Trim down `lazy` docs at route level
markdalgleish Feb 13, 2023
1dd74ee
Run lazy actions after cancellation on lazy load
markdalgleish Feb 13, 2023
b688d43
Add initial react-router-dom test cases
markdalgleish Feb 13, 2023
39e6522
Add `ready` function, add more RR tests, fix types
markdalgleish Feb 14, 2023
890cab3
WIP lazy load error handling
markdalgleish Feb 16, 2023
ab8bd20
Partial Revert "WIP lazy load error handling"
brophdawg11 Feb 17, 2023
88431bc
Move route.lazy() execution into callLoaderOrAction
brophdawg11 Feb 17, 2023
508614c
Merge branch 'dev' into brophdawg11/lazy-route-modules
brophdawg11 Feb 17, 2023
5656963
Rename/update changeset
brophdawg11 Feb 21, 2023
0826818
Remove uneeded empty abortPromise.catch
brophdawg11 Feb 21, 2023
e673bb0
Extract to standalone lazy example
brophdawg11 Feb 21, 2023
938d8db
Merge branch 'dev' into brophdawg11/lazy-route-modules
brophdawg11 Feb 21, 2023
7f9b7b5
Fix tests
brophdawg11 Feb 21, 2023
bbfbf85
Remove __DEV__ configs from router lint/jest config
brophdawg11 Feb 21, 2023
95974d9
Add decision doc
brophdawg11 Feb 21, 2023
ca8685c
Call loader even if lazy() is aborted, like action
brophdawg11 Feb 23, 2023
c821784
Update decision doc
brophdawg11 Feb 23, 2023
664464a
Update comments and re-organize tests
brophdawg11 Feb 23, 2023
caed6bf
hasErrorBoundary function -> detectErrorBoundary
brophdawg11 Feb 23, 2023
5d329ae
Add support for route Component/ErrorBoundary props
brophdawg11 Feb 23, 2023
87f8bf0
Remove router.ready() in favor of resolveLazyRoutes() utility
brophdawg11 Feb 27, 2023
0886eb4
Remove server ready() and update decision doc
brophdawg11 Feb 27, 2023
de84075
Revert "Remove server ready() and update decision doc"
brophdawg11 Mar 1, 2023
3fe87ed
Revert "Remove router.ready() in favor of resolveLazyRoutes() utility"
brophdawg11 Mar 1, 2023
be19639
Remove ready() and update tests/docs
brophdawg11 Mar 1, 2023
15e46d9
Merge branch 'dev' into brophdawg11/lazy-route-modules
brophdawg11 Mar 1, 2023
1e309fd
Add more tests for Component/ErrorBoundary
brophdawg11 Mar 1, 2023
38d9f06
Update docs with notes on Component/ErrorBoundary
brophdawg11 Mar 1, 2023
4430768
Update docs
brophdawg11 Mar 1, 2023
b6c4d7d
Minor updates
brophdawg11 Mar 1, 2023
b72d4f2
Optimize execution of static handlers in parallel with lazy
brophdawg11 Mar 2, 2023
629e423
Fix static router test case
brophdawg11 Mar 2, 2023
a4b12e9
Bundle bump
brophdawg11 Mar 2, 2023
6f8d08f
Fix typos
brophdawg11 Mar 3, 2023
d8fe279
Update to docs
brophdawg11 Mar 3, 2023
7b5dc38
Few more typos
brophdawg11 Mar 3, 2023
2c9f13d
Change typing from FunctionComponent -> ComponentType
brophdawg11 Mar 6, 2023
6d79d3d
Bump bundle
brophdawg11 Mar 7, 2023
2560422
Final docs upates
brophdawg11 Mar 8, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .changeset/lazy-route-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
"react-router": minor
"react-router-dom": minor
"@remix-run/router": minor
---

**Introducing Lazy Route Modules!**

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`, `errorElement`, etc.). Additionally we've added support for route `Component` and `ErrorBoundary` fields that take precedence over `element`/`errorElement` and make a bit more sense in a statically-defined router as well as when using `route.lazy()`.

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.

Your `lazy` functions will typically return the result of a dynamic import.

```jsx
// In this example, we assume most folks land on the homepage so we include that
// in our critical-path bundle, but then we lazily load modules for /a and /b so
// they don't load until the user navigates to those routes
let routes = createRoutesFromElements(
<Route path="/" element={<Layout />}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we update all usages of element to Component in docs and release notes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think where I tried to land was using element for static JSX definitions, and Component for static-JSON or lazily-defined. But I could see an argument for Component everywhere for sure. I'd probably punt this to Michael/Ryan and see what they think?

<Route index element={<Home />} />
<Route path="a" lazy={() => import("./a")} />
<Route path="b" lazy={() => import("./b")} />
</Route>
);
```

Then in your lazy route modules, export the properties you want defined for the route:

```jsx
export async function loader({ request }) {
let data = await fetchData(request);
return json(data);
}

// Export a `Component` directly instead of needing to create a React element from it
export function Component() {
let data = useLoaderData();

return (
<>
<h1>You made it!</h1>
<p>{data}</p>
</>
);
}

export function ErrorBoundary() {
let error = useRouteError();
return isRouteErrorResponse(error) ? (
<h1>
{error.status} {error.statusText}
</h1>
) : (
<h1>{error.message || error}</h1>
);
}
```

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.

🙌 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).
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
- Manc
- manzano78
- marc2332
- markdalgleish
- markivancho
- maruffahmed
- marvinruder
Expand Down
250 changes: 250 additions & 0 deletions decisions/0002-lazy-route-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
# Lazy Route Modules

Date: 2023-02-21

Status: accepted

## Context

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.

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` 🙌).

## Original POC

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

```js
// Assuming route.module is a function returning a Remix-style route module
let Component = React.lazy(route.module);
route.element = <Component />;
route.loader = async (args) => {
const { loader } = await route.module();
return typeof loader === "function" ? loader(args) : null;
};
```

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!

To combat this, a `route.use` property was considered which would allow the user to define the exports of the module:

```js
const route = {
path: "/",
module: () => import("./route"),
use: ["loader", "element"],
};
```

This wasn't ideal since it introduced a tight coupling of the file contents and the route definitions.

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.

## Decision

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:

- We can load at a more specific spot internal to the router
- We can access the navigation `AbortSignal` in case the `lazy()` call gets interrupted
- We can also load once and update the internal route definition so subsequent navigations don't have a repeated `lazy()` call
- 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

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.

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:

```jsx
// app.jsx
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
index: true,
element: <Home />,
},
{
path: "about",
lazy: () => import("./about"),
},
],
},
]);
```

And then your `about.jsx` file would export the properties to be lazily defined on the route:

```jsx
// about.jsx
export function loader() { ... }

export const element = <Component />

function Component() { ... }
```

## Choices

Here's a few choices we made along the way:

### Immutable Route Properties

A route has 3 types of fields defined on it:

- Path matching properties: `path`, `index`, `caseSensitive` and `children`
- While not strictly used for matching, `id` is also considered static since it is needed up-front to uniquely identify all defined routes
- Data loading properties: `loader`, `action`, `hasErrorBoundary`, `shouldRevalidate`
- Rendering properties: `handle` and the framework-aware `element`/`errorElement`

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.

## Static Route Properties

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:

1. Using a small statically-defined `loader`/`action` which just hits an API endpoint to load/submit data.
- 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.
2. Re-using a common statically-defined `ErrorBoundary` across multiple routes

### Addition of route `Component` and `ErrorBoundary` fields

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:

```jsx
<BrowserRouter>
<Routes>
<Route path="/" element={<Homepage prop="value" />} />
</Routes>
</BrowserRouter>
```

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:

```js
const routes = [
{
path: "/",
element: <Homepage prop="value" />,
},
];
```

It also means that you cannot easily use hooks inline, and have to add a level of indirection to access hooks.

This gets a bit more awkward with the introduction of `lazy()` since your file now has to export a root-level JSX element:

```jsx
// home.jsx
export const element = <Homepage />

function Homepage() { ... }
```

In reality, what we want in this "static route definition" landscape is just the component for the Route:

```js
const routes = [
{
path: "/",
Component: Homepage,
},
];
```

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:

```jsx
const routes = [
{
path: "/",
// You can include just the component
Component: Homepage,
},
{
path: "/a",
// Or you can inline your component and pass props
Component: () => <Homepage prop="value" />,
},
{
path: "/b",
// And even use use hooks without indirection 💥
Component: () => {
let data = useLoaderData();
return <Homepage data={data} />;
},
},
];
```

So in the end, the work for `lazy()` introduced support for `route.Component` and `route.ErrorBoundary`, which can be statically or lazily defined. `element`/`errorElement` will be considered deprecated in data routers and may go away in version 7.

### Interruptions

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.

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.
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved

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

### Error Handling

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

## Consequences

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.

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:

```jsx
const routes = [{
path: '/',
lazy: () => import("./route"),
}]
let router = createBrowserRouter(routes, {
hydrationData: window.__hydrationData,
});

// ⚠️ At this point, the router has the data but not the route definition!

ReactDOM.hydrateRoot(
document.getElementById("app")!,
<RouterProvider router={router} fallbackElement={null} />
);
```

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.

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

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:

```jsx
// Determine if any of the initial routes are lazy
let lazyMatches = matchRoutes(routes, window.location)?.filter(
(m) => m.route.lazy
);

// Load the lazy matches and update the routes before creating your router
// so we can hydrate the SSR-rendered content synchronously
if (lazyMatches && lazyMatches.length > 0) {
await Promise.all(
lazyMatches.map(async (m) => {
let routeModule = await m.route.lazy!();
Object.assign(m.route, { ...routeModule, lazy: undefined });
})
);
}

// Create router and hydrate
let router = createBrowserRouter(routes)
ReactDOM.hydrateRoot(
document.getElementById("app")!,
<RouterProvider router={router} fallbackElement={null} />
);
```

[manually-code-split]: https://www.infoxicator.com/en/react-router-6-4-code-splitting
[proposal]: https://github.com/remix-run/react-router/discussions/9826
[poc]: https://github.com/remix-run/react-router/pull/9830
2 changes: 2 additions & 0 deletions docs/route/error-element.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ new: true

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

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

<docs-error>This feature only works if using a data router like [`createBrowserRouter`][createbrowserrouter]</docs-error>

```tsx
Expand Down
62 changes: 62 additions & 0 deletions docs/route/lazy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
title: lazy
new: true
---

# `lazy`

In order to keep your application bundles small and support code-splitting of your routes, each route can provide an async function that resolves the non-route-matching portions of your route definition (`loader`, `action`, `Component`/`element`, `ErrorBoundary`/`errorElement`, etc.).

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`, `caseSensitive`) since we only execute your lazy route functions after we've matched known routes.

<docs-warning>This feature only works if using a data router, see [Picking a Router][pickingarouter]</docs-warning>

Each `lazy` function will typically return the result of a dynamic import.

```jsx
let routes = createRoutesFromElements(
<Route path="/" element={<Layout />}>
<Route path="a" lazy={() => import("./a")} />
<Route path="b" lazy={() => import("./b")} />
</Route>
);
```

Then in your lazy route modules, export the properties you want defined for the route:

```jsx
export async function loader({ request }) {
let data = await fetchData(request);
return json(data);
}

export function Component() {
let data = useLoaderData();

return (
<>
<h1>You made it!</h1>
<p>{data}</p>
</>
);
}

// If you want to customize the component display name in React dev tools:
Component.displayName = "SampleLazyRoute";
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved

export function ErrorBoundary() {
let error = useRouteError();
return isRouteErrorResponse(error) ? (
<h1>
{error.status} {error.statusText}
</h1>
) : (
<h1>{error.message || error}</h1>
);
}

// If you want to customize the component display name in React dev tools:
ErrorBoundary.displayName = "SampleErrorBoundary";
```

[pickingarouter]: ../routers/picking-a-router
Loading