Skip to content

Commit 710626f

Browse files
authored
refactor(react-router): internal data strategy (#13253)
1 parent c63b509 commit 710626f

File tree

9 files changed

+665
-305
lines changed

9 files changed

+665
-305
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

.changeset/yellow-mangos-impress.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+
Add support for the new `unstable_shouldCallHandler`/`unstable_shouldRevalidateArgs` APIs in `dataStrategy`

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-export/hydrated-router.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,6 @@ function createHydratedRouter({
209209
},
210210
dataStrategy: getSingleFetchDataStrategy(
211211
ssrInfo.manifest,
212-
ssrInfo.routeModules,
213212
ssrInfo.context.ssr,
214213
ssrInfo.context.basename,
215214
() => router

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

Lines changed: 24 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ function handleMiddlewareError(error: unknown, routeId: string) {
139139

140140
export function getSingleFetchDataStrategy(
141141
manifest: AssetsManifest,
142-
routeModules: RouteModules,
143142
ssr: boolean,
144143
basename: string | undefined,
145144
getRouter: () => DataRouter
@@ -152,12 +151,11 @@ export function getSingleFetchDataStrategy(
152151
return runMiddlewarePipeline(
153152
args,
154153
false,
155-
() => singleFetchActionStrategy(request, matches, basename),
154+
() => singleFetchActionStrategy(args, basename),
156155
handleMiddlewareError
157156
) as Promise<Record<string, DataStrategyResult>>;
158157
}
159158

160-
// TODO: Enable middleware for this flow
161159
if (!ssr) {
162160
// If this is SPA mode, there won't be any loaders below root and we'll
163161
// disable single fetch. We have to keep the `dataStrategy` defined for
@@ -193,15 +191,15 @@ export function getSingleFetchDataStrategy(
193191
// the other end
194192
let foundRevalidatingServerLoader = matches.some(
195193
(m) =>
196-
m.shouldLoad &&
194+
m.unstable_shouldCallHandler() &&
197195
manifest.routes[m.route.id]?.hasLoader &&
198196
!manifest.routes[m.route.id]?.hasClientLoader
199197
);
200198
if (!foundRevalidatingServerLoader) {
201199
return runMiddlewarePipeline(
202200
args,
203201
false,
204-
() => nonSsrStrategy(manifest, request, matches, basename),
202+
() => nonSsrStrategy(args, manifest, basename),
205203
handleMiddlewareError
206204
) as Promise<Record<string, DataStrategyResult>>;
207205
}
@@ -223,12 +221,10 @@ export function getSingleFetchDataStrategy(
223221
false,
224222
() =>
225223
singleFetchLoaderNavigationStrategy(
224+
args,
226225
manifest,
227-
routeModules,
228226
ssr,
229227
getRouter(),
230-
request,
231-
matches,
232228
basename
233229
),
234230
handleMiddlewareError
@@ -239,11 +235,10 @@ export function getSingleFetchDataStrategy(
239235
// Actions are simple since they're singular calls to the server for both
240236
// navigations and fetchers)
241237
async function singleFetchActionStrategy(
242-
request: Request,
243-
matches: DataStrategyFunctionArgs["matches"],
238+
{ request, matches }: DataStrategyFunctionArgs,
244239
basename: string | undefined
245240
) {
246-
let actionMatch = matches.find((m) => m.shouldLoad);
241+
let actionMatch = matches.find((m) => m.unstable_shouldCallHandler());
247242
invariant(actionMatch, "No action match found");
248243
let actionStatus: number | undefined = undefined;
249244
let result = await actionMatch.resolve(async (handler) => {
@@ -276,12 +271,11 @@ async function singleFetchActionStrategy(
276271

277272
// We want to opt-out of Single Fetch when we aren't in SSR mode
278273
async function nonSsrStrategy(
274+
{ request, matches }: DataStrategyFunctionArgs,
279275
manifest: AssetsManifest,
280-
request: Request,
281-
matches: DataStrategyFunctionArgs["matches"],
282276
basename: string | undefined
283277
) {
284-
let matchesToLoad = matches.filter((m) => m.shouldLoad);
278+
let matchesToLoad = matches.filter((m) => m.unstable_shouldCallHandler());
285279
let url = stripIndexParam(singleFetchUrl(request.url, basename));
286280
let init = await createRequestInit(request);
287281
let results: Record<string, DataStrategyResult> = {};
@@ -308,12 +302,10 @@ async function nonSsrStrategy(
308302
// Loaders are trickier since we only want to hit the server once, so we
309303
// create a singular promise for all server-loader routes to latch onto.
310304
async function singleFetchLoaderNavigationStrategy(
305+
{ request, matches }: DataStrategyFunctionArgs,
311306
manifest: AssetsManifest,
312-
routeModules: RouteModules,
313307
ssr: boolean,
314308
router: DataRouter,
315-
request: Request,
316-
matches: DataStrategyFunctionArgs["matches"],
317309
basename: string | undefined
318310
) {
319311
// Track which routes need a server load - in case we need to tack on a
@@ -348,32 +340,20 @@ async function singleFetchLoaderNavigationStrategy(
348340

349341
let manifestRoute = manifest.routes[m.route.id];
350342

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 (
366-
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-
}
343+
let defaultShouldRevalidate =
344+
!m.unstable_shouldRevalidateArgs ||
345+
m.unstable_shouldRevalidateArgs.actionStatus == null ||
346+
m.unstable_shouldRevalidateArgs.actionStatus < 400;
347+
let shouldCall = m.unstable_shouldCallHandler(defaultShouldRevalidate);
348+
349+
if (!shouldCall) {
350+
// If this route opted out of revalidation, we don't want to include
351+
// it in the single fetch .data request
352+
foundOptOutRoute ||=
353+
m.unstable_shouldRevalidateArgs != null && // This is a revalidation,
354+
manifestRoute?.hasLoader === true && // for a route with a server loader,
355+
m.route.shouldRevalidate != null; // and a shouldRevalidate function
356+
return;
377357
}
378358

379359
// When a route has a client loader, it opts out of the singular call and
@@ -469,7 +449,7 @@ async function singleFetchLoaderFetcherStrategy(
469449
matches: DataStrategyFunctionArgs["matches"],
470450
basename: string | undefined
471451
) {
472-
let fetcherMatch = matches.find((m) => m.shouldLoad);
452+
let fetcherMatch = matches.find((m) => m.unstable_shouldCallHandler());
473453
invariant(fetcherMatch, "No fetcher match found");
474454
let result = await fetcherMatch.resolve(async (handler) => {
475455
let url = stripIndexParam(singleFetchUrl(request.url, basename));

0 commit comments

Comments
 (0)