Skip to content

Commit

Permalink
Support lazy route discovery (fog of war) (#11626)
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 committed Jun 14, 2024
1 parent be8a259 commit 4e85e98
Show file tree
Hide file tree
Showing 12 changed files with 2,436 additions and 94 deletions.
10 changes: 10 additions & 0 deletions .changeset/fog-of-war.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"react-router-dom": minor
"react-router": minor
"@remix-run/router": minor
---

Add support for Lazy Route Discovery (a.k.a. Fog of War)

- RFC: https://github.com/remix-run/react-router/discussions/11113
- `unstable_patchRoutesOnMiss` docs: https://reactrouter.com/en/main/routers/create-browser-router
168 changes: 162 additions & 6 deletions docs/routers/create-browser-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ function createBrowserRouter(
basename?: string;
future?: FutureConfig;
hydrationData?: HydrationState;
unstable_dataStrategy?: unstable_DataStrategyFunction;
unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction;
window?: Window;
}
): RemixRouter;
Expand All @@ -77,7 +79,7 @@ createBrowserRouter([
]);
```

## `basename`
## `opts.basename`

The basename of the app for situations where you can't deploy to the root of the domain, but a sub directory.

Expand All @@ -101,7 +103,7 @@ createBrowserRouter(routes, {
<Link to="/" />; // results in <a href="/app/" />
```

## `future`
## `opts.future`

An optional set of [Future Flags][api-development-strategy] to enable for this Router. We recommend opting into newly released future flags sooner rather than later to ease your eventual migration to v7.

Expand All @@ -125,7 +127,7 @@ The following future flags are currently available:
| [`v7_relativeSplatPath`][relativesplatpath] | Fix buggy relative path resolution in splat routes |
| `unstable_skipActionErrorRevalidation` | Do not revalidate by default if the action returns a 4xx/5xx `Response` |

## `hydrationData`
## `opts.hydrationData`

When [Server-Rendering][ssr] and [opting-out of automatic hydration][hydrate-false], the `hydrationData` option allows you to pass in hydration data from your server-render. This will almost always be a subset of data from the `StaticHandlerContext` value you get back from [handler.query][query]:

Expand Down Expand Up @@ -182,7 +184,7 @@ const router = createBrowserRouter(
);
```

## `unstable_dataStrategy`
## `opts.unstable_dataStrategy`

<docs-warning>This is a low-level API intended for advanced use-cases. This overrides React Router's internal handling of `loader`/`action` execution, and if done incorrectly will break your app code. Please use with caution and perform the appropriate testing.</docs-warning>

Expand Down Expand Up @@ -228,6 +230,8 @@ interface HandlerResult {
}
```

### Overview

`unstable_dataStrategy` receives the same arguments as a `loader`/`action` (`request`, `params`) but it also receives a `matches` array which is an array of the matched routes where each match is extended with 2 new fields for use in the data strategy function:

- **`match.resolve`** - An async function that will resolve any `route.lazy` implementations and execute the route's handler (if necessary), returning a `HandlerResult`
Expand Down Expand Up @@ -359,7 +363,156 @@ let router = createBrowserRouter(routes, {
});
```

## `window`
## `opts.unstable_patchRoutesOnMiss`

<docs-warning>This API is marked "unstable" so it is subject to breaking API changes in minor releases</docs-warning>

By default, React Router wants you to provide a full route tree up front via `createBrowserRouter(routes)`. This allows React Router to perform synchronous route matching, execute loaders, and then render route components in the most optimistic manner without introducing waterfalls. The tradeoff is that your initial JS bundle is larger by definition - which may slow down application start-up times as your application grows.

To combat this, we introduced [`route.lazy`][route-lazy] in [v6.9.0][6-9-0] which let's you lazily load the route _implementation_ (`loader`, `Component`, etc.) while still providing the route _definition_ aspects up front (`path`, `index`, etc.). This is a good middle ground because React Router still knows about your routes up front and can perform synchronous route matching, but then delay loading any of the route implementation aspects until the route is actually navigated to.

In some cases, even this doesn't go far enough. For very large applications, providing all route definitions up front can be prohibitively expensive. Additionally, it might not even be possible to provide all route definitions up front in certain Micro-Frontend or Module-Federation architectures.

This is where `unstable_patchRoutesOnMiss` comes in ([RFC][fog-of-war-rfc]). This API is for advanced use-cases where you are unable to provide the full route tree up-front and need a way to lazily "discover" portions of the route tree at runtime. This feature is often referred to as ["Fog of War"][fog-of-war] because similar to how video games expand the "world" as you move around - the router would be expanding its routing tree as the user navigated around the app - but would only ever end up loading portions of the tree that the user visited.

### Type Declaration

```ts
export interface unstable_PatchRoutesOnMissFunction {
(opts: {
path: string;
matches: RouteMatch[];
patch: (
routeId: string | null,
children: RouteObject[]
) => void;
}): void | Promise<void>;
}
```

### Overview

`unstable_patchRoutesOnMiss` will be called anytime React Router is unable to match a `path`. The arguments include the `path`, any partial `matches`, and a `patch` function you can call to patch new routes into the tree at a specific location. This method is executed during the `loading` portion of the navigation for `GET` requests and during the `submitting` portion of the navigation for non-`GET` requests.

**Patching children into an existing route**

```jsx
const router = createBrowserRouter(
[
{
id: "root",
path: "/",
Component: RootComponent,
},
],
{
async unstable_patchRoutesOnMiss({ path, patch }) {
if (path === "/a") {
// Load/patch the `a` route as a child of the route with id `root`
let route = await getARoute(); // { path: 'a', Component: A }
patch("root", [route]);
}
},
}
);
```

In the above example, if the user clicks a clink to `/a`, React Router won't be able to match it initially and will call `patchRoutesOnMiss` with `/a` and a `matches` array containing the root route match. By calling `patch`, it the `a` route will be added to the route tree and React Router will perform matching again. This time it will successfully match the `/a` path and the navigation will complete successfully.

**Patching new root-level routes**

If you need to patch a new route to the top of the tree (i.e., it doesn't have a parent), you can pass `null` as the `routeId`:

```jsx
const router = createBrowserRouter(
[
{
id: "root",
path: "/",
Component: RootComponent,
},
],
{
async unstable_patchRoutesOnMiss({ path, patch }) {
if (path === "/root-sibling") {
// Load/patch the `/sibling` route at the top
let route = await getRootSiblingRoute(); // { path: '/sibling', Component: Sibling }
patch(null, [route]);
}
},
}
);
```

**Patching sub-trees asyncronously**

You can also perform asynchronous matching to lazily fetch entire sections of your application:

```jsx
let router = createBrowserRouter(
[
{
path: "/",
Component: Home,
},
{
id: "dashboard",
path: "/dashboard",
},
{
id: "account",
path: "/account",
},
],
{
async unstable_patchRoutesOnMiss({ path, patch }) {
if (path.startsWith("/dashboard")) {
let children = await import("./dashboard");
patch("dashboard", children);
}
if (path.startsWith("/account")) {
let children = await import("./account");
patch("account", children);
}
},
}
);
```

**Co-locating route discovery with route definition**

If you don't wish to perform your own pseudo-matching, you can leverage the partial `matches` array and the `handle` field on a route to keep the children definitions co-located:

```jsx
let router = createBrowserRouter([
{
path: "/",
Component: Home,
},
{
path: "/dashboard",
handle: {
lazyChildren: () => import('./dashboard');
}
},
{
path: "/account",
handle: {
lazyChildren: () => import('./account');
}
},
], {
async unstable_patchRoutesOnMiss({ matches, patch }) {
let leafRoute = matches[matches.length - 1]?.route;
if (leafRoute?.handle?.lazyChildren) {
let children = await leafRoute.handle.lazyChildren();
patch(leafRoute.id, children);
}
}
});
```
## `opts.window`
Useful for environments like browser devtool plugins or testing to use a different window than the global `window`.
Expand All @@ -377,4 +530,7 @@ Useful for environments like browser devtool plugins or testing to use a differe
[clientloader]: https://remix.run/route/client-loader
[hydratefallback]: ../route/hydrate-fallback-element
[relativesplatpath]: ../hooks/use-resolved-path#splat-paths
[currying]: https://stackoverflow.com/questions/36314/what-is-currying
[route-lazy]: ../route/lazy
[6-9-0]: https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v690
[fog-of-war]: https://en.wikipedia.org/wiki/Fog_of_war
[fog-of-war-rfc]: https://github.com/remix-run/react-router/discussions/11113
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,19 @@
},
"filesize": {
"packages/router/dist/router.umd.min.js": {
"none": "52.8 kB"
"none": "56.3 kB"
},
"packages/react-router/dist/react-router.production.min.js": {
"none": "14.8 kB"
"none": "14.9 kB"
},
"packages/react-router/dist/umd/react-router.production.min.js": {
"none": "17.21 kB"
"none": "17.3 kB"
},
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
"none": "17.1 kB"
"none": "17.2 kB"
},
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
"none": "23.5 kB"
"none": "23.6 kB"
}
},
"pnpm": {
Expand Down
5 changes: 5 additions & 0 deletions packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
RouteObject,
RouterProviderProps,
To,
unstable_PatchRoutesOnMissFunction,
} from "react-router";
import {
Router,
Expand Down Expand Up @@ -151,6 +152,7 @@ export type {
To,
UIMatch,
unstable_HandlerResult,
unstable_PatchRoutesOnMissFunction,
} from "react-router";
export {
AbortedDeferredError,
Expand Down Expand Up @@ -257,6 +259,7 @@ interface DOMRouterOpts {
future?: Partial<Omit<RouterFutureConfig, "v7_prependBasename">>;
hydrationData?: HydrationState;
unstable_dataStrategy?: unstable_DataStrategyFunction;
unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction;
window?: Window;
}

Expand All @@ -275,6 +278,7 @@ export function createBrowserRouter(
routes,
mapRouteProperties,
unstable_dataStrategy: opts?.unstable_dataStrategy,
unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss,
window: opts?.window,
}).initialize();
}
Expand All @@ -294,6 +298,7 @@ export function createHashRouter(
routes,
mapRouteProperties,
unstable_dataStrategy: opts?.unstable_dataStrategy,
unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss,
window: opts?.window,
}).initialize();
}
Expand Down
3 changes: 3 additions & 0 deletions packages/react-router-dom/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,9 @@ export function createStaticRouter(
deleteBlocker() {
throw msg("deleteBlocker");
},
patchRoutes() {
throw msg("patchRoutes");
},
_internalFetchControllers: new Map(),
_internalActiveDeferreds: new Map(),
_internalSetRoutes() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3098,7 +3098,6 @@ describe("createMemoryRouter", () => {
</React.Suspense>
);

console.log(getHtml(container));
expect(getHtml(container)).toMatchInlineSnapshot(`
"<div>
<p>
Expand Down
6 changes: 6 additions & 0 deletions packages/react-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
To,
UIMatch,
unstable_HandlerResult,
unstable_AgnosticPatchRoutesOnMissFunction,
} from "@remix-run/router";
import {
AbortedDeferredError,
Expand Down Expand Up @@ -288,6 +289,9 @@ function mapRouteProperties(route: RouteObject) {
return updates;
}

export interface unstable_PatchRoutesOnMissFunction
extends unstable_AgnosticPatchRoutesOnMissFunction<RouteMatch> {}

export function createMemoryRouter(
routes: RouteObject[],
opts?: {
Expand All @@ -297,6 +301,7 @@ export function createMemoryRouter(
initialEntries?: InitialEntry[];
initialIndex?: number;
unstable_dataStrategy?: unstable_DataStrategyFunction;
unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction;
}
): RemixRouter {
return createRouter({
Expand All @@ -313,6 +318,7 @@ export function createMemoryRouter(
routes,
mapRouteProperties,
unstable_dataStrategy: opts?.unstable_dataStrategy,
unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss,
}).initialize();
}

Expand Down
Loading

0 comments on commit 4e85e98

Please sign in to comment.