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

Support lazy route discovery (fog of war) #11626

Merged
merged 37 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7a345ec
POC of Fog of War approach
brophdawg11 Dec 11, 2023
cdb546c
Remove splat requirement
brophdawg11 Dec 12, 2023
8c99860
Supprt depth > 1
brophdawg11 Dec 12, 2023
b8dea1b
Refactor for reusability
brophdawg11 May 20, 2024
30b8888
Support actions
brophdawg11 May 20, 2024
881c5d1
404 and Error handling
brophdawg11 May 20, 2024
8627d55
Handle thrown errors from route.children()
brophdawg11 May 21, 2024
278abda
Update children() thrown errors to be a 400 with partial matches
brophdawg11 May 21, 2024
9e4ed91
Updated for pathless and splat routes
brophdawg11 May 23, 2024
086eb3d
pathless/splat tests
brophdawg11 May 23, 2024
52e9b8e
Get experimental releases working
brophdawg11 May 23, 2024
e4bca04
Merge branch 'dev' into brophdawg11/fog-of-war
brophdawg11 May 29, 2024
41f805c
Handle route discovery in router.fetch
brophdawg11 May 29, 2024
50e4a41
Refactor API from children() -> patchRoutesOnMiss()
brophdawg11 May 31, 2024
c4e96c3
Add test for old-style children() API
brophdawg11 May 31, 2024
b0c78a7
Remove invalid tests for index routes
brophdawg11 May 31, 2024
cfd0e7f
POC of patchRoutes()
brophdawg11 May 31, 2024
40105aa
Remove lingering children type updates
brophdawg11 Jun 5, 2024
abead7a
Add patchRoutes to createStaticRouter
brophdawg11 Jun 5, 2024
822bfcc
Revert "Get experimental releases working"
brophdawg11 Jun 6, 2024
abedd15
Fix false positive when no patchRoutesOnMiss is provided
brophdawg11 Jun 6, 2024
ed5c3d9
Changeset and export through RR packages
brophdawg11 Jun 6, 2024
ade9ea9
update docs
brophdawg11 Jun 6, 2024
a67fe79
fix indents in docs
brophdawg11 Jun 6, 2024
b69830f
Remove leftover code block
brophdawg11 Jun 6, 2024
42a5087
Bump bundle
brophdawg11 Jun 6, 2024
ba2138a
Support for no partial matches
brophdawg11 Jun 7, 2024
edf4162
Get experimental releases working
brophdawg11 May 23, 2024
e157216
Fix agnostic types and hydration issues
brophdawg11 Jun 10, 2024
9c60e75
Fix type
brophdawg11 Jun 10, 2024
f370cca
Change arguments to object and remove return shorthand
brophdawg11 Jun 12, 2024
c5cae38
Update docs/routers/create-browser-router.md
brophdawg11 Jun 12, 2024
ecf191a
Fix node 16 tests
brophdawg11 Jun 12, 2024
57f7290
Fix typo
brophdawg11 Jun 13, 2024
7fe07e9
Revert "Get experimental releases working"
brophdawg11 Jun 13, 2024
499ee1b
Updates
brophdawg11 Jun 13, 2024
ce23383
Merge branch 'dev' into brophdawg11/fog-of-war
brophdawg11 Jun 14, 2024
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
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
2 changes: 1 addition & 1 deletion .github/workflows/release-experimental.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
SHORT_SHA=$(git rev-parse --short HEAD)
NEXT_VERSION=0.0.0-experimental-${SHORT_SHA}
git checkout -b experimental/${NEXT_VERSION}
pnpm run version ${NEXT_VERSION}
pnpm run version:experimental
git push origin --tags

- name: 🏗 Build
Expand Down
149 changes: 143 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,138 @@ 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. However, it means 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] 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.

However, in some cases - even this doesn't go far enough. If your application is large enough, providing all route definitions up front can be prohibitively large. 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 `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

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

```jsx
const router = createBrowserRouter(
[
{
id: "root",
path: "/",
Component: RootComponent,
},
],
{
unstable_patchRoutesOnMiss(path, matches, patch) {
// Patch the `a` route as a child of the route with id `root`
patch("root", [
{
path: "a",
Component: AComponent,
},
]);

// Or, if you want to patch these routes as siblings of the root route
// (i.e., they have no parent), you can pass `null` for the parent route id
patch(null, [
{
path: "/a",
Component: AComponent,
},
]);
},
}
);
```

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.

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.

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, matches) {
if (path.startsWith("/dashboard")) {
let children = await import("./dashboard");
patch("dashboard", children);
}
if (path.startsWith("/account")) {
let children = await import("./account");
patch("account", children);
}
},
}
);
```

If you don't wish to perform your own pseudo-matching, you can leverage the partial `matches` srray and the `handle` field on a route to keep the children definitions co-located:
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved

```jsx
let router = createBrowserRouter([
{
path: "/",
Component: Home,
},
{
path: "/dashboard",
handle: {
lazyChildren: () => import('./dashboard');
}
},
{
path: "/account",
handle: {
lazyChildren: () => import('./account');
}
},
], {
unstable_patchRoutesOnMiss(path, matches) {
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 +512,6 @@ 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
[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
4 changes: 4 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 @@ -256,6 +257,7 @@ interface DOMRouterOpts {
future?: Partial<Omit<RouterFutureConfig, "v7_prependBasename">>;
hydrationData?: HydrationState;
unstable_dataStrategy?: unstable_DataStrategyFunction;
unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction;
window?: Window;
}

Expand All @@ -274,6 +276,7 @@ export function createBrowserRouter(
routes,
mapRouteProperties,
unstable_dataStrategy: opts?.unstable_dataStrategy,
unstable_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss,
window: opts?.window,
}).initialize();
}
Expand All @@ -293,6 +296,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