Skip to content

Commit 51546cc

Browse files
authored
Remove internal cache from patchRoutesOnNavigation (#12055)
1 parent 51984d1 commit 51546cc

File tree

10 files changed

+104
-88
lines changed

10 files changed

+104
-88
lines changed

.changeset/gold-frogs-pump.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
Remove internal cache to fix issues with interrupted `patchRoutesOnNavigation` calls
6+
7+
- We used to cache in-progress calls to `patchRoutesOnNavigation` internally so that multiple navigations with the same start/end would only execute the function once and use the same promise
8+
- However, this approach was at odds with `patch` short circuiting if a navigation was interrupted (and the `request.signal` aborted) since the first invocation's `patch` would no-op
9+
- This cache also made some assumptions as to what a valid cache key might be - and is oblivious to any other application-state changes that may have occurred
10+
- So, the cache has been removed because in _most_ cases, repeated calls to something like `import()` for async routes will already be cached automatically - and if not it's easy enough for users to implement this cache in userland

docs/components/form.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,6 @@ See also: [`<Link preventScrollReset>`][link-preventscrollreset]
285285

286286
The `viewTransition` prop enables a [View Transition][view-transitions] for this navigation by wrapping the final state update in `document.startViewTransition()`. If you need to apply specific styles for this view transition, you will also need to leverage the [`useViewTransitionState()`][use-view-transition-state].
287287

288-
<docs-warning>Please note that this API is marked unstable and may be subject to breaking changes without a major release</docs-warning>
289-
290288
# Examples
291289

292290
TODO: More examples

docs/components/link.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,7 @@ If you need to apply specific styles for this view transition, you will also nee
185185

186186
```jsx
187187
function ImageLink(to) {
188-
const isTransitioning =
189-
useViewTransitionState(to);
188+
const isTransitioning = useViewTransitionState(to);
190189
return (
191190
<Link to={to} viewTransition>
192191
<p
@@ -214,8 +213,6 @@ function ImageLink(to) {
214213

215214
<docs-warning>`viewTransition` only works when using a data router, see [Picking a Router][picking-a-router]</docs-warning>
216215

217-
<docs-warning>Please note that this API is marked unstable and may be subject to breaking changes without a major release</docs-warning>
218-
219216
[link-native]: ./link-native
220217
[scrollrestoration]: ./scroll-restoration
221218
[history-replace-state]: https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState

docs/components/nav-link.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,5 @@ You may also use the `className`/`style` props or the render props passed to `ch
173173
</NavLink>
174174
```
175175

176-
<docs-warning>
177-
Please note that this API is marked unstable and may be subject to breaking changes without a major release.
178-
</docs-warning>
179-
180176
[aria-current]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current
181177
[view-transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API

docs/hooks/use-fetcher.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,6 @@ If you find yourself calling this function inside of click handlers, you can pro
137137

138138
The `flushSync` option tells React Router DOM to wrap the initial state update for this `fetcher.load` in a [`ReactDOM.flushSync`][flush-sync] call instead of the default [`React.startTransition`][start-transition]. This allows you to perform synchronous DOM actions immediately after the update is flushed to the DOM.
139139

140-
<docs-warning>Please note that this API is marked unstable and may be subject to breaking changes without a major release</docs-warning>
141-
142140
### `fetcher.submit()`
143141

144142
The imperative version of `<fetcher.Form>`. If a user interaction should initiate the fetch, you should use `<fetcher.Form>`. But if you, the programmer are initiating the fetch (not in response to a user clicking a button, etc.), then use this function.

docs/hooks/use-navigate.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,12 @@ The `flushSync` option tells React Router DOM to wrap the initial state update f
120120

121121
<docs-warning>`flushSync` only works when using a data router, see [Picking a Router][picking-a-router]</docs-warning>
122122

123-
<docs-warning>Please note that this API is marked unstable and may be subject to breaking changes without a major release</docs-warning>
124-
125123
## `options.viewTransition`
126124

127125
The `viewTransition` option enables a [View Transition][view-transitions] for this navigation by wrapping the final state update in `document.startViewTransition()`. If you need to apply specific styles for this view transition, you will also need to leverage the [`useViewTransitionState()`][use-view-transition-state].
128126

129127
<docs-warning>`viewTransition` only works when using a data router, see [Picking a Router][picking-a-router]</docs-warning>
130128

131-
<docs-warning>Please note that this API is marked unstable and may be subject to breaking changes without a major release</docs-warning>
132-
133129
[link]: ../components/link
134130
[redirect]: ../fetch/redirect
135131
[loaders]: ../route/loader

docs/hooks/use-submit.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,6 @@ Because submissions are navigations, the options may also contain the other navi
166166

167167
The `flushSync` option tells React Router DOM to wrap the initial state update for this submission in a [`ReactDOM.flushSync`][flush-sync] call instead of the default [`React.startTransition`][start-transition]. This allows you to perform synchronous DOM actions immediately after the update is flushed to the DOM.
168168

169-
<docs-warning>Please note that this API is marked unstable and may be subject to breaking changes without a major release</docs-warning>
170-
171169
[pickingarouter]: ../routers/picking-a-router
172170
[form]: ../components/form
173171
[flush-sync]: https://react.dev/reference/react-dom/flushSync

docs/routers/create-browser-router.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,6 @@ const router = createBrowserRouter(
188188

189189
<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>
190190

191-
<docs-warning>This API is marked "unstable" so it is subject to breaking API changes in minor releases</docs-warning>
192-
193191
By default, React Router is opinionated about how your data is loaded/submitted - and most notably, executes all of your loaders in parallel for optimal data fetching. While we think this is the right behavior for most use-cases, we realize that there is no "one size fits all" solution when it comes to data fetching for the wide landscape of application requirements.
194192

195193
The `dataStrategy` option gives you full control over how your loaders and actions are executed and lays the foundation to build in more advanced APIs such as middleware, context, and caching layers. Over time, we expect that we'll leverage this API internally to bring more first class APIs to React Router, but until then (and beyond), this is your way to add more advanced functionality for your applications data needs.
@@ -436,11 +434,9 @@ let router = createBrowserRouter(routes, {
436434

437435
## `opts.patchRoutesOnNavigation`
438436

439-
<docs-warning>This API is marked "unstable" so it is subject to breaking API changes in minor releases</docs-warning>
440-
441437
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.
442438

443-
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.
439+
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 route definitions (the lightweight part) up front and can perform synchronous route matching, but then delay loading any of the route implementation aspects (the heavier part) until the route is actually navigated to.
444440

445441
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.
446442

@@ -489,7 +485,7 @@ const router = createBrowserRouter(
489485
);
490486
```
491487

492-
In the above example, if the user clicks a link to `/a`, React Router won't be able to match it initially and will call `patchRoutesOnNavigation` with `/a` and a `matches` array containing the root route match. By calling `patch`, 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.
488+
In the above example, if the user clicks a link to `/a`, React Router won't match any routes initially and will call `patchRoutesOnNavigation` with a `path = "/a"` and a `matches` array containing the root route match. By calling `patch('root', [route])`, the new route will be added to the route tree as a child of the `root` route and React Router will perform matching on the updated routes. This time it will successfully match the `/a` path and the navigation will complete successfully.
493489

494490
**Patching new root-level routes**
495491

@@ -544,6 +540,8 @@ let router = createBrowserRouter(
544540
);
545541
```
546542

543+
<docs-info>If in-progress execution of `patchRoutesOnNavigation` is interrupted by a subsequent navigation, then any remaining `patch` calls in the interrupted execution will not update the route tree because the operation was cancelled.</docs-info>
544+
547545
**Co-locating route discovery with route definition**
548546

549547
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:

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

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { AgnosticDataRouteObject, Router } from "../index";
2-
import { createMemoryHistory, createRouter } from "../index";
2+
import { IDLE_NAVIGATION, createMemoryHistory, createRouter } from "../index";
33
import { ErrorResponseImpl } from "../utils";
44
import { createDeferred, createFormData, tick } from "./utils/utils";
55

@@ -269,7 +269,7 @@ describe("Lazy Route Discovery (Fog of War)", () => {
269269
]);
270270
});
271271

272-
it("reuses promises", async () => {
272+
it("does not reuse former calls to patchRoutes on interruptions", async () => {
273273
let aDfd = createDeferred<AgnosticDataRouteObject[]>();
274274
let calls: string[][] = [];
275275
router = createRouter({
@@ -305,8 +305,10 @@ describe("Lazy Route Discovery (Fog of War)", () => {
305305
expect(router.state).toMatchObject({
306306
navigation: { state: "submitting", location: { pathname: "/a/b" } },
307307
});
308-
// Didn't call again for the same path
309-
expect(calls).toEqual([["/a/b", "a"]]);
308+
expect(calls).toEqual([
309+
["/a/b", "a"],
310+
["/a/b", "a"],
311+
]);
310312

311313
aDfd.resolve([
312314
{
@@ -321,10 +323,71 @@ describe("Lazy Route Discovery (Fog of War)", () => {
321323
navigation: { state: "idle" },
322324
location: { pathname: "/a/b" },
323325
});
324-
expect(calls).toEqual([["/a/b", "a"]]);
326+
expect(calls).toEqual([
327+
["/a/b", "a"],
328+
["/a/b", "a"],
329+
]);
330+
});
331+
332+
it("handles interruptions when navigating to the same route", async () => {
333+
let dfd1 = createDeferred<AgnosticDataRouteObject[]>();
334+
let dfd2 = createDeferred<AgnosticDataRouteObject[]>();
335+
let called = false;
336+
router = createRouter({
337+
history: createMemoryHistory(),
338+
routes: [
339+
{
340+
path: "/",
341+
},
342+
],
343+
async patchRoutesOnNavigation({ patch }) {
344+
if (!called) {
345+
called = true;
346+
patch(null, await dfd1.promise);
347+
} else {
348+
patch(null, await dfd2.promise);
349+
}
350+
},
351+
});
352+
353+
router.navigate("/a");
354+
await tick();
355+
expect(router.state).toMatchObject({
356+
navigation: { state: "loading", location: { pathname: "/a" } },
357+
});
358+
359+
router.navigate("/a");
360+
await tick();
361+
expect(router.state).toMatchObject({
362+
navigation: { state: "loading", location: { pathname: "/a" } },
363+
});
364+
365+
dfd1.resolve([
366+
{
367+
id: "a1",
368+
path: "/a",
369+
},
370+
]);
371+
await tick();
372+
expect(router.state).toMatchObject({
373+
navigation: { state: "loading", location: { pathname: "/a" } },
374+
});
375+
376+
dfd2.resolve([
377+
{
378+
id: "a2",
379+
path: "/a",
380+
},
381+
]);
382+
await tick();
383+
expect(router.state).toMatchObject({
384+
location: { pathname: "/a" },
385+
navigation: IDLE_NAVIGATION,
386+
matches: [{ route: { id: "a2" } }],
387+
});
325388
});
326389

327-
it("handles interruptions", async () => {
390+
it("handles interruptions when navigating to a new route", async () => {
328391
let aDfd = createDeferred<AgnosticDataRouteObject[]>();
329392
let bDfd = createDeferred<AgnosticDataRouteObject[]>();
330393
router = createRouter({

packages/router/router.ts

Lines changed: 20 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3295,21 +3295,30 @@ export function createRouter(init: RouterInit): Router {
32953295
pathname: string,
32963296
signal: AbortSignal
32973297
): Promise<DiscoverRoutesResult> {
3298+
if (!patchRoutesOnNavigationImpl) {
3299+
return { type: "success", matches };
3300+
}
3301+
32983302
let partialMatches: AgnosticDataRouteMatch[] | null = matches;
32993303
while (true) {
33003304
let isNonHMR = inFlightDataRoutes == null;
33013305
let routesToUse = inFlightDataRoutes || dataRoutes;
3306+
let localManifest = manifest;
33023307
try {
3303-
await loadLazyRouteChildren(
3304-
patchRoutesOnNavigationImpl!,
3305-
pathname,
3306-
partialMatches,
3307-
routesToUse,
3308-
manifest,
3309-
mapRouteProperties,
3310-
pendingPatchRoutes,
3311-
signal
3312-
);
3308+
await patchRoutesOnNavigationImpl({
3309+
path: pathname,
3310+
matches: partialMatches,
3311+
patch: (routeId, children) => {
3312+
if (signal.aborted) return;
3313+
patchRoutesImpl(
3314+
routeId,
3315+
children,
3316+
routesToUse,
3317+
localManifest,
3318+
mapRouteProperties
3319+
);
3320+
},
3321+
});
33133322
} catch (e) {
33143323
return { type: "error", error: e, partialMatches };
33153324
} finally {
@@ -3319,7 +3328,7 @@ export function createRouter(init: RouterInit): Router {
33193328
// trigger a re-run of memoized `router.routes` dependencies.
33203329
// HMR will already update the identity and reflow when it lands
33213330
// `inFlightDataRoutes` in `completeNavigation`
3322-
if (isNonHMR) {
3331+
if (isNonHMR && !signal.aborted) {
33233332
dataRoutes = [...dataRoutes];
33243333
}
33253334
}
@@ -4601,53 +4610,6 @@ function shouldRevalidateLoader(
46014610
return arg.defaultShouldRevalidate;
46024611
}
46034612

4604-
/**
4605-
* Idempotent utility to execute patchRoutesOnNavigation() to lazily load route
4606-
* definitions and update the routes/routeManifest
4607-
*/
4608-
async function loadLazyRouteChildren(
4609-
patchRoutesOnNavigationImpl: AgnosticPatchRoutesOnNavigationFunction,
4610-
path: string,
4611-
matches: AgnosticDataRouteMatch[],
4612-
routes: AgnosticDataRouteObject[],
4613-
manifest: RouteManifest,
4614-
mapRouteProperties: MapRoutePropertiesFunction,
4615-
pendingRouteChildren: Map<
4616-
string,
4617-
ReturnType<typeof patchRoutesOnNavigationImpl>
4618-
>,
4619-
signal: AbortSignal
4620-
) {
4621-
let key = [path, ...matches.map((m) => m.route.id)].join("-");
4622-
try {
4623-
let pending = pendingRouteChildren.get(key);
4624-
if (!pending) {
4625-
pending = patchRoutesOnNavigationImpl({
4626-
path,
4627-
matches,
4628-
patch: (routeId, children) => {
4629-
if (!signal.aborted) {
4630-
patchRoutesImpl(
4631-
routeId,
4632-
children,
4633-
routes,
4634-
manifest,
4635-
mapRouteProperties
4636-
);
4637-
}
4638-
},
4639-
});
4640-
pendingRouteChildren.set(key, pending);
4641-
}
4642-
4643-
if (pending && isPromise<AgnosticRouteObject[]>(pending)) {
4644-
await pending;
4645-
}
4646-
} finally {
4647-
pendingRouteChildren.delete(key);
4648-
}
4649-
}
4650-
46514613
function patchRoutesImpl(
46524614
routeId: string | null,
46534615
children: AgnosticRouteObject[],

0 commit comments

Comments
 (0)