Skip to content

Commit fca7946

Browse files
authored
Add data route matcher abstraction (#15297)
* Add data route matcher abstraction * Keep static router branches option compatible Assisted-By: devx/755516d2-c45b-45bf-b52b-d964afb579bb * Remove static router branches warning test Assisted-By: devx/755516d2-c45b-45bf-b52b-d964afb579bb * Mark router match method private Assisted-By: devx/755516d2-c45b-45bf-b52b-d964afb579bb * Add static router branches deprecation change file Assisted-By: devx/755516d2-c45b-45bf-b52b-d964afb579bb * Use data route matcher for internal matching * Reuse static handler route matcher Assisted-By: devx/597728f0-1b3f-4153-b929-18dbff6cd683 * Reuse static handler matcher for static routers Assisted-By: devx/597728f0-1b3f-4153-b929-18dbff6cd683 * Reuse static handler matcher for RSC requests Assisted-By: devx/b2552a8f-d5a9-42ae-99b3-bb72dd06a325 * Hide route branches from matcher updates Assisted-By: devx/b2552a8f-d5a9-42ae-99b3-bb72dd06a325 * Preserve deprecated EntryContext branches * Move RSC matcher creation out of createStaticRouter
1 parent 8a22a65 commit fca7946

23 files changed

Lines changed: 465 additions & 329 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Deprecate the `createStaticRouter({ branches })` option
2+
3+
`createStaticRouter` now caches route branches internally, so the `branches` option is no longer used and logs a deprecation warning when provided
4+
5+
The deprecated `EntryContext.branches` property is retained for compatibility but is now always an empty array

packages/react-router/__tests__/data-memory-router-test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import {
3535
useSubmit,
3636
type ErrorResponse,
3737
} from "../index";
38+
import { DataRoutes, Router } from "../lib/components";
39+
import { DataRouterContext, DataRouterStateContext } from "../lib/context";
3840
import urlDataStrategy from "./router/utils/urlDataStrategy";
3941
import { createDeferred } from "./router/utils/utils";
4042
import MemoryNavigate from "./utils/MemoryNavigate";
@@ -147,6 +149,63 @@ describe("createMemoryRouter", () => {
147149
`);
148150
});
149151

152+
it("uses the router matcher when data router matches are empty with a basename", () => {
153+
let router = createMemoryRouter(
154+
createRoutesFromElements(
155+
<Route path="thing" element={<h1>Heyooo</h1>} />,
156+
),
157+
{
158+
basename: "/my/base/path",
159+
initialEntries: ["/my/base/path/thing"],
160+
},
161+
);
162+
let state = {
163+
...router.state,
164+
matches: [],
165+
};
166+
let navigator = {
167+
createHref: (to: any) => (typeof to === "string" ? to : to.pathname),
168+
go: () => {},
169+
push: () => {},
170+
replace: () => {},
171+
};
172+
173+
let { container } = render(
174+
<DataRouterContext.Provider
175+
value={{
176+
basename: router.basename,
177+
navigator,
178+
router,
179+
static: false,
180+
}}
181+
>
182+
<DataRouterStateContext.Provider value={state}>
183+
<Router
184+
basename={router.basename}
185+
location={state.location}
186+
navigationType={state.historyAction}
187+
navigator={navigator}
188+
>
189+
<DataRoutes
190+
manifest={router.manifest}
191+
routes={router.routes}
192+
state={state}
193+
isStatic={false}
194+
/>
195+
</Router>
196+
</DataRouterStateContext.Provider>
197+
</DataRouterContext.Provider>,
198+
);
199+
200+
expect(getHtml(container)).toMatchInlineSnapshot(`
201+
"<div>
202+
<h1>
203+
Heyooo
204+
</h1>
205+
</div>"
206+
`);
207+
});
208+
150209
it("prepends basename to loader/action redirects", async () => {
151210
let router = createMemoryRouter(
152211
createRoutesFromElements(

packages/react-router/__tests__/dom/data-static-router-test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,47 @@ beforeEach(() => {
2323
});
2424

2525
describe("A <StaticRouterProvider>", () => {
26+
it("reuses the static handler route matcher", async () => {
27+
let { query } = createStaticHandler([
28+
{
29+
id: "parent",
30+
path: "parent",
31+
children: [
32+
{ id: "child", path: "child" },
33+
{ id: "other", path: "other" },
34+
],
35+
},
36+
]);
37+
let context = (await query(
38+
new Request("http://localhost/parent/child"),
39+
)) as StaticHandlerContext;
40+
expect(typeof context._match).toBe("function");
41+
let otherElement = <h1>Other</h1>;
42+
43+
// This route tree is intentionally invalid so compiling a new matcher for
44+
// it would throw instead of reusing the static handler matcher.
45+
let router = createStaticRouter(
46+
[
47+
{
48+
id: "parent",
49+
path: "parent",
50+
children: [
51+
{ id: "child", path: "/absolute" },
52+
{ id: "other", path: "other", element: otherElement },
53+
],
54+
},
55+
],
56+
context,
57+
);
58+
59+
let matches = router.match("/parent/other");
60+
expect(matches?.map((match) => match.route.id)).toEqual([
61+
"parent",
62+
"other",
63+
]);
64+
expect(matches?.[1].route.element).toBe(otherElement);
65+
});
66+
2667
it("renders an initialized router", async () => {
2768
let hooksData1: {
2869
location: ReturnType<typeof useLocation>;

packages/react-router/__tests__/dom/ssr/components-test.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -163,16 +163,21 @@ describe("<NavLink />", () => {
163163

164164
describe("<ServerRouter>", () => {
165165
it("handles empty default export objects from the compiler", async () => {
166-
let staticHandlerContext = await createStaticHandler([{ path: "/" }]).query(
167-
new Request("http://localhost/"),
168-
);
166+
let staticHandlerContext = await createStaticHandler([
167+
{
168+
id: "root",
169+
path: "/",
170+
children: [{ id: "empty", index: true }],
171+
},
172+
]).query(new Request("http://localhost/"));
169173

170174
invariant(
171175
!(staticHandlerContext instanceof Response),
172176
"Expected a context",
173177
);
174178

175179
let context = mockEntryContext({
180+
staticHandlerContext,
176181
manifest: {
177182
routes: {
178183
root: {
@@ -528,16 +533,17 @@ describe("<Links />", () => {
528533

529534
describe("<Scripts />", () => {
530535
it("propagates nonce to modulepreload links", async () => {
531-
let staticHandlerContext = await createStaticHandler([{ path: "/" }]).query(
532-
new Request("http://localhost/"),
533-
);
536+
let staticHandlerContext = await createStaticHandler([
537+
{ id: "root", path: "/" },
538+
]).query(new Request("http://localhost/"));
534539

535540
invariant(
536541
!(staticHandlerContext instanceof Response),
537542
"Expected a context",
538543
);
539544

540545
let context = mockEntryContext({
546+
staticHandlerContext,
541547
manifest: {
542548
routes: {
543549
root: {
@@ -604,16 +610,17 @@ describe("<Scripts />", () => {
604610
});
605611

606612
it("propagates the ServerRouter nonce to default HydrateFallback scripts when a route has a clientLoader without a HydrateFallback", async () => {
607-
let staticHandlerContext = await createStaticHandler([{ path: "/" }]).query(
608-
new Request("http://localhost/"),
609-
);
613+
let staticHandlerContext = await createStaticHandler([
614+
{ id: "root", path: "/" },
615+
]).query(new Request("http://localhost/"));
610616

611617
invariant(
612618
!(staticHandlerContext instanceof Response),
613619
"Expected a context",
614620
);
615621

616622
let context = mockEntryContext({
623+
staticHandlerContext,
617624
manifest: {
618625
routes: {
619626
root: {

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,26 @@ describe("ssr", () => {
138138
"web+remix:whatever",
139139
];
140140

141+
describe("match", () => {
142+
it("matches against the static handler routes with a basename", () => {
143+
let handler = createStaticHandler(SSR_ROUTES, { basename: "/base" });
144+
145+
expect(handler.match("/base/parent/child")).toMatchObject([
146+
{
147+
params: {},
148+
pathname: "/parent",
149+
route: { id: "parent" },
150+
},
151+
{
152+
params: {},
153+
pathname: "/parent/child",
154+
route: { id: "child" },
155+
},
156+
]);
157+
expect(handler.match("/parent/child")).toBeNull();
158+
});
159+
});
160+
141161
describe("document requests", () => {
142162
it("should support document load navigations", async () => {
143163
let { query } = createStaticHandler(SSR_ROUTES);

packages/react-router/__tests__/router/utils/data-router-setup.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import type {
77
RouterInit,
88
} from "../../../lib/router/router";
99
import type { DataRouteObject, RouteMatch } from "../../../lib/router/utils";
10-
import { createRouter, IDLE_FETCHER } from "../../../lib/router/router";
10+
import {
11+
createDataRouteMatcher,
12+
createRouter,
13+
IDLE_FETCHER,
14+
} from "../../../lib/router/router";
1115
import {
1216
createMemoryHistory,
1317
invariant,
@@ -19,7 +23,6 @@ import type {
1923
} from "../../../lib/router/utils";
2024
import {
2125
defaultMapRouteProperties,
22-
matchRoutes,
2326
redirect,
2427
stripBasename,
2528
} from "../../../lib/router/utils";
@@ -332,6 +335,8 @@ export function setup({
332335
window: testWindow,
333336
...routerInit,
334337
});
338+
let dataRouteMatcher = createDataRouteMatcher("/");
339+
dataRouteMatcher.update(currentRouter.routes);
335340

336341
let fetcherData = getFetcherData(currentRouter);
337342
currentRouter.initialize();
@@ -431,10 +436,9 @@ export function setup({
431436
);
432437
}
433438

434-
let inFlightRoutes: DataRouteObject[] | undefined;
435439
function _internalSetRoutes(routes: DataRouteObject[]) {
436-
inFlightRoutes = routes;
437440
currentRouter?._internalSetRoutes(routes);
441+
dataRouteMatcher.update(routes);
438442
}
439443

440444
function getNavigationHelpers(
@@ -445,7 +449,7 @@ export function setup({
445449
currentRouter?.routes,
446450
"No currentRouter.routes available in getNavigationHelpers",
447451
);
448-
let matches = matchRoutes(inFlightRoutes || currentRouter.routes, href);
452+
let matches = dataRouteMatcher.match(href);
449453

450454
let loaderHelpers = getHelpers(
451455
(matches || []).filter((m) => m.route.loader),
@@ -483,8 +487,8 @@ export function setup({
483487
currentRouter?.routes,
484488
"No currentRouter.routes available in getFetcherHelpers",
485489
);
486-
let matches = matchRoutes(inFlightRoutes || currentRouter.routes, href);
487490
invariant(currentRouter, "No currentRouter available");
491+
let matches = dataRouteMatcher.match(href);
488492
let search = parsePath(href).search || "";
489493
let hasNakedIndexQuery = new URLSearchParams(search)
490494
.getAll("index")
@@ -515,8 +519,7 @@ export function setup({
515519
// @ts-expect-error
516520
if (opts?.formMethod != null && opts.formMethod.toUpperCase() !== "GET") {
517521
if (currentRouter.state.navigation?.location) {
518-
let matches = matchRoutes(
519-
inFlightRoutes || currentRouter.routes,
522+
let matches = dataRouteMatcher.match(
520523
currentRouter.state.navigation.location,
521524
);
522525
invariant(matches, "No matches found for fetcher");

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,39 @@ import {
66
import { URL_LIMIT } from "../../lib/dom/ssr/fog-of-war";
77

88
describe("RSC server", () => {
9+
test("reuses the static handler route matcher after exploding lazy routes", async () => {
10+
let childRoute: RSCRouteConfigEntry = {
11+
id: "child",
12+
path: "child",
13+
lazy: async () => {
14+
// Mutate the source route tree so compiling it again would fail. The
15+
// static handler's enhanced route tree and matcher should be reused.
16+
childRoute.path = "/invalid";
17+
return { Component: () => null };
18+
},
19+
};
20+
let match: RSCMatch | undefined;
21+
22+
let response = await matchRSCServerRequest({
23+
createTemporaryReferenceSet: () => ({}),
24+
request: new Request("https://remix.run/parent/child"),
25+
routes: [
26+
{
27+
id: "parent",
28+
path: "/parent",
29+
children: [childRoute],
30+
},
31+
],
32+
generateResponse(nextMatch) {
33+
match = nextMatch;
34+
return new Response(null, { status: nextMatch.statusCode });
35+
},
36+
});
37+
38+
expect(response.status).toBe(200);
39+
expect(match?.payload.type).toBe("render");
40+
});
41+
942
describe("manifest requests", () => {
1043
test("rejects manifest requests over the URL limit", async () => {
1144
let path = `/${"a".repeat(URL_LIMIT)}.manifest`;

packages/react-router/__tests__/utils/framework.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export function mockEntryContext(
4545
): EntryContext {
4646
return {
4747
...mockFrameworkContext(overrides),
48+
branches: [],
4849
staticHandlerContext: {
4950
location: {
5051
pathname: "/",

packages/react-router/lib/components.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,6 @@ export function RouterProvider({
727727
<MemoizedDataRoutes
728728
routes={router.routes}
729729
manifest={router.manifest}
730-
future={router.future}
731730
state={state}
732731
isStatic={false}
733732
onError={onError}
@@ -777,24 +776,29 @@ const MemoizedDataRoutes = React.memo(DataRoutes);
777776
export function DataRoutes({
778777
routes,
779778
manifest,
780-
future,
781779
state,
782780
isStatic,
783781
onError,
784782
}: {
785783
routes: DataRouteObject[];
786784
manifest: RouteManifest;
787-
future: DataRouter["future"];
788785
state: RouterState;
789786
isStatic: boolean;
790787
onError?: ClientOnErrorFunction;
791788
}): React.ReactElement | null {
789+
let dataRouterContext = React.useContext(DataRouterContext);
790+
791+
invariant(
792+
dataRouterContext,
793+
"You must render this element inside a <DataRouterContext.Provider> element",
794+
);
795+
792796
return useRoutesImpl(routes, undefined, {
797+
router: dataRouterContext.router,
793798
manifest,
794799
state,
795800
isStatic,
796801
onError,
797-
future,
798802
});
799803
}
800804

0 commit comments

Comments
 (0)