-
-
Notifications
You must be signed in to change notification settings - Fork 10.2k
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
Lazy Loaded Route Modules #10045
Changes from all commits
Commits
Show all changes
68 commits
Select commit
Hold shift + click to select a range
566672b
POC of lazily loaded route modules
brophdawg11 36975ff
Update logic and add Marks examples
brophdawg11 1d4439d
Update createRoutesFromChildren tests to reflect new lazy property
brophdawg11 971172f
1kb bundle bump
brophdawg11 c15b7cb
Add route mapper
markdalgleish 47d09a8
Replace routeMapper with hasErrorBoundary function
markdalgleish 77060e4
Add markdalgleish to contributors
markdalgleish af16b4b
Minor updates
brophdawg11 6284984
Add onInitialize callback
markdalgleish 3e6039a
Unit test harness updates
brophdawg11 68a143b
Add lazy route error handling tests
markdalgleish 661eb4c
Fix lazy actions after navigation, add more tests
markdalgleish f86e58e
Remove unused type import
markdalgleish e7ad8f8
Minor cleanups
brophdawg11 a6b49c6
DRY up typings with LazyRouteFunction<R>
brophdawg11 54af8c0
Fix lazy() handling on aborted requests
brophdawg11 e8daf54
Update changeset
brophdawg11 f94adc5
Merge branch 'dev' into brophdawg11/lazy-route-modules
brophdawg11 7c8f3a4
Bump bundle
brophdawg11 29a01f6
Fix typos in changeset
markdalgleish 4fdc3cc
Keep resolved lazy routes even after cancellation
markdalgleish 84369ad
Ensure immutable route key references are coupled
markdalgleish a708858
Clean up stray space character in example JSX
markdalgleish fcb59cd
Move comment
markdalgleish 63d49ec
Add docs, tweak changeset
markdalgleish 68c67c8
update changeset with proposal and POC implementation
brophdawg11 5d3b172
Ensure static route props take priority over lazy
markdalgleish 0ea4435
Exclude hasErrorBoundary from static route props
markdalgleish 112c4fd
Bump router bundle size
markdalgleish 5049aea
Trim down `lazy` docs at route level
markdalgleish 1dd74ee
Run lazy actions after cancellation on lazy load
markdalgleish b688d43
Add initial react-router-dom test cases
markdalgleish 39e6522
Add `ready` function, add more RR tests, fix types
markdalgleish 890cab3
WIP lazy load error handling
markdalgleish ab8bd20
Partial Revert "WIP lazy load error handling"
brophdawg11 88431bc
Move route.lazy() execution into callLoaderOrAction
brophdawg11 508614c
Merge branch 'dev' into brophdawg11/lazy-route-modules
brophdawg11 5656963
Rename/update changeset
brophdawg11 0826818
Remove uneeded empty abortPromise.catch
brophdawg11 e673bb0
Extract to standalone lazy example
brophdawg11 938d8db
Merge branch 'dev' into brophdawg11/lazy-route-modules
brophdawg11 7f9b7b5
Fix tests
brophdawg11 bbfbf85
Remove __DEV__ configs from router lint/jest config
brophdawg11 95974d9
Add decision doc
brophdawg11 ca8685c
Call loader even if lazy() is aborted, like action
brophdawg11 c821784
Update decision doc
brophdawg11 664464a
Update comments and re-organize tests
brophdawg11 caed6bf
hasErrorBoundary function -> detectErrorBoundary
brophdawg11 5d329ae
Add support for route Component/ErrorBoundary props
brophdawg11 87f8bf0
Remove router.ready() in favor of resolveLazyRoutes() utility
brophdawg11 0886eb4
Remove server ready() and update decision doc
brophdawg11 de84075
Revert "Remove server ready() and update decision doc"
brophdawg11 3fe87ed
Revert "Remove router.ready() in favor of resolveLazyRoutes() utility"
brophdawg11 be19639
Remove ready() and update tests/docs
brophdawg11 15e46d9
Merge branch 'dev' into brophdawg11/lazy-route-modules
brophdawg11 1e309fd
Add more tests for Component/ErrorBoundary
brophdawg11 38d9f06
Update docs with notes on Component/ErrorBoundary
brophdawg11 4430768
Update docs
brophdawg11 b6c4d7d
Minor updates
brophdawg11 b72d4f2
Optimize execution of static handlers in parallel with lazy
brophdawg11 629e423
Fix static router test case
brophdawg11 a4b12e9
Bundle bump
brophdawg11 6f8d08f
Fix typos
brophdawg11 d8fe279
Update to docs
brophdawg11 7b5dc38
Few more typos
brophdawg11 2c9f13d
Change typing from FunctionComponent -> ComponentType
brophdawg11 6d79d3d
Bump bundle
brophdawg11 2560422
Final docs upates
brophdawg11 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
--- | ||
"react-router": minor | ||
"react-router-dom": minor | ||
--- | ||
|
||
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". | ||
|
||
**Example JSON Syntax** | ||
|
||
```jsx | ||
// Both of these work the same: | ||
const elementRoutes = [{ | ||
path: '/', | ||
element: <Home />, | ||
errorElement: <HomeError />, | ||
}] | ||
|
||
const componentRoutes = [{ | ||
path: '/', | ||
Component: Home, | ||
ErrorBoundary: HomeError, | ||
}] | ||
|
||
function Home() { ... } | ||
function HomeError() { ... } | ||
``` | ||
|
||
**Example JSX Syntax** | ||
|
||
```jsx | ||
// Both of these work the same: | ||
const elementRoutes = createRoutesFromElements( | ||
<Route path='/' element={<Home />} errorElement={<HomeError /> } /> | ||
); | ||
|
||
const elementRoutes = createRoutesFromElements( | ||
<Route path='/' Component={Home} ErrorBoundary={HomeError} /> | ||
); | ||
|
||
function Home() { ... } | ||
function HomeError() { ... } | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
--- | ||
"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`/`Component`, `errorElement`/`ErrorBoundary`, `shouldRevalidate`, `handle`). | ||
|
||
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 />}> | ||
<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 an `ErrorBoundary` directly instead of needing to create a React Element from it | ||
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). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -118,6 +118,7 @@ | |
- Manc | ||
- manzano78 | ||
- marc2332 | ||
- markdalgleish | ||
- markivancho | ||
- maruffahmed | ||
- marvinruder | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,248 @@ | ||
# 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. Note we're using the new `Component` API introduced along with this work. | ||
|
||
```jsx | ||
// app.jsx | ||
const router = createBrowserRouter([ | ||
{ | ||
path: "/", | ||
Component: Layout, | ||
children: [ | ||
{ | ||
index: true, | ||
Component: 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 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`/`Component`/`ErrorBoundary` | ||
|
||
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. 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. | ||
|
||
### 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
toComponent
in docs and release notes?There was a problem hiding this comment.
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, andComponent
for static-JSON or lazily-defined. But I could see an argument forComponent
everywhere for sure. I'd probably punt this to Michael/Ryan and see what they think?