Skip to content

Commit 4e85e98

Browse files
authored
Support lazy route discovery (fog of war) (#11626)
1 parent be8a259 commit 4e85e98

File tree

12 files changed

+2436
-94
lines changed

12 files changed

+2436
-94
lines changed

.changeset/fog-of-war.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"react-router-dom": minor
3+
"react-router": minor
4+
"@remix-run/router": minor
5+
---
6+
7+
Add support for Lazy Route Discovery (a.k.a. Fog of War)
8+
9+
- RFC: https://github.com/remix-run/react-router/discussions/11113
10+
- `unstable_patchRoutesOnMiss` docs: https://reactrouter.com/en/main/routers/create-browser-router

docs/routers/create-browser-router.md

Lines changed: 162 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ function createBrowserRouter(
5151
basename?: string;
5252
future?: FutureConfig;
5353
hydrationData?: HydrationState;
54+
unstable_dataStrategy?: unstable_DataStrategyFunction;
55+
unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction;
5456
window?: Window;
5557
}
5658
): RemixRouter;
@@ -77,7 +79,7 @@ createBrowserRouter([
7779
]);
7880
```
7981

80-
## `basename`
82+
## `opts.basename`
8183

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

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

104-
## `future`
106+
## `opts.future`
105107

106108
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.
107109

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

128-
## `hydrationData`
130+
## `opts.hydrationData`
129131

130132
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]:
131133

@@ -182,7 +184,7 @@ const router = createBrowserRouter(
182184
);
183185
```
184186

185-
## `unstable_dataStrategy`
187+
## `opts.unstable_dataStrategy`
186188

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

@@ -228,6 +230,8 @@ interface HandlerResult {
228230
}
229231
```
230232

233+
### Overview
234+
231235
`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:
232236

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

362-
## `window`
366+
## `opts.unstable_patchRoutesOnMiss`
367+
368+
<docs-warning>This API is marked "unstable" so it is subject to breaking API changes in minor releases</docs-warning>
369+
370+
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.
371+
372+
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.
373+
374+
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.
375+
376+
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.
377+
378+
### Type Declaration
379+
380+
```ts
381+
export interface unstable_PatchRoutesOnMissFunction {
382+
(opts: {
383+
path: string;
384+
matches: RouteMatch[];
385+
patch: (
386+
routeId: string | null,
387+
children: RouteObject[]
388+
) => void;
389+
}): void | Promise<void>;
390+
}
391+
```
392+
393+
### Overview
394+
395+
`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.
396+
397+
**Patching children into an existing route**
398+
399+
```jsx
400+
const router = createBrowserRouter(
401+
[
402+
{
403+
id: "root",
404+
path: "/",
405+
Component: RootComponent,
406+
},
407+
],
408+
{
409+
async unstable_patchRoutesOnMiss({ path, patch }) {
410+
if (path === "/a") {
411+
// Load/patch the `a` route as a child of the route with id `root`
412+
let route = await getARoute(); // { path: 'a', Component: A }
413+
patch("root", [route]);
414+
}
415+
},
416+
}
417+
);
418+
```
419+
420+
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.
421+
422+
**Patching new root-level routes**
423+
424+
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`:
425+
426+
```jsx
427+
const router = createBrowserRouter(
428+
[
429+
{
430+
id: "root",
431+
path: "/",
432+
Component: RootComponent,
433+
},
434+
],
435+
{
436+
async unstable_patchRoutesOnMiss({ path, patch }) {
437+
if (path === "/root-sibling") {
438+
// Load/patch the `/sibling` route at the top
439+
let route = await getRootSiblingRoute(); // { path: '/sibling', Component: Sibling }
440+
patch(null, [route]);
441+
}
442+
},
443+
}
444+
);
445+
```
446+
447+
**Patching sub-trees asyncronously**
448+
449+
You can also perform asynchronous matching to lazily fetch entire sections of your application:
450+
451+
```jsx
452+
let router = createBrowserRouter(
453+
[
454+
{
455+
path: "/",
456+
Component: Home,
457+
},
458+
{
459+
id: "dashboard",
460+
path: "/dashboard",
461+
},
462+
{
463+
id: "account",
464+
path: "/account",
465+
},
466+
],
467+
{
468+
async unstable_patchRoutesOnMiss({ path, patch }) {
469+
if (path.startsWith("/dashboard")) {
470+
let children = await import("./dashboard");
471+
patch("dashboard", children);
472+
}
473+
if (path.startsWith("/account")) {
474+
let children = await import("./account");
475+
patch("account", children);
476+
}
477+
},
478+
}
479+
);
480+
```
481+
482+
**Co-locating route discovery with route definition**
483+
484+
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:
485+
486+
```jsx
487+
let router = createBrowserRouter([
488+
{
489+
path: "/",
490+
Component: Home,
491+
},
492+
{
493+
path: "/dashboard",
494+
handle: {
495+
lazyChildren: () => import('./dashboard');
496+
}
497+
},
498+
{
499+
path: "/account",
500+
handle: {
501+
lazyChildren: () => import('./account');
502+
}
503+
},
504+
], {
505+
async unstable_patchRoutesOnMiss({ matches, patch }) {
506+
let leafRoute = matches[matches.length - 1]?.route;
507+
if (leafRoute?.handle?.lazyChildren) {
508+
let children = await leafRoute.handle.lazyChildren();
509+
patch(leafRoute.id, children);
510+
}
511+
}
512+
});
513+
```
514+
515+
## `opts.window`
363516
364517
Useful for environments like browser devtool plugins or testing to use a different window than the global `window`.
365518
@@ -377,4 +530,7 @@ Useful for environments like browser devtool plugins or testing to use a differe
377530
[clientloader]: https://remix.run/route/client-loader
378531
[hydratefallback]: ../route/hydrate-fallback-element
379532
[relativesplatpath]: ../hooks/use-resolved-path#splat-paths
380-
[currying]: https://stackoverflow.com/questions/36314/what-is-currying
533+
[route-lazy]: ../route/lazy
534+
[6-9-0]: https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#v690
535+
[fog-of-war]: https://en.wikipedia.org/wiki/Fog_of_war
536+
[fog-of-war-rfc]: https://github.com/remix-run/react-router/discussions/11113

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,19 +105,19 @@
105105
},
106106
"filesize": {
107107
"packages/router/dist/router.umd.min.js": {
108-
"none": "52.8 kB"
108+
"none": "56.3 kB"
109109
},
110110
"packages/react-router/dist/react-router.production.min.js": {
111-
"none": "14.8 kB"
111+
"none": "14.9 kB"
112112
},
113113
"packages/react-router/dist/umd/react-router.production.min.js": {
114-
"none": "17.21 kB"
114+
"none": "17.3 kB"
115115
},
116116
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
117-
"none": "17.1 kB"
117+
"none": "17.2 kB"
118118
},
119119
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
120-
"none": "23.5 kB"
120+
"none": "23.6 kB"
121121
}
122122
},
123123
"pnpm": {

packages/react-router-dom/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
RouteObject,
1616
RouterProviderProps,
1717
To,
18+
unstable_PatchRoutesOnMissFunction,
1819
} from "react-router";
1920
import {
2021
Router,
@@ -151,6 +152,7 @@ export type {
151152
To,
152153
UIMatch,
153154
unstable_HandlerResult,
155+
unstable_PatchRoutesOnMissFunction,
154156
} from "react-router";
155157
export {
156158
AbortedDeferredError,
@@ -257,6 +259,7 @@ interface DOMRouterOpts {
257259
future?: Partial<Omit<RouterFutureConfig, "v7_prependBasename">>;
258260
hydrationData?: HydrationState;
259261
unstable_dataStrategy?: unstable_DataStrategyFunction;
262+
unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction;
260263
window?: Window;
261264
}
262265

@@ -275,6 +278,7 @@ export function createBrowserRouter(
275278
routes,
276279
mapRouteProperties,
277280
unstable_dataStrategy: opts?.unstable_dataStrategy,
281+
unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss,
278282
window: opts?.window,
279283
}).initialize();
280284
}
@@ -294,6 +298,7 @@ export function createHashRouter(
294298
routes,
295299
mapRouteProperties,
296300
unstable_dataStrategy: opts?.unstable_dataStrategy,
301+
unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss,
297302
window: opts?.window,
298303
}).initialize();
299304
}

packages/react-router-dom/server.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,9 @@ export function createStaticRouter(
376376
deleteBlocker() {
377377
throw msg("deleteBlocker");
378378
},
379+
patchRoutes() {
380+
throw msg("patchRoutes");
381+
},
379382
_internalFetchControllers: new Map(),
380383
_internalActiveDeferreds: new Map(),
381384
_internalSetRoutes() {

packages/react-router/__tests__/data-memory-router-test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3098,7 +3098,6 @@ describe("createMemoryRouter", () => {
30983098
</React.Suspense>
30993099
);
31003100

3101-
console.log(getHtml(container));
31023101
expect(getHtml(container)).toMatchInlineSnapshot(`
31033102
"<div>
31043103
<p>

packages/react-router/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
To,
3333
UIMatch,
3434
unstable_HandlerResult,
35+
unstable_AgnosticPatchRoutesOnMissFunction,
3536
} from "@remix-run/router";
3637
import {
3738
AbortedDeferredError,
@@ -288,6 +289,9 @@ function mapRouteProperties(route: RouteObject) {
288289
return updates;
289290
}
290291

292+
export interface unstable_PatchRoutesOnMissFunction
293+
extends unstable_AgnosticPatchRoutesOnMissFunction<RouteMatch> {}
294+
291295
export function createMemoryRouter(
292296
routes: RouteObject[],
293297
opts?: {
@@ -297,6 +301,7 @@ export function createMemoryRouter(
297301
initialEntries?: InitialEntry[];
298302
initialIndex?: number;
299303
unstable_dataStrategy?: unstable_DataStrategyFunction;
304+
unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction;
300305
}
301306
): RemixRouter {
302307
return createRouter({
@@ -313,6 +318,7 @@ export function createMemoryRouter(
313318
routes,
314319
mapRouteProperties,
315320
unstable_dataStrategy: opts?.unstable_dataStrategy,
321+
unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss,
316322
}).initialize();
317323
}
318324

0 commit comments

Comments
 (0)