diff --git a/.changeset/serious-garlics-push.md b/.changeset/serious-garlics-push.md
new file mode 100644
index 0000000000..7755ca52c3
--- /dev/null
+++ b/.changeset/serious-garlics-push.md
@@ -0,0 +1,13 @@
+---
+"react-router": patch
+---
+
+Add `unstable_instrumentations` API to allow users to add observablity to their apps by instrumenting route loaders, actions, middlewares, lazy, as well as server-side request handlers and client side navigations/fetches
+
+- Framework Mode:
+ - `entry.server.tsx`: `export const unstable_instrumentations = [...]`
+ - `entry.client.tsx`: ` `
+- Data Mode
+ - `createBrowserRouter(routes, { unstable_instrumentations: [...] })`
+
+This also adds a new `unstable_pattern` parameter to loaders/actions/middleware which contains the un-interpolated route pattern (i.e., `/blog/:slug`) which is useful for aggregating performance metrics by route
diff --git a/decisions/0015-observability.md b/decisions/0015-observability.md
new file mode 100644
index 0000000000..adddd4e425
--- /dev/null
+++ b/decisions/0015-observability.md
@@ -0,0 +1,221 @@
+# Title
+
+Date: 2025-09-22
+
+Status: proposed
+
+## Context
+
+We want it to be easy to add observability to production React Router applications. This involves the ability to add logging, error reporting, and performance tracing to your application on both the server and the client.
+
+We always had a good story for user-facing error handling via `ErrorBoundary`, but until recently we only had a server-side error-reporting solution via the `entry.server` `handleError` export. In `7.8.2`, we shipped an `onError` client-side equivalent so it should now be possible to report on errors on the server and client pretty easily.
+
+We have not historically had great recommendations for the other 2 facets of observability - logging and performance tracing. Middleware, shipped in `7.3.0` and stabilized in `7.9.0` gave us a way to "wrap" request handlers at any level of the tree, which provides a good solution for logging and _some_ high-level performance tracing. But it's too coarse-grained and does not allow folks to drill down into their applications.
+
+This has also been raised in the (currently) 2nd-most upvoted Proposal in the past year: https://github.com/remix-run/react-router/discussions/13749.
+
+One way to add fine-grained logging/tracing today is to manually include it in all of your loaders and actions, but this is tedious and error-prone.
+
+Another way is to "instrument" the server build, which has long been our suggestion - initially to the folks at Sentry - and over time to RR users here and there in discord and github issues. but, we've never formally documented this as a recommended pattern, and it currently only works on the server and requires that you use a custom server.
+
+## Decision
+
+Adopt instrumentation as a first class API and the recommended way to implement observability in your application.
+
+There are 2 levels in which we want to instrument:
+
+- "router" level - ability to track the start and end of a router operation
+ - requests on the server handler
+ - navigations and fetchers on the client router
+- route level
+ - loaders, actions, middlewares, lazy
+
+On the server, if you are using a custom server, this is already possible by wrapping the react router handler and walking the `build.routes` tree and wrapping the route handlers.
+
+To provide the same functionality when using `@react-router/serve` we need to open up a new API. Currently, I am proposing a new `instrumentations` export from `entry.server`. This will be run on the server build in `createRequestHandler` and that way can work without a custom server. This will also allow custom-server users today to move some more code from their custom server into React Router by leveraging these new exports.
+
+```tsx
+// entry.server.tsx
+
+export const instrumentations = [
+ {
+ // Wrap incoming request handlers. Currently applies to _all_ requests handled
+ // by the RR handler, including:
+ // - manifest reqeusts
+ // - document requests
+ // - `.data` requests
+ // - resource route requests
+ handler({ instrument }) {
+ // Calling instrument performs the actual instrumentation
+ instrument({
+ // Provide the instrumentation implementation for the equest handler
+ async request(handleRequest, { request }) {
+ let start = Date.now();
+ console.log(`Request start: ${request.method} ${request.url}`);
+ try {
+ await handleRequest();
+ } finally {
+ let duration = Date.now() - start;
+ console.log(
+ `Request end: ${request.method} ${request.url} (${duration}ms)`,
+ );
+ }
+ },
+ });
+ },
+ // Instrument an individual route, allowing you to wrap middleware/loader/action/etc.
+ // This also gives you a place to do global "shouldRevalidate" which is a nice side
+ // effect as folks have asked for that for a long time
+ route({ instrument, id }) {
+ // `id` is the route id in case you want to instrument only some routes or
+ // instrument in a route-specific manner
+ if (id === "routes/i-dont-care") return;
+
+ instrument({
+ loader(callLoader, { request }) {
+ let start = Date.now();
+ console.log(`Loader start: ${request.method} ${request.url}`);
+ try {
+ await callLoader();
+ } finally {
+ let duration = Date.now() - start;
+ console.log(
+ `Loader end: ${request.method} ${request.url} (${duration}ms)`,
+ );
+ }
+ },
+ // action(), middleware(), lazy()
+ });
+ },
+ },
+];
+```
+
+Open questions:
+
+- On the server we could technically do this at build time, but I don't expect this to have a large startup cost and doing it at build-time just feels a bit more magical and would differ from any examples we want to show in data mode.
+- Another option for custom server folks would be to make these parameters to `createRequestHandler`, but then we'd still need a way for `react-router-server` users to use them and thus we'd still need to support them in `entry.server`, so might as well make it consistent for both.
+
+Client-side, it's a similar story. You could do this today at the route level in Data mode before calling `createBrowserRouter`, and you could wrap `router.navigate`/`router.fetch` after that. but there's no way to instrument the router `initialize` method without "ejecting" to using the lower level `createRouter`. And there is no way to do this in framework mode.
+
+I think we can open up APIs similar to those in `entry.server` but do them on `createBrowserRouter` and `HydratedRouter`:
+
+```tsx
+// entry.client.tsx
+
+export const instrumentations = [{
+ // Instrument router operations
+ router({ instrument }) {
+ instrument({
+ async initialize(callNavigate, info) { /*...*/ },
+ async navigate(callNavigate, info) { /*...*/ },
+ async fetch(callNavigate, info) { /*...*/ },
+ });
+ },
+ route({ instrument, id }) {
+ instrument({
+ lazy(callLazy, info) { /*...*/ },
+ middleware(callMiddleware, info) { /*...*/ },
+ loader(callLoader, info) { /*...*/ },
+ action(callAction, info) { /*...*/ },
+ });
+ },
+}];
+
+// Data mode
+let router = createBrowserRouter(routes, { instrumentations })
+
+// Framework mode
+
+```
+
+In both of these cases, we'll handle the instrumentation at the router creation level. And by passing `instrumentRoute` into the router, we can properly instrument future routes discovered via `route.lazy` or `patchRouteOnNavigation`
+
+### Composition
+
+Instrumentations is an aray so that you can compose together multiple independent instrumentations easily:
+
+```tsx
+let router = createBrowserRouter(routes, {
+ instrumentations: [logNavigations, addWindowPerfTraces, addSentryPerfTraces],
+});
+```
+
+### Dynamic Instrumentations
+
+By doing this at runtime, you should be able to enable instrumentation conditionally.
+
+Client side, it's trivial because it can be done on page load and avoid overhead on normal flows:
+
+```tsx
+let enableInstrumentation = window.location.search.startsWith("?DEBUG");
+let router = createBrowserRouter(routes, {
+ instrumentations: enableInstrumentation ? [debuggingInstrumentations] : [],
+});
+```
+
+Server side, it's a bit tricker but should be doable with a custom server:
+
+```tsx
+// Assume you export `instrumentations` from entry.server
+let getBuild = () => import("virtual:react-router/server-build");
+
+let instrumentedHandler = createRequestHandler({
+ build: getBuild,
+});
+
+let unInstrumentedHandler = createRequestHandler({
+ build: () =>
+ getBuild().then((m) => ({
+ ...m,
+ entry: {
+ ...m.entry,
+ module: {
+ ...m.entry.module,
+ unstable_instrumentations: undefined,
+ },
+ },
+ })),
+});
+
+app.use((req, res, next) => {
+ let url = new URL(req.url, `http://${req.headers.host}`);
+ if (url.searchParams.has("DEBUG")) {
+ return instrumentedHandler(req, res, next);
+ }
+ return unInstrumentedHandler(req, res, next);
+});
+```
+
+## Alternatives Considered
+
+### Events
+
+Originally we wanted to add an [Events API](https://github.com/remix-run/react-router/discussions/9565), but this proved to [have issues](https://github.com/remix-run/react-router/discussions/13749#discussioncomment-14135422) with the ability to "wrap" logic for easier OTEL instrumentation. These were not [insurmountable](https://github.com/remix-run/react-router/discussions/13749#discussioncomment-14421335), but the solutions didn't feel great.
+
+### patchRoutes
+
+Client side, we also considered whether this could be done via `patchRoutes`, but that's currently intended mostly to add new routes and doesn't work for `route.lazy` routes. In some RSC-use cases it can update parts of an existing route, but it sonly allows updates for the server-rendered RSC "elements," and doesn't walk the entire child tree to update children routes so it's not an ideal solution for updating loaders in the entire tree.
+
+### Naive Function wrapping
+
+The original implementation of this proposal was a naive simple wrapping of functions, but we moved away from this because by putting the wrapped function arguments (i.e., loader) in control of the user, they could potentially modify them and abuse the API to change runtime behavior instead of just instrument/observe. We want instrumentation to be limited to that - and it should not be able to change app behavior.
+
+```tsx
+function instrumentRoute(route: RouteModule): RequestHandler {
+ let { loader } = route;
+ let newRoute = { ...route };
+ if (loader) {
+ newRoute.loader = (args) => {
+ console.log("Loader start");
+ try {
+ // ⚠️ The user could send whatever they want into the actual loader here
+ return await loader(...args);
+ } finally {
+ console.log("Loader end");
+ }
+ };
+ }
+ return newRoute;
+}
+```
diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts
index 37e1dbce47..ac3a915bbb 100644
--- a/integration/browser-entry-test.ts
+++ b/integration/browser-entry-test.ts
@@ -196,3 +196,126 @@ test("allows users to pass an onError function to HydratedRouter", async ({
appFixture.close();
});
+
+test("allows users to instrument the client side router via HydratedRouter", async ({
+ page,
+}) => {
+ let fixture = await createFixture({
+ files: {
+ "app/entry.client.tsx": js`
+ import { HydratedRouter } from "react-router/dom";
+ import { startTransition, StrictMode } from "react";
+ import { hydrateRoot } from "react-dom/client";
+
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+ `,
+ "app/routes/_index.tsx": js`
+ import { Link } from "react-router";
+ export default function Index() {
+ return Go to Page;
+ }
+ `,
+ "app/routes/page.tsx": js`
+ import { useFetcher } from "react-router";
+ export function loader() {
+ return { data: "hello world" };
+ }
+ export function action() {
+ return "OK";
+ }
+ export default function Page({ loaderData }) {
+ let fetcher = useFetcher({ key: 'a' });
+ return (
+ <>
+
{loaderData.data} ;
+ fetcher.submit({ key: 'value' }, {
+ method: 'post',
+ action: "/page"
+ })}>
+ Fetch
+
+ {fetcher.data ? {fetcher.data} : null}
+ >
+ );
+ }
+ `,
+ },
+ });
+
+ let logs: string[] = [];
+ page.on("console", (msg) => logs.push(msg.text()));
+
+ let appFixture = await createAppFixture(fixture);
+ let app = new PlaywrightFixture(appFixture, page);
+
+ await app.goto("/", true);
+ await page.click('a[href="/page"]');
+ await page.waitForSelector("[data-page]");
+
+ expect(await app.getHtml()).toContain("hello world");
+ expect(logs).toEqual([
+ 'start navigate {"to":"/page","currentUrl":"/"}',
+ "start loader root /page",
+ "start loader routes/page /page",
+ "end loader root /page",
+ "end loader routes/page /page",
+ 'end navigate {"to":"/page","currentUrl":"/"}',
+ ]);
+ logs.splice(0);
+
+ await page.click("[data-fetch]");
+ await page.waitForSelector("[data-fetcher-data]");
+ await expect(page.locator("[data-fetcher-data]")).toContainText("OK");
+ expect(logs).toEqual([
+ 'start fetch {"href":"/page","currentUrl":"/page","fetcherKey":"a","formMethod":"post","formEncType":"application/x-www-form-urlencoded","body":{"key":"value"}}',
+ "start action routes/page /page",
+ "end action routes/page /page",
+ "start loader root /page",
+ "start loader routes/page /page",
+ "end loader root /page",
+ "end loader routes/page /page",
+ 'end fetch {"href":"/page","currentUrl":"/page","fetcherKey":"a","formMethod":"post","formEncType":"application/x-www-form-urlencoded","body":{"key":"value"}}',
+ ]);
+
+ appFixture.close();
+});
diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts
index 0fca5f7a39..0fdf16c762 100644
--- a/packages/react-router/__tests__/router/fetchers-test.ts
+++ b/packages/react-router/__tests__/router/fetchers-test.ts
@@ -373,6 +373,7 @@ describe("fetchers", () => {
request: new Request("http://localhost/foo", {
signal: A.loaders.root.stub.mock.calls[0][0].request.signal,
}),
+ unstable_pattern: expect.any(String),
context: {},
});
});
@@ -3373,6 +3374,7 @@ describe("fetchers", () => {
expect(F.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -3402,6 +3404,7 @@ describe("fetchers", () => {
expect(F.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -3429,6 +3432,7 @@ describe("fetchers", () => {
expect(F.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -3456,6 +3460,7 @@ describe("fetchers", () => {
expect(F.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -3484,6 +3489,7 @@ describe("fetchers", () => {
expect(F.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -3514,6 +3520,7 @@ describe("fetchers", () => {
expect(F.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -3543,6 +3550,7 @@ describe("fetchers", () => {
expect(F.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
diff --git a/packages/react-router/__tests__/router/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts
new file mode 100644
index 0000000000..9ef2eeba36
--- /dev/null
+++ b/packages/react-router/__tests__/router/instrumentation-test.ts
@@ -0,0 +1,1846 @@
+import { createMemoryRouter } from "../../lib/components";
+import type { StaticHandlerContext } from "../../lib/router/router";
+import { createStaticHandler } from "../../lib/router/router";
+import {
+ type ActionFunction,
+ type LoaderFunction,
+ type MiddlewareFunction,
+} from "../../lib/router/utils";
+import { createRequestHandler } from "../../lib/server-runtime/server";
+import { mockServerBuild } from "../server-runtime/utils";
+import { cleanup, setup } from "./utils/data-router-setup";
+import { createDeferred, createFormData, tick } from "./utils/utils";
+
+// Detect any failures inside the router navigate code
+afterEach(() => {
+ cleanup();
+});
+
+describe("instrumentation", () => {
+ describe("client-side router", () => {
+ it("allows instrumentation of middleware", async () => {
+ let spy = jest.fn();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ middleware: [
+ async (_, next) => {
+ spy("start middleware");
+ await next();
+ spy("end middleware");
+ },
+ ],
+ loader: true,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async middleware(middleware) {
+ spy("start");
+ await middleware();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ let A = await t.navigate("/page");
+ expect(spy.mock.calls).toEqual([["start"], ["start middleware"]]);
+ await A.loaders.page.resolve("PAGE");
+ expect(spy.mock.calls).toEqual([
+ ["start"],
+ ["start middleware"],
+ ["end middleware"],
+ ["end"],
+ ]);
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of loaders", async () => {
+ let spy = jest.fn();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ loader: true,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async loader(loader) {
+ spy("start");
+ await loader();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ let A = await t.navigate("/page");
+ expect(spy).toHaveBeenNthCalledWith(1, "start");
+ await A.loaders.page.resolve("PAGE");
+ expect(spy).toHaveBeenNthCalledWith(2, "end");
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of actions", async () => {
+ let spy = jest.fn();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ action: true,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async action(action) {
+ spy("start");
+ await action();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ let A = await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ expect(spy).toHaveBeenNthCalledWith(1, "start");
+ await A.actions.page.resolve("PAGE");
+ expect(spy).toHaveBeenNthCalledWith(2, "end");
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of lazy function", async () => {
+ let spy = jest.fn();
+ let lazyDfd = createDeferred<{ loader: LoaderFunction }>();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ lazy: () => lazyDfd.promise,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async lazy(lazy) {
+ spy("start");
+ await lazy();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page");
+ await tick();
+ expect(spy.mock.calls).toEqual([["start"]]);
+
+ await lazyDfd.resolve({ loader: () => "PAGE" });
+ await tick();
+ expect(spy.mock.calls).toEqual([["start"], ["end"]]);
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of lazy function loaders", async () => {
+ let spy = jest.fn();
+ let lazyDfd = createDeferred<{ loader: LoaderFunction }>();
+ let loaderDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ lazy: () => lazyDfd.promise,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async loader(loader) {
+ spy("start");
+ await loader();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page");
+ expect(spy).not.toHaveBeenCalled();
+
+ await lazyDfd.resolve({ loader: () => loaderDfd.promise });
+ expect(spy.mock.calls).toEqual([["start"]]);
+
+ await loaderDfd.resolve("PAGE");
+ await tick();
+ expect(spy.mock.calls).toEqual([["start"], ["end"]]);
+
+ await tick();
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of lazy function actions", async () => {
+ let spy = jest.fn();
+ let lazyDfd = createDeferred<{ loader: LoaderFunction }>();
+ let actionDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ lazy: () => lazyDfd.promise,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async action(action) {
+ spy("start");
+ await action();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ expect(spy).not.toHaveBeenCalled();
+
+ await lazyDfd.resolve({ action: () => actionDfd.promise });
+ expect(spy.mock.calls).toEqual([["start"]]);
+
+ await actionDfd.resolve("PAGE");
+ await tick();
+ expect(spy.mock.calls).toEqual([["start"], ["end"]]);
+
+ await tick();
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "PAGE" },
+ });
+ });
+
+ it("does not double-instrument when a static `loader` is used alongside `lazy()`", async () => {
+ let spy = jest.fn();
+ let lazyDfd = createDeferred<{ loader: LoaderFunction }>();
+ let warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ loader: true,
+ lazy: () => lazyDfd.promise,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async loader(loader) {
+ spy("start");
+ await loader();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ let A = await t.navigate("/page");
+ expect(spy.mock.calls).toEqual([["start"]]);
+ await lazyDfd.resolve({ action: () => "ACTION", loader: () => "WRONG" });
+ await A.loaders.page.resolve("PAGE");
+ expect(spy.mock.calls).toEqual([["start"], ["end"]]);
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ loaderData: { page: "PAGE" },
+ });
+ spy.mockClear();
+
+ await t.navigate("/");
+
+ let C = await t.navigate("/page");
+ expect(spy.mock.calls).toEqual([["start"]]);
+ await C.loaders.page.resolve("PAGE");
+ expect(spy.mock.calls).toEqual([["start"], ["end"]]);
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ loaderData: { page: "PAGE" },
+ });
+ spy.mockClear();
+
+ warnSpy.mockRestore();
+ });
+
+ it("does not double-instrument when a static `action` is used alongside `lazy()`", async () => {
+ let spy = jest.fn();
+ let lazyDfd = createDeferred<{ loader: LoaderFunction }>();
+ let warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ action: true,
+ lazy: () => lazyDfd.promise,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async action(action) {
+ spy("start");
+ await action();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ let A = await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ expect(spy.mock.calls).toEqual([["start"]]);
+ await lazyDfd.resolve({ action: () => "WRONG" });
+ await A.actions.page.resolve("PAGE");
+ expect(spy.mock.calls).toEqual([["start"], ["end"]]);
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "PAGE" },
+ });
+ spy.mockClear();
+
+ let B = await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ expect(spy.mock.calls).toEqual([["start"]]);
+ await B.actions.page.resolve("PAGE");
+ expect(spy.mock.calls).toEqual([["start"], ["end"]]);
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "PAGE" },
+ });
+ spy.mockClear();
+
+ warnSpy.mockRestore();
+ });
+
+ it("allows instrumentation of lazy object middleware", async () => {
+ let spy = jest.fn();
+ let middlewareDfd = createDeferred();
+ let loaderDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ lazy: {
+ middleware: () => middlewareDfd.promise,
+ loader: () => Promise.resolve(() => loaderDfd.promise),
+ },
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ "lazy.middleware": async (middleware) => {
+ spy("start");
+ await middleware();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page");
+ expect(spy.mock.calls).toEqual([["start"]]);
+
+ await middlewareDfd.resolve([
+ async (_, next) => {
+ spy("middleware start");
+ await next();
+ spy("middleware end");
+ },
+ ]);
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start"],
+ ["end"],
+ ["middleware start"],
+ ]);
+
+ await loaderDfd.resolve("PAGE");
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start"],
+ ["end"],
+ ["middleware start"],
+ ["middleware end"],
+ ]);
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of lazy object loaders", async () => {
+ let spy = jest.fn();
+ let loaderDfd = createDeferred();
+ let loaderValueDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ lazy: {
+ loader: () => loaderDfd.promise,
+ },
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ "lazy.loader": async (load) => {
+ spy("start");
+ await load();
+ spy("end");
+ },
+ loader: async (loader) => {
+ spy("loader start");
+ await loader();
+ spy("loader end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page");
+ await tick();
+ expect(spy.mock.calls).toEqual([["start"]]);
+
+ await loaderDfd.resolve(() => loaderValueDfd.promise);
+ await tick();
+ expect(spy.mock.calls).toEqual([["start"], ["end"], ["loader start"]]);
+
+ await loaderValueDfd.resolve("PAGE");
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start"],
+ ["end"],
+ ["loader start"],
+ ["loader end"],
+ ]);
+
+ await tick();
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of lazy object actions", async () => {
+ let spy = jest.fn();
+ let actionDfd = createDeferred();
+ let actionValueDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ lazy: {
+ action: () => actionDfd.promise,
+ },
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ "lazy.action": async (load) => {
+ spy("start");
+ await load();
+ spy("end");
+ },
+ action: async (action) => {
+ spy("action start");
+ await action();
+ spy("action end");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ await tick();
+ expect(spy.mock.calls).toEqual([["start"]]);
+
+ await actionDfd.resolve(() => actionValueDfd.promise);
+ await tick();
+ expect(spy.mock.calls).toEqual([["start"], ["end"], ["action start"]]);
+
+ await actionValueDfd.resolve("PAGE");
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start"],
+ ["end"],
+ ["action start"],
+ ["action end"],
+ ]);
+
+ await tick();
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of everything for a statically defined route", async () => {
+ let spy = jest.fn();
+ let middlewareDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ middleware: [
+ async (_, next) => {
+ await middlewareDfd.promise;
+ return next();
+ },
+ ],
+ loader: true,
+ action: true,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ middleware: async (impl) => {
+ spy("start middleware");
+ await impl();
+ spy("end middleware");
+ },
+ action: async (impl) => {
+ spy("start action");
+ await impl();
+ spy("end action");
+ },
+ loader: async (impl) => {
+ spy("start loader");
+ await impl();
+ spy("end loader");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ let A = await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ expect(spy.mock.calls).toEqual([["start middleware"]]);
+
+ await middlewareDfd.resolve(undefined);
+ await tick();
+ expect(spy.mock.calls).toEqual([["start middleware"], ["start action"]]);
+
+ await A.actions.page.resolve("ACTION");
+ expect(spy.mock.calls).toEqual([
+ ["start middleware"],
+ ["start action"],
+ ["end action"],
+ ["end middleware"],
+ ["start middleware"],
+ ["start loader"],
+ ]);
+
+ await A.loaders.page.resolve("PAGE");
+ expect(spy.mock.calls).toEqual([
+ ["start middleware"],
+ ["start action"],
+ ["end action"],
+ ["end middleware"],
+ ["start middleware"],
+ ["start loader"],
+ ["end loader"],
+ ["end middleware"],
+ ]);
+
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "ACTION" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of everything for a lazy function route", async () => {
+ let spy = jest.fn();
+ let lazyDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ // Middleware can't be returned from lazy()
+ middleware: [(_, next) => next()],
+ lazy: () => lazyDfd.promise,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ middleware: async (impl) => {
+ spy("start middleware");
+ await impl();
+ spy("end middleware");
+ },
+ action: async (impl) => {
+ spy("start action");
+ await impl();
+ spy("end action");
+ },
+ loader: async (impl) => {
+ spy("start loader");
+ await impl();
+ spy("end loader");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ expect(spy.mock.calls).toEqual([["start middleware"]]);
+
+ await lazyDfd.resolve({
+ loader: () => "PAGE",
+ action: () => "ACTION",
+ });
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start middleware"],
+ ["start action"],
+ ["end action"],
+ ["end middleware"],
+ ["start middleware"],
+ ["start loader"],
+ ["end loader"],
+ ["end middleware"],
+ ]);
+
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "ACTION" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of everything for a lazy object route", async () => {
+ let spy = jest.fn();
+ let middlewareDfd = createDeferred();
+ let actionDfd = createDeferred();
+ let loaderDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ lazy: {
+ middleware: () => middlewareDfd.promise,
+ action: () => actionDfd.promise,
+ loader: () => loaderDfd.promise,
+ },
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ middleware: async (impl) => {
+ spy("start middleware");
+ await impl();
+ spy("end middleware");
+ },
+ action: async (impl) => {
+ spy("start action");
+ await impl();
+ spy("end action");
+ },
+ loader: async (impl) => {
+ spy("start loader");
+ await impl();
+ spy("end loader");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ expect(spy.mock.calls).toEqual([]);
+
+ await middlewareDfd.resolve([(_, next) => next()]);
+ await tick();
+ expect(spy.mock.calls).toEqual([["start middleware"]]);
+
+ await actionDfd.resolve(() => "ACTION");
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start middleware"],
+ ["start action"],
+ ["end action"],
+ ]);
+
+ await loaderDfd.resolve(() => "PAGE");
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start middleware"],
+ ["start action"],
+ ["end action"],
+ ["end middleware"],
+ ["start middleware"],
+ ["start loader"],
+ ["end loader"],
+ ["end middleware"],
+ ]);
+
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "ACTION" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of everything for a statically defined route via patchRoutesOnNavigation", async () => {
+ let spy = jest.fn();
+ let middlewareDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ ],
+ patchRoutesOnNavigation: ({ path, patch }) => {
+ if (path === "/page") {
+ patch(null, [
+ {
+ id: "page",
+ path: "/page",
+ middleware: [
+ async (_, next) => {
+ await middlewareDfd.promise;
+ return next();
+ },
+ ],
+ loader: () => "PAGE",
+ action: () => "ACTION",
+ },
+ ]);
+ }
+ },
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ middleware: async (impl) => {
+ spy("start middleware");
+ await impl();
+ spy("end middleware");
+ },
+ action: async (impl) => {
+ spy("start action");
+ await impl();
+ spy("end action");
+ },
+ loader: async (impl) => {
+ spy("start loader");
+ await impl();
+ spy("end loader");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ await tick();
+ await tick();
+ expect(spy.mock.calls).toEqual([["start middleware"]]);
+
+ await middlewareDfd.resolve(undefined);
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start middleware"],
+ ["start action"],
+ ["end action"],
+ ["end middleware"],
+ ["start middleware"],
+ ["start loader"],
+ ["end loader"],
+ ["end middleware"],
+ ]);
+
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "ACTION" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of everything for a lazy function route via patchRoutesOnNavigation", async () => {
+ let spy = jest.fn();
+ let lazyDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ ],
+ patchRoutesOnNavigation: ({ path, patch }) => {
+ if (path === "/page") {
+ patch(null, [
+ {
+ id: "page",
+ path: "/page",
+ // Middleware can't be returned from lazy()
+ middleware: [(_, next) => next()],
+ lazy: () => lazyDfd.promise,
+ },
+ ]);
+ }
+ },
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ middleware: async (impl) => {
+ spy("start middleware");
+ await impl();
+ spy("end middleware");
+ },
+ action: async (impl) => {
+ spy("start action");
+ await impl();
+ spy("end action");
+ },
+ loader: async (impl) => {
+ spy("start loader");
+ await impl();
+ spy("end loader");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ expect(spy.mock.calls).toEqual([]);
+
+ await lazyDfd.resolve({
+ loader: () => "PAGE",
+ action: () => "ACTION",
+ });
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start middleware"],
+ ["start action"],
+ ["end action"],
+ ["end middleware"],
+ ["start middleware"],
+ ["start loader"],
+ ["end loader"],
+ ["end middleware"],
+ ]);
+
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "ACTION" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of everything for a lazy object route via patchRoutesOnNavigation", async () => {
+ let spy = jest.fn();
+ let middlewareDfd = createDeferred();
+ let actionDfd = createDeferred();
+ let loaderDfd = createDeferred();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ ],
+ patchRoutesOnNavigation: ({ path, patch }) => {
+ if (path === "/page") {
+ patch(null, [
+ {
+ id: "page",
+ path: "/page",
+ lazy: {
+ middleware: () => middlewareDfd.promise,
+ action: () => actionDfd.promise,
+ loader: () => loaderDfd.promise,
+ },
+ },
+ ]);
+ }
+ },
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ middleware: async (impl) => {
+ spy("start middleware");
+ await impl();
+ spy("end middleware");
+ },
+ action: async (impl) => {
+ spy("start action");
+ await impl();
+ spy("end action");
+ },
+ loader: async (impl) => {
+ spy("start loader");
+ await impl();
+ spy("end loader");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ await t.navigate("/page", {
+ formMethod: "POST",
+ formData: createFormData({}),
+ });
+ expect(spy.mock.calls).toEqual([]);
+
+ await middlewareDfd.resolve([(_, next) => next()]);
+ await tick();
+ expect(spy.mock.calls).toEqual([["start middleware"]]);
+
+ await actionDfd.resolve(() => "ACTION");
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start middleware"],
+ ["start action"],
+ ["end action"],
+ ]);
+
+ await loaderDfd.resolve(() => "PAGE");
+ await tick();
+ expect(spy.mock.calls).toEqual([
+ ["start middleware"],
+ ["start action"],
+ ["end action"],
+ ["end middleware"],
+ ["start middleware"],
+ ["start loader"],
+ ["end loader"],
+ ["end middleware"],
+ ]);
+
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ actionData: { page: "ACTION" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("provides read-only information to instrumentation wrappers", async () => {
+ let spy = jest.fn();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "slug",
+ path: "/:slug",
+ loader: true,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async loader(loader, info) {
+ spy(info);
+ Object.assign(info.params, { extra: "extra" });
+ await loader();
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ let A = await t.navigate("/a");
+ await A.loaders.slug.resolve("A");
+ let args = spy.mock.calls[0][0];
+ expect(args.request.method).toBe("GET");
+ expect(args.request.url).toBe("http://localhost/a");
+ expect(args.request.url).toBe("http://localhost/a");
+ expect(args.request.headers.get).toBeDefined();
+ expect(args.request.headers.set).not.toBeDefined();
+ expect(args.params).toEqual({ slug: "a", extra: "extra" });
+ expect(args.unstable_pattern).toBe("/:slug");
+ expect(args.context.get).toBeDefined();
+ expect(args.context.set).not.toBeDefined();
+ expect(t.router.state.matches[0].params).toEqual({ slug: "a" });
+ });
+
+ it("allows composition of multiple instrumentations", async () => {
+ let spy = jest.fn();
+ let t = setup({
+ routes: [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ loader: true,
+ },
+ ],
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async loader(loader) {
+ spy("start inner");
+ await loader();
+ spy("end inner");
+ },
+ });
+ route.instrument({
+ async loader(loader) {
+ spy("start outer");
+ await loader();
+ spy("end outer");
+ },
+ });
+ },
+ },
+ ],
+ });
+
+ let A = await t.navigate("/page");
+ await A.loaders.page.resolve("PAGE");
+ expect(spy.mock.calls).toEqual([
+ ["start outer"],
+ ["start inner"],
+ ["end inner"],
+ ["end outer"],
+ ]);
+ expect(t.router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of navigations", async () => {
+ let spy = jest.fn();
+ let router = createMemoryRouter(
+ [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ loader: () => "PAGE",
+ },
+ ],
+ {
+ unstable_instrumentations: [
+ {
+ router(router) {
+ router.instrument({
+ async navigate(navigate, info) {
+ spy("start", info);
+ await navigate();
+ spy("end", info);
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+
+ await router.navigate("/page");
+ expect(spy.mock.calls).toEqual([
+ ["start", { currentUrl: "/", to: "/page" }],
+ ["end", { currentUrl: "/", to: "/page" }],
+ ]);
+ expect(router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/page" },
+ loaderData: { page: "PAGE" },
+ });
+ });
+
+ it("allows instrumentation of fetchers", async () => {
+ let spy = jest.fn();
+ let router = createMemoryRouter(
+ [
+ {
+ index: true,
+ },
+ {
+ id: "page",
+ path: "/page",
+ loader: () => "PAGE",
+ },
+ ],
+ {
+ unstable_instrumentations: [
+ {
+ router(router) {
+ router.instrument({
+ async fetch(fetch, info) {
+ spy("start", info);
+ await fetch();
+ spy("end", info);
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+
+ let data: unknown;
+ router.subscribe((state) => {
+ data = data ?? state.fetchers.get("key")?.data;
+ });
+ await router.fetch("key", "0", "/page");
+ expect(spy.mock.calls).toEqual([
+ ["start", { href: "/page", currentUrl: "/", fetcherKey: "key" }],
+ ["end", { href: "/page", currentUrl: "/", fetcherKey: "key" }],
+ ]);
+ expect(router.state).toMatchObject({
+ navigation: { state: "idle" },
+ location: { pathname: "/" },
+ });
+ expect(data).toBe("PAGE");
+ });
+ });
+
+ describe("static handler", () => {
+ it("allows instrumentation of lazy", async () => {
+ let spy = jest.fn();
+ let { query } = createStaticHandler(
+ [
+ {
+ id: "index",
+ index: true,
+ lazy: async () => {
+ spy("lazy");
+ return {
+ loader: () => {
+ spy("loader");
+ return new Response("INDEX");
+ },
+ };
+ },
+ },
+ ],
+ {
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async lazy(loader) {
+ spy("start");
+ await loader();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+
+ let context = await query(new Request("http://localhost/"));
+ expect(spy.mock.calls).toEqual([
+ ["start"],
+ ["lazy"],
+ ["end"],
+ ["loader"],
+ ]);
+ expect(context).toMatchObject({
+ location: { pathname: "/" },
+ loaderData: { index: "INDEX" },
+ });
+ spy.mockClear();
+
+ // Recreate to get a fresh execution of lazy
+ let { queryRoute } = createStaticHandler(
+ [
+ {
+ id: "index",
+ index: true,
+ lazy: async () => {
+ spy("lazy");
+ return {
+ loader: () => {
+ spy("loader");
+ return new Response("INDEX");
+ },
+ };
+ },
+ },
+ ],
+ {
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async lazy(loader) {
+ spy("start");
+ await loader();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+ let response = await queryRoute(new Request("http://localhost/"));
+ expect(spy.mock.calls).toEqual([
+ ["start"],
+ ["lazy"],
+ ["end"],
+ ["loader"],
+ ]);
+ expect(await response.text()).toBe("INDEX");
+ });
+
+ it("allows instrumentation of middleware", async () => {
+ let spy = jest.fn();
+ let { query, queryRoute } = createStaticHandler(
+ [
+ {
+ id: "index",
+ index: true,
+ middleware: [
+ async (_, next) => {
+ spy("middleware");
+ return await next();
+ },
+ ],
+ loader: () => {
+ spy("loader");
+ return new Response("INDEX");
+ },
+ },
+ ],
+ {
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async middleware(middleware) {
+ spy("start");
+ await middleware();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+
+ let request = new Request("http://localhost/");
+ let response = (await query(request, {
+ async generateMiddlewareResponse(query) {
+ let ctx = (await query(request)) as StaticHandlerContext;
+ return new Response(
+ JSON.stringify({
+ location: ctx.location,
+ loaderData: ctx.loaderData,
+ }),
+ );
+ },
+ })) as Response;
+ expect(spy.mock.calls).toEqual([
+ ["start"],
+ ["middleware"],
+ ["loader"],
+ ["end"],
+ ]);
+ expect(JSON.parse(await response.text())).toMatchObject({
+ location: { pathname: "/" },
+ loaderData: { index: "INDEX" },
+ });
+ spy.mockClear();
+
+ response = await queryRoute(request, {
+ generateMiddlewareResponse: async (queryRoute) => {
+ return await queryRoute(request);
+ },
+ });
+ expect(spy.mock.calls).toEqual([
+ ["start"],
+ ["middleware"],
+ ["loader"],
+ ["end"],
+ ]);
+ expect(await response.text()).toBe("INDEX");
+ });
+
+ it("allows instrumentation of loaders", async () => {
+ let spy = jest.fn();
+ let { query, queryRoute } = createStaticHandler(
+ [
+ {
+ id: "index",
+ index: true,
+ loader: () => {
+ spy("loader");
+ return new Response("INDEX");
+ },
+ },
+ ],
+ {
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async loader(loader) {
+ spy("start");
+ await loader();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+
+ let context = await query(new Request("http://localhost/"));
+ expect(spy.mock.calls).toEqual([["start"], ["loader"], ["end"]]);
+ expect(context).toMatchObject({
+ location: { pathname: "/" },
+ loaderData: { index: "INDEX" },
+ });
+ spy.mockClear();
+
+ let response = await queryRoute(new Request("http://localhost/"));
+ expect(spy.mock.calls).toEqual([["start"], ["loader"], ["end"]]);
+ expect(await response.text()).toBe("INDEX");
+ });
+
+ it("allows instrumentation of actions", async () => {
+ let spy = jest.fn();
+ let { query, queryRoute } = createStaticHandler(
+ [
+ {
+ id: "index",
+ index: true,
+ action: () => {
+ spy("action");
+ return new Response("INDEX");
+ },
+ },
+ ],
+ {
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async action(action) {
+ spy("start");
+ await action();
+ spy("end");
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+
+ let context = await query(
+ new Request("http://localhost/", { method: "post", body: "data" }),
+ );
+ expect(spy.mock.calls).toEqual([["start"], ["action"], ["end"]]);
+ expect(context).toMatchObject({
+ location: { pathname: "/" },
+ actionData: { index: "INDEX" },
+ });
+ spy.mockClear();
+
+ let response = await queryRoute(
+ new Request("http://localhost/", { method: "post", body: "data" }),
+ );
+ expect(spy.mock.calls).toEqual([["start"], ["action"], ["end"]]);
+ expect(await response.text()).toBe("INDEX");
+ });
+ });
+
+ describe("request handler", () => {
+ it("allows instrumentation of the request handler", async () => {
+ let spy = jest.fn();
+ let build = mockServerBuild(
+ {
+ root: {
+ path: "",
+ loader: () => {
+ spy("loader");
+ return "ROOT";
+ },
+ default: () => "COMPONENT",
+ },
+ },
+ {
+ handleDocumentRequest(request) {
+ return new Response(`${request.method} ${request.url} COMPONENT`);
+ },
+ unstable_instrumentations: [
+ {
+ handler(handler) {
+ handler.instrument({
+ async request(handler, info) {
+ spy("start", info);
+ await handler();
+ spy("end", info);
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+ let handler = createRequestHandler(build);
+ let response = await handler(new Request("http://localhost/"));
+
+ expect(await response.text()).toBe("GET http://localhost/ COMPONENT");
+ expect(spy.mock.calls).toEqual([
+ [
+ "start",
+ {
+ request: {
+ method: "GET",
+ url: "http://localhost/",
+ headers: {
+ get: expect.any(Function),
+ },
+ },
+ context: {
+ get: expect.any(Function),
+ },
+ },
+ ],
+ ["loader"],
+ [
+ "end",
+ {
+ request: {
+ method: "GET",
+ url: "http://localhost/",
+ headers: {
+ get: expect.any(Function),
+ },
+ },
+ context: {
+ get: expect.any(Function),
+ },
+ },
+ ],
+ ]);
+ });
+
+ it("allows instrumentation of middleware", async () => {
+ let spy = jest.fn();
+ let build = mockServerBuild(
+ {
+ root: {
+ path: "/",
+ middleware: [
+ (_, next) => {
+ spy("middleware");
+ return next();
+ },
+ ],
+ loader: () => {
+ spy("loader");
+ return "ROOT";
+ },
+ default: () => "COMPONENT",
+ },
+ },
+ {
+ future: {
+ v8_middleware: true,
+ },
+ handleDocumentRequest(request) {
+ return new Response(`${request.method} ${request.url} COMPONENT`);
+ },
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async middleware(middleware, info) {
+ spy("start", info);
+ await middleware();
+ spy("end", info);
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+ let handler = createRequestHandler(build);
+ let response = await handler(new Request("http://localhost/"));
+
+ expect(await response.text()).toBe("GET http://localhost/ COMPONENT");
+ expect(spy.mock.calls).toEqual([
+ [
+ "start",
+ {
+ request: {
+ method: "GET",
+ url: "http://localhost/",
+ headers: {
+ get: expect.any(Function),
+ },
+ },
+ params: {},
+ unstable_pattern: "/",
+ context: {
+ get: expect.any(Function),
+ },
+ },
+ ],
+ ["middleware"],
+ ["loader"],
+ [
+ "end",
+ {
+ request: {
+ method: "GET",
+ url: "http://localhost/",
+ headers: {
+ get: expect.any(Function),
+ },
+ },
+ params: {},
+ unstable_pattern: "/",
+ context: {
+ get: expect.any(Function),
+ },
+ },
+ ],
+ ]);
+ });
+
+ it("allows instrumentation of loaders", async () => {
+ let spy = jest.fn();
+ let build = mockServerBuild(
+ {
+ root: {
+ path: "/",
+ loader: () => {
+ spy("loader");
+ return "ROOT";
+ },
+ default: () => "COMPONENT",
+ },
+ },
+ {
+ handleDocumentRequest(request) {
+ return new Response(`${request.method} ${request.url} COMPONENT`);
+ },
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async loader(loader, info) {
+ spy("start", info);
+ await loader();
+ spy("end", info);
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+ let handler = createRequestHandler(build);
+ let response = await handler(new Request("http://localhost/"));
+
+ expect(await response.text()).toBe("GET http://localhost/ COMPONENT");
+ expect(spy.mock.calls).toEqual([
+ [
+ "start",
+ {
+ request: {
+ method: "GET",
+ url: "http://localhost/",
+ headers: {
+ get: expect.any(Function),
+ },
+ },
+ params: {},
+ unstable_pattern: "/",
+ context: {
+ get: expect.any(Function),
+ },
+ },
+ ],
+ ["loader"],
+ [
+ "end",
+ {
+ request: {
+ method: "GET",
+ url: "http://localhost/",
+ headers: {
+ get: expect.any(Function),
+ },
+ },
+ params: {},
+ unstable_pattern: "/",
+ context: {
+ get: expect.any(Function),
+ },
+ },
+ ],
+ ]);
+ });
+
+ it("allows instrumentation of actions", async () => {
+ let spy = jest.fn();
+ let build = mockServerBuild(
+ {
+ root: {
+ path: "/",
+ action: () => {
+ spy("action");
+ return "ROOT";
+ },
+ default: () => "COMPONENT",
+ },
+ },
+ {
+ handleDocumentRequest(request) {
+ return new Response(`${request.method} ${request.url} COMPONENT`);
+ },
+ unstable_instrumentations: [
+ {
+ route(route) {
+ route.instrument({
+ async action(action, info) {
+ spy("start", info);
+ await action();
+ spy("end", info);
+ },
+ });
+ },
+ },
+ ],
+ },
+ );
+ let handler = createRequestHandler(build);
+ let response = await handler(
+ new Request("http://localhost/", { method: "post", body: "data" }),
+ );
+
+ expect(await response.text()).toBe("POST http://localhost/ COMPONENT");
+ expect(spy.mock.calls).toEqual([
+ [
+ "start",
+ {
+ request: {
+ method: "POST",
+ url: "http://localhost/",
+ headers: {
+ get: expect.any(Function),
+ },
+ },
+ params: {},
+ unstable_pattern: "/",
+ context: {
+ get: expect.any(Function),
+ },
+ },
+ ],
+ ["action"],
+ [
+ "end",
+ {
+ request: {
+ method: "POST",
+ url: "http://localhost/",
+ headers: {
+ get: expect.any(Function),
+ },
+ },
+ params: {},
+ unstable_pattern: "/",
+ context: {
+ get: expect.any(Function),
+ },
+ },
+ ],
+ ]);
+ });
+ });
+});
diff --git a/packages/react-router/__tests__/router/router-test.ts b/packages/react-router/__tests__/router/router-test.ts
index 704d7eff3f..df63df71b3 100644
--- a/packages/react-router/__tests__/router/router-test.ts
+++ b/packages/react-router/__tests__/router/router-test.ts
@@ -1503,6 +1503,7 @@ describe("a router", () => {
request: new Request("http://localhost/tasks", {
signal: nav.loaders.tasks.stub.mock.calls[0][0].request.signal,
}),
+ unstable_pattern: "/tasks",
context: {},
});
@@ -1512,6 +1513,7 @@ describe("a router", () => {
request: new Request("http://localhost/tasks/1", {
signal: nav2.loaders.tasksId.stub.mock.calls[0][0].request.signal,
}),
+ unstable_pattern: "/tasks/:id",
context: {},
});
@@ -1521,6 +1523,7 @@ describe("a router", () => {
request: new Request("http://localhost/tasks?foo=bar", {
signal: nav3.loaders.tasks.stub.mock.calls[0][0].request.signal,
}),
+ unstable_pattern: "/tasks",
context: {},
});
@@ -1532,6 +1535,7 @@ describe("a router", () => {
request: new Request("http://localhost/tasks?foo=bar", {
signal: nav4.loaders.tasks.stub.mock.calls[0][0].request.signal,
}),
+ unstable_pattern: "/tasks",
context: {},
});
@@ -1930,6 +1934,7 @@ describe("a router", () => {
expect(nav.actions.tasks.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: "/tasks",
context: {},
});
@@ -1974,6 +1979,7 @@ describe("a router", () => {
expect(nav.actions.tasks.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
// Assert request internals, cannot do a deep comparison above since some
@@ -2007,6 +2013,7 @@ describe("a router", () => {
expect(nav.actions.tasks.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
diff --git a/packages/react-router/__tests__/router/submission-test.ts b/packages/react-router/__tests__/router/submission-test.ts
index a89bb430e1..7cc38b1c31 100644
--- a/packages/react-router/__tests__/router/submission-test.ts
+++ b/packages/react-router/__tests__/router/submission-test.ts
@@ -948,6 +948,7 @@ describe("submissions", () => {
expect(nav.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -982,6 +983,7 @@ describe("submissions", () => {
expect(nav.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -1014,6 +1016,7 @@ describe("submissions", () => {
expect(nav.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -1118,6 +1121,7 @@ describe("submissions", () => {
expect(nav.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -1156,6 +1160,7 @@ describe("submissions", () => {
expect(nav.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
@@ -1191,6 +1196,7 @@ describe("submissions", () => {
expect(nav.actions.root.stub).toHaveBeenCalledWith({
params: {},
request: expect.any(Request),
+ unstable_pattern: expect.any(String),
context: {},
});
diff --git a/packages/react-router/__tests__/router/utils/data-router-setup.ts b/packages/react-router/__tests__/router/utils/data-router-setup.ts
index 0a2dc3a9cc..dffbd34c95 100644
--- a/packages/react-router/__tests__/router/utils/data-router-setup.ts
+++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts
@@ -2,9 +2,9 @@ import type { InitialEntry } from "../../../lib/router/history";
import type {
Fetcher,
RouterFetchOptions,
- HydrationState,
Router,
RouterNavigateOptions,
+ RouterInit,
} from "../../../lib/router/router";
import type {
AgnosticDataRouteObject,
@@ -19,7 +19,6 @@ import {
import type {
AgnosticIndexRouteObject,
AgnosticNonIndexRouteObject,
- DataStrategyFunction,
} from "../../../lib/router/utils";
import {
matchRoutes,
@@ -34,7 +33,13 @@ import { isRedirect, tick } from "./utils";
// by our test harness
export type TestIndexRouteObject = Pick<
AgnosticIndexRouteObject,
- "id" | "index" | "path" | "shouldRevalidate" | "handle" | "lazy"
+ | "id"
+ | "index"
+ | "path"
+ | "shouldRevalidate"
+ | "handle"
+ | "lazy"
+ | "middleware"
> & {
loader?: boolean;
action?: boolean;
@@ -43,7 +48,13 @@ export type TestIndexRouteObject = Pick<
export type TestNonIndexRouteObject = Pick<
AgnosticNonIndexRouteObject,
- "id" | "index" | "path" | "shouldRevalidate" | "handle" | "lazy"
+ | "id"
+ | "index"
+ | "path"
+ | "shouldRevalidate"
+ | "handle"
+ | "lazy"
+ | "middleware"
> & {
loader?: boolean;
action?: boolean;
@@ -134,14 +145,10 @@ export const TASK_ROUTES: TestRouteObject[] = [
},
];
-type SetupOpts = {
+type SetupOpts = Omit & {
routes: TestRouteObject[];
- basename?: string;
initialEntries?: InitialEntry[];
initialIndex?: number;
- hydrationRouteProperties?: string[];
- hydrationData?: HydrationState;
- dataStrategy?: DataStrategyFunction;
};
// We use a slightly modified version of createDeferred here that includes the
@@ -202,12 +209,9 @@ export function getFetcherData(router: Router) {
export function setup({
routes,
- basename,
initialEntries,
initialIndex,
- hydrationRouteProperties,
- hydrationData,
- dataStrategy,
+ ...routerInit
}: SetupOpts) {
let guid = 0;
// Global "active" helpers, keyed by navType:guid:loaderOrAction:routeId.
@@ -318,13 +322,10 @@ export function setup({
jest.spyOn(history, "push");
jest.spyOn(history, "replace");
currentRouter = createRouter({
- basename,
history,
routes: enhanceRoutes(routes),
- hydrationRouteProperties,
- hydrationData,
window: testWindow,
- dataStrategy: dataStrategy,
+ ...routerInit,
});
let fetcherData = getFetcherData(currentRouter);
diff --git a/packages/react-router/__tests__/server-runtime/utils.ts b/packages/react-router/__tests__/server-runtime/utils.ts
index ec5634255a..10771ca1dc 100644
--- a/packages/react-router/__tests__/server-runtime/utils.ts
+++ b/packages/react-router/__tests__/server-runtime/utils.ts
@@ -10,7 +10,12 @@ import type {
} from "../../lib/server-runtime/build";
import type { HeadersFunction } from "../../lib/dom/ssr/routeModules";
import type { EntryRoute } from "../../lib/dom/ssr/routes";
-import type { ActionFunction, LoaderFunction } from "../../lib/router/utils";
+import type {
+ ActionFunction,
+ LoaderFunction,
+ MiddlewareFunction,
+} from "../../lib/router/utils";
+import type { unstable_ServerInstrumentation } from "../../lib/router/instrumentation";
export function mockServerBuild(
routes: Record<
@@ -24,12 +29,14 @@ export function mockServerBuild(
action?: ActionFunction;
headers?: HeadersFunction;
loader?: LoaderFunction;
+ middleware?: MiddlewareFunction[];
}
>,
opts: {
future?: Partial;
handleError?: HandleErrorFunction;
handleDocumentRequest?: HandleDocumentRequestFunction;
+ unstable_instrumentations?: unstable_ServerInstrumentation[];
} = {},
): ServerBuild {
return {
@@ -91,6 +98,7 @@ export function mockServerBuild(
),
handleDataRequest: jest.fn(async (response) => response),
handleError: opts.handleError,
+ unstable_instrumentations: opts.unstable_instrumentations,
},
},
routes: Object.entries(routes).reduce(
@@ -104,8 +112,8 @@ export function mockServerBuild(
default: config.default,
ErrorBoundary: config.ErrorBoundary,
action: config.action,
- headers: config.headers,
loader: config.loader,
+ middleware: config.middleware,
},
};
return {
diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts
index ed1418c11e..5072dc09c6 100644
--- a/packages/react-router/index.ts
+++ b/packages/react-router/index.ts
@@ -61,6 +61,13 @@ export {
createPath,
parsePath,
} from "./lib/router/history";
+export type {
+ unstable_ServerInstrumentation,
+ unstable_ClientInstrumentation,
+ unstable_InstrumentRequestHandlerFunction,
+ unstable_InstrumentRouterFunction,
+ unstable_InstrumentRouteFunction,
+} from "./lib/router/instrumentation";
export {
IDLE_NAVIGATION,
IDLE_FETCHER,
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index ae4613cb13..67921f6f91 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -68,6 +68,12 @@ import {
} from "./hooks";
import type { ViewTransition } from "./dom/global";
import { warnOnce } from "./server-runtime/warnings";
+import type {
+ unstable_ClientInstrumentation,
+ unstable_InstrumentRouteFunction,
+ unstable_InstrumentRouterFunction,
+} from "./router/instrumentation";
+import { instrumentClientSideRouter } from "./router/instrumentation";
export function mapRouteProperties(route: RouteObject) {
let updates: Partial & { hasErrorBoundary: boolean } = {
@@ -168,6 +174,58 @@ export interface MemoryRouterOpts {
* Index of `initialEntries` the application should initialize to
*/
initialIndex?: number;
+ /**
+ * Array of instrumentation objects allowing you to instrument the router and
+ * individual routes prior to router initialization (and on any subsequently
+ * added routes via `route.lazy` or `patchRoutesOnNavigation`). This is
+ * mostly useful for observability such as wrapping navigations, fetches,
+ * as well as route loaders/actions/middlewares with logging and/or performance
+ * tracing.
+ *
+ * ```tsx
+ * let router = createBrowserRouter(routes, {
+ * unstable_instrumentations: [logging]
+ * });
+ *
+ *
+ * let logging = {
+ * router({ instrument }) {
+ * instrument({
+ * navigate: (impl, info) => logExecution(`navigate ${info.to}`, impl),
+ * fetch: (impl, info) => logExecution(`fetch ${info.to}`, impl)
+ * });
+ * },
+ * route({ instrument, id }) {
+ * instrument({
+ * middleware: (impl, info) => logExecution(
+ * `middleware ${info.request.url} (route ${id})`,
+ * impl
+ * ),
+ * loader: (impl, info) => logExecution(
+ * `loader ${info.request.url} (route ${id})`,
+ * impl
+ * ),
+ * action: (impl, info) => logExecution(
+ * `action ${info.request.url} (route ${id})`,
+ * impl
+ * ),
+ * })
+ * }
+ * };
+ *
+ * async function logExecution(label: string, impl: () => Promise) {
+ * let start = performance.now();
+ * console.log(`start ${label}`);
+ * try {
+ * await impl();
+ * } finally {
+ * let end = performance.now();
+ * console.log(`end ${label} (${Math.round(end - start)}ms)`);
+ * }
+ * }
+ * ```
+ */
+ unstable_instrumentations?: unstable_ClientInstrumentation[];
/**
* Override the default data strategy of loading in parallel.
* Only intended for advanced usage.
@@ -196,6 +254,7 @@ export interface MemoryRouterOpts {
* @param {MemoryRouterOpts.hydrationData} opts.hydrationData n/a
* @param {MemoryRouterOpts.initialEntries} opts.initialEntries n/a
* @param {MemoryRouterOpts.initialIndex} opts.initialIndex n/a
+ * @param {MemoryRouterOpts.unstable_instrumentations} opts.unstable_instrumentations n/a
* @param {MemoryRouterOpts.patchRoutesOnNavigation} opts.patchRoutesOnNavigation n/a
* @returns An initialized {@link DataRouter} to pass to {@link RouterProvider | ``}
*/
@@ -217,6 +276,7 @@ export function createMemoryRouter(
mapRouteProperties,
dataStrategy: opts?.dataStrategy,
patchRoutesOnNavigation: opts?.patchRoutesOnNavigation,
+ unstable_instrumentations: opts?.unstable_instrumentations,
}).initialize();
}
diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx
index c6429c82f1..d962eecc4a 100644
--- a/packages/react-router/lib/dom-export/hydrated-router.tsx
+++ b/packages/react-router/lib/dom-export/hydrated-router.tsx
@@ -27,6 +27,7 @@ import {
} from "react-router";
import { CRITICAL_CSS_DATA_ATTRIBUTE } from "../dom/ssr/components";
import { RouterProvider } from "./dom-router-provider";
+import type { unstable_ClientInstrumentation } from "../router/instrumentation";
type SSRInfo = {
context: NonNullable<(typeof window)["__reactRouterContext"]>;
@@ -78,8 +79,10 @@ function initSsrInfo(): void {
function createHydratedRouter({
getContext,
+ unstable_instrumentations,
}: {
getContext?: RouterInit["getContext"];
+ unstable_instrumentations?: unstable_ClientInstrumentation[];
}): DataRouter {
initSsrInfo();
@@ -172,6 +175,7 @@ function createHydratedRouter({
getContext,
hydrationData,
hydrationRouteProperties,
+ unstable_instrumentations,
mapRouteProperties,
future: {
middleware: ssrInfo.context.future.v8_middleware,
@@ -192,6 +196,7 @@ function createHydratedRouter({
ssrInfo.context.basename,
),
});
+
ssrInfo.router = router;
// We can call initialize() immediately if the router doesn't have any
@@ -217,12 +222,67 @@ function createHydratedRouter({
*/
export interface HydratedRouterProps {
/**
- * Context object to be passed through to {@link createBrowserRouter} and made
- * available to
+ * Context factory function to be passed through to {@link createBrowserRouter}.
+ * This function will be called to create a fresh `context` instance on each
+ * navigation/fetch and made available to
* [`clientAction`](../../start/framework/route-module#clientAction)/[`clientLoader`](../../start/framework/route-module#clientLoader)
- * functions
+ * functions.
*/
getContext?: RouterInit["getContext"];
+ /**
+ * Array of instrumentation objects allowing you to instrument the router and
+ * individual routes prior to router initialization (and on any subsequently
+ * added routes via `route.lazy` or `patchRoutesOnNavigation`). This is
+ * mostly useful for observability such as wrapping navigations, fetches,
+ * as well as route loaders/actions/middlewares with logging and/or performance
+ * tracing.
+ *
+ * ```tsx
+ * startTransition(() => {
+ * hydrateRoot(
+ * document,
+ *
+ * );
+ * });
+ *
+ * const logging = {
+ * router({ instrument }) {
+ * instrument({
+ * navigate: (impl, { to }) => logExecution(`navigate ${to}`, impl),
+ * fetch: (impl, { to }) => logExecution(`fetch ${to}`, impl)
+ * });
+ * },
+ * route({ instrument, id }) {
+ * instrument({
+ * middleware: (impl, { request }) => logExecution(
+ * `middleware ${request.url} (route ${id})`,
+ * impl
+ * ),
+ * loader: (impl, { request }) => logExecution(
+ * `loader ${request.url} (route ${id})`,
+ * impl
+ * ),
+ * action: (impl, { request }) => logExecution(
+ * `action ${request.url} (route ${id})`,
+ * impl
+ * ),
+ * })
+ * }
+ * };
+ *
+ * async function logExecution(label: string, impl: () => Promise) {
+ * let start = performance.now();
+ * console.log(`start ${label}`);
+ * try {
+ * await impl();
+ * } finally {
+ * let end = performance.now();
+ * console.log(`end ${label} (${Math.round(end - start)}ms)`);
+ * }
+ * }
+ * ```
+ */
+ unstable_instrumentations?: unstable_ClientInstrumentation[];
/**
* An error handler function that will be called for any loader/action/render
* errors that are encountered in your application. This is useful for
@@ -259,6 +319,7 @@ export function HydratedRouter(props: HydratedRouterProps) {
if (!router) {
router = createHydratedRouter({
getContext: props.getContext,
+ unstable_instrumentations: props.unstable_instrumentations,
});
}
diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx
index ecbe52374e..3a57a0d34e 100644
--- a/packages/react-router/lib/dom/lib.tsx
+++ b/packages/react-router/lib/dom/lib.tsx
@@ -95,6 +95,7 @@ import {
useRouteId,
} from "../hooks";
import type { SerializeFrom } from "../types/route-data";
+import type { unstable_ClientInstrumentation } from "../router/instrumentation";
////////////////////////////////////////////////////////////////////////////////
//#region Global Stuff
@@ -235,6 +236,58 @@ export interface DOMRouterOpts {
* ```
*/
hydrationData?: HydrationState;
+ /**
+ * Array of instrumentation objects allowing you to instrument the router and
+ * individual routes prior to router initialization (and on any subsequently
+ * added routes via `route.lazy` or `patchRoutesOnNavigation`). This is
+ * mostly useful for observability such as wrapping navigations, fetches,
+ * as well as route loaders/actions/middlewares with logging and/or performance
+ * tracing.
+ *
+ * ```tsx
+ * let router = createBrowserRouter(routes, {
+ * unstable_instrumentations: [logging]
+ * });
+ *
+ *
+ * let logging = {
+ * router({ instrument }) {
+ * instrument({
+ * navigate: (impl, info) => logExecution(`navigate ${info.to}`, impl),
+ * fetch: (impl, info) => logExecution(`fetch ${info.to}`, impl)
+ * });
+ * },
+ * route({ instrument, id }) {
+ * instrument({
+ * middleware: (impl, info) => logExecution(
+ * `middleware ${info.request.url} (route ${id})`,
+ * impl
+ * ),
+ * loader: (impl, info) => logExecution(
+ * `loader ${info.request.url} (route ${id})`,
+ * impl
+ * ),
+ * action: (impl, info) => logExecution(
+ * `action ${info.request.url} (route ${id})`,
+ * impl
+ * ),
+ * })
+ * }
+ * };
+ *
+ * async function logExecution(label: string, impl: () => Promise) {
+ * let start = performance.now();
+ * console.log(`start ${label}`);
+ * try {
+ * await impl();
+ * } finally {
+ * let end = performance.now();
+ * console.log(`end ${label} (${Math.round(end - start)}ms)`);
+ * }
+ * }
+ * ```
+ */
+ unstable_instrumentations?: unstable_ClientInstrumentation[];
/**
* Override the default data strategy of running loaders in parallel.
* See {@link DataStrategyFunction}.
@@ -741,6 +794,7 @@ export interface DOMRouterOpts {
* @param {DOMRouterOpts.future} opts.future n/a
* @param {DOMRouterOpts.getContext} opts.getContext n/a
* @param {DOMRouterOpts.hydrationData} opts.hydrationData n/a
+ * @param {DOMRouterOpts.unstable_instrumentations} opts.unstable_instrumentations n/a
* @param {DOMRouterOpts.patchRoutesOnNavigation} opts.patchRoutesOnNavigation n/a
* @param {DOMRouterOpts.window} opts.window n/a
* @returns An initialized {@link DataRouter| data router} to pass to {@link RouterProvider | ``}
@@ -761,6 +815,7 @@ export function createBrowserRouter(
dataStrategy: opts?.dataStrategy,
patchRoutesOnNavigation: opts?.patchRoutesOnNavigation,
window: opts?.window,
+ unstable_instrumentations: opts?.unstable_instrumentations,
}).initialize();
}
@@ -777,6 +832,7 @@ export function createBrowserRouter(
* @param {DOMRouterOpts.future} opts.future n/a
* @param {DOMRouterOpts.getContext} opts.getContext n/a
* @param {DOMRouterOpts.hydrationData} opts.hydrationData n/a
+ * @param {DOMRouterOpts.unstable_instrumentations} opts.unstable_instrumentations n/a
* @param {DOMRouterOpts.dataStrategy} opts.dataStrategy n/a
* @param {DOMRouterOpts.patchRoutesOnNavigation} opts.patchRoutesOnNavigation n/a
* @param {DOMRouterOpts.window} opts.window n/a
@@ -798,6 +854,7 @@ export function createHashRouter(
dataStrategy: opts?.dataStrategy,
patchRoutesOnNavigation: opts?.patchRoutesOnNavigation,
window: opts?.window,
+ unstable_instrumentations: opts?.unstable_instrumentations,
}).initialize();
}
diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx
index 7c1f773a6d..577cdf0624 100644
--- a/packages/react-router/lib/dom/ssr/routes.tsx
+++ b/packages/react-router/lib/dom/ssr/routes.tsx
@@ -340,7 +340,7 @@ export function createClientRoutes(
(routeModule.clientLoader?.hydrate === true || !route.hasLoader);
dataRoute.loader = async (
- { request, params, context }: LoaderFunctionArgs,
+ { request, params, context, unstable_pattern }: LoaderFunctionArgs,
singleFetch?: unknown,
) => {
try {
@@ -358,6 +358,7 @@ export function createClientRoutes(
request,
params,
context,
+ unstable_pattern,
async serverLoader() {
preventInvalidServerHandlerCall("loader", route);
@@ -393,7 +394,7 @@ export function createClientRoutes(
);
dataRoute.action = (
- { request, params, context }: ActionFunctionArgs,
+ { request, params, context, unstable_pattern }: ActionFunctionArgs,
singleFetch?: unknown,
) => {
return prefetchStylesAndCallHandler(async () => {
@@ -412,6 +413,7 @@ export function createClientRoutes(
request,
params,
context,
+ unstable_pattern,
async serverAction() {
preventInvalidServerHandlerCall("action", route);
return fetchServerAction(singleFetch);
diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts
new file mode 100644
index 0000000000..668bc5f02c
--- /dev/null
+++ b/packages/react-router/lib/router/instrumentation.ts
@@ -0,0 +1,446 @@
+import type { RequestHandler } from "../server-runtime/server";
+import { createPath } from "./history";
+import type { Router } from "./router";
+import type {
+ ActionFunctionArgs,
+ AgnosticDataRouteObject,
+ FormEncType,
+ HTMLFormMethod,
+ LazyRouteObject,
+ LoaderFunctionArgs,
+ MaybePromise,
+ MiddlewareFunction,
+ RouterContext,
+ RouterContextProvider,
+} from "./utils";
+
+// Public APIs
+export type unstable_ServerInstrumentation = {
+ handler?: unstable_InstrumentRequestHandlerFunction;
+ route?: unstable_InstrumentRouteFunction;
+};
+
+export type unstable_ClientInstrumentation = {
+ router?: unstable_InstrumentRouterFunction;
+ route?: unstable_InstrumentRouteFunction;
+};
+
+export type unstable_InstrumentRequestHandlerFunction = (
+ handler: InstrumentableRequestHandler,
+) => void;
+
+export type unstable_InstrumentRouterFunction = (
+ router: InstrumentableRouter,
+) => void;
+
+export type unstable_InstrumentRouteFunction = (
+ route: InstrumentableRoute,
+) => void;
+
+// Shared
+interface InstrumentFunction {
+ (handler: () => Promise, info: T): Promise;
+}
+
+type InstrumentationInfo =
+ | RouteLazyInstrumentationInfo
+ | RouteHandlerInstrumentationInfo
+ | RouterNavigationInstrumentationInfo
+ | RouterFetchInstrumentationInfo
+ | RequestHandlerInstrumentationInfo;
+
+type ReadonlyRequest = {
+ method: string;
+ url: string;
+ headers: Pick;
+};
+
+// TODO: Fix for non-middleware
+type ReadonlyContext = Pick;
+
+// Route Instrumentation
+type InstrumentableRoute = {
+ id: string;
+ index: boolean | undefined;
+ path: string | undefined;
+ instrument(instrumentations: RouteInstrumentations): void;
+};
+
+type RouteInstrumentations = {
+ lazy?: InstrumentFunction;
+ "lazy.loader"?: InstrumentFunction;
+ "lazy.action"?: InstrumentFunction;
+ "lazy.middleware"?: InstrumentFunction;
+ middleware?: InstrumentFunction;
+ loader?: InstrumentFunction;
+ action?: InstrumentFunction;
+};
+
+type RouteLazyInstrumentationInfo = undefined;
+
+type RouteHandlerInstrumentationInfo = Readonly<{
+ request: ReadonlyRequest;
+ params: LoaderFunctionArgs["params"];
+ unstable_pattern: string;
+ context: ReadonlyContext;
+}>;
+
+// Router Instrumentation
+type InstrumentableRouter = {
+ instrument(instrumentations: RouterInstrumentations): void;
+};
+
+type RouterInstrumentations = {
+ navigate?: InstrumentFunction;
+ fetch?: InstrumentFunction;
+};
+
+type RouterNavigationInstrumentationInfo = Readonly<{
+ to: string | number;
+ currentUrl: string;
+ formMethod?: HTMLFormMethod;
+ formEncType?: FormEncType;
+ formData?: FormData;
+ body?: any;
+}>;
+
+type RouterFetchInstrumentationInfo = Readonly<{
+ href: string;
+ currentUrl: string;
+ fetcherKey: string;
+ formMethod?: HTMLFormMethod;
+ formEncType?: FormEncType;
+ formData?: FormData;
+ body?: any;
+}>;
+
+// Request Handler Instrumentation
+type InstrumentableRequestHandler = {
+ instrument(instrumentations: RequestHandlerInstrumentations): void;
+};
+
+type RequestHandlerInstrumentations = {
+ request?: InstrumentFunction;
+};
+
+type RequestHandlerInstrumentationInfo = Readonly<{
+ request: ReadonlyRequest;
+ context: ReadonlyContext;
+}>;
+
+const UninstrumentedSymbol = Symbol("Uninstrumented");
+
+export function getRouteInstrumentationUpdates(
+ fns: unstable_InstrumentRouteFunction[],
+ route: Readonly,
+) {
+ let aggregated: {
+ lazy: InstrumentFunction[];
+ "lazy.loader": InstrumentFunction[];
+ "lazy.action": InstrumentFunction[];
+ "lazy.middleware": InstrumentFunction[];
+ middleware: InstrumentFunction[];
+ loader: InstrumentFunction[];
+ action: InstrumentFunction[];
+ } = {
+ lazy: [],
+ "lazy.loader": [],
+ "lazy.action": [],
+ "lazy.middleware": [],
+ middleware: [],
+ loader: [],
+ action: [],
+ };
+
+ fns.forEach((fn) =>
+ fn({
+ id: route.id,
+ index: route.index,
+ path: route.path,
+ instrument(i) {
+ let keys = Object.keys(aggregated) as Array;
+ for (let key of keys) {
+ if (i[key]) {
+ aggregated[key].push(i[key] as any);
+ }
+ }
+ },
+ }),
+ );
+
+ let updates: {
+ middleware?: AgnosticDataRouteObject["middleware"];
+ loader?: AgnosticDataRouteObject["loader"];
+ action?: AgnosticDataRouteObject["action"];
+ lazy?: AgnosticDataRouteObject["lazy"];
+ } = {};
+
+ // Instrument lazy functions
+ if (typeof route.lazy === "function" && aggregated.lazy.length > 0) {
+ let instrumented = wrapImpl(aggregated.lazy, route.lazy, () => undefined);
+ if (instrumented) {
+ updates.lazy = instrumented as AgnosticDataRouteObject["lazy"];
+ }
+ }
+
+ // Instrument the lazy object format
+ if (typeof route.lazy === "object") {
+ let lazyObject: LazyRouteObject = route.lazy;
+ (["middleware", "loader", "action"] as const).forEach((key) => {
+ let lazyFn = lazyObject[key];
+ let instrumentations = aggregated[`lazy.${key}`];
+ if (typeof lazyFn === "function" && instrumentations.length > 0) {
+ let instrumented = wrapImpl(instrumentations, lazyFn, () => undefined);
+ if (instrumented) {
+ updates.lazy = Object.assign(updates.lazy || {}, {
+ [key]: instrumented,
+ });
+ }
+ }
+ });
+ }
+
+ // Instrument loader/action functions
+ (["loader", "action"] as const).forEach((key) => {
+ let handler = route[key];
+ if (typeof handler === "function" && aggregated[key].length > 0) {
+ // @ts-expect-error
+ let original = handler[UninstrumentedSymbol] ?? handler;
+ let instrumented = wrapImpl(aggregated[key], original, (...args) =>
+ getHandlerInfo(args[0] as LoaderFunctionArgs | ActionFunctionArgs),
+ );
+ if (instrumented) {
+ // @ts-expect-error
+ instrumented[UninstrumentedSymbol] = original;
+ updates[key] = instrumented;
+ }
+ }
+ });
+
+ // Instrument middleware functions
+ if (
+ route.middleware &&
+ route.middleware.length > 0 &&
+ aggregated.middleware.length > 0
+ ) {
+ updates.middleware = route.middleware.map((middleware) => {
+ // @ts-expect-error
+ let original = middleware[UninstrumentedSymbol] ?? middleware;
+ let instrumented = wrapImpl(aggregated.middleware, original, (...args) =>
+ getHandlerInfo(args[0] as Parameters[0]),
+ );
+ if (instrumented) {
+ // @ts-expect-error
+ instrumented[UninstrumentedSymbol] = original;
+ return instrumented;
+ }
+ return middleware;
+ });
+ }
+
+ return updates;
+}
+
+export function instrumentClientSideRouter(
+ router: Router,
+ fns: unstable_InstrumentRouterFunction[],
+): Router {
+ let aggregated: {
+ navigate: InstrumentFunction[];
+ fetch: InstrumentFunction[];
+ } = {
+ navigate: [],
+ fetch: [],
+ };
+
+ fns.forEach((fn) =>
+ fn({
+ instrument(i) {
+ let keys = Object.keys(i) as Array;
+ for (let key of keys) {
+ if (i[key]) {
+ aggregated[key].push(i[key] as any);
+ }
+ }
+ },
+ }),
+ );
+
+ if (aggregated.navigate.length > 0) {
+ // @ts-expect-error
+ let navigate = router.navigate[UninstrumentedSymbol] ?? router.navigate;
+ let instrumentedNavigate = wrapImpl(
+ aggregated.navigate,
+ navigate,
+ (...args) => {
+ let [to, opts] = args as Parameters;
+ return {
+ to:
+ typeof to === "number" || typeof to === "string"
+ ? to
+ : to
+ ? createPath(to)
+ : ".",
+ ...getRouterInfo(router, opts ?? {}),
+ } satisfies RouterNavigationInstrumentationInfo;
+ },
+ ) as Router["navigate"];
+ if (instrumentedNavigate) {
+ // @ts-expect-error
+ instrumentedNavigate[UninstrumentedSymbol] = navigate;
+ router.navigate = instrumentedNavigate;
+ }
+ }
+
+ if (aggregated.fetch.length > 0) {
+ // @ts-expect-error
+ let fetch = router.fetch[UninstrumentedSymbol] ?? router.fetch;
+ let instrumentedFetch = wrapImpl(aggregated.fetch, fetch, (...args) => {
+ let [key, , href, opts] = args as Parameters;
+ return {
+ href: href ?? ".",
+ fetcherKey: key,
+ ...getRouterInfo(router, opts ?? {}),
+ } satisfies RouterFetchInstrumentationInfo;
+ }) as Router["fetch"];
+ if (instrumentedFetch) {
+ // @ts-expect-error
+ instrumentedFetch[UninstrumentedSymbol] = fetch;
+ router.fetch = instrumentedFetch;
+ }
+ }
+
+ return router;
+}
+
+export function instrumentHandler(
+ handler: RequestHandler,
+ fns: unstable_InstrumentRequestHandlerFunction[],
+): RequestHandler {
+ let aggregated: {
+ request: InstrumentFunction[];
+ } = {
+ request: [],
+ };
+
+ fns.forEach((fn) =>
+ fn({
+ instrument(i) {
+ let keys = Object.keys(i) as Array;
+ for (let key of keys) {
+ if (i[key]) {
+ aggregated[key].push(i[key] as any);
+ }
+ }
+ },
+ }),
+ );
+
+ let instrumentedHandler = handler;
+
+ if (aggregated.request.length > 0) {
+ instrumentedHandler = wrapImpl(aggregated.request, handler, (...args) => {
+ let [request, context] = args as Parameters;
+ return {
+ request: getReadonlyRequest(request),
+ // TODO: Handle non-middleware flows
+ // @ts-expect-error
+ context: getReadonlyContext(context),
+ } satisfies RequestHandlerInstrumentationInfo;
+ }) as RequestHandler;
+ }
+
+ return instrumentedHandler;
+}
+
+function wrapImpl(
+ impls: InstrumentFunction[],
+ handler: (...args: any[]) => MaybePromise,
+ getInfo: (...args: unknown[]) => T,
+) {
+ if (impls.length === 0) {
+ return null;
+ }
+ return async (...args: unknown[]) => {
+ let value: unknown;
+ let info = getInfo(...args);
+ await recurseRight(
+ impls,
+ info,
+ async () => {
+ value = await handler(...args);
+ },
+ impls.length - 1,
+ );
+ return value;
+ };
+}
+
+async function recurseRight(
+ impls: InstrumentFunction[],
+ info: T,
+ handler: () => MaybePromise,
+ index: number,
+): Promise {
+ let impl = impls[index];
+ if (!impl) {
+ await handler();
+ } else {
+ await impl(async () => {
+ await recurseRight(impls, info, handler, index - 1);
+ }, info);
+ }
+}
+
+function getHandlerInfo(
+ args:
+ | LoaderFunctionArgs
+ | ActionFunctionArgs
+ | Parameters[0],
+): RouteHandlerInstrumentationInfo {
+ let { request, context, params, unstable_pattern } = args;
+ return {
+ request: getReadonlyRequest(request),
+ params: { ...params },
+ unstable_pattern,
+ context: getReadonlyContext(context),
+ };
+}
+
+function getRouterInfo(
+ router: Router,
+ opts: NonNullable<
+ Parameters[1] | Parameters[3]
+ >,
+) {
+ return {
+ currentUrl: createPath(router.state.location),
+ ...("formMethod" in opts ? { formMethod: opts.formMethod } : {}),
+ ...("formEncType" in opts ? { formEncType: opts.formEncType } : {}),
+ ...("formData" in opts ? { formData: opts.formData } : {}),
+ ...("body" in opts ? { body: opts.body } : {}),
+ };
+}
+// Return a shallow readonly "clone" of the Request with the info they may
+// want to read from during instrumentation
+function getReadonlyRequest(request: Request): {
+ method: string;
+ url: string;
+ headers: Pick;
+} {
+ return {
+ method: request.method,
+ url: request.url,
+ headers: {
+ get: (...args) => request.headers.get(...args),
+ },
+ };
+}
+
+function getReadonlyContext(
+ context: RouterContextProvider,
+): Pick {
+ return {
+ get: (ctx: RouterContext) => context.get(ctx),
+ };
+}
diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts
index 4cd9d1df51..b2531f9a35 100644
--- a/packages/react-router/lib/router/router.ts
+++ b/packages/react-router/lib/router/router.ts
@@ -9,6 +9,16 @@ import {
parsePath,
warning,
} from "./history";
+import type {
+ unstable_ClientInstrumentation,
+ unstable_InstrumentRouteFunction,
+ unstable_InstrumentRouterFunction,
+ unstable_ServerInstrumentation,
+} from "./instrumentation";
+import {
+ getRouteInstrumentationUpdates,
+ instrumentClientSideRouter,
+} from "./instrumentation";
import type {
AgnosticDataRouteMatch,
AgnosticDataRouteObject,
@@ -40,7 +50,6 @@ import type {
ActionFunction,
MiddlewareFunction,
MiddlewareNextFunction,
- ErrorResponse,
} from "./utils";
import {
ErrorResponseImpl,
@@ -58,6 +67,7 @@ import {
resolveTo,
stripBasename,
RouterContextProvider,
+ getRoutePattern,
} from "./utils";
////////////////////////////////////////////////////////////////////////////////
@@ -403,6 +413,7 @@ export interface RouterInit {
history: History;
basename?: string;
getContext?: () => MaybePromise;
+ unstable_instrumentations?: unstable_ClientInstrumentation[];
mapRouteProperties?: MapRoutePropertiesFunction;
future?: Partial;
hydrationRouteProperties?: string[];
@@ -866,7 +877,27 @@ export function createRouter(init: RouterInit): Router {
);
let hydrationRouteProperties = init.hydrationRouteProperties || [];
- let mapRouteProperties = init.mapRouteProperties || defaultMapRouteProperties;
+ let _mapRouteProperties =
+ init.mapRouteProperties || defaultMapRouteProperties;
+ let mapRouteProperties = _mapRouteProperties;
+
+ // Leverage the existing mapRouteProperties logic to execute instrumentRoute
+ // (if it exists) on all routes in the application
+ if (init.unstable_instrumentations) {
+ let instrumentations = init.unstable_instrumentations;
+
+ mapRouteProperties = (route: AgnosticDataRouteObject) => {
+ return {
+ ..._mapRouteProperties(route),
+ ...getRouteInstrumentationUpdates(
+ instrumentations
+ .map((i) => i.route)
+ .filter(Boolean) as unstable_InstrumentRouteFunction[],
+ route,
+ ),
+ };
+ };
+ }
// Routes keyed by ID
let manifest: RouteManifest = {};
@@ -3507,6 +3538,15 @@ export function createRouter(init: RouterInit): Router {
},
};
+ if (init.unstable_instrumentations) {
+ router = instrumentClientSideRouter(
+ router,
+ init.unstable_instrumentations
+ .map((i) => i.router)
+ .filter(Boolean) as unstable_InstrumentRouterFunction[],
+ );
+ }
+
return router;
}
//#endregion
@@ -3518,6 +3558,7 @@ export function createRouter(init: RouterInit): Router {
export interface CreateStaticHandlerOptions {
basename?: string;
mapRouteProperties?: MapRoutePropertiesFunction;
+ unstable_instrumentations?: Pick[];
future?: {};
}
@@ -3532,8 +3573,27 @@ export function createStaticHandler(
let manifest: RouteManifest = {};
let basename = (opts ? opts.basename : null) || "/";
- let mapRouteProperties =
+ let _mapRouteProperties =
opts?.mapRouteProperties || defaultMapRouteProperties;
+ let mapRouteProperties = _mapRouteProperties;
+
+ // Leverage the existing mapRouteProperties logic to execute instrumentRoute
+ // (if it exists) on all routes in the application
+ if (opts?.unstable_instrumentations) {
+ let instrumentations = opts.unstable_instrumentations;
+
+ mapRouteProperties = (route: AgnosticDataRouteObject) => {
+ return {
+ ..._mapRouteProperties(route),
+ ...getRouteInstrumentationUpdates(
+ instrumentations
+ .map((i) => i.route)
+ .filter(Boolean) as unstable_InstrumentRouteFunction[],
+ route,
+ ),
+ };
+ };
+ }
let dataRoutes = convertRoutesToDataRoutes(
routes,
@@ -3660,6 +3720,7 @@ export function createStaticHandler(
let response = await runServerMiddlewarePipeline(
{
request,
+ unstable_pattern: getRoutePattern(matches.map((m) => m.route.path)),
matches,
params: matches[0].params,
// If we're calling middleware then it must be enabled so we can cast
@@ -3891,6 +3952,7 @@ export function createStaticHandler(
let response = await runServerMiddlewarePipeline(
{
request,
+ unstable_pattern: getRoutePattern(matches.map((m) => m.route.path)),
matches,
params: matches[0].params,
// If we're calling middleware then it must be enabled so we can cast
@@ -4283,12 +4345,14 @@ export function createStaticHandler(
matches.findIndex((m) => m.route.id === pendingActionResult[0]) - 1
: undefined;
+ let pattern = getRoutePattern(matches.map((m) => m.route.path));
dsMatches = matches.map((match, index) => {
if (maxIdx != null && index > maxIdx) {
return getDataStrategyMatch(
mapRouteProperties,
manifest,
request,
+ pattern,
match,
[],
requestContext,
@@ -4300,6 +4364,7 @@ export function createStaticHandler(
mapRouteProperties,
manifest,
request,
+ pattern,
match,
[],
requestContext,
@@ -4754,6 +4819,7 @@ function getMatchesToLoad(
actionStatus,
};
+ let pattern = getRoutePattern(matches.map((m) => m.route.path));
let dsMatches: DataStrategyMatch[] = matches.map((match, index) => {
let { route } = match;
@@ -4787,6 +4853,7 @@ function getMatchesToLoad(
mapRouteProperties,
manifest,
request,
+ pattern,
match,
lazyRoutePropertiesToSkip,
scopedContext,
@@ -4816,6 +4883,7 @@ function getMatchesToLoad(
mapRouteProperties,
manifest,
request,
+ pattern,
match,
lazyRoutePropertiesToSkip,
scopedContext,
@@ -5577,13 +5645,18 @@ async function runMiddlewarePipeline(
nextResult: { value: Result } | undefined,
) => Promise,
): Promise {
- let { matches, request, params, context } = args;
+ let { matches, request, params, context, unstable_pattern } = args;
let tuples = matches.flatMap((m) =>
m.route.middleware ? m.route.middleware.map((fn) => [m.route.id, fn]) : [],
) as [string, MiddlewareFunction][];
let result = await callRouteMiddleware(
- { request, params, context },
+ {
+ request,
+ params,
+ context,
+ unstable_pattern,
+ },
tuples,
handler,
processResult,
@@ -5705,6 +5778,7 @@ function getDataStrategyMatch(
mapRouteProperties: MapRoutePropertiesFunction,
manifest: RouteManifest,
request: Request,
+ unstable_pattern: string,
match: DataRouteMatch,
lazyRoutePropertiesToSkip: string[],
scopedContext: unknown,
@@ -5762,6 +5836,7 @@ function getDataStrategyMatch(
if (callHandler && !isMiddlewareOnlyRoute) {
return callLoaderOrAction({
request,
+ unstable_pattern,
match,
lazyHandlerPromise: _lazyPromises?.handler,
lazyRoutePromise: _lazyPromises?.route,
@@ -5808,6 +5883,7 @@ function getTargetedDataStrategyMatches(
mapRouteProperties,
manifest,
request,
+ getRoutePattern(matches.map((m) => m.route.path)),
match,
lazyRoutePropertiesToSkip,
scopedContext,
@@ -5835,6 +5911,7 @@ async function callDataStrategyImpl(
// back out below.
let dataStrategyArgs = {
request,
+ unstable_pattern: getRoutePattern(matches.map((m) => m.route.path)),
params: matches[0].params,
context: scopedContext,
matches,
@@ -5892,6 +5969,7 @@ async function callDataStrategyImpl(
// Default logic for calling a loader/action is the user has no specified a dataStrategy
async function callLoaderOrAction({
request,
+ unstable_pattern,
match,
lazyHandlerPromise,
lazyRoutePromise,
@@ -5899,6 +5977,7 @@ async function callLoaderOrAction({
scopedContext,
}: {
request: Request;
+ unstable_pattern: string;
match: AgnosticDataRouteMatch;
lazyHandlerPromise: Promise | undefined;
lazyRoutePromise: Promise | undefined;
@@ -5932,6 +6011,7 @@ async function callLoaderOrAction({
return handler(
{
request,
+ unstable_pattern,
params: match.params,
context: scopedContext,
},
diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts
index 14e5e40c51..9d3fbcd864 100644
--- a/packages/react-router/lib/router/utils.ts
+++ b/packages/react-router/lib/router/utils.ts
@@ -269,6 +269,11 @@ type DefaultContext = MiddlewareEnabled extends true
interface DataFunctionArgs {
/** A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read headers (like cookies, and {@link https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams URLSearchParams} from the request. */
request: Request;
+ /**
+ * Matched un-interpolated route pattern for the current path (i.e., /blog/:slug).
+ * Mostly useful as a identifier to aggregate on for logging/tracing/etc.
+ */
+ unstable_pattern: string;
/**
* {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route.
* @example
@@ -535,7 +540,7 @@ export type AgnosticPatchRoutesOnNavigationFunction<
* properties from framework-agnostic properties
*/
export interface MapRoutePropertiesFunction {
- (route: AgnosticRouteObject): {
+ (route: AgnosticDataRouteObject): {
hasErrorBoundary: boolean;
} & Record;
}
@@ -808,19 +813,23 @@ export function convertRoutesToDataRoutes(
if (isIndexRoute(route)) {
let indexRoute: AgnosticDataIndexRouteObject = {
...route,
- ...mapRouteProperties(route),
id,
};
- manifest[id] = indexRoute;
+ manifest[id] = mergeRouteUpdates(
+ indexRoute,
+ mapRouteProperties(indexRoute),
+ );
return indexRoute;
} else {
let pathOrLayoutRoute: AgnosticDataNonIndexRouteObject = {
...route,
- ...mapRouteProperties(route),
id,
children: undefined,
};
- manifest[id] = pathOrLayoutRoute;
+ manifest[id] = mergeRouteUpdates(
+ pathOrLayoutRoute,
+ mapRouteProperties(pathOrLayoutRoute),
+ );
if (route.children) {
pathOrLayoutRoute.children = convertRoutesToDataRoutes(
@@ -837,6 +846,23 @@ export function convertRoutesToDataRoutes(
});
}
+function mergeRouteUpdates(
+ route: T,
+ updates: ReturnType,
+): T {
+ return Object.assign(route, {
+ ...updates,
+ ...(typeof updates.lazy === "object" && updates.lazy != null
+ ? {
+ lazy: {
+ ...route.lazy,
+ ...updates.lazy,
+ },
+ }
+ : {}),
+ });
+}
+
/**
* Matches the given routes to a location and returns the match data.
*
@@ -1995,3 +2021,7 @@ export function isRouteErrorResponse(error: any): error is ErrorResponse {
"data" in error
);
}
+
+export function getRoutePattern(paths: (string | undefined)[]) {
+ return paths.filter(Boolean).join("/").replace(/\/\/*/g, "/");
+}
diff --git a/packages/react-router/lib/server-runtime/build.ts b/packages/react-router/lib/server-runtime/build.ts
index 25462b1be9..c18e7e3d85 100644
--- a/packages/react-router/lib/server-runtime/build.ts
+++ b/packages/react-router/lib/server-runtime/build.ts
@@ -12,6 +12,11 @@ import type {
import type { ServerRouteManifest } from "./routes";
import type { AppLoadContext } from "./data";
import type { MiddlewareEnabled } from "../types/future";
+import type {
+ unstable_InstrumentRequestHandlerFunction,
+ unstable_InstrumentRouteFunction,
+ unstable_ServerInstrumentation,
+} from "../router/instrumentation";
type OptionalCriticalCss = CriticalCss | undefined;
@@ -58,12 +63,23 @@ export interface HandleDocumentRequestFunction {
export interface HandleDataRequestFunction {
(
response: Response,
- args: LoaderFunctionArgs | ActionFunctionArgs,
+ args: {
+ request: LoaderFunctionArgs["request"] | ActionFunctionArgs["request"];
+ context: LoaderFunctionArgs["context"] | ActionFunctionArgs["context"];
+ params: LoaderFunctionArgs["params"] | ActionFunctionArgs["params"];
+ },
): Promise | Response;
}
export interface HandleErrorFunction {
- (error: unknown, args: LoaderFunctionArgs | ActionFunctionArgs): void;
+ (
+ error: unknown,
+ args: {
+ request: LoaderFunctionArgs["request"] | ActionFunctionArgs["request"];
+ context: LoaderFunctionArgs["context"] | ActionFunctionArgs["context"];
+ params: LoaderFunctionArgs["params"] | ActionFunctionArgs["params"];
+ },
+ ): void;
}
/**
@@ -74,5 +90,6 @@ export interface ServerEntryModule {
default: HandleDocumentRequestFunction;
handleDataRequest?: HandleDataRequestFunction;
handleError?: HandleErrorFunction;
+ unstable_instrumentations?: unstable_ServerInstrumentation[];
streamTimeout?: number;
}
diff --git a/packages/react-router/lib/server-runtime/data.ts b/packages/react-router/lib/server-runtime/data.ts
index a1b67be014..db680dfd78 100644
--- a/packages/react-router/lib/server-runtime/data.ts
+++ b/packages/react-router/lib/server-runtime/data.ts
@@ -26,6 +26,7 @@ export async function callRouteHandler(
request: stripRoutesParam(stripIndexParam(args.request)),
params: args.params,
context: args.context,
+ unstable_pattern: args.unstable_pattern,
});
// If they returned a redirect via data(), re-throw it as a Response
diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts
index 71f4d11b04..6a8091cf25 100644
--- a/packages/react-router/lib/server-runtime/server.ts
+++ b/packages/react-router/lib/server-runtime/server.ts
@@ -36,6 +36,8 @@ import { getDocumentHeaders } from "./headers";
import type { EntryRoute } from "../dom/ssr/routes";
import type { MiddlewareEnabled } from "../types/future";
import { getManifestPath } from "../dom/ssr/fog-of-war";
+import type { unstable_InstrumentRequestHandlerFunction } from "../router/instrumentation";
+import { instrumentHandler } from "../router/instrumentation";
export type RequestHandler = (
request: Request,
@@ -55,6 +57,7 @@ function derive(build: ServerBuild, mode?: string) {
let serverMode = isServerMode(mode) ? mode : ServerMode.Production;
let staticHandler = createStaticHandler(dataRoutes, {
basename: build.basename,
+ unstable_instrumentations: build.entry.module.unstable_instrumentations,
});
let errorHandler =
@@ -67,42 +70,8 @@ function derive(build: ServerBuild, mode?: string) {
);
}
});
- return {
- routes,
- dataRoutes,
- serverMode,
- staticHandler,
- errorHandler,
- };
-}
-
-export const createRequestHandler: CreateRequestHandlerFunction = (
- build,
- mode,
-) => {
- let _build: ServerBuild;
- let routes: ServerRoute[];
- let serverMode: ServerMode;
- let staticHandler: StaticHandler;
- let errorHandler: HandleErrorFunction;
-
- return async function requestHandler(request, initialContext) {
- _build = typeof build === "function" ? await build() : build;
-
- if (typeof build === "function") {
- let derived = derive(_build, mode);
- routes = derived.routes;
- serverMode = derived.serverMode;
- staticHandler = derived.staticHandler;
- errorHandler = derived.errorHandler;
- } else if (!routes || !serverMode || !staticHandler || !errorHandler) {
- let derived = derive(_build, mode);
- routes = derived.routes;
- serverMode = derived.serverMode;
- staticHandler = derived.staticHandler;
- errorHandler = derived.errorHandler;
- }
+ let requestHandler: RequestHandler = async (request, initialContext) => {
let params: RouteMatch["params"] = {};
let loadContext: AppLoadContext | RouterContextProvider;
@@ -118,7 +87,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
});
};
- if (_build.future.v8_middleware) {
+ if (build.future.v8_middleware) {
if (
initialContext &&
!(initialContext instanceof RouterContextProvider)
@@ -138,7 +107,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
let url = new URL(request.url);
- let normalizedBasename = _build.basename || "/";
+ let normalizedBasename = build.basename || "/";
let normalizedPath = url.pathname;
if (stripBasename(normalizedPath, normalizedBasename) === "/_root.data") {
normalizedPath = normalizedBasename;
@@ -158,7 +127,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
// When runtime SSR is disabled, make our dev server behave like the deployed
// pre-rendered site would
- if (!_build.ssr) {
+ if (!build.ssr) {
// Decode the URL path before checking against the prerender config
let decodedPath = decodeURI(normalizedPath);
@@ -188,12 +157,12 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
// When SSR is disabled this, file can only ever run during dev because we
// delete the server build at the end of the build
- if (_build.prerender.length === 0) {
+ if (build.prerender.length === 0) {
// ssr:false and no prerender config indicates "SPA Mode"
isSpaMode = true;
} else if (
- !_build.prerender.includes(decodedPath) &&
- !_build.prerender.includes(decodedPath + "/")
+ !build.prerender.includes(decodedPath) &&
+ !build.prerender.includes(decodedPath + "/")
) {
if (url.pathname.endsWith(".data")) {
// 404 on non-pre-rendered `.data` requests
@@ -222,12 +191,12 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
// Manifest request for fog of war
let manifestUrl = getManifestPath(
- _build.routeDiscovery.manifestPath,
+ build.routeDiscovery.manifestPath,
normalizedBasename,
);
if (url.pathname === manifestUrl) {
try {
- let res = await handleManifestRequest(_build, routes, url);
+ let res = await handleManifestRequest(build, routes, url);
return res;
} catch (e) {
handleError(e);
@@ -235,7 +204,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
}
}
- let matches = matchServerRoutes(routes, normalizedPath, _build.basename);
+ let matches = matchServerRoutes(routes, normalizedPath, build.basename);
if (matches && matches.length > 0) {
Object.assign(params, matches[0].params);
}
@@ -248,12 +217,12 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
let singleFetchMatches = matchServerRoutes(
routes,
handlerUrl.pathname,
- _build.basename,
+ build.basename,
);
response = await handleSingleFetchRequest(
serverMode,
- _build,
+ build,
staticHandler,
request,
handlerUrl,
@@ -265,13 +234,13 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
response = generateSingleFetchRedirectResponse(
response,
request,
- _build,
+ build,
serverMode,
);
}
- if (_build.entry.module.handleDataRequest) {
- response = await _build.entry.module.handleDataRequest(response, {
+ if (build.entry.module.handleDataRequest) {
+ response = await build.entry.module.handleDataRequest(response, {
context: loadContext,
params: singleFetchMatches ? singleFetchMatches[0].params : {},
request,
@@ -281,7 +250,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
response = generateSingleFetchRedirectResponse(
response,
request,
- _build,
+ build,
serverMode,
);
}
@@ -294,7 +263,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
) {
response = await handleResourceRequest(
serverMode,
- _build,
+ build,
staticHandler,
matches.slice(-1)[0].route.id,
request,
@@ -305,8 +274,8 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
let { pathname } = url;
let criticalCss: CriticalCss | undefined = undefined;
- if (_build.unstable_getCriticalCss) {
- criticalCss = await _build.unstable_getCriticalCss({ pathname });
+ if (build.unstable_getCriticalCss) {
+ criticalCss = await build.unstable_getCriticalCss({ pathname });
} else if (
mode === ServerMode.Development &&
getDevServerHooks()?.getCriticalCss
@@ -316,7 +285,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
response = await handleDocumentRequest(
serverMode,
- _build,
+ build,
staticHandler,
request,
loadContext,
@@ -336,6 +305,64 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
return response;
};
+
+ if (build.entry.module.unstable_instrumentations) {
+ requestHandler = instrumentHandler(
+ requestHandler,
+ build.entry.module.unstable_instrumentations
+ .map((i) => i.handler)
+ .filter(Boolean) as unstable_InstrumentRequestHandlerFunction[],
+ );
+ }
+
+ return {
+ routes,
+ dataRoutes,
+ serverMode,
+ staticHandler,
+ errorHandler,
+ requestHandler,
+ };
+}
+
+export const createRequestHandler: CreateRequestHandlerFunction = (
+ build,
+ mode,
+) => {
+ let _build: ServerBuild;
+ let routes: ServerRoute[];
+ let serverMode: ServerMode;
+ let staticHandler: StaticHandler;
+ let errorHandler: HandleErrorFunction;
+ let _requestHandler: RequestHandler;
+
+ return async function requestHandler(request, initialContext) {
+ _build = typeof build === "function" ? await build() : build;
+
+ if (typeof build === "function") {
+ let derived = derive(_build, mode);
+ routes = derived.routes;
+ serverMode = derived.serverMode;
+ staticHandler = derived.staticHandler;
+ errorHandler = derived.errorHandler;
+ _requestHandler = derived.requestHandler;
+ } else if (
+ !routes ||
+ !serverMode ||
+ !staticHandler ||
+ !errorHandler ||
+ !_requestHandler
+ ) {
+ let derived = derive(_build, mode);
+ routes = derived.routes;
+ serverMode = derived.serverMode;
+ staticHandler = derived.staticHandler;
+ errorHandler = derived.errorHandler;
+ _requestHandler = derived.requestHandler;
+ }
+
+ return _requestHandler(request, initialContext);
+ };
};
async function handleManifestRequest(
diff --git a/packages/react-router/lib/types/route-data.ts b/packages/react-router/lib/types/route-data.ts
index 058a2f5aef..52eefee088 100644
--- a/packages/react-router/lib/types/route-data.ts
+++ b/packages/react-router/lib/types/route-data.ts
@@ -92,6 +92,11 @@ export type ClientDataFunctionArgs = {
* }
**/
params: Params;
+ /**
+ * Matched un-interpolated route pattern for the current path (i.e., /blog/:slug).
+ * Mostly useful as a identifier to aggregate on for logging/tracing/etc.
+ */
+ unstable_pattern: string;
/**
* When `future.v8_middleware` is not enabled, this is undefined.
*
@@ -121,6 +126,11 @@ export type ServerDataFunctionArgs = {
* }
**/
params: Params;
+ /**
+ * Matched un-interpolated route pattern for the current path (i.e., /blog/:slug).
+ * Mostly useful as a identifier to aggregate on for logging/tracing/etc.
+ */
+ unstable_pattern: string;
/**
* Without `future.v8_middleware` enabled, this is the context passed in
* to your server adapter's `getLoadContext` function. It's a way to bridge the
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c3e43eaea6..c3a5108ea5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1688,6 +1688,67 @@ importers:
specifier: ^4.2.1
version: 4.3.2(typescript@5.4.5)(vite@6.2.5(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0))
+ playground/observability:
+ dependencies:
+ '@react-router/express':
+ specifier: workspace:*
+ version: link:../../packages/react-router-express
+ '@react-router/node':
+ specifier: workspace:*
+ version: link:../../packages/react-router-node
+ compression:
+ specifier: ^1.7.4
+ version: 1.8.0
+ express:
+ specifier: ^4.19.2
+ version: 4.21.2
+ isbot:
+ specifier: ^5.1.11
+ version: 5.1.11
+ morgan:
+ specifier: ^1.10.0
+ version: 1.10.0
+ react:
+ specifier: ^19.1.0
+ version: 19.1.0
+ react-dom:
+ specifier: ^19.1.0
+ version: 19.1.0(react@19.1.0)
+ react-router:
+ specifier: workspace:*
+ version: link:../../packages/react-router
+ devDependencies:
+ '@react-router/dev':
+ specifier: workspace:*
+ version: link:../../packages/react-router-dev
+ '@types/compression':
+ specifier: ^1.7.5
+ version: 1.7.5
+ '@types/express':
+ specifier: ^4.17.20
+ version: 4.17.21
+ '@types/morgan':
+ specifier: ^1.9.9
+ version: 1.9.9
+ '@types/react':
+ specifier: ^18.2.18
+ version: 18.2.18
+ '@types/react-dom':
+ specifier: ^18.2.7
+ version: 18.2.7
+ cross-env:
+ specifier: ^7.0.3
+ version: 7.0.3
+ typescript:
+ specifier: ^5.1.6
+ version: 5.4.5
+ vite:
+ specifier: ^6.1.0
+ version: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0)
+ vite-tsconfig-paths:
+ specifier: ^4.2.1
+ version: 4.3.2(typescript@5.4.5)(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0))
+
playground/rsc-parcel:
dependencies:
'@mjackson/node-fetch-server':