Skip to content

Commit dd40228

Browse files
committed
Adopt new unstable_shouldCallHandler API to fix revalidation bug on reused routes
1 parent 4fc6e96 commit dd40228

File tree

6 files changed

+187
-32
lines changed

6 files changed

+187
-32
lines changed

.changeset/fair-weeks-beam.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Fix single fetch bug where no revalidation request would be made when navigating upwards to a reused parent route

integration/single-fetch-test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,80 @@ test.describe("single-fetch", () => {
408408
]);
409409
});
410410

411+
test("revalidates on reused routes by default", async ({ page }) => {
412+
let fixture = await createFixture({
413+
files: {
414+
...files,
415+
"app/routes/_index.tsx": js`
416+
import { Link } from "react-router";
417+
export default function Index() {
418+
return <Link to="/parent">Go to Parent</Link>
419+
}
420+
`,
421+
"app/routes/parent.tsx": js`
422+
import { Link, Outlet } from "react-router";
423+
import type { Route } from "./+types/parent";
424+
425+
let count = 0;
426+
export function loader() {
427+
return ++count;
428+
}
429+
430+
export default function Parent({ loaderData }: Route.ComponentProps) {
431+
return (
432+
<>
433+
<h1 data-parent={loaderData}>PARENT:{loaderData}</h1>
434+
<Link to="/parent">Go to Parent</Link><br/>
435+
<Link to="/parent/child">Go to Child</Link>
436+
<Outlet />
437+
</>
438+
);
439+
}
440+
`,
441+
"app/routes/parent.child.tsx": js`
442+
import { Outlet } from "react-router";
443+
import type { Route } from "./+types/parent";
444+
445+
export function loader() {
446+
return "CHILD"
447+
}
448+
449+
export default function Parent({ loaderData }: Route.ComponentProps) {
450+
return <h2 data-child>{loaderData}</h2>
451+
}
452+
`,
453+
},
454+
});
455+
456+
let urls: string[] = [];
457+
page.on("request", (req) => {
458+
let url = new URL(req.url());
459+
if (req.method() === "GET" && url.pathname.endsWith(".data")) {
460+
urls.push(url.pathname + url.search);
461+
}
462+
});
463+
464+
let appFixture = await createAppFixture(fixture);
465+
let app = new PlaywrightFixture(appFixture, page);
466+
await app.goto("/", true);
467+
468+
await app.clickLink("/parent");
469+
await page.waitForSelector('[data-parent="1"]');
470+
expect(urls).toEqual(["/parent.data"]);
471+
urls.length = 0;
472+
473+
await app.clickLink("/parent/child");
474+
await page.waitForSelector("[data-child]");
475+
await expect(page.locator('[data-parent="2"]')).toBeDefined();
476+
expect(urls).toEqual(["/parent/child.data"]);
477+
urls.length = 0;
478+
479+
await app.clickLink("/parent");
480+
await page.waitForSelector('[data-parent="3"]');
481+
expect(urls).toEqual(["/parent.data"]);
482+
urls.length = 0;
483+
});
484+
411485
test("does not revalidate on 4xx/5xx action responses", async ({ page }) => {
412486
let fixture = await createFixture({
413487
files: {

integration/vite-prerender-test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2341,7 +2341,6 @@ test.describe("Prerendering", () => {
23412341
await app.clickLink("/param/1");
23422342
await page.waitForSelector('[data-param="1"]');
23432343
expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 1");
2344-
console.log("asserting", requests);
23452344
expect(requests).toEqual(["/param/1.data"]);
23462345
clearRequests(requests);
23472346

@@ -2426,7 +2425,6 @@ test.describe("Prerendering", () => {
24262425
await app.clickLink("/param/1");
24272426
await page.waitForSelector('[data-param="1"]');
24282427
expect(await (await page.$("[data-param]"))?.innerText()).toBe("Param 1");
2429-
console.log("asserting", requests);
24302428
expect(requests).toEqual(["/param/1.data"]);
24312429
clearRequests(requests);
24322430

packages/react-router/__tests__/router/data-strategy-test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,92 @@ describe("router dataStrategy", () => {
577577
child: "CHILD",
578578
});
579579
});
580+
581+
it("does not short circuit when there are no matchesToLoad", async () => {
582+
let dataStrategy = mockDataStrategy(async ({ matches }) => {
583+
let results = await Promise.all(
584+
matches.map((m) => m.resolve((handler) => handler()))
585+
);
586+
// Don't use keyedResults since it checks for shouldLoad and this test
587+
// is always loading
588+
return results.reduce(
589+
(acc, r, i) => Object.assign(acc, { [matches[i].route.id]: r }),
590+
{}
591+
);
592+
});
593+
let t = setup({
594+
routes: [
595+
{
596+
path: "/",
597+
},
598+
{
599+
id: "parent",
600+
path: "/parent",
601+
loader: true,
602+
children: [
603+
{
604+
id: "child",
605+
path: "child",
606+
loader: true,
607+
},
608+
],
609+
},
610+
],
611+
dataStrategy,
612+
});
613+
614+
let A = await t.navigate("/parent");
615+
await A.loaders.parent.resolve("PARENT1");
616+
expect(A.loaders.parent.stub).toHaveBeenCalled();
617+
expect(t.router.state.loaderData).toEqual({
618+
parent: "PARENT1",
619+
});
620+
expect(dataStrategy.mock.calls[0][0].matches).toEqual([
621+
expect.objectContaining({
622+
route: expect.objectContaining({
623+
id: "parent",
624+
}),
625+
}),
626+
]);
627+
628+
let B = await t.navigate("/parent/child");
629+
await B.loaders.parent.resolve("PARENT2");
630+
await B.loaders.child.resolve("CHILD");
631+
expect(B.loaders.parent.stub).toHaveBeenCalled();
632+
expect(B.loaders.child.stub).toHaveBeenCalled();
633+
expect(t.router.state.loaderData).toEqual({
634+
parent: "PARENT2",
635+
child: "CHILD",
636+
});
637+
expect(dataStrategy.mock.calls[1][0].matches).toEqual([
638+
expect.objectContaining({
639+
route: expect.objectContaining({
640+
id: "parent",
641+
}),
642+
}),
643+
expect.objectContaining({
644+
route: expect.objectContaining({
645+
id: "child",
646+
}),
647+
}),
648+
]);
649+
650+
let C = await t.navigate("/parent");
651+
await C.loaders.parent.resolve("PARENT3");
652+
expect(C.loaders.parent.stub).toHaveBeenCalled();
653+
expect(t.router.state.loaderData).toEqual({
654+
parent: "PARENT3",
655+
});
656+
expect(dataStrategy.mock.calls[2][0].matches).toEqual([
657+
expect.objectContaining({
658+
route: expect.objectContaining({
659+
id: "parent",
660+
}),
661+
}),
662+
]);
663+
664+
expect(dataStrategy).toHaveBeenCalledTimes(3);
665+
});
580666
});
581667

582668
describe("actions", () => {

packages/react-router/lib/dom/ssr/single-fetch.tsx

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,11 @@ export function getSingleFetchDataStrategy(
224224
() =>
225225
singleFetchLoaderNavigationStrategy(
226226
manifest,
227-
routeModules,
228227
ssr,
229228
getRouter(),
230229
request,
231230
matches,
231+
args.unstable_shouldRevalidateArgs,
232232
basename
233233
),
234234
handleMiddlewareError
@@ -309,11 +309,11 @@ async function nonSsrStrategy(
309309
// create a singular promise for all server-loader routes to latch onto.
310310
async function singleFetchLoaderNavigationStrategy(
311311
manifest: AssetsManifest,
312-
routeModules: RouteModules,
313312
ssr: boolean,
314313
router: DataRouter,
315314
request: Request,
316315
matches: DataStrategyFunctionArgs["matches"],
316+
shouldRevalidateArgs: DataStrategyFunctionArgs["unstable_shouldRevalidateArgs"],
317317
basename: string | undefined
318318
) {
319319
// Track which routes need a server load - in case we need to tack on a
@@ -348,32 +348,21 @@ async function singleFetchLoaderNavigationStrategy(
348348

349349
let manifestRoute = manifest.routes[m.route.id];
350350

351-
// Note: If this logic changes for routes that should not participate
352-
// in Single Fetch, make sure you update getLowestLoadingIndex above
353-
// as well
354-
if (!m.shouldLoad) {
355-
// If we're not yet initialized and this is the initial load, respect
356-
// `shouldLoad` because we're only dealing with `clientLoader.hydrate`
357-
// routes which will fall into the `clientLoader` section below.
358-
if (!router.state.initialized) {
359-
return;
360-
}
361-
362-
// Otherwise, we opt out if we currently have data and a
363-
// `shouldRevalidate` function. This implies that the user opted out
364-
// via `shouldRevalidate`
365-
if (
351+
let defaultShouldRevalidate =
352+
!shouldRevalidateArgs ||
353+
shouldRevalidateArgs.actionStatus == null ||
354+
shouldRevalidateArgs.actionStatus < 400;
355+
let shouldCall = m.unstable_shouldCallHandler(defaultShouldRevalidate);
356+
357+
if (!shouldCall) {
358+
// Mark an opt out route if we currently have data and a `shouldRevalidate`
359+
// function. This implies that the user opted out via `shouldRevalidate`
360+
// so we don't want to include it in the single fetch .data request
361+
foundOptOutRoute ||=
362+
manifestRoute?.hasLoader === true &&
366363
m.route.id in router.state.loaderData &&
367-
manifestRoute &&
368-
m.route.shouldRevalidate
369-
) {
370-
if (manifestRoute.hasLoader) {
371-
// If we have a server loader, make sure we don't include it in the
372-
// single fetch .data request
373-
foundOptOutRoute = true;
374-
}
375-
return;
376-
}
364+
m.route.shouldRevalidate != null;
365+
return;
377366
}
378367

379368
// When a route has a client loader, it opts out of the singular call and

packages/react-router/lib/router/router.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1987,8 +1987,10 @@ export function createRouter(init: RouterInit): Router {
19871987

19881988
pendingNavigationLoadId = ++incrementingLoadId;
19891989

1990-
// Short circuit if we have no loaders to run
1990+
// Short circuit if we have no loaders to run, unless there's a custom dataStrategy
1991+
// since they may have different revalidation rules (i.e., single fetch)
19911992
if (
1993+
!init.dataStrategy &&
19921994
!dsMatches.some((m) => m.shouldLoad) &&
19931995
revalidatingFetchers.length === 0
19941996
) {
@@ -4176,8 +4178,9 @@ export function createStaticHandler(
41764178
});
41774179
}
41784180

4179-
// Short circuit if we have no loaders to run (query())
4180-
if (!dsMatches.some((m) => m.shouldLoad)) {
4181+
// Short circuit if we have no loaders to run, unless there's a custom dataStrategy
4182+
// since they may have different revalidation rules (i.e., single fetch)
4183+
if (!dataStrategy && !dsMatches.some((m) => m.shouldLoad)) {
41814184
return {
41824185
matches,
41834186
// Add a null for all matched routes for proper revalidation on the client

0 commit comments

Comments
 (0)