Skip to content

Commit abcb86d

Browse files
committed
Switch to route.instrument API
1 parent fc90407 commit abcb86d

File tree

5 files changed

+311
-28
lines changed

5 files changed

+311
-28
lines changed
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { cleanup, setup } from "./utils/data-router-setup";
2+
import { createFormData } from "./utils/utils";
3+
4+
// Detect any failures inside the router navigate code
5+
afterEach(() => {
6+
cleanup();
7+
});
8+
9+
describe("instrumentation", () => {
10+
it("allows instrumentation of loaders", async () => {
11+
let spy = jest.fn();
12+
let t = setup({
13+
routes: [
14+
{
15+
index: true,
16+
},
17+
{
18+
id: "page",
19+
path: "/page",
20+
loader: true,
21+
},
22+
],
23+
unstable_instrumentRoute: (route) => {
24+
route.instrument({
25+
async loader(loader) {
26+
spy("start");
27+
await loader();
28+
spy("end");
29+
},
30+
});
31+
},
32+
});
33+
34+
let A = await t.navigate("/page");
35+
expect(spy).toHaveBeenNthCalledWith(1, "start");
36+
await A.loaders.page.resolve("PAGE");
37+
expect(spy).toHaveBeenNthCalledWith(2, "end");
38+
expect(t.router.state).toMatchObject({
39+
navigation: { state: "idle" },
40+
location: { pathname: "/page" },
41+
loaderData: { page: "PAGE" },
42+
});
43+
});
44+
45+
it("allows instrumentation of actions", async () => {
46+
let spy = jest.fn();
47+
let t = setup({
48+
routes: [
49+
{
50+
index: true,
51+
},
52+
{
53+
id: "page",
54+
path: "/page",
55+
action: true,
56+
},
57+
],
58+
unstable_instrumentRoute: (route) => {
59+
route.instrument({
60+
async action(action) {
61+
spy("start");
62+
await action();
63+
spy("end");
64+
},
65+
});
66+
},
67+
});
68+
69+
let A = await t.navigate("/page", {
70+
formMethod: "POST",
71+
formData: createFormData({}),
72+
});
73+
expect(spy).toHaveBeenNthCalledWith(1, "start");
74+
await A.actions.page.resolve("PAGE");
75+
expect(spy).toHaveBeenNthCalledWith(2, "end");
76+
expect(t.router.state).toMatchObject({
77+
navigation: { state: "idle" },
78+
location: { pathname: "/page" },
79+
actionData: { page: "PAGE" },
80+
});
81+
});
82+
83+
it("provides read-only information to instrumentation wrappers", async () => {
84+
let spy = jest.fn();
85+
let t = setup({
86+
routes: [
87+
{
88+
index: true,
89+
},
90+
{
91+
id: "slug",
92+
path: "/:slug",
93+
loader: true,
94+
},
95+
],
96+
unstable_instrumentRoute: (route) => {
97+
route.instrument({
98+
async loader(loader, info) {
99+
spy(info);
100+
Object.assign(info.params, { extra: "extra" });
101+
await loader();
102+
},
103+
});
104+
},
105+
});
106+
107+
let A = await t.navigate("/a");
108+
await A.loaders.slug.resolve("A");
109+
let args = spy.mock.calls[0][0];
110+
expect(args.request.method).toBe("GET");
111+
expect(args.request.url).toBe("http://localhost/a");
112+
expect(args.request.url).toBe("http://localhost/a");
113+
expect(args.request.headers.get).toBeDefined();
114+
expect(args.request.headers.set).not.toBeDefined();
115+
expect(args.params).toEqual({ slug: "a", extra: "extra" });
116+
expect(args.pattern).toBe("/:slug");
117+
expect(args.context.get).toBeDefined();
118+
expect(args.context.set).not.toBeDefined();
119+
expect(t.router.state.matches[0].params).toEqual({ slug: "a" });
120+
});
121+
122+
it("allows composition of multiple instrumentations", async () => {
123+
let spy = jest.fn();
124+
let t = setup({
125+
routes: [
126+
{
127+
index: true,
128+
},
129+
{
130+
id: "page",
131+
path: "/page",
132+
loader: true,
133+
},
134+
],
135+
unstable_instrumentRoute: (route) => {
136+
route.instrument({
137+
async loader(loader) {
138+
spy("start inner");
139+
await loader();
140+
spy("end inner");
141+
},
142+
});
143+
route.instrument({
144+
async loader(loader) {
145+
spy("start outer");
146+
await loader();
147+
spy("end outer");
148+
},
149+
});
150+
},
151+
});
152+
153+
let A = await t.navigate("/page");
154+
await A.loaders.page.resolve("PAGE");
155+
expect(spy.mock.calls).toEqual([
156+
["start outer"],
157+
["start inner"],
158+
["end inner"],
159+
["end outer"],
160+
]);
161+
expect(t.router.state).toMatchObject({
162+
navigation: { state: "idle" },
163+
location: { pathname: "/page" },
164+
loaderData: { page: "PAGE" },
165+
});
166+
});
167+
});

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

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
HydrationState,
66
Router,
77
RouterNavigateOptions,
8+
RouterInit,
89
} from "../../../lib/router/router";
910
import type {
1011
AgnosticDataRouteObject,
@@ -134,14 +135,10 @@ export const TASK_ROUTES: TestRouteObject[] = [
134135
},
135136
];
136137

137-
type SetupOpts = {
138+
type SetupOpts = Omit<RouterInit, "routes" | "history" | "window"> & {
138139
routes: TestRouteObject[];
139-
basename?: string;
140140
initialEntries?: InitialEntry[];
141141
initialIndex?: number;
142-
hydrationRouteProperties?: string[];
143-
hydrationData?: HydrationState;
144-
dataStrategy?: DataStrategyFunction;
145142
};
146143

147144
// We use a slightly modified version of createDeferred here that includes the
@@ -202,12 +199,9 @@ export function getFetcherData(router: Router) {
202199

203200
export function setup({
204201
routes,
205-
basename,
206202
initialEntries,
207203
initialIndex,
208-
hydrationRouteProperties,
209-
hydrationData,
210-
dataStrategy,
204+
...routerInit
211205
}: SetupOpts) {
212206
let guid = 0;
213207
// Global "active" helpers, keyed by navType:guid:loaderOrAction:routeId.
@@ -318,13 +312,10 @@ export function setup({
318312
jest.spyOn(history, "push");
319313
jest.spyOn(history, "replace");
320314
currentRouter = createRouter({
321-
basename,
322315
history,
323316
routes: enhanceRoutes(routes),
324-
hydrationRouteProperties,
325-
hydrationData,
326317
window: testWindow,
327-
dataStrategy: dataStrategy,
318+
...routerInit,
328319
});
329320

330321
let fetcherData = getFetcherData(currentRouter);
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type {
2+
ActionFunction,
3+
AgnosticDataRouteObject,
4+
LoaderFunction,
5+
LoaderFunctionArgs,
6+
RouterContextProvider,
7+
} from "./utils";
8+
9+
type InstrumentationInfo = Readonly<{
10+
request: {
11+
method: string;
12+
url: string;
13+
headers: Pick<Headers, "get">;
14+
};
15+
params: LoaderFunctionArgs["params"];
16+
pattern: string;
17+
// TODO: Fix for non-middleware
18+
context: Pick<RouterContextProvider, "get">;
19+
}>;
20+
21+
type InstrumentHandlerFunction = (
22+
handler: () => undefined,
23+
info: InstrumentationInfo,
24+
) => MaybePromise<void>;
25+
26+
type Instrumentations = {
27+
loader?: InstrumentHandlerFunction;
28+
action?: InstrumentHandlerFunction;
29+
};
30+
31+
type InstrumentableRoute = {
32+
id: string;
33+
index: boolean | undefined;
34+
path: string | undefined;
35+
instrument(instrumentations: Instrumentations): void;
36+
};
37+
38+
export type unstable_InstrumentRouteFunction = (
39+
route: InstrumentableRoute,
40+
) => void;
41+
42+
function getInstrumentedHandler<H extends LoaderFunction | ActionFunction>(
43+
impls: InstrumentHandlerFunction[],
44+
handler: H,
45+
) {
46+
if (impls.length === 0) {
47+
return null;
48+
}
49+
return async (...args: Parameters<H>) => {
50+
let value;
51+
let composed = impls.reduce(
52+
(acc, fn) => (i) => fn(acc as () => undefined, i),
53+
async () => {
54+
value = await handler(...args);
55+
},
56+
) as unknown as (info: InstrumentationInfo) => Promise<void>;
57+
await composed(getInstrumentationInfo(args[0]));
58+
return value;
59+
};
60+
}
61+
62+
function getInstrumentationInfo(args: LoaderFunctionArgs): InstrumentationInfo {
63+
let { request, context, params, pattern } = args;
64+
return {
65+
// pseudo "Request" with the info they may want to read from
66+
request: {
67+
method: request.method,
68+
url: request.url,
69+
// Maybe make this a proxy that only supports `get`?
70+
headers: {
71+
get: (...args) => request.headers.get(...args),
72+
},
73+
},
74+
params: { ...params },
75+
pattern,
76+
context: {
77+
get: (...args: Parameters<RouterContextProvider["get"]>) =>
78+
context.get(...args),
79+
},
80+
};
81+
}
82+
83+
export function getInstrumentationUpdates(
84+
unstable_instrumentRoute: unstable_InstrumentRouteFunction,
85+
route: AgnosticDataRouteObject,
86+
) {
87+
let updates: {
88+
loader?: LoaderFunction;
89+
action?: ActionFunction;
90+
} = {};
91+
let instrumentations: Instrumentations[] = [];
92+
unstable_instrumentRoute({
93+
id: route.id,
94+
index: route.index,
95+
path: route.path,
96+
instrument(i) {
97+
instrumentations.push(i);
98+
},
99+
});
100+
if (instrumentations.length > 0) {
101+
if (typeof route.loader === "function") {
102+
let instrumented = getInstrumentedHandler(
103+
instrumentations
104+
.map((i) => i.loader)
105+
.filter(Boolean) as InstrumentHandlerFunction[],
106+
route.loader,
107+
);
108+
if (instrumented) {
109+
updates.loader = instrumented;
110+
}
111+
}
112+
if (typeof route.action === "function") {
113+
let instrumented = getInstrumentedHandler(
114+
instrumentations
115+
.map((i) => i.action)
116+
.filter(Boolean) as InstrumentHandlerFunction[],
117+
route.action,
118+
);
119+
if (instrumented) {
120+
updates.action = instrumented;
121+
}
122+
}
123+
}
124+
return updates;
125+
}

0 commit comments

Comments
 (0)