Skip to content

Commit 2dd13c6

Browse files
authored
Remove internal discoverRoutes queue and make patch idempotent (#11977)
1 parent 8c9e2b6 commit 2dd13c6

File tree

4 files changed

+155
-138
lines changed

4 files changed

+155
-138
lines changed

.changeset/unlucky-keys-collect.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
Remove internal `discoveredRoutes` FIFO queue from `unstable_patchRoutesOnNavigation`

docs/routers/create-browser-router.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,88 @@ let router = createBrowserRouter(
597597
);
598598
```
599599
600+
### A note on routes with parameters
601+
602+
Because React Router uses ranked routes to find the best match for a given path, there is an interesting ambiguity introduced when only a partial route tree is known at any given point in time. If we match a fully static route such as `path: "/about/contact-us"` then we know we've found the right match since it's composed entirely of static URL segments, and thus we do not need to bother asking for any other potentially higher-scoring routes.
603+
604+
However, routes with parameters (dynamic or splat) can't make this assumption because there might be a not-yet-discovered route tht scores higher. Consider a full route tree such as:
605+
606+
```js
607+
// Assume this is the full route tree for your app
608+
const routes = [
609+
{
610+
path: "/",
611+
Component: Home,
612+
},
613+
{
614+
id: "blog",
615+
path: "/blog",
616+
Component: BlogLayout,
617+
children: [
618+
{ path: "new", Component: NewPost },
619+
{ path: ":slug", Component: BlogPost },
620+
],
621+
},
622+
];
623+
```
624+
625+
And then assume we want to use `patchRoutesOnNavigation` to fill this in as the user navigates around:
626+
627+
```js
628+
// Start with only the index route
629+
const router = createBrowserRouter(
630+
[
631+
{
632+
path: "/",
633+
Component: Home,
634+
},
635+
],
636+
{
637+
patchRoutesOnNavigation({ path, patch }) {
638+
if (path === "/blog/new") {
639+
patch("blog", [
640+
{
641+
path: "new",
642+
Component: NewPost,
643+
},
644+
]);
645+
} else if (path.startsWith("/blog")) {
646+
patch("blog", [
647+
{
648+
path: ":slug",
649+
Component: BlogPost,
650+
},
651+
]);
652+
}
653+
},
654+
}
655+
);
656+
```
657+
658+
If the user were to a blog post first (i.e., `/blog/my-post`) we would patch in the `:slug` route. Then if the user navigated to `/blog/new` to write a new post, we'd match `/blog/:slug` but it wouldn't be the _right_ match! We need to call `patchRoutesOnNavigation` just in case there exists a higher-scoring route we've not yet discovered, which in this case there is.
659+
660+
So, anytime React Router matches a path that contains at least one param, it will call `patchRoutesOnNavigation` and match routes again just to confirm it has found the best match.
661+
662+
If your `patchRoutesOnNavigation` implementation is expensive or making side-effect `fetch` calls to a backend server, you may want to consider tracking previously seen routes to avoid over-fetching in cases where you know the proper route has already been found. This can usually be as simple as maintaining a small cache of prior `path` values for which you've already patched in the right routes:
663+
664+
```js
665+
let discoveredRoutes = new Set();
666+
667+
const router = createBrowserRouter(routes, {
668+
patchRoutesOnNavigation({ path, patch }) {
669+
if (discoveredRoutes.has(path)) {
670+
// We've seen this before so nothing to patch in and we can let the router
671+
// use the routes it already knows about
672+
return;
673+
}
674+
675+
discoveredRoutes.add(path);
676+
677+
// ... patch routes in accordingly
678+
},
679+
});
680+
```
681+
600682
## `opts.window`
601683
602684
Useful for environments like browser devtool plugins or testing to use a different window than the global `window`.

packages/router/__tests__/lazy-discovery-test.ts

Lines changed: 41 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,134 +1259,75 @@ describe("Lazy Route Discovery (Fog of War)", () => {
12591259
unsubscribe();
12601260
});
12611261

1262-
it('does not re-call for previously called "good" paths', async () => {
1262+
it("does not re-patch previously patched routes", async () => {
12631263
let count = 0;
12641264
router = createRouter({
12651265
history: createMemoryHistory(),
12661266
routes: [
12671267
{
12681268
path: "/",
12691269
},
1270-
{
1271-
id: "param",
1272-
path: ":param",
1273-
},
12741270
],
1275-
async patchRoutesOnNavigation() {
1271+
async patchRoutesOnNavigation({ patch }) {
12761272
count++;
1273+
patch(null, [
1274+
{
1275+
id: "param",
1276+
path: ":param",
1277+
},
1278+
]);
12771279
await tick();
1278-
// Nothing to patch - there is no better static route in this case
12791280
},
12801281
});
12811282

1282-
await router.navigate("/whatever");
1283-
expect(count).toBe(1);
1284-
expect(router.state.location.pathname).toBe("/whatever");
1283+
await router.navigate("/a");
1284+
expect(router.state.location.pathname).toBe("/a");
12851285
expect(router.state.matches.map((m) => m.route.id)).toEqual(["param"]);
1286-
1287-
await router.navigate("/");
12881286
expect(count).toBe(1);
1289-
expect(router.state.location.pathname).toBe("/");
1290-
1291-
await router.navigate("/whatever");
1292-
expect(count).toBe(1); // Not called again
1293-
expect(router.state.location.pathname).toBe("/whatever");
1294-
expect(router.state.matches.map((m) => m.route.id)).toEqual(["param"]);
1295-
});
1296-
1297-
it("does not re-call for previously called 404 paths", async () => {
1298-
let count = 0;
1299-
router = createRouter({
1300-
history: createMemoryHistory(),
1301-
routes: [
1287+
expect(router.routes).toMatchInlineSnapshot(`
1288+
[
13021289
{
1303-
id: "index",
1304-
path: "/",
1290+
"children": undefined,
1291+
"hasErrorBoundary": false,
1292+
"id": "0",
1293+
"path": "/",
13051294
},
13061295
{
1307-
id: "static",
1308-
path: "static",
1296+
"children": undefined,
1297+
"hasErrorBoundary": false,
1298+
"id": "param",
1299+
"path": ":param",
13091300
},
1310-
],
1311-
async patchRoutesOnNavigation() {
1312-
count++;
1313-
},
1314-
});
1315-
1316-
await router.navigate("/junk");
1317-
expect(count).toBe(1);
1318-
expect(router.state.location.pathname).toBe("/junk");
1319-
expect(router.state.errors?.index).toEqual(
1320-
new ErrorResponseImpl(
1321-
404,
1322-
"Not Found",
1323-
new Error('No route matches URL "/junk"'),
1324-
true
1325-
)
1326-
);
1301+
]
1302+
`);
13271303

13281304
await router.navigate("/");
1329-
expect(count).toBe(1);
13301305
expect(router.state.location.pathname).toBe("/");
1331-
expect(router.state.errors).toBeNull();
1332-
1333-
await router.navigate("/junk");
13341306
expect(count).toBe(1);
1335-
expect(router.state.location.pathname).toBe("/junk");
1336-
expect(router.state.errors?.index).toEqual(
1337-
new ErrorResponseImpl(
1338-
404,
1339-
"Not Found",
1340-
new Error('No route matches URL "/junk"'),
1341-
true
1342-
)
1343-
);
1344-
});
13451307

1346-
it("caps internal fifo queue at 1000 paths", async () => {
1347-
let count = 0;
1348-
router = createRouter({
1349-
history: createMemoryHistory(),
1350-
routes: [
1308+
await router.navigate("/b");
1309+
expect(router.state.location.pathname).toBe("/b");
1310+
expect(router.state.matches.map((m) => m.route.id)).toEqual(["param"]);
1311+
expect(router.state.errors).toBeNull();
1312+
// Called again
1313+
expect(count).toBe(2);
1314+
// But not patched again
1315+
expect(router.routes).toMatchInlineSnapshot(`
1316+
[
13511317
{
1352-
path: "/",
1318+
"children": undefined,
1319+
"hasErrorBoundary": false,
1320+
"id": "0",
1321+
"path": "/",
13531322
},
13541323
{
1355-
id: "param",
1356-
path: ":param",
1324+
"children": undefined,
1325+
"hasErrorBoundary": false,
1326+
"id": "param",
1327+
"path": ":param",
13571328
},
1358-
],
1359-
async patchRoutesOnNavigation() {
1360-
count++;
1361-
// Nothing to patch - there is no better static route in this case
1362-
},
1363-
});
1364-
1365-
// Fill it up with 1000 paths
1366-
for (let i = 1; i <= 1000; i++) {
1367-
await router.navigate(`/path-${i}`);
1368-
expect(count).toBe(i);
1369-
expect(router.state.location.pathname).toBe(`/path-${i}`);
1370-
1371-
await router.navigate("/");
1372-
expect(count).toBe(i);
1373-
expect(router.state.location.pathname).toBe("/");
1374-
}
1375-
1376-
// Don't call patchRoutesOnNavigation since this is the first item in the queue
1377-
await router.navigate(`/path-1`);
1378-
expect(count).toBe(1000);
1379-
expect(router.state.location.pathname).toBe(`/path-1`);
1380-
1381-
// Call patchRoutesOnNavigation and evict the first item
1382-
await router.navigate(`/path-1001`);
1383-
expect(count).toBe(1001);
1384-
expect(router.state.location.pathname).toBe(`/path-1001`);
1385-
1386-
// Call patchRoutesOnNavigation since this item was evicted
1387-
await router.navigate(`/path-1`);
1388-
expect(count).toBe(1002);
1389-
expect(router.state.location.pathname).toBe(`/path-1`);
1329+
]
1330+
`);
13901331
});
13911332

13921333
describe("errors", () => {

packages/router/router.ts

Lines changed: 27 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -814,10 +814,6 @@ export function createRouter(init: RouterInit): Router {
814814
let unlistenHistory: (() => void) | null = null;
815815
// Externally-provided functions to call on all state changes
816816
let subscribers = new Set<RouterSubscriber>();
817-
// FIFO queue of previously discovered routes to prevent re-calling on
818-
// subsequent navigations to the same path
819-
let discoveredRoutesMaxSize = 1000;
820-
let discoveredRoutes = new Set<string>();
821817
// Externally-provided object to hold scroll restoration locations during routing
822818
let savedScrollPositions: Record<string, number> | null = null;
823819
// Externally-provided function to get scroll restoration keys
@@ -3243,13 +3239,6 @@ export function createRouter(init: RouterInit): Router {
32433239
pathname: string
32443240
): { active: boolean; matches: AgnosticDataRouteMatch[] | null } {
32453241
if (patchRoutesOnNavigationImpl) {
3246-
// Don't bother re-calling patchRouteOnMiss for a path we've already
3247-
// processed. the last execution would have patched the route tree
3248-
// accordingly so `matches` here are already accurate.
3249-
if (discoveredRoutes.has(pathname)) {
3250-
return { active: false, matches };
3251-
}
3252-
32533242
if (!matches) {
32543243
let fogMatches = matchRoutesImpl<AgnosticDataRouteObject>(
32553244
routesToUse,
@@ -3333,7 +3322,6 @@ export function createRouter(init: RouterInit): Router {
33333322

33343323
let newMatches = matchRoutes(routesToUse, pathname, basename);
33353324
if (newMatches) {
3336-
addToFifoQueue(pathname, discoveredRoutes);
33373325
return { type: "success", matches: newMatches };
33383326
}
33393327

@@ -3352,22 +3340,13 @@ export function createRouter(init: RouterInit): Router {
33523340
(m, i) => m.route.id === newPartialMatches![i].route.id
33533341
))
33543342
) {
3355-
addToFifoQueue(pathname, discoveredRoutes);
33563343
return { type: "success", matches: null };
33573344
}
33583345

33593346
partialMatches = newPartialMatches;
33603347
}
33613348
}
33623349

3363-
function addToFifoQueue(path: string, queue: Set<string>) {
3364-
if (queue.size >= discoveredRoutesMaxSize) {
3365-
let first = queue.values().next().value;
3366-
queue.delete(first);
3367-
}
3368-
queue.add(path);
3369-
}
3370-
33713350
function _internalSetRoutes(newRoutes: AgnosticDataRouteObject[]) {
33723351
manifest = {};
33733352
inFlightDataRoutes = convertRoutesToDataRoutes(
@@ -4661,32 +4640,42 @@ function patchRoutesImpl(
46614640
manifest: RouteManifest,
46624641
mapRouteProperties: MapRoutePropertiesFunction
46634642
) {
4643+
let childrenToPatch: AgnosticDataRouteObject[];
46644644
if (routeId) {
46654645
let route = manifest[routeId];
46664646
invariant(
46674647
route,
46684648
`No route found to patch children into: routeId = ${routeId}`
46694649
);
4670-
let dataChildren = convertRoutesToDataRoutes(
4671-
children,
4672-
mapRouteProperties,
4673-
[routeId, "patch", String(route.children?.length || "0")],
4674-
manifest
4675-
);
4676-
if (route.children) {
4677-
route.children.push(...dataChildren);
4678-
} else {
4679-
route.children = dataChildren;
4650+
if (!route.children) {
4651+
route.children = [];
46804652
}
4653+
childrenToPatch = route.children;
46814654
} else {
4682-
let dataChildren = convertRoutesToDataRoutes(
4683-
children,
4684-
mapRouteProperties,
4685-
["patch", String(routesToUse.length || "0")],
4686-
manifest
4687-
);
4688-
routesToUse.push(...dataChildren);
4655+
childrenToPatch = routesToUse;
46894656
}
4657+
4658+
// Don't patch in routes we already know about so that `patch` is idempotent
4659+
// to simplify user-land code. This is useful because we re-call the
4660+
// `patchRoutesOnNavigation` function for matched routes with params.
4661+
let uniqueChildren = children.filter(
4662+
(a) =>
4663+
!childrenToPatch.some(
4664+
(b) =>
4665+
a.index === b.index &&
4666+
a.path === b.path &&
4667+
a.caseSensitive === b.caseSensitive
4668+
)
4669+
);
4670+
4671+
let newRoutes = convertRoutesToDataRoutes(
4672+
uniqueChildren,
4673+
mapRouteProperties,
4674+
[routeId || "_", "patch", String(childrenToPatch?.length || "0")],
4675+
manifest
4676+
);
4677+
4678+
childrenToPatch.push(...newRoutes);
46904679
}
46914680

46924681
/**

0 commit comments

Comments
 (0)