Skip to content

Commit 2efa7df

Browse files
authored
fix middleware when used with createRoutesStub (#14348)
1 parent 70abce5 commit 2efa7df

File tree

5 files changed

+191
-10
lines changed

5 files changed

+191
-10
lines changed

.changeset/blue-masks-boil.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
- Update client-side router to run client `middleware` on initial load even if no loaders exist
6+
- Update `createRoutesStub` to run route middleware
7+
- You will need to set the `<RoutesStub future={{ v8_middleware: true }} />` flag to enable the proper `context` type

packages/react-router/__tests__/dom/stub-test.tsx

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from "react";
2-
import { render, screen, waitFor } from "@testing-library/react";
2+
import { act, render, screen, waitFor } from "@testing-library/react";
33
import user from "@testing-library/user-event";
44
import {
55
Form,
@@ -12,7 +12,11 @@ import {
1212
type LoaderFunctionArgs,
1313
useRouteError,
1414
} from "../../index";
15-
import { RouterContextProvider, createContext } from "../../lib/router/utils";
15+
import {
16+
RouterContextProvider,
17+
createContext,
18+
redirect,
19+
} from "../../lib/router/utils";
1620

1721
test("renders a route", () => {
1822
let RoutesStub = createRoutesStub([
@@ -53,6 +57,57 @@ test("renders a nested route", () => {
5357
expect(screen.getByText("INDEX")).toBeInTheDocument();
5458
});
5559

60+
test("middleware works without loader", async () => {
61+
let RoutesStub = createRoutesStub([
62+
{
63+
path: "/",
64+
middleware: [
65+
() => {
66+
throw redirect("/target");
67+
},
68+
],
69+
HydrateFallback: () => null,
70+
Component() {
71+
return <pre>Home</pre>;
72+
},
73+
},
74+
{
75+
path: "/target",
76+
Component() {
77+
return <pre>Target</pre>;
78+
},
79+
},
80+
]);
81+
82+
act(() => {
83+
render(<RoutesStub future={{ v8_middleware: true }} />);
84+
});
85+
86+
await waitFor(() => screen.findByText("Target"));
87+
});
88+
89+
test("middleware works with loader", async () => {
90+
let stringContext = createContext<string>();
91+
let RoutesStub = createRoutesStub([
92+
{
93+
path: "/",
94+
HydrateFallback: () => null,
95+
Component() {
96+
let data = useLoaderData();
97+
return <pre data-testid="data">Message: {data.message}</pre>;
98+
},
99+
middleware: [({ context }) => context.set(stringContext, "hello")],
100+
loader({ context }) {
101+
return { message: context.get(stringContext) };
102+
},
103+
},
104+
]);
105+
106+
render(<RoutesStub future={{ v8_middleware: true }} />);
107+
108+
await waitFor(() => screen.findByText("Message: hello"));
109+
});
110+
56111
// eslint-disable-next-line jest/expect-expect
57112
test("loaders work", async () => {
58113
let RoutesStub = createRoutesStub([

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

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,42 @@ describe("a router", () => {
936936
});
937937
});
938938

939+
it("does not run middlewares when complete hydrationData exists", async () => {
940+
let middlewareSpy = jest.fn();
941+
let loaderSpy = jest.fn();
942+
let router = createRouter({
943+
history: createMemoryHistory(),
944+
routes: [
945+
{
946+
id: "index",
947+
path: "/",
948+
middleware: [middlewareSpy],
949+
loader: loaderSpy,
950+
},
951+
],
952+
hydrationData: {
953+
loaderData: {
954+
index: "INDEX DATA",
955+
},
956+
},
957+
});
958+
router.initialize();
959+
960+
expect(router.state).toMatchObject({
961+
historyAction: "POP",
962+
location: {
963+
pathname: "/",
964+
},
965+
initialized: true,
966+
navigation: IDLE_NAVIGATION,
967+
loaderData: {
968+
index: "INDEX DATA",
969+
},
970+
});
971+
expect(middlewareSpy).not.toHaveBeenCalled();
972+
expect(loaderSpy).not.toHaveBeenCalled();
973+
});
974+
939975
it("kicks off initial data load if no hydration data is provided", async () => {
940976
let parentDfd = createDeferred();
941977
let parentSpy = jest.fn(() => parentDfd.promise);
@@ -993,6 +1029,61 @@ describe("a router", () => {
9931029
router.dispose();
9941030
});
9951031

1032+
it("run middlewares without loaders on initial load if no hydration data is provided", async () => {
1033+
let parentDfd = createDeferred();
1034+
let parentSpy = jest.fn(() => parentDfd.promise);
1035+
let childDfd = createDeferred();
1036+
let childSpy = jest.fn(() => childDfd.promise);
1037+
let router = createRouter({
1038+
history: createMemoryHistory(),
1039+
routes: [
1040+
{
1041+
path: "/",
1042+
middleware: [parentSpy],
1043+
children: [
1044+
{
1045+
index: true,
1046+
middleware: [childSpy],
1047+
},
1048+
],
1049+
},
1050+
],
1051+
});
1052+
router.initialize();
1053+
await tick();
1054+
1055+
expect(console.warn).not.toHaveBeenCalled();
1056+
expect(parentSpy.mock.calls.length).toBe(1);
1057+
expect(childSpy.mock.calls.length).toBe(0);
1058+
expect(router.state).toMatchObject({
1059+
historyAction: "POP",
1060+
location: expect.objectContaining({ pathname: "/" }),
1061+
initialized: false,
1062+
navigation: IDLE_NAVIGATION,
1063+
});
1064+
expect(router.state.loaderData).toEqual({});
1065+
1066+
await parentDfd.resolve(undefined);
1067+
expect(router.state).toMatchObject({
1068+
historyAction: "POP",
1069+
location: expect.objectContaining({ pathname: "/" }),
1070+
initialized: false,
1071+
navigation: IDLE_NAVIGATION,
1072+
});
1073+
expect(router.state.loaderData).toEqual({});
1074+
1075+
await childDfd.resolve(undefined);
1076+
expect(router.state).toMatchObject({
1077+
historyAction: "POP",
1078+
location: expect.objectContaining({ pathname: "/" }),
1079+
initialized: true,
1080+
navigation: IDLE_NAVIGATION,
1081+
loaderData: {},
1082+
});
1083+
1084+
router.dispose();
1085+
});
1086+
9961087
it("allows routes to be initialized with undefined loaderData", async () => {
9971088
let t = setup({
9981089
routes: [

packages/react-router/lib/dom/ssr/routes-test-stub.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
ActionFunctionArgs,
55
LoaderFunction,
66
LoaderFunctionArgs,
7+
MiddlewareFunction,
78
} from "../../router/utils";
89
import type {
910
DataRouteObject,
@@ -209,6 +210,16 @@ function processRoutes(
209210
loader: route.loader
210211
? (args: LoaderFunctionArgs) => route.loader!({ ...args, context })
211212
: undefined,
213+
middleware: route.middleware
214+
? route.middleware.map(
215+
(mw) =>
216+
(...args: Parameters<MiddlewareFunction>) =>
217+
mw(
218+
{ ...args[0], context: context as RouterContextProvider },
219+
args[1],
220+
),
221+
)
222+
: undefined,
212223
handle: route.handle,
213224
shouldRevalidate: route.shouldRevalidate,
214225
};

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -959,8 +959,10 @@ export function createRouter(init: RouterInit): Router {
959959
// All initialMatches need to be loaded before we're ready. If we have lazy
960960
// functions around still then we'll need to run them in initialize()
961961
initialized = false;
962-
} else if (!initialMatches.some((m) => m.route.loader)) {
963-
// If we've got no loaders to run, then we're good to go
962+
} else if (
963+
!initialMatches.some((m) => routeHasLoaderOrMiddleware(m.route))
964+
) {
965+
// If we've got no loaders or middleware to run, then we're good to go
964966
initialized = true;
965967
} else {
966968
// With "partial hydration", we're initialized so long as we were
@@ -2092,7 +2094,9 @@ export function createRouter(init: RouterInit): Router {
20922094
if (
20932095
!init.dataStrategy &&
20942096
!dsMatches.some((m) => m.shouldLoad) &&
2095-
!dsMatches.some((m) => m.route.middleware) &&
2097+
!dsMatches.some(
2098+
(m) => m.route.middleware && m.route.middleware.length > 0,
2099+
) &&
20962100
revalidatingFetchers.length === 0
20972101
) {
20982102
let updatedFetchers = markFetchRedirectsDone();
@@ -4763,7 +4767,7 @@ function getMatchesToLoad(
47634767
} else if (route.lazy) {
47644768
// We haven't loaded this route yet so we don't know if it's got a loader!
47654769
forceShouldLoad = true;
4766-
} else if (route.loader == null) {
4770+
} else if (!routeHasLoaderOrMiddleware(route)) {
47674771
// Nothing to load!
47684772
forceShouldLoad = false;
47694773
} else if (initialHydration) {
@@ -4950,6 +4954,13 @@ function getMatchesToLoad(
49504954
return { dsMatches, revalidatingFetchers };
49514955
}
49524956

4957+
function routeHasLoaderOrMiddleware(route: RouteObject) {
4958+
return (
4959+
route.loader != null ||
4960+
(route.middleware != null && route.middleware.length > 0)
4961+
);
4962+
}
4963+
49534964
function shouldLoadRouteOnHydration(
49544965
route: AgnosticDataRouteObject,
49554966
loaderData: RouteData | null | undefined,
@@ -4960,8 +4971,8 @@ function shouldLoadRouteOnHydration(
49604971
return true;
49614972
}
49624973

4963-
// No loader, nothing to initialize
4964-
if (!route.loader) {
4974+
// No loader or middleware, nothing to run
4975+
if (!routeHasLoaderOrMiddleware(route)) {
49654976
return false;
49664977
}
49674978

@@ -5519,9 +5530,15 @@ function runClientMiddlewarePipeline(
55195530
let { matches } = args;
55205531
let maxBoundaryIdx = Math.min(
55215532
// Throwing route
5522-
matches.findIndex((m) => m.route.id === routeId) || 0,
5533+
Math.max(
5534+
matches.findIndex((m) => m.route.id === routeId),
5535+
0,
5536+
),
55235537
// or the shallowest route that needs to load data
5524-
matches.findIndex((m) => m.unstable_shouldCallHandler()) || 0,
5538+
Math.max(
5539+
matches.findIndex((m) => m.unstable_shouldCallHandler()),
5540+
0,
5541+
),
55255542
);
55265543
let boundaryRouteId = findNearestBoundary(
55275544
matches,

0 commit comments

Comments
 (0)