From 574456620b4d52885d9511cec8e0dff36ecc6ae6 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 23 Sep 2025 10:33:12 -0400 Subject: [PATCH 01/22] Add instrumentRoutes/instrumentRouter APIs to client side routers --- packages/react-router/lib/components.tsx | 56 ++++++++++++++++--- .../lib/dom-export/hydrated-router.tsx | 25 +++++++++ packages/react-router/lib/dom/lib.tsx | 40 +++++++++++-- packages/react-router/lib/router/router.ts | 23 +++++++- 4 files changed, 130 insertions(+), 14 deletions(-) diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index ae4613cb13..a44d278816 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { + History, InitialEntry, Location, MemoryHistory, @@ -168,6 +169,19 @@ export interface MemoryRouterOpts { * Index of `initialEntries` the application should initialize to */ initialIndex?: number; + /** + * Function allowing you to instrument a route object prior to creating the + * client-side router (and on any subsequently added routes via `route.lazy` or + * `patchRoutesOnNavigation`). This is mostly useful for observability such + * as wrapping loaders/actions/middlewares with logging and/or performance tracing. + */ + unstable_instrumentRoute?: (r: DataRouteObject) => DataRouteObject; + /** + * Function allowing you to instrument the client-side router. This is mostly + * useful for observability such as wrapping `router.navigate`/`router.fetch` + * with logging and/or performance tracing. + */ + unstable_instrumentRouter?: (r: DataRouter) => DataRouter; /** * Override the default data strategy of loading in parallel. * Only intended for advanced usage. @@ -196,6 +210,8 @@ 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_instrumentRoute} opts.unstable_instrumentRoute n/a + * @param {MemoryRouterOpts.unstable_instrumentRouter} opts.unstable_instrumentRouter n/a * @param {MemoryRouterOpts.patchRoutesOnNavigation} opts.patchRoutesOnNavigation n/a * @returns An initialized {@link DataRouter} to pass to {@link RouterProvider | ``} */ @@ -203,21 +219,45 @@ export function createMemoryRouter( routes: RouteObject[], opts?: MemoryRouterOpts, ): DataRouter { - return createRouter({ - basename: opts?.basename, - getContext: opts?.getContext, - future: opts?.future, - history: createMemoryHistory({ + return createAndInitializeDataRouter( + routes, + createMemoryHistory({ initialEntries: opts?.initialEntries, initialIndex: opts?.initialIndex, }), + opts, + ); +} + +export function createAndInitializeDataRouter( + routes: RouteObject[], + history: History, + opts?: Omit< + RouterInit, + "routes" | "history" | "mapRouteProperties" | "hydrationRouteProperties" + > & { + unstable_instrumentRouter?: (r: DataRouter) => DataRouter; + }, +): DataRouter { + let router = createRouter({ + basename: opts?.basename, + dataStrategy: opts?.dataStrategy, + future: opts?.future, + getContext: opts?.getContext, + history, hydrationData: opts?.hydrationData, - routes, hydrationRouteProperties, + unstable_instrumentRoute: opts?.unstable_instrumentRoute, mapRouteProperties, - dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, - }).initialize(); + routes, + }); + + if (opts?.unstable_instrumentRouter) { + router = opts.unstable_instrumentRouter(router); + } + + return router.initialize(); } class Deferred { diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index c6429c82f1..222259e040 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -7,6 +7,7 @@ import type { HydrationState, RouterInit, unstable_ClientOnErrorFunction, + DataRouteObject, } from "react-router"; import { UNSAFE_getHydrationData as getHydrationData, @@ -78,8 +79,12 @@ function initSsrInfo(): void { function createHydratedRouter({ getContext, + unstable_instrumentRoute, + unstable_instrumentRouter, }: { getContext?: RouterInit["getContext"]; + unstable_instrumentRoute?: RouterInit["unstable_instrumentRoute"]; + unstable_instrumentRouter?: (r: DataRouter) => DataRouter; }): DataRouter { initSsrInfo(); @@ -172,6 +177,7 @@ function createHydratedRouter({ getContext, hydrationData, hydrationRouteProperties, + unstable_instrumentRoute, mapRouteProperties, future: { middleware: ssrInfo.context.future.v8_middleware, @@ -192,6 +198,11 @@ function createHydratedRouter({ ssrInfo.context.basename, ), }); + + if (unstable_instrumentRouter) { + router = unstable_instrumentRouter(router); + } + ssrInfo.router = router; // We can call initialize() immediately if the router doesn't have any @@ -223,6 +234,18 @@ export interface HydratedRouterProps { * functions */ getContext?: RouterInit["getContext"]; + /** + * Function allowing you to instrument a route object prior to creating the + * client-side router. This is mostly useful for observability such as wrapping + * loaders/actions/middlewares with logging and/or performance tracing. + */ + unstable_instrumentRoute(route: DataRouteObject): DataRouteObject; + /** + * Function allowing you to instrument the client-side router. This is mostly + * useful for observability such as wrapping `router.navigate`/`router.fetch` + * with logging and/or performance tracing. + */ + unstable_instrumentRouter(router: DataRouter): DataRouter; /** * 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 +282,8 @@ export function HydratedRouter(props: HydratedRouterProps) { if (!router) { router = createHydratedRouter({ getContext: props.getContext, + unstable_instrumentRoute: props.unstable_instrumentRoute, + unstable_instrumentRouter: props.unstable_instrumentRouter, }); } diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index ecbe52374e..43471e358a 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -75,6 +75,7 @@ import type { RouteObject, NavigateOptions, PatchRoutesOnNavigationFunction, + DataRouteObject, } from "../context"; import { DataRouterContext, @@ -235,6 +236,19 @@ export interface DOMRouterOpts { * ``` */ hydrationData?: HydrationState; + /** + * Function allowing you to instrument a route object prior to creating the + * client-side router (and on any subsequently added routes via `route.lazy` or + * `patchRoutesOnNavigation`). This is mostly useful for observability such + * as wrapping loaders/actions/middlewares with logging and/or performance tracing. + */ + unstable_instrumentRoute?: (r: DataRouteObject) => DataRouteObject; + /** + * Function allowing you to instrument the client-side router. This is mostly + * useful for observability such as wrapping `router.navigate`/`router.fetch` + * with logging and/or performance tracing. + */ + unstable_instrumentRouter?: (r: DataRouter) => DataRouter; /** * Override the default data strategy of running loaders in parallel. * See {@link DataStrategyFunction}. @@ -741,6 +755,8 @@ 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_instrumentRoute} opts.unstable_instrumentRoute n/a + * @param {DOMRouterOpts.unstable_instrumentRouter} opts.unstable_instrumentRouter 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 | ``} @@ -749,19 +765,26 @@ export function createBrowserRouter( routes: RouteObject[], opts?: DOMRouterOpts, ): DataRouter { - return createRouter({ + let router = createRouter({ basename: opts?.basename, getContext: opts?.getContext, future: opts?.future, history: createBrowserHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), + unstable_instrumentRoute: opts?.unstable_instrumentRoute, routes, mapRouteProperties, hydrationRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, - }).initialize(); + }); + + if (opts?.unstable_instrumentRouter) { + router = opts.unstable_instrumentRouter(router); + } + + return router.initialize(); } /** @@ -777,6 +800,8 @@ 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_instrumentRoute} opts.unstable_instrumentRoute n/a + * @param {DOMRouterOpts.unstable_instrumentRouter} opts.unstable_instrumentRouter n/a * @param {DOMRouterOpts.dataStrategy} opts.dataStrategy n/a * @param {DOMRouterOpts.patchRoutesOnNavigation} opts.patchRoutesOnNavigation n/a * @param {DOMRouterOpts.window} opts.window n/a @@ -786,19 +811,26 @@ export function createHashRouter( routes: RouteObject[], opts?: DOMRouterOpts, ): DataRouter { - return createRouter({ + let router = createRouter({ basename: opts?.basename, getContext: opts?.getContext, future: opts?.future, history: createHashHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), + unstable_instrumentRoute: opts?.unstable_instrumentRoute, routes, mapRouteProperties, hydrationRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, - }).initialize(); + }); + + if (opts?.unstable_instrumentRouter) { + router = opts.unstable_instrumentRouter(router); + } + + return router.initialize(); } function parseHydrationData(): HydrationState | undefined { diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 4cd9d1df51..6d32c5bf14 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -40,7 +40,6 @@ import type { ActionFunction, MiddlewareFunction, MiddlewareNextFunction, - ErrorResponse, } from "./utils"; import { ErrorResponseImpl, @@ -403,6 +402,9 @@ export interface RouterInit { history: History; basename?: string; getContext?: () => MaybePromise; + unstable_instrumentRoute?: ( + route: AgnosticDataRouteObject, + ) => AgnosticDataRouteObject; mapRouteProperties?: MapRoutePropertiesFunction; future?: Partial; hydrationRouteProperties?: string[]; @@ -866,7 +868,24 @@ 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_instrumentRoute) { + let instrument = init.unstable_instrumentRoute; + // TODO: Clean up these types + mapRouteProperties = (r: AgnosticRouteObject) => { + return instrument({ + ...r, + ..._mapRouteProperties(r), + } as AgnosticDataRouteObject) as AgnosticDataRouteObject & { + hasErrorBoundary: boolean; + }; + }; + } // Routes keyed by ID let manifest: RouteManifest = {}; From 4bf476db186301cd174fd10639a258c381e60bcb Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 23 Sep 2025 10:33:48 -0400 Subject: [PATCH 02/22] Add pattern parameter to loader/action args for o11y --- .../__tests__/router/fetchers-test.ts | 8 +++++++ .../__tests__/router/router-test.ts | 7 +++++++ .../__tests__/router/submission-test.ts | 6 ++++++ packages/react-router/lib/dom/ssr/routes.tsx | 6 ++++-- packages/react-router/lib/router/router.ts | 21 ++++++++++++++++++- packages/react-router/lib/router/utils.ts | 9 ++++++++ .../react-router/lib/server-runtime/build.ts | 15 +++++++++++-- .../react-router/lib/server-runtime/data.ts | 1 + 8 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 0fca5f7a39..4d516b16ae 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, }), + pattern: expect.any(String), context: {}, }); }); @@ -3373,6 +3374,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); @@ -3402,6 +3404,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); @@ -3429,6 +3432,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); @@ -3456,6 +3460,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); @@ -3484,6 +3489,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); @@ -3514,6 +3520,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); @@ -3543,6 +3550,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); diff --git a/packages/react-router/__tests__/router/router-test.ts b/packages/react-router/__tests__/router/router-test.ts index 704d7eff3f..3580b2d176 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, }), + 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, }), + 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, }), + 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, }), + pattern: "/tasks", context: {}, }); @@ -1930,6 +1934,7 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: "/tasks", context: {}, }); @@ -1974,6 +1979,7 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + 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), + 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..75e296227e 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), + pattern: expect.any(String), context: {}, }); @@ -982,6 +983,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); @@ -1014,6 +1016,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); @@ -1118,6 +1121,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); @@ -1156,6 +1160,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); @@ -1191,6 +1196,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), + pattern: expect.any(String), context: {}, }); diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 7c1f773a6d..4ffaf4fe36 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, pattern }: LoaderFunctionArgs, singleFetch?: unknown, ) => { try { @@ -358,6 +358,7 @@ export function createClientRoutes( request, params, context, + pattern, async serverLoader() { preventInvalidServerHandlerCall("loader", route); @@ -393,7 +394,7 @@ export function createClientRoutes( ); dataRoute.action = ( - { request, params, context }: ActionFunctionArgs, + { request, params, context, pattern }: ActionFunctionArgs, singleFetch?: unknown, ) => { return prefetchStylesAndCallHandler(async () => { @@ -412,6 +413,7 @@ export function createClientRoutes( request, params, context, + pattern, async serverAction() { preventInvalidServerHandlerCall("action", route); return fetchServerAction(singleFetch); diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 6d32c5bf14..fcf269891e 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -57,6 +57,7 @@ import { resolveTo, stripBasename, RouterContextProvider, + getRoutePattern, } from "./utils"; //////////////////////////////////////////////////////////////////////////////// @@ -3679,6 +3680,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + 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 @@ -3910,6 +3912,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + 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 @@ -4308,6 +4311,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + getRoutePattern(matches.map((m) => m.route.path)), match, [], requestContext, @@ -4319,6 +4323,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + getRoutePattern(matches.map((m) => m.route.path)), match, [], requestContext, @@ -4806,6 +4811,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + getRoutePattern(matches.map((m) => m.route.path)), match, lazyRoutePropertiesToSkip, scopedContext, @@ -4835,6 +4841,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + getRoutePattern(matches.map((m) => m.route.path)), match, lazyRoutePropertiesToSkip, scopedContext, @@ -5602,7 +5609,12 @@ async function runMiddlewarePipeline( ) as [string, MiddlewareFunction][]; let result = await callRouteMiddleware( - { request, params, context }, + { + request, + params, + context, + pattern: getRoutePattern(matches.map((m) => m.route.path)), + }, tuples, handler, processResult, @@ -5724,6 +5736,7 @@ function getDataStrategyMatch( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, + pattern: string, match: DataRouteMatch, lazyRoutePropertiesToSkip: string[], scopedContext: unknown, @@ -5781,6 +5794,7 @@ function getDataStrategyMatch( if (callHandler && !isMiddlewareOnlyRoute) { return callLoaderOrAction({ request, + pattern, match, lazyHandlerPromise: _lazyPromises?.handler, lazyRoutePromise: _lazyPromises?.route, @@ -5827,6 +5841,7 @@ function getTargetedDataStrategyMatches( mapRouteProperties, manifest, request, + getRoutePattern(matches.map((m) => m.route.path)), match, lazyRoutePropertiesToSkip, scopedContext, @@ -5854,6 +5869,7 @@ async function callDataStrategyImpl( // back out below. let dataStrategyArgs = { request, + pattern: getRoutePattern(matches.map((m) => m.route.path)), params: matches[0].params, context: scopedContext, matches, @@ -5911,6 +5927,7 @@ async function callDataStrategyImpl( // Default logic for calling a loader/action is the user has no specified a dataStrategy async function callLoaderOrAction({ request, + pattern, match, lazyHandlerPromise, lazyRoutePromise, @@ -5918,6 +5935,7 @@ async function callLoaderOrAction({ scopedContext, }: { request: Request; + pattern: string; match: AgnosticDataRouteMatch; lazyHandlerPromise: Promise | undefined; lazyRoutePromise: Promise | undefined; @@ -5951,6 +5969,7 @@ async function callLoaderOrAction({ return handler( { request, + 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..76e63b5223 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. + */ + pattern: string; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example @@ -1995,3 +2000,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..ed5689d40e 100644 --- a/packages/react-router/lib/server-runtime/build.ts +++ b/packages/react-router/lib/server-runtime/build.ts @@ -58,12 +58,23 @@ export interface HandleDocumentRequestFunction { export interface HandleDataRequestFunction { ( response: Response, - args: LoaderFunctionArgs | ActionFunctionArgs, + args: { + request: LoaderFunctionArgs["request"]; + context: LoaderFunctionArgs["context"]; + params: LoaderFunctionArgs["params"]; + }, ): Promise | Response; } export interface HandleErrorFunction { - (error: unknown, args: LoaderFunctionArgs | ActionFunctionArgs): void; + ( + error: unknown, + args: { + request: LoaderFunctionArgs["request"]; + context: LoaderFunctionArgs["context"]; + params: LoaderFunctionArgs["params"]; + }, + ): void; } /** diff --git a/packages/react-router/lib/server-runtime/data.ts b/packages/react-router/lib/server-runtime/data.ts index a1b67be014..17df22d468 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, + pattern: args.pattern, }); // If they returned a redirect via data(), re-throw it as a Response From 869c433335c7b79be7f4850c5b51eea37a276515 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 23 Sep 2025 10:38:01 -0400 Subject: [PATCH 03/22] Decision doc --- decisions/0015-observability.md | 119 ++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 decisions/0015-observability.md diff --git a/decisions/0015-observability.md b/decisions/0015-observability.md new file mode 100644 index 0000000000..e4aa369ac0 --- /dev/null +++ b/decisions/0015-observability.md @@ -0,0 +1,119 @@ +# 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 + - initialization, navigations, and fetchers on the client router +- route level + - loaders, actions, middlewares + +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 2 new exports from `entry.server`. These 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 + +// Wrap incoming request handlers. Currently applies to _all_ requests handled +// by the RR handler, including: +// - manifest reqeusts +// - document requests +// - `.data` requests +// - resource route requests +export function instrumentHandler(handler: RequestHandler): RequestHandler { + return (...args) => { + let [request] = args; + let path = new URL(request.url).pathname; + let start = Date.now(); + console.log(`Request start: ${request.method} ${path}`); + + try { + return await handler(...args); + } finally { + let duration = Date.now() - start; + console.log(`Request end: ${request.method} ${path} (${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 +export function instrumentRoute(route: RouteModule): RequestHandler { + let { loader } = route; + let newRoute = { ...route }; + if (loader) { + newRoute.loader = (args) => { + let { request } = args; + let path = new URL(request.url).pathname; + let start = Date.now(); + console.log(`Loader start: ${request.method} ${path}`); + + try { + return await loader(...args); + } finally { + let duration = Date.now() - start; + console.log(`Loader end: ${request.method} ${path} (${duration}ms)`); + } + }; + } + return newRoute; +} +``` + +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 +function instrumentRouter(router: DataRouter): DataRouter { /* ... */ } + +function instrumentRoute(route: RouteObject): RouteObject { /* ... */ } + +// Data mode +let router = createBrowserRouter(routes, { + instrumentRouter, + instrumentRoute, +}) + +// 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` + +## Alternatives Considered + +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. + +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. From 0014b8e9926e287453506b95cc0ecae6c4e016fb Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 23 Sep 2025 11:01:52 -0400 Subject: [PATCH 04/22] pattern --- packages/react-router/lib/types/route-data.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/react-router/lib/types/route-data.ts b/packages/react-router/lib/types/route-data.ts index 058a2f5aef..9efebafa8e 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. + */ + 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. + */ + 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 From 43bb7d02848b5e4c97414b1ce95eccb5962b52df Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 23 Sep 2025 11:01:59 -0400 Subject: [PATCH 05/22] Add playground --- playground/observability/.gitignore | 6 ++ playground/observability/app/entry.client.tsx | 99 ++++++++++++++++++ playground/observability/app/o11y.ts | 40 +++++++ playground/observability/app/root.tsx | 56 ++++++++++ playground/observability/app/routes.ts | 6 ++ playground/observability/app/routes/index.tsx | 24 +++++ playground/observability/app/routes/slug.tsx | 46 ++++++++ playground/observability/package.json | 39 +++++++ playground/observability/public/favicon.ico | Bin 0 -> 15086 bytes .../observability/react-router.config.ts | 7 ++ playground/observability/server.js | 43 ++++++++ playground/observability/tsconfig.json | 31 ++++++ playground/observability/vite.config.ts | 7 ++ pnpm-lock.yaml | 61 +++++++++++ 14 files changed, 465 insertions(+) create mode 100644 playground/observability/.gitignore create mode 100644 playground/observability/app/entry.client.tsx create mode 100644 playground/observability/app/o11y.ts create mode 100644 playground/observability/app/root.tsx create mode 100644 playground/observability/app/routes.ts create mode 100644 playground/observability/app/routes/index.tsx create mode 100644 playground/observability/app/routes/slug.tsx create mode 100644 playground/observability/package.json create mode 100644 playground/observability/public/favicon.ico create mode 100644 playground/observability/react-router.config.ts create mode 100644 playground/observability/server.js create mode 100644 playground/observability/tsconfig.json create mode 100644 playground/observability/vite.config.ts diff --git a/playground/observability/.gitignore b/playground/observability/.gitignore new file mode 100644 index 0000000000..752e5fe866 --- /dev/null +++ b/playground/observability/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/build +.env + +.react-router/ diff --git a/playground/observability/app/entry.client.tsx b/playground/observability/app/entry.client.tsx new file mode 100644 index 0000000000..c778e9fcb6 --- /dev/null +++ b/playground/observability/app/entry.client.tsx @@ -0,0 +1,99 @@ +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import type { + DataRouteObject, + DataRouter, + RouterNavigateOptions, +} from "react-router"; +import { HydratedRouter } from "react-router/dom"; +import { getPattern, measure, startMeasure } from "./o11y"; + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); + +function instrumentRouter(router: DataRouter) { + let initialize = router.initialize; + router.initialize = () => { + let pattern = getPattern(router.routes, router.state.location.pathname); + let end = startMeasure(["initialize", pattern]); + + if (router.state.initialized) { + end(); + } else { + let unsubscribe = router.subscribe((state) => { + if (state.initialized) { + end(); + unsubscribe(); + } + }); + } + return initialize(); + }; + + let navigate = router.navigate; + router.navigate = async (to, opts?: RouterNavigateOptions) => { + let path = + typeof to === "string" + ? to + : typeof to === "number" + ? String(to) + : (to?.pathname ?? "unknown"); + await measure([`navigate`, getPattern(router.routes, path)], () => + typeof to === "number" ? navigate(to) : navigate(to, opts), + ); + }; + + return router; +} + +function instrumentRoute(route: DataRouteObject): DataRouteObject { + if (typeof route.lazy === "function") { + let lazy = route.lazy; + route.lazy = () => measure(["lazy", route.id], () => lazy()); + } + + if ( + route.middleware && + route.middleware.length > 0 && + // @ts-expect-error + route.middleware.instrumented !== true + ) { + route.middleware = route.middleware.map((mw, i) => { + return ({ request, params, pattern, context }, next) => + measure(["middleware", route.id, i.toString(), pattern], async () => + mw({ request, params, pattern, context }, next), + ); + }); + // When `route.lazy` is used alongside a statically defined `loader`, make + // sure we don't double-instrument the `loader` after `route.lazy` completes + // and we re-call `instrumentRoute` via `mapRouteProperties` + // @ts-expect-error + route.middleware.instrumented = true; + } + + // @ts-expect-error + if (typeof route.loader === "function" && !route.loader.instrumented) { + let loader = route.loader; + route.loader = (...args) => { + return measure([`loader:${route.id}`, args[0].pattern], async () => + loader(...args), + ); + }; + // When `route.lazy` is used alongside a statically defined `loader`, make + // sure we don't double-instrument the `loader` after `route.lazy` completes + // and we re-call `instrumentRoute` via `mapRouteProperties` + // @ts-expect-error + route.loader.instrumented = true; + } + + return route; +} diff --git a/playground/observability/app/o11y.ts b/playground/observability/app/o11y.ts new file mode 100644 index 0000000000..6bca573ef9 --- /dev/null +++ b/playground/observability/app/o11y.ts @@ -0,0 +1,40 @@ +import { matchRoutes, type DataRouteObject } from "react-router"; + +export function getPattern(routes: DataRouteObject[], path: string) { + let matches = matchRoutes(routes, path); + if (matches && matches.length > 0) { + return matches + ?.map((m) => m.route.path) + .filter(Boolean) + .join("/") + .replace(/\/\/+/g, "/"); + } + return "unknown-pattern"; +} + +export function startMeasure(label: string[]) { + let strLabel = label.join("--"); + let now = Date.now().toString(); + let start = `start:${strLabel}:${now}`; + console.log(new Date().toISOString(), "start", strLabel); + start += `start:${strLabel}:${now}`; + performance.mark(start); + return () => { + let end = `end:${strLabel}:${now}`; + console.log(new Date().toISOString(), "end", strLabel); + performance.mark(end); + performance.measure(strLabel, start, end); + }; +} + +export async function measure( + label: string[], + cb: () => Promise, +): Promise { + let end = startMeasure(label); + try { + return await cb(); + } finally { + end(); + } +} diff --git a/playground/observability/app/root.tsx b/playground/observability/app/root.tsx new file mode 100644 index 0000000000..52a3684e85 --- /dev/null +++ b/playground/observability/app/root.tsx @@ -0,0 +1,56 @@ +import { + Link, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, + type MiddlewareFunction, +} from "react-router"; + +let sleep = (ms: number = Math.max(100, Math.round(Math.random() * 500))) => + new Promise((r) => setTimeout(r, ms)); + +export const middleware = [ + async (_: unknown, next: Parameters>[1]) => { + await sleep(); + await next(); + await sleep(); + }, +]; + +export async function loader() { + await sleep(); +} + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} diff --git a/playground/observability/app/routes.ts b/playground/observability/app/routes.ts new file mode 100644 index 0000000000..3d0c769294 --- /dev/null +++ b/playground/observability/app/routes.ts @@ -0,0 +1,6 @@ +import { type RouteConfig, index, route } from "@react-router/dev/routes"; + +export default [ + index("routes/index.tsx"), + route(":slug", "routes/slug.tsx"), +] satisfies RouteConfig; diff --git a/playground/observability/app/routes/index.tsx b/playground/observability/app/routes/index.tsx new file mode 100644 index 0000000000..7e934a0a6c --- /dev/null +++ b/playground/observability/app/routes/index.tsx @@ -0,0 +1,24 @@ +import type { MiddlewareFunction } from "react-router"; + +let sleep = (ms: number = Math.max(100, Math.round(Math.random() * 500))) => + new Promise((r) => setTimeout(r, ms)); + +export const middleware = [ + async (_: unknown, next: Parameters>[1]) => { + await sleep(); + await next(); + await sleep(); + }, +]; + +export async function loader() { + await sleep(); +} + +export default function Index() { + return ( +
+

Welcome to React Router

+
+ ); +} diff --git a/playground/observability/app/routes/slug.tsx b/playground/observability/app/routes/slug.tsx new file mode 100644 index 0000000000..ce58440893 --- /dev/null +++ b/playground/observability/app/routes/slug.tsx @@ -0,0 +1,46 @@ +import { startMeasure } from "~/o11y"; +import { type Route } from "../../.react-router/types/app/routes/+types/slug"; + +let sleep = (ms: number = Math.max(100, Math.round(Math.random() * 500))) => + new Promise((r) => setTimeout(r, ms)); + +export const middleware: Route.MiddlewareFunction[] = [ + async (_, next) => { + await sleep(); + await next(); + await sleep(); + }, +]; + +export const clientMiddleware: Route.ClientMiddlewareFunction[] = [ + async (_, next) => { + await sleep(); + await next(); + await sleep(); + }, +]; + +export async function loader({ params }: Route.LoaderArgs) { + await sleep(); + return params.slug; +} + +export async function clientLoader({ + serverLoader, + pattern, +}: Route.ClientLoaderArgs) { + await sleep(); + let end = startMeasure(["serverLoader", pattern]); + let value = await serverLoader(); + end(); + await sleep(); + return value; +} + +export default function Slug({ loaderData }: Route.ComponentProps) { + return ( +
+

Slug: {loaderData}

+
+ ); +} diff --git a/playground/observability/package.json b/playground/observability/package.json new file mode 100644 index 0000000000..c0288d54a4 --- /dev/null +++ b/playground/observability/package.json @@ -0,0 +1,39 @@ +{ + "name": "@playground/framework-express", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "react-router build", + "dev": "node ./server.js", + "start": "cross-env NODE_ENV=production node ./server.js", + "typecheck": "react-router typegen && tsc" + }, + "dependencies": { + "@react-router/express": "workspace:*", + "@react-router/node": "workspace:*", + "compression": "^1.7.4", + "express": "^4.19.2", + "isbot": "^5.1.11", + "morgan": "^1.10.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "workspace:*" + }, + "devDependencies": { + "@react-router/dev": "workspace:*", + "@types/compression": "^1.7.5", + "@types/express": "^4.17.20", + "@types/morgan": "^1.9.9", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "cross-env": "^7.0.3", + "typescript": "^5.1.6", + "vite": "^6.1.0", + "vite-tsconfig-paths": "^4.2.1" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/playground/observability/public/favicon.ico b/playground/observability/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5dbdfcddcb14182535f6d32d1c900681321b1aa3 GIT binary patch literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j literal 0 HcmV?d00001 diff --git a/playground/observability/react-router.config.ts b/playground/observability/react-router.config.ts new file mode 100644 index 0000000000..039108a6a7 --- /dev/null +++ b/playground/observability/react-router.config.ts @@ -0,0 +1,7 @@ +import type { Config } from "@react-router/dev/config"; + +export default { + future: { + v8_middleware: true, + }, +} satisfies Config; diff --git a/playground/observability/server.js b/playground/observability/server.js new file mode 100644 index 0000000000..fa5048f32c --- /dev/null +++ b/playground/observability/server.js @@ -0,0 +1,43 @@ +import { createRequestHandler } from "@react-router/express"; +import compression from "compression"; +import express from "express"; +import morgan from "morgan"; + +const viteDevServer = + process.env.NODE_ENV === "production" + ? undefined + : await import("vite").then((vite) => + vite.createServer({ + server: { middlewareMode: true }, + }) + ); + +const reactRouterHandler = createRequestHandler({ + build: viteDevServer + ? () => viteDevServer.ssrLoadModule("virtual:react-router/server-build") + : await import("./build/server/index.js"), +}); + +const app = express(); + +app.use(compression()); +app.disable("x-powered-by"); + +if (viteDevServer) { + app.use(viteDevServer.middlewares); +} else { + app.use( + "/assets", + express.static("build/client/assets", { immutable: true, maxAge: "1y" }) + ); +} + +app.use(express.static("build/client", { maxAge: "1h" })); +app.use(morgan("tiny")); + +app.all("*", reactRouterHandler); + +const port = process.env.PORT || 3000; +app.listen(port, () => + console.log(`Express server listening at http://localhost:${port}`) +); diff --git a/playground/observability/tsconfig.json b/playground/observability/tsconfig.json new file mode 100644 index 0000000000..79cf7b5af6 --- /dev/null +++ b/playground/observability/tsconfig.json @@ -0,0 +1,31 @@ +{ + "include": [ + "**/*.ts", + "**/*.tsx", + "**/.server/**/*.ts", + "**/.server/**/*.tsx", + "**/.client/**/*.ts", + "**/.client/**/*.tsx", + "./.react-router/types/**/*" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@react-router/node", "vite/client"], + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true, + "rootDirs": [".", "./.react-router/types"] + } +} diff --git a/playground/observability/vite.config.ts b/playground/observability/vite.config.ts new file mode 100644 index 0000000000..f910ad4c18 --- /dev/null +++ b/playground/observability/vite.config.ts @@ -0,0 +1,7 @@ +import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [reactRouter(), tsconfigPaths()], +}); 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': From e0d8ea226d7909db39fc14f30da972a6b17776e9 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 23 Sep 2025 12:04:49 -0400 Subject: [PATCH 06/22] Add instrumentRoute APIs for server side --- packages/react-router/lib/router/router.ts | 23 +++- .../react-router/lib/server-runtime/build.ts | 18 ++- .../react-router/lib/server-runtime/server.ts | 129 ++++++++++-------- playground/observability/app/entry.server.tsx | 119 ++++++++++++++++ playground/observability/app/o11y.ts | 15 +- 5 files changed, 239 insertions(+), 65 deletions(-) create mode 100644 playground/observability/app/entry.server.tsx diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index fcf269891e..3cbb847afa 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1,4 +1,4 @@ -import type { DataRouteMatch, RouteObject } from "../context"; +import type { DataRouteMatch, DataRouteObject, RouteObject } from "../context"; import type { History, Location, Path, To } from "./history"; import { Action as NavigationType, @@ -3538,6 +3538,9 @@ export function createRouter(init: RouterInit): Router { export interface CreateStaticHandlerOptions { basename?: string; mapRouteProperties?: MapRoutePropertiesFunction; + unstable_instrumentRoute?: ( + r: AgnosticDataRouteObject, + ) => AgnosticDataRouteObject; future?: {}; } @@ -3552,8 +3555,24 @@ 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_instrumentRoute) { + let instrument = opts?.unstable_instrumentRoute; + // TODO: Clean up these types + mapRouteProperties = (r: AgnosticRouteObject) => { + return instrument({ + ...r, + ..._mapRouteProperties(r), + } as AgnosticDataRouteObject) as AgnosticDataRouteObject & { + hasErrorBoundary: boolean; + }; + }; + } let dataRoutes = convertRoutesToDataRoutes( routes, diff --git a/packages/react-router/lib/server-runtime/build.ts b/packages/react-router/lib/server-runtime/build.ts index ed5689d40e..3ca14141aa 100644 --- a/packages/react-router/lib/server-runtime/build.ts +++ b/packages/react-router/lib/server-runtime/build.ts @@ -1,5 +1,6 @@ import type { ActionFunctionArgs, + AgnosticDataRouteObject, LoaderFunctionArgs, RouterContextProvider, } from "../router/utils"; @@ -12,6 +13,7 @@ import type { import type { ServerRouteManifest } from "./routes"; import type { AppLoadContext } from "./data"; import type { MiddlewareEnabled } from "../types/future"; +import type { RequestHandler } from "./server"; type OptionalCriticalCss = CriticalCss | undefined; @@ -59,9 +61,9 @@ export interface HandleDataRequestFunction { ( response: Response, args: { - request: LoaderFunctionArgs["request"]; - context: LoaderFunctionArgs["context"]; - params: LoaderFunctionArgs["params"]; + request: LoaderFunctionArgs["request"] | ActionFunctionArgs["request"]; + context: LoaderFunctionArgs["context"] | ActionFunctionArgs["context"]; + params: LoaderFunctionArgs["params"] | ActionFunctionArgs["params"]; }, ): Promise | Response; } @@ -70,9 +72,9 @@ export interface HandleErrorFunction { ( error: unknown, args: { - request: LoaderFunctionArgs["request"]; - context: LoaderFunctionArgs["context"]; - params: LoaderFunctionArgs["params"]; + request: LoaderFunctionArgs["request"] | ActionFunctionArgs["request"]; + context: LoaderFunctionArgs["context"] | ActionFunctionArgs["context"]; + params: LoaderFunctionArgs["params"] | ActionFunctionArgs["params"]; }, ): void; } @@ -85,5 +87,9 @@ export interface ServerEntryModule { default: HandleDocumentRequestFunction; handleDataRequest?: HandleDataRequestFunction; handleError?: HandleErrorFunction; + unstable_instrumentHandler?: (handler: RequestHandler) => RequestHandler; + unstable_instrumentRoute?: ( + route: AgnosticDataRouteObject, + ) => AgnosticDataRouteObject; streamTimeout?: number; } diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 71f4d11b04..dd495b4755 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -55,6 +55,7 @@ function derive(build: ServerBuild, mode?: string) { let serverMode = isServerMode(mode) ? mode : ServerMode.Production; let staticHandler = createStaticHandler(dataRoutes, { basename: build.basename, + unstable_instrumentRoute: build.entry.module.unstable_instrumentRoute, }); let errorHandler = @@ -67,42 +68,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 +85,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( }); }; - if (_build.future.v8_middleware) { + if (build.future.v8_middleware) { if ( initialContext && !(initialContext instanceof RouterContextProvider) @@ -138,7 +105,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 +125,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 +155,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 +189,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 +202,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 +215,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 +232,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 +248,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( response = generateSingleFetchRedirectResponse( response, request, - _build, + build, serverMode, ); } @@ -294,7 +261,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( ) { response = await handleResourceRequest( serverMode, - _build, + build, staticHandler, matches.slice(-1)[0].route.id, request, @@ -305,8 +272,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 +283,7 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( response = await handleDocumentRequest( serverMode, - _build, + build, staticHandler, request, loadContext, @@ -336,6 +303,60 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( return response; }; + + if (build.entry.module.unstable_instrumentHandler) { + requestHandler = + build.entry.module.unstable_instrumentHandler(requestHandler); + } + + 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/playground/observability/app/entry.server.tsx b/playground/observability/app/entry.server.tsx new file mode 100644 index 0000000000..f40c00afe1 --- /dev/null +++ b/playground/observability/app/entry.server.tsx @@ -0,0 +1,119 @@ +import { PassThrough } from "node:stream"; + +import type { AppLoadContext, EntryContext } from "react-router"; +import { createReadableStreamFromReadable } from "@react-router/node"; +import { ServerRouter } from "react-router"; +import { isbot } from "isbot"; +import type { RenderToPipeableStreamOptions } from "react-dom/server"; +import { renderToPipeableStream } from "react-dom/server"; +import type { RequestHandler } from "react-router"; +import { log } from "./o11y"; +import type { ServerBuild } from "react-router"; +import type { DataRouteObject } from "react-router"; +import type { MiddlewareFunction } from "react-router"; + +export const streamTimeout = 5_000; + +export function unstable_instrumentHandler( + handler: RequestHandler, +): RequestHandler { + let instrumented: RequestHandler = async (request, context) => { + let pattern = new URL(request.url).pathname; + return await log([`request`, pattern], () => handler(request, context)); + }; + return instrumented; +} + +export function unstable_instrumentRoute( + route: DataRouteObject, +): DataRouteObject { + if (route.middleware && route.middleware.length > 0) { + route.middleware = route.middleware.map((mw, i) => { + return (...args: Parameters>) => + log(["middleware", route.id, i.toString(), args[0].pattern], async () => + mw(...args), + ); + }) as MiddlewareFunction[]; + } + + if (typeof route.loader === "function") { + let loader = route.loader; + route.loader = (...args) => { + return log([`loader:${route.id}`, args[0].pattern], async () => + loader(...args), + ); + }; + } + + return route; +} + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: AppLoadContext, + // If you have middleware enabled: + // loadContext: RouterContextProvider +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + let userAgent = request.headers.get("user-agent"); + + // Ensure requests from bots and SPA Mode renders wait for all content to load before responding + // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation + let readyOption: keyof RenderToPipeableStreamOptions = + (userAgent && isbot(userAgent)) || routerContext.isSpaMode + ? "onAllReady" + : "onShellReady"; + + // Abort the rendering stream after the `streamTimeout` so it has time to + // flush down the rejected boundaries + let timeoutId: ReturnType | undefined = setTimeout( + () => abort(), + streamTimeout + 1000, + ); + + const { pipe, abort } = renderToPipeableStream( + , + { + [readyOption]() { + shellRendered = true; + const body = new PassThrough({ + final(callback) { + // Clear the timeout to prevent retaining the closure and memory leak + clearTimeout(timeoutId); + timeoutId = undefined; + callback(); + }, + }); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + pipe(body); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + }); +} diff --git a/playground/observability/app/o11y.ts b/playground/observability/app/o11y.ts index 6bca573ef9..83038496fa 100644 --- a/playground/observability/app/o11y.ts +++ b/playground/observability/app/o11y.ts @@ -16,12 +16,9 @@ export function startMeasure(label: string[]) { let strLabel = label.join("--"); let now = Date.now().toString(); let start = `start:${strLabel}:${now}`; - console.log(new Date().toISOString(), "start", strLabel); - start += `start:${strLabel}:${now}`; performance.mark(start); return () => { let end = `end:${strLabel}:${now}`; - console.log(new Date().toISOString(), "end", strLabel); performance.mark(end); performance.measure(strLabel, start, end); }; @@ -38,3 +35,15 @@ export async function measure( end(); } } + +export async function log( + label: string[], + cb: () => Promise, +): Promise { + console.log(new Date().toISOString(), "start", label.join("--")); + try { + return await cb(); + } finally { + console.log(new Date().toISOString(), "end", label.join("--")); + } +} From 5e635ce97a39e8fc867a5c0298df9f07a52e9d42 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 25 Sep 2025 14:21:23 -0400 Subject: [PATCH 07/22] Switch to route.instrument API --- .../__tests__/router/instrumentation-test.ts | 167 ++++++++++++++++++ .../router/utils/data-router-setup.ts | 17 +- .../lib/router/instrumentation.ts | 125 +++++++++++++ packages/react-router/lib/router/router.ts | 24 +-- packages/react-router/lib/router/utils.ts | 6 +- 5 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 packages/react-router/__tests__/router/instrumentation-test.ts create mode 100644 packages/react-router/lib/router/instrumentation.ts 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..22f564fcf3 --- /dev/null +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -0,0 +1,167 @@ +import { cleanup, setup } from "./utils/data-router-setup"; +import { createFormData } from "./utils/utils"; + +// Detect any failures inside the router navigate code +afterEach(() => { + cleanup(); +}); + +describe("instrumentation", () => { + it("allows instrumentation of loaders", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + ], + unstable_instrumentRoute: (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_instrumentRoute: (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("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_instrumentRoute: (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.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_instrumentRoute: (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" }, + }); + }); +}); 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..dd1d253fcd 100644 --- a/packages/react-router/__tests__/router/utils/data-router-setup.ts +++ b/packages/react-router/__tests__/router/utils/data-router-setup.ts @@ -5,6 +5,7 @@ import type { HydrationState, Router, RouterNavigateOptions, + RouterInit, } from "../../../lib/router/router"; import type { AgnosticDataRouteObject, @@ -134,14 +135,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 +199,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 +312,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/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts new file mode 100644 index 0000000000..809d5b6c36 --- /dev/null +++ b/packages/react-router/lib/router/instrumentation.ts @@ -0,0 +1,125 @@ +import type { + ActionFunction, + AgnosticDataRouteObject, + LoaderFunction, + LoaderFunctionArgs, + RouterContextProvider, +} from "./utils"; + +type InstrumentationInfo = Readonly<{ + request: { + method: string; + url: string; + headers: Pick; + }; + params: LoaderFunctionArgs["params"]; + pattern: string; + // TODO: Fix for non-middleware + context: Pick; +}>; + +type InstrumentHandlerFunction = ( + handler: () => undefined, + info: InstrumentationInfo, +) => MaybePromise; + +type Instrumentations = { + loader?: InstrumentHandlerFunction; + action?: InstrumentHandlerFunction; +}; + +type InstrumentableRoute = { + id: string; + index: boolean | undefined; + path: string | undefined; + instrument(instrumentations: Instrumentations): void; +}; + +export type unstable_InstrumentRouteFunction = ( + route: InstrumentableRoute, +) => void; + +function getInstrumentedHandler( + impls: InstrumentHandlerFunction[], + handler: H, +) { + if (impls.length === 0) { + return null; + } + return async (...args: Parameters) => { + let value; + let composed = impls.reduce( + (acc, fn) => (i) => fn(acc as () => undefined, i), + async () => { + value = await handler(...args); + }, + ) as unknown as (info: InstrumentationInfo) => Promise; + await composed(getInstrumentationInfo(args[0])); + return value; + }; +} + +function getInstrumentationInfo(args: LoaderFunctionArgs): InstrumentationInfo { + let { request, context, params, pattern } = args; + return { + // pseudo "Request" with the info they may want to read from + request: { + method: request.method, + url: request.url, + // Maybe make this a proxy that only supports `get`? + headers: { + get: (...args) => request.headers.get(...args), + }, + }, + params: { ...params }, + pattern, + context: { + get: (...args: Parameters) => + context.get(...args), + }, + }; +} + +export function getInstrumentationUpdates( + unstable_instrumentRoute: unstable_InstrumentRouteFunction, + route: AgnosticDataRouteObject, +) { + let updates: { + loader?: LoaderFunction; + action?: ActionFunction; + } = {}; + let instrumentations: Instrumentations[] = []; + unstable_instrumentRoute({ + id: route.id, + index: route.index, + path: route.path, + instrument(i) { + instrumentations.push(i); + }, + }); + if (instrumentations.length > 0) { + if (typeof route.loader === "function") { + let instrumented = getInstrumentedHandler( + instrumentations + .map((i) => i.loader) + .filter(Boolean) as InstrumentHandlerFunction[], + route.loader, + ); + if (instrumented) { + updates.loader = instrumented; + } + } + if (typeof route.action === "function") { + let instrumented = getInstrumentedHandler( + instrumentations + .map((i) => i.action) + .filter(Boolean) as InstrumentHandlerFunction[], + route.action, + ); + if (instrumented) { + updates.action = instrumented; + } + } + } + return updates; +} diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 3cbb847afa..9214ad61f0 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1,4 +1,4 @@ -import type { DataRouteMatch, DataRouteObject, RouteObject } from "../context"; +import type { DataRouteMatch, RouteObject } from "../context"; import type { History, Location, Path, To } from "./history"; import { Action as NavigationType, @@ -9,6 +9,10 @@ import { parsePath, warning, } from "./history"; +import { + getInstrumentationUpdates, + unstable_InstrumentRouteFunction, +} from "./instrumentation"; import type { AgnosticDataRouteMatch, AgnosticDataRouteObject, @@ -403,9 +407,7 @@ export interface RouterInit { history: History; basename?: string; getContext?: () => MaybePromise; - unstable_instrumentRoute?: ( - route: AgnosticDataRouteObject, - ) => AgnosticDataRouteObject; + unstable_instrumentRoute?: unstable_InstrumentRouteFunction; mapRouteProperties?: MapRoutePropertiesFunction; future?: Partial; hydrationRouteProperties?: string[]; @@ -877,13 +879,11 @@ export function createRouter(init: RouterInit): Router { // (if it exists) on all routes in the application if (init.unstable_instrumentRoute) { let instrument = init.unstable_instrumentRoute; - // TODO: Clean up these types - mapRouteProperties = (r: AgnosticRouteObject) => { - return instrument({ - ...r, - ..._mapRouteProperties(r), - } as AgnosticDataRouteObject) as AgnosticDataRouteObject & { - hasErrorBoundary: boolean; + + mapRouteProperties = (route: AgnosticDataRouteObject) => { + return { + ..._mapRouteProperties(route), + ...getInstrumentationUpdates(instrument, route), }; }; } @@ -3564,7 +3564,7 @@ export function createStaticHandler( if (opts?.unstable_instrumentRoute) { let instrument = opts?.unstable_instrumentRoute; // TODO: Clean up these types - mapRouteProperties = (r: AgnosticRouteObject) => { + mapRouteProperties = (r: AgnosticDataRouteObject) => { return instrument({ ...r, ..._mapRouteProperties(r), diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 76e63b5223..4ba9871a0e 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -540,7 +540,7 @@ export type AgnosticPatchRoutesOnNavigationFunction< * properties from framework-agnostic properties */ export interface MapRoutePropertiesFunction { - (route: AgnosticRouteObject): { + (route: AgnosticDataRouteObject): { hasErrorBoundary: boolean; } & Record; } @@ -813,18 +813,18 @@ export function convertRoutesToDataRoutes( if (isIndexRoute(route)) { let indexRoute: AgnosticDataIndexRouteObject = { ...route, - ...mapRouteProperties(route), id, }; + Object.assign(indexRoute, mapRouteProperties(indexRoute)); manifest[id] = indexRoute; return indexRoute; } else { let pathOrLayoutRoute: AgnosticDataNonIndexRouteObject = { ...route, - ...mapRouteProperties(route), id, children: undefined, }; + Object.assign(pathOrLayoutRoute, mapRouteProperties(pathOrLayoutRoute)); manifest[id] = pathOrLayoutRoute; if (route.children) { From 0b7833837eb3a4520d871634ae5a3c33edfb13d7 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 26 Sep 2025 15:40:13 -0400 Subject: [PATCH 08/22] Handle instrumentation of lazy --- .../__tests__/router/instrumentation-test.ts | 246 +++++++++++++++++- .../lib/router/instrumentation.ts | 109 ++++++-- packages/react-router/lib/router/router.ts | 6 +- 3 files changed, 338 insertions(+), 23 deletions(-) diff --git a/packages/react-router/__tests__/router/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts index 22f564fcf3..3960964ff4 100644 --- a/packages/react-router/__tests__/router/instrumentation-test.ts +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -1,5 +1,6 @@ +import type { LoaderFunction } from "../../lib/router/utils"; import { cleanup, setup } from "./utils/data-router-setup"; -import { createFormData } from "./utils/utils"; +import { createDeferred, createFormData, tick } from "./utils/utils"; // Detect any failures inside the router navigate code afterEach(() => { @@ -7,6 +8,44 @@ afterEach(() => { }); describe("instrumentation", () => { + it("allows instrumentation of lazy", async () => { + let spy = jest.fn(); + let lazyDfd = createDeferred<{ loader: LoaderFunction }>(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + lazy: () => lazyDfd.promise, + }, + ], + unstable_instrumentRoute: (route) => { + route.instrument({ + async lazy(lazy) { + spy("start"); + await lazy(); + spy("end"); + }, + }); + }, + }); + + await t.navigate("/page"); + 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 loaders", async () => { let spy = jest.fn(); let t = setup({ @@ -80,6 +119,211 @@ describe("instrumentation", () => { }); }); + it("allows instrumentation of loaders when lazy is used", 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_instrumentRoute: (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 actions when lazy is used", 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_instrumentRoute: (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_instrumentRoute: (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_instrumentRoute: (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("provides read-only information to instrumentation wrappers", async () => { let spy = jest.fn(); let t = setup({ diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index 809d5b6c36..8b547f0e66 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -1,8 +1,11 @@ import type { ActionFunction, AgnosticDataRouteObject, + LazyRouteFunction, LoaderFunction, LoaderFunctionArgs, + MaybePromise, + MiddlewareFunction, RouterContextProvider, } from "./utils"; @@ -19,11 +22,14 @@ type InstrumentationInfo = Readonly<{ }>; type InstrumentHandlerFunction = ( - handler: () => undefined, + handler: () => Promise, info: InstrumentationInfo, ) => MaybePromise; +type InstrumentLazyFunction = (handler: () => Promise) => Promise; + type Instrumentations = { + lazy?: InstrumentLazyFunction; loader?: InstrumentHandlerFunction; action?: InstrumentHandlerFunction; }; @@ -35,28 +41,73 @@ type InstrumentableRoute = { instrument(instrumentations: Instrumentations): void; }; +const UninstrumentedSymbol = Symbol("Uninstrumented"); + export type unstable_InstrumentRouteFunction = ( route: InstrumentableRoute, ) => void; -function getInstrumentedHandler( - impls: InstrumentHandlerFunction[], - handler: H, -) { +function getInstrumentedHandler< + H extends + | LoaderFunction + | ActionFunction, +>(impls: InstrumentHandlerFunction[], handler: H): H | null { if (impls.length === 0) { return null; } - return async (...args: Parameters) => { + return (async (args, ctx?) => { let value; - let composed = impls.reduce( - (acc, fn) => (i) => fn(acc as () => undefined, i), + await recurseRight( + impls, + getInstrumentationInfo(args), async () => { - value = await handler(...args); + value = await handler(args, ctx); }, - ) as unknown as (info: InstrumentationInfo) => Promise; - await composed(getInstrumentationInfo(args[0])); + impls.length - 1, + ); return value; - }; + }) as H; +} + +function getInstrumentedLazy( + impls: InstrumentLazyFunction[], + handler: LazyRouteFunction, +): LazyRouteFunction | null { + if (impls.length === 0) { + return null; + } + return (async () => { + let value; + await recurseRight( + impls, + undefined, + async () => { + value = await handler(); + }, + impls.length - 1, + ); + return value; + }) as unknown as LazyRouteFunction; +} + +async function recurseRight( + impls: InstrumentHandlerFunction[] | InstrumentLazyFunction[], + info: InstrumentationInfo | undefined, + handler: () => MaybePromise, + index: number, +): Promise { + let impl = impls[index]; + if (!impl) { + await handler(); + } else if (info) { + await impl(async () => { + await recurseRight(impls, info, handler, index - 1); + }, info); + } else { + await (impl as InstrumentLazyFunction)(async () => { + await recurseRight(impls, info, handler, index - 1); + }); + } } function getInstrumentationInfo(args: LoaderFunctionArgs): InstrumentationInfo { @@ -84,10 +135,6 @@ export function getInstrumentationUpdates( unstable_instrumentRoute: unstable_InstrumentRouteFunction, route: AgnosticDataRouteObject, ) { - let updates: { - loader?: LoaderFunction; - action?: ActionFunction; - } = {}; let instrumentations: Instrumentations[] = []; unstable_instrumentRoute({ id: route.id, @@ -97,26 +144,52 @@ export function getInstrumentationUpdates( instrumentations.push(i); }, }); + + let updates: { + loader?: AgnosticDataRouteObject["loader"]; + action?: AgnosticDataRouteObject["action"]; + lazy?: AgnosticDataRouteObject["lazy"]; + } = {}; + if (instrumentations.length > 0) { + if (typeof route.lazy === "function") { + let instrumented = getInstrumentedLazy( + instrumentations + .map((i) => i.lazy) + .filter(Boolean) as InstrumentLazyFunction[], + route.lazy, + ); + if (instrumented) { + updates.lazy = instrumented; + } + } + if (typeof route.loader === "function") { + // @ts-expect-error + let original = route.loader[UninstrumentedSymbol] ?? route.loader; let instrumented = getInstrumentedHandler( instrumentations .map((i) => i.loader) .filter(Boolean) as InstrumentHandlerFunction[], - route.loader, + original, ); if (instrumented) { + instrumented[UninstrumentedSymbol] = original; updates.loader = instrumented; } } + if (typeof route.action === "function") { + // @ts-expect-error + let original = route.action[UninstrumentedSymbol] ?? route.action; let instrumented = getInstrumentedHandler( instrumentations .map((i) => i.action) .filter(Boolean) as InstrumentHandlerFunction[], - route.action, + original, ); if (instrumented) { + instrumented[UninstrumentedSymbol] = original; updates.action = instrumented; } } diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 9214ad61f0..d7e70d7bb5 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -9,10 +9,8 @@ import { parsePath, warning, } from "./history"; -import { - getInstrumentationUpdates, - unstable_InstrumentRouteFunction, -} from "./instrumentation"; +import type { unstable_InstrumentRouteFunction } from "./instrumentation"; +import { getInstrumentationUpdates } from "./instrumentation"; import type { AgnosticDataRouteMatch, AgnosticDataRouteObject, From 689c7212e774b501ae6cc58b85e9e40c4607fe03 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 26 Sep 2025 16:05:57 -0400 Subject: [PATCH 09/22] Middleware instrumentation --- .../__tests__/router/instrumentation-test.ts | 47 +++++++++++++++++++ .../router/utils/data-router-setup.ts | 18 +++++-- .../lib/router/instrumentation.ts | 45 +++++++++++++++++- 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/packages/react-router/__tests__/router/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts index 3960964ff4..cc518120a7 100644 --- a/packages/react-router/__tests__/router/instrumentation-test.ts +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -46,6 +46,53 @@ describe("instrumentation", () => { }); }); + 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_instrumentRoute: (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({ 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 dd1d253fcd..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,7 +2,6 @@ import type { InitialEntry } from "../../../lib/router/history"; import type { Fetcher, RouterFetchOptions, - HydrationState, Router, RouterNavigateOptions, RouterInit, @@ -20,7 +19,6 @@ import { import type { AgnosticIndexRouteObject, AgnosticNonIndexRouteObject, - DataStrategyFunction, } from "../../../lib/router/utils"; import { matchRoutes, @@ -35,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; @@ -44,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; diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index 8b547f0e66..5eb99a8be8 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -21,15 +21,16 @@ type InstrumentationInfo = Readonly<{ context: Pick; }>; +type InstrumentLazyFunction = (handler: () => Promise) => Promise; + type InstrumentHandlerFunction = ( handler: () => Promise, info: InstrumentationInfo, ) => MaybePromise; -type InstrumentLazyFunction = (handler: () => Promise) => Promise; - type Instrumentations = { lazy?: InstrumentLazyFunction; + middleware?: InstrumentHandlerFunction; loader?: InstrumentHandlerFunction; action?: InstrumentHandlerFunction; }; @@ -47,6 +48,27 @@ export type unstable_InstrumentRouteFunction = ( route: InstrumentableRoute, ) => void; +function getInstrumentedMiddleware( + impls: InstrumentHandlerFunction[], + handler: MiddlewareFunction, +): MiddlewareFunction | null { + if (impls.length === 0) { + return null; + } + return async (args, next) => { + let value; + await recurseRight( + impls, + getInstrumentationInfo(args), + async () => { + value = await handler(args, next); + }, + impls.length - 1, + ); + return value; + }; +} + function getInstrumentedHandler< H extends | LoaderFunction @@ -164,6 +186,25 @@ export function getInstrumentationUpdates( } } + if (route.middleware && route.middleware.length > 0) { + route.middleware = route.middleware.map((middleware) => { + // @ts-expect-error + let original = middleware[UninstrumentedSymbol] ?? middleware; + let instrumented = getInstrumentedMiddleware( + instrumentations + .map((i) => i.middleware) + .filter(Boolean) as InstrumentHandlerFunction[], + original, + ); + if (instrumented) { + // @ts-expect-error + instrumented[UninstrumentedSymbol] = original; + return instrumented; + } + return middleware; + }); + } + if (typeof route.loader === "function") { // @ts-expect-error let original = route.loader[UninstrumentedSymbol] ?? route.loader; From 00825f522d471674e52cfbb523895c7943ef273b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 29 Sep 2025 14:40:31 -0400 Subject: [PATCH 10/22] Handle lazy object format --- .../__tests__/router/instrumentation-test.ts | 496 ++++++++++++++++-- .../lib/router/instrumentation.ts | 45 ++ packages/react-router/lib/router/utils.ts | 29 +- 3 files changed, 523 insertions(+), 47 deletions(-) diff --git a/packages/react-router/__tests__/router/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts index cc518120a7..23d74625ed 100644 --- a/packages/react-router/__tests__/router/instrumentation-test.ts +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -1,4 +1,9 @@ -import type { LoaderFunction } from "../../lib/router/utils"; +import { + AgnosticDataRouteObject, + type ActionFunction, + type LoaderFunction, + type MiddlewareFunction, +} from "../../lib/router/utils"; import { cleanup, setup } from "./utils/data-router-setup"; import { createDeferred, createFormData, tick } from "./utils/utils"; @@ -8,44 +13,6 @@ afterEach(() => { }); describe("instrumentation", () => { - it("allows instrumentation of lazy", async () => { - let spy = jest.fn(); - let lazyDfd = createDeferred<{ loader: LoaderFunction }>(); - let t = setup({ - routes: [ - { - index: true, - }, - { - id: "page", - path: "/page", - lazy: () => lazyDfd.promise, - }, - ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async lazy(lazy) { - spy("start"); - await lazy(); - spy("end"); - }, - }); - }, - }); - - await t.navigate("/page"); - 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 middleware", async () => { let spy = jest.fn(); let t = setup({ @@ -166,7 +133,45 @@ describe("instrumentation", () => { }); }); - it("allows instrumentation of loaders when lazy is used", async () => { + 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_instrumentRoute: (route) => { + route.instrument({ + async lazy(lazy) { + spy("start"); + await lazy(); + spy("end"); + }, + }); + }, + }); + + await t.navigate("/page"); + 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(); @@ -210,7 +215,7 @@ describe("instrumentation", () => { }); }); - it("allows instrumentation of actions when lazy is used", async () => { + it("allows instrumentation of lazy function actions", async () => { let spy = jest.fn(); let lazyDfd = createDeferred<{ loader: LoaderFunction }>(); let actionDfd = createDeferred(); @@ -257,7 +262,7 @@ describe("instrumentation", () => { }); }); - it("does not double-instrument when a static `loader` is used alongside `lazy`", async () => { + 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(() => {}); @@ -312,7 +317,7 @@ describe("instrumentation", () => { warnSpy.mockRestore(); }); - it("does not double-instrument when a static `action` is used alongside `lazy`", async () => { + 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(() => {}); @@ -371,6 +376,411 @@ describe("instrumentation", () => { 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_instrumentRoute: (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_instrumentRoute: (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_instrumentRoute: (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_instrumentRoute: (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_instrumentRoute: (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_instrumentRoute: (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({ diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index 5eb99a8be8..332c93e0c5 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -30,6 +30,9 @@ type InstrumentHandlerFunction = ( type Instrumentations = { lazy?: InstrumentLazyFunction; + "lazy.loader"?: InstrumentLazyFunction; + "lazy.action"?: InstrumentLazyFunction; + "lazy.middleware"?: InstrumentLazyFunction; middleware?: InstrumentHandlerFunction; loader?: InstrumentHandlerFunction; action?: InstrumentHandlerFunction; @@ -184,6 +187,48 @@ export function getInstrumentationUpdates( if (instrumented) { updates.lazy = instrumented; } + } else if (typeof route.lazy === "object") { + if (typeof route.lazy.middleware === "function") { + let instrumented = getInstrumentedLazy( + instrumentations + .map((i) => i["lazy.middleware"]) + .filter(Boolean) as InstrumentLazyFunction[], + route.lazy.middleware, + ); + if (instrumented) { + updates.lazy = Object.assign(updates.lazy || {}, { + middleware: instrumented, + }); + } + } + + if (typeof route.lazy.loader === "function") { + let instrumented = getInstrumentedLazy( + instrumentations + .map((i) => i["lazy.loader"]) + .filter(Boolean) as InstrumentLazyFunction[], + route.lazy.loader, + ); + if (instrumented) { + updates.lazy = Object.assign(updates.lazy || {}, { + loader: instrumented, + }); + } + } + + if (typeof route.lazy.action === "function") { + let instrumented = getInstrumentedLazy( + instrumentations + .map((i) => i["lazy.action"]) + .filter(Boolean) as InstrumentLazyFunction[], + route.lazy.action, + ); + if (instrumented) { + updates.lazy = Object.assign(updates.lazy || {}, { + action: instrumented, + }); + } + } } if (route.middleware && route.middleware.length > 0) { diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 4ba9871a0e..0ae2d2e45c 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -815,8 +815,10 @@ export function convertRoutesToDataRoutes( ...route, id, }; - Object.assign(indexRoute, mapRouteProperties(indexRoute)); - manifest[id] = indexRoute; + manifest[id] = mergeRouteUpdates( + indexRoute, + mapRouteProperties(indexRoute), + ); return indexRoute; } else { let pathOrLayoutRoute: AgnosticDataNonIndexRouteObject = { @@ -824,8 +826,10 @@ export function convertRoutesToDataRoutes( id, children: undefined, }; - Object.assign(pathOrLayoutRoute, mapRouteProperties(pathOrLayoutRoute)); - manifest[id] = pathOrLayoutRoute; + manifest[id] = mergeRouteUpdates( + pathOrLayoutRoute, + mapRouteProperties(pathOrLayoutRoute), + ); if (route.children) { pathOrLayoutRoute.children = convertRoutesToDataRoutes( @@ -842,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. * From 00ec4f7022d73518d4ff9f6f4ebcf4779d21531a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 29 Sep 2025 14:54:00 -0400 Subject: [PATCH 11/22] Add tests for fog of war --- .../__tests__/router/instrumentation-test.ts | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/packages/react-router/__tests__/router/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts index 23d74625ed..6bbf0368dd 100644 --- a/packages/react-router/__tests__/router/instrumentation-test.ts +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -781,6 +781,242 @@ describe("instrumentation", () => { }); }); + 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_instrumentRoute: (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_instrumentRoute: (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_instrumentRoute: (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({ From 33aa4e425372b1465751bcc1e170b270a6ac296c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 29 Sep 2025 15:23:53 -0400 Subject: [PATCH 12/22] Cleanup code --- .../lib/router/instrumentation.ts | 195 ++++++------------ 1 file changed, 58 insertions(+), 137 deletions(-) diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index 332c93e0c5..4441bb2ff8 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -1,11 +1,8 @@ import type { - ActionFunction, AgnosticDataRouteObject, - LazyRouteFunction, - LoaderFunction, + LazyRouteObject, LoaderFunctionArgs, MaybePromise, - MiddlewareFunction, RouterContextProvider, } from "./utils"; @@ -51,20 +48,21 @@ export type unstable_InstrumentRouteFunction = ( route: InstrumentableRoute, ) => void; -function getInstrumentedMiddleware( - impls: InstrumentHandlerFunction[], - handler: MiddlewareFunction, -): MiddlewareFunction | null { +function getInstrumentedImplementation( + impls: Instrumentations[keyof Instrumentations][], + handler: (...args: any[]) => Promise, +) { if (impls.length === 0) { return null; } - return async (args, next) => { - let value; + return async (arg1: any, arg2?: any) => { + let value: unknown; + let info = arg1 != null ? getInstrumentationInfo(arg1) : undefined; await recurseRight( impls, - getInstrumentationInfo(args), + info, async () => { - value = await handler(args, next); + value = arg1 == null ? await handler() : await handler(arg1, arg2); }, impls.length - 1, ); @@ -72,51 +70,8 @@ function getInstrumentedMiddleware( }; } -function getInstrumentedHandler< - H extends - | LoaderFunction - | ActionFunction, ->(impls: InstrumentHandlerFunction[], handler: H): H | null { - if (impls.length === 0) { - return null; - } - return (async (args, ctx?) => { - let value; - await recurseRight( - impls, - getInstrumentationInfo(args), - async () => { - value = await handler(args, ctx); - }, - impls.length - 1, - ); - return value; - }) as H; -} - -function getInstrumentedLazy( - impls: InstrumentLazyFunction[], - handler: LazyRouteFunction, -): LazyRouteFunction | null { - if (impls.length === 0) { - return null; - } - return (async () => { - let value; - await recurseRight( - impls, - undefined, - async () => { - value = await handler(); - }, - impls.length - 1, - ); - return value; - }) as unknown as LazyRouteFunction; -} - async function recurseRight( - impls: InstrumentHandlerFunction[] | InstrumentLazyFunction[], + impls: Instrumentations[keyof Instrumentations][], info: InstrumentationInfo | undefined, handler: () => MaybePromise, index: number, @@ -156,6 +111,20 @@ function getInstrumentationInfo(args: LoaderFunctionArgs): InstrumentationInfo { }; } +function getInstrumentationsByType( + instrumentations: Instrumentations[], + key: T, +): Instrumentations[T][] { + let value: Instrumentations[T][] = []; + for (let i in instrumentations) { + let instrumentation = instrumentations[i]; + if (key in instrumentation && instrumentation[key] != null) { + value.push(instrumentation[key]!); + } + } + return value; +} + export function getInstrumentationUpdates( unstable_instrumentRoute: unstable_InstrumentRouteFunction, route: AgnosticDataRouteObject, @@ -177,68 +146,31 @@ export function getInstrumentationUpdates( } = {}; if (instrumentations.length > 0) { - if (typeof route.lazy === "function") { - let instrumented = getInstrumentedLazy( - instrumentations - .map((i) => i.lazy) - .filter(Boolean) as InstrumentLazyFunction[], - route.lazy, - ); - if (instrumented) { - updates.lazy = instrumented; - } - } else if (typeof route.lazy === "object") { - if (typeof route.lazy.middleware === "function") { - let instrumented = getInstrumentedLazy( - instrumentations - .map((i) => i["lazy.middleware"]) - .filter(Boolean) as InstrumentLazyFunction[], - route.lazy.middleware, - ); - if (instrumented) { - updates.lazy = Object.assign(updates.lazy || {}, { - middleware: instrumented, - }); - } - } - - if (typeof route.lazy.loader === "function") { - let instrumented = getInstrumentedLazy( - instrumentations - .map((i) => i["lazy.loader"]) - .filter(Boolean) as InstrumentLazyFunction[], - route.lazy.loader, - ); - if (instrumented) { - updates.lazy = Object.assign(updates.lazy || {}, { - loader: instrumented, - }); - } - } - - if (typeof route.lazy.action === "function") { - let instrumented = getInstrumentedLazy( - instrumentations - .map((i) => i["lazy.action"]) - .filter(Boolean) as InstrumentLazyFunction[], - route.lazy.action, + // Instrument lazy, loader, and action functions + (["lazy", "loader", "action"] as const).forEach((key) => { + let func = route[key as "loader" | "action"]; + if (typeof func === "function") { + // @ts-expect-error + let original = func[UninstrumentedSymbol] ?? func; + let instrumented = getInstrumentedImplementation( + getInstrumentationsByType(instrumentations, key), + original, ); if (instrumented) { - updates.lazy = Object.assign(updates.lazy || {}, { - action: instrumented, - }); + // @ts-expect-error + instrumented[UninstrumentedSymbol] = original; + updates[key] = instrumented; } } - } + }); + // Instrument middleware functions if (route.middleware && route.middleware.length > 0) { route.middleware = route.middleware.map((middleware) => { // @ts-expect-error let original = middleware[UninstrumentedSymbol] ?? middleware; - let instrumented = getInstrumentedMiddleware( - instrumentations - .map((i) => i.middleware) - .filter(Boolean) as InstrumentHandlerFunction[], + let instrumented = getInstrumentedImplementation( + getInstrumentationsByType(instrumentations, "middleware"), original, ); if (instrumented) { @@ -250,34 +182,23 @@ export function getInstrumentationUpdates( }); } - if (typeof route.loader === "function") { - // @ts-expect-error - let original = route.loader[UninstrumentedSymbol] ?? route.loader; - let instrumented = getInstrumentedHandler( - instrumentations - .map((i) => i.loader) - .filter(Boolean) as InstrumentHandlerFunction[], - original, - ); - if (instrumented) { - instrumented[UninstrumentedSymbol] = original; - updates.loader = instrumented; - } - } - - if (typeof route.action === "function") { - // @ts-expect-error - let original = route.action[UninstrumentedSymbol] ?? route.action; - let instrumented = getInstrumentedHandler( - instrumentations - .map((i) => i.action) - .filter(Boolean) as InstrumentHandlerFunction[], - original, - ); - if (instrumented) { - instrumented[UninstrumentedSymbol] = original; - updates.action = instrumented; - } + // Instrument the lazy object format + if (typeof route.lazy === "object") { + let lazyObject: LazyRouteObject = route.lazy; + (["middleware", "loader", "action"] as const).forEach((key) => { + let func = lazyObject[key]; + if (typeof func === "function") { + let instrumented = getInstrumentedImplementation( + getInstrumentationsByType(instrumentations, `lazy.${key}`), + func, + ); + if (instrumented) { + updates.lazy = Object.assign(updates.lazy || {}, { + [key]: instrumented, + }); + } + } + }); } } return updates; From a8bba4e9b867023dbd87c8de5de91ddd8a9d90af Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 30 Sep 2025 14:53:36 -0400 Subject: [PATCH 13/22] Add router instrumentation --- integration/browser-entry-test.ts | 121 +++++++++++ .../__tests__/router/instrumentation-test.ts | 82 +++++++- packages/react-router/lib/components.tsx | 36 ++-- .../lib/dom-export/hydrated-router.tsx | 23 +- packages/react-router/lib/dom/lib.tsx | 14 +- .../lib/router/instrumentation.ts | 199 +++++++++++++++--- 6 files changed, 407 insertions(+), 68 deletions(-) diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts index 37e1dbce47..95d0361a7b 100644 --- a/integration/browser-entry-test.ts +++ b/integration/browser-entry-test.ts @@ -196,3 +196,124 @@ 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, + + { + router.instrument({ + async navigate(impl, info) { + console.log("start navigate", JSON.stringify(info)); + await impl(); + console.log("end navigate", JSON.stringify(info)); + }, + async fetch(impl, info) { + console.log("start fetch", JSON.stringify(info)); + await impl(); + console.log("end fetch", JSON.stringify(info)); + } + }) + }} + unstable_instrumentRoute={(route) => { + route.instrument({ + async loader(impl, info) { + let path = new URL(info.request.url).pathname; + console.log("start loader", route.id, path); + await impl(); + console.log("end loader", route.id, path); + }, + async action(impl, info) { + let path = new URL(info.request.url).pathname; + console.log("start action", route.id, path); + await impl(); + console.log("end action", route.id, path); + } + }) + }} + /> + + ); + }); + `, + "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.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/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts index 6bbf0368dd..7ba36c33da 100644 --- a/packages/react-router/__tests__/router/instrumentation-test.ts +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -1,5 +1,5 @@ +import { createMemoryRouter } from "../../lib/components"; import { - AgnosticDataRouteObject, type ActionFunction, type LoaderFunction, type MiddlewareFunction, @@ -159,6 +159,7 @@ describe("instrumentation", () => { }); await t.navigate("/page"); + await tick(); expect(spy.mock.calls).toEqual([["start"]]); await lazyDfd.resolve({ loader: () => "PAGE" }); @@ -1101,4 +1102,83 @@ describe("instrumentation", () => { 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_instrumentRouter: (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_instrumentRouter: (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"); + }); }); diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index a44d278816..e8cedfaa75 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -69,6 +69,11 @@ import { } from "./hooks"; import type { ViewTransition } from "./dom/global"; import { warnOnce } from "./server-runtime/warnings"; +import type { + unstable_InstrumentRouteFunction, + unstable_InstrumentRouterFunction, +} from "./router/instrumentation"; +import { instrumentClientSideRouter } from "./router/instrumentation"; export function mapRouteProperties(route: RouteObject) { let updates: Partial & { hasErrorBoundary: boolean } = { @@ -175,13 +180,13 @@ export interface MemoryRouterOpts { * `patchRoutesOnNavigation`). This is mostly useful for observability such * as wrapping loaders/actions/middlewares with logging and/or performance tracing. */ - unstable_instrumentRoute?: (r: DataRouteObject) => DataRouteObject; + unstable_instrumentRoute?: unstable_InstrumentRouteFunction; /** * Function allowing you to instrument the client-side router. This is mostly * useful for observability such as wrapping `router.navigate`/`router.fetch` * with logging and/or performance tracing. */ - unstable_instrumentRouter?: (r: DataRouter) => DataRouter; + unstable_instrumentRouter?: unstable_InstrumentRouterFunction; /** * Override the default data strategy of loading in parallel. * Only intended for advanced usage. @@ -218,33 +223,16 @@ export interface MemoryRouterOpts { export function createMemoryRouter( routes: RouteObject[], opts?: MemoryRouterOpts, -): DataRouter { - return createAndInitializeDataRouter( - routes, - createMemoryHistory({ - initialEntries: opts?.initialEntries, - initialIndex: opts?.initialIndex, - }), - opts, - ); -} - -export function createAndInitializeDataRouter( - routes: RouteObject[], - history: History, - opts?: Omit< - RouterInit, - "routes" | "history" | "mapRouteProperties" | "hydrationRouteProperties" - > & { - unstable_instrumentRouter?: (r: DataRouter) => DataRouter; - }, ): DataRouter { let router = createRouter({ basename: opts?.basename, dataStrategy: opts?.dataStrategy, future: opts?.future, getContext: opts?.getContext, - history, + history: createMemoryHistory({ + initialEntries: opts?.initialEntries, + initialIndex: opts?.initialIndex, + }), hydrationData: opts?.hydrationData, hydrationRouteProperties, unstable_instrumentRoute: opts?.unstable_instrumentRoute, @@ -254,7 +242,7 @@ export function createAndInitializeDataRouter( }); if (opts?.unstable_instrumentRouter) { - router = opts.unstable_instrumentRouter(router); + router = instrumentClientSideRouter(router, opts.unstable_instrumentRouter); } return router.initialize(); diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 222259e040..e8d70a5765 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -7,7 +7,6 @@ import type { HydrationState, RouterInit, unstable_ClientOnErrorFunction, - DataRouteObject, } from "react-router"; import { UNSAFE_getHydrationData as getHydrationData, @@ -28,6 +27,11 @@ import { } from "react-router"; import { CRITICAL_CSS_DATA_ATTRIBUTE } from "../dom/ssr/components"; import { RouterProvider } from "./dom-router-provider"; +import { + instrumentClientSideRouter, + type unstable_InstrumentRouteFunction, + type unstable_InstrumentRouterFunction, +} from "../router/instrumentation"; type SSRInfo = { context: NonNullable<(typeof window)["__reactRouterContext"]>; @@ -83,8 +87,8 @@ function createHydratedRouter({ unstable_instrumentRouter, }: { getContext?: RouterInit["getContext"]; - unstable_instrumentRoute?: RouterInit["unstable_instrumentRoute"]; - unstable_instrumentRouter?: (r: DataRouter) => DataRouter; + unstable_instrumentRoute?: unstable_InstrumentRouteFunction; + unstable_instrumentRouter?: unstable_InstrumentRouterFunction; }): DataRouter { initSsrInfo(); @@ -200,7 +204,7 @@ function createHydratedRouter({ }); if (unstable_instrumentRouter) { - router = unstable_instrumentRouter(router); + router = instrumentClientSideRouter(router, unstable_instrumentRouter); } ssrInfo.router = router; @@ -228,10 +232,11 @@ 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"]; /** @@ -239,13 +244,13 @@ export interface HydratedRouterProps { * client-side router. This is mostly useful for observability such as wrapping * loaders/actions/middlewares with logging and/or performance tracing. */ - unstable_instrumentRoute(route: DataRouteObject): DataRouteObject; + unstable_instrumentRoute?: unstable_InstrumentRouteFunction; /** * Function allowing you to instrument the client-side router. This is mostly * useful for observability such as wrapping `router.navigate`/`router.fetch` * with logging and/or performance tracing. */ - unstable_instrumentRouter(router: DataRouter): DataRouter; + unstable_instrumentRouter?: unstable_InstrumentRouterFunction; /** * An error handler function that will be called for any loader/action/render * errors that are encountered in your application. This is useful for diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 43471e358a..0668107127 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -75,7 +75,6 @@ import type { RouteObject, NavigateOptions, PatchRoutesOnNavigationFunction, - DataRouteObject, } from "../context"; import { DataRouterContext, @@ -96,6 +95,11 @@ import { useRouteId, } from "../hooks"; import type { SerializeFrom } from "../types/route-data"; +import { + instrumentClientSideRouter, + type unstable_InstrumentRouteFunction, + type unstable_InstrumentRouterFunction, +} from "../router/instrumentation"; //////////////////////////////////////////////////////////////////////////////// //#region Global Stuff @@ -242,13 +246,13 @@ export interface DOMRouterOpts { * `patchRoutesOnNavigation`). This is mostly useful for observability such * as wrapping loaders/actions/middlewares with logging and/or performance tracing. */ - unstable_instrumentRoute?: (r: DataRouteObject) => DataRouteObject; + unstable_instrumentRoute?: unstable_InstrumentRouteFunction; /** * Function allowing you to instrument the client-side router. This is mostly * useful for observability such as wrapping `router.navigate`/`router.fetch` * with logging and/or performance tracing. */ - unstable_instrumentRouter?: (r: DataRouter) => DataRouter; + unstable_instrumentRouter?: unstable_InstrumentRouterFunction; /** * Override the default data strategy of running loaders in parallel. * See {@link DataStrategyFunction}. @@ -781,7 +785,7 @@ export function createBrowserRouter( }); if (opts?.unstable_instrumentRouter) { - router = opts.unstable_instrumentRouter(router); + router = instrumentClientSideRouter(router, opts.unstable_instrumentRouter); } return router.initialize(); @@ -827,7 +831,7 @@ export function createHashRouter( }); if (opts?.unstable_instrumentRouter) { - router = opts.unstable_instrumentRouter(router); + router = instrumentClientSideRouter(router, opts.unstable_instrumentRouter); } return router.initialize(); diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index 4441bb2ff8..ed0d7577c7 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -1,12 +1,18 @@ +import { createPath } from "./history"; +import type { Router } from "./router"; import type { + ActionFunctionArgs, AgnosticDataRouteObject, + FormEncType, + HTMLFormMethod, LazyRouteObject, LoaderFunctionArgs, MaybePromise, + MiddlewareFunction, RouterContextProvider, } from "./utils"; -type InstrumentationInfo = Readonly<{ +type RouteHandlerInstrumentationInfo = Readonly<{ request: { method: string; url: string; @@ -18,14 +24,22 @@ type InstrumentationInfo = Readonly<{ context: Pick; }>; -type InstrumentLazyFunction = (handler: () => Promise) => Promise; +interface GenericInstrumentFunction { + (handler: () => Promise, info: unknown): Promise; +} -type InstrumentHandlerFunction = ( - handler: () => Promise, - info: InstrumentationInfo, -) => MaybePromise; +interface InstrumentLazyFunction extends GenericInstrumentFunction { + (handler: () => Promise): Promise; +} -type Instrumentations = { +interface InstrumentHandlerFunction extends GenericInstrumentFunction { + ( + handler: () => Promise, + info: RouteHandlerInstrumentationInfo, + ): Promise; +} + +type RouteInstrumentations = { lazy?: InstrumentLazyFunction; "lazy.loader"?: InstrumentLazyFunction; "lazy.action"?: InstrumentLazyFunction; @@ -39,30 +53,77 @@ type InstrumentableRoute = { id: string; index: boolean | undefined; path: string | undefined; - instrument(instrumentations: Instrumentations): void; + instrument(instrumentations: RouteInstrumentations): void; }; -const UninstrumentedSymbol = Symbol("Uninstrumented"); +interface InstrumentNavigateFunction extends GenericInstrumentFunction { + ( + handler: () => Promise, + info: RouterNavigationInstrumentationInfo, + ): MaybePromise; +} + +interface InstrumentFetchFunction extends GenericInstrumentFunction { + ( + handler: () => Promise, + info: RouterFetchInstrumentationInfo, + ): MaybePromise; +} + +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; +}>; + +type RouterInstrumentations = { + navigate?: InstrumentNavigateFunction; + fetch?: InstrumentFetchFunction; +}; + +type InstrumentableRouter = { + instrument(instrumentations: RouterInstrumentations): void; +}; export type unstable_InstrumentRouteFunction = ( route: InstrumentableRoute, ) => void; +export type unstable_InstrumentRouterFunction = ( + route: InstrumentableRouter, +) => void; + +const UninstrumentedSymbol = Symbol("Uninstrumented"); + function getInstrumentedImplementation( - impls: Instrumentations[keyof Instrumentations][], + impls: GenericInstrumentFunction[], handler: (...args: any[]) => Promise, + getInfo: (...args: unknown[]) => unknown = () => undefined, ) { if (impls.length === 0) { return null; } - return async (arg1: any, arg2?: any) => { + return async (...args: unknown[]) => { let value: unknown; - let info = arg1 != null ? getInstrumentationInfo(arg1) : undefined; + let info = getInfo(...args); await recurseRight( impls, info, async () => { - value = arg1 == null ? await handler() : await handler(arg1, arg2); + value = await handler(...args); }, impls.length - 1, ); @@ -71,26 +132,24 @@ function getInstrumentedImplementation( } async function recurseRight( - impls: Instrumentations[keyof Instrumentations][], - info: InstrumentationInfo | undefined, + impls: GenericInstrumentFunction[], + info: Parameters[1] | undefined, handler: () => MaybePromise, index: number, ): Promise { let impl = impls[index]; if (!impl) { await handler(); - } else if (info) { + } else { await impl(async () => { await recurseRight(impls, info, handler, index - 1); }, info); - } else { - await (impl as InstrumentLazyFunction)(async () => { - await recurseRight(impls, info, handler, index - 1); - }); } } -function getInstrumentationInfo(args: LoaderFunctionArgs): InstrumentationInfo { +function getInstrumentationInfo( + args: LoaderFunctionArgs, +): RouteHandlerInstrumentationInfo { let { request, context, params, pattern } = args; return { // pseudo "Request" with the info they may want to read from @@ -111,15 +170,15 @@ function getInstrumentationInfo(args: LoaderFunctionArgs): InstrumentationInfo { }; } -function getInstrumentationsByType( - instrumentations: Instrumentations[], - key: T, -): Instrumentations[T][] { - let value: Instrumentations[T][] = []; +function getInstrumentationsByType< + T extends RouteInstrumentations | RouterInstrumentations, + K extends keyof T, +>(instrumentations: T[], key: K): GenericInstrumentFunction[] { + let value: GenericInstrumentFunction[] = []; for (let i in instrumentations) { let instrumentation = instrumentations[i]; if (key in instrumentation && instrumentation[key] != null) { - value.push(instrumentation[key]!); + value.push(instrumentation[key] as GenericInstrumentFunction); } } return value; @@ -129,7 +188,7 @@ export function getInstrumentationUpdates( unstable_instrumentRoute: unstable_InstrumentRouteFunction, route: AgnosticDataRouteObject, ) { - let instrumentations: Instrumentations[] = []; + let instrumentations: RouteInstrumentations[] = []; unstable_instrumentRoute({ id: route.id, index: route.index, @@ -148,13 +207,19 @@ export function getInstrumentationUpdates( if (instrumentations.length > 0) { // Instrument lazy, loader, and action functions (["lazy", "loader", "action"] as const).forEach((key) => { - let func = route[key as "loader" | "action"]; + let func = route[key]; if (typeof func === "function") { // @ts-expect-error let original = func[UninstrumentedSymbol] ?? func; let instrumented = getInstrumentedImplementation( getInstrumentationsByType(instrumentations, key), original, + key === "lazy" + ? () => undefined + : (...args) => + getInstrumentationInfo( + args[0] as LoaderFunctionArgs | ActionFunctionArgs, + ), ); if (instrumented) { // @ts-expect-error @@ -172,6 +237,10 @@ export function getInstrumentationUpdates( let instrumented = getInstrumentedImplementation( getInstrumentationsByType(instrumentations, "middleware"), original, + (...args) => + getInstrumentationInfo( + args[0] as Parameters[0], + ), ); if (instrumented) { // @ts-expect-error @@ -203,3 +272,75 @@ export function getInstrumentationUpdates( } return updates; } + +export function instrumentClientSideRouter( + router: Router, + unstable_instrumentRouter: unstable_InstrumentRouterFunction, +): Router { + let instrumentations: RouterInstrumentations[] = []; + unstable_instrumentRouter({ + instrument(i) { + instrumentations.push(i); + }, + }); + + if (instrumentations.length > 0) { + // @ts-expect-error + let navigate = router.navigate[UninstrumentedSymbol] ?? router.navigate; + let instrumentedNavigate = getInstrumentedImplementation( + getInstrumentationsByType(instrumentations, "navigate"), + navigate, + (...args) => { + let [to, opts] = args as Parameters; + opts = opts ?? {}; + let info: RouterNavigationInstrumentationInfo = { + to: + typeof to === "number" || typeof to === "string" + ? to + : to + ? createPath(to) + : ".", + 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 info; + }, + ) as Router["navigate"]; + if (instrumentedNavigate) { + // @ts-expect-error + instrumentedNavigate[UninstrumentedSymbol] = navigate; + router.navigate = instrumentedNavigate; + } + + // @ts-expect-error + let fetch = router.fetch[UninstrumentedSymbol] ?? router.fetch; + let instrumentedFetch = getInstrumentedImplementation( + getInstrumentationsByType(instrumentations, "fetch"), + fetch, + (...args) => { + let [key, , href, opts] = args as Parameters; + opts = opts ?? {}; + let info: RouterFetchInstrumentationInfo = { + href: href ?? ".", + currentUrl: createPath(router.state.location), + fetcherKey: key, + ...("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 info; + }, + ) as Router["fetch"]; + if (instrumentedFetch) { + // @ts-expect-error + instrumentedFetch[UninstrumentedSymbol] = fetch; + router.fetch = instrumentedFetch; + } + } + + return router; +} From 29fb0f036f6683ac91dea0324d712b5f4824fa57 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 30 Sep 2025 16:37:52 -0400 Subject: [PATCH 14/22] Add handler instrumentation --- .../__tests__/router/instrumentation-test.ts | 230 ++++++++++++++++++ .../__tests__/server-runtime/utils.ts | 8 + packages/react-router/lib/components.tsx | 9 +- packages/react-router/lib/dom/lib.tsx | 4 +- .../lib/router/instrumentation.ts | 79 +++++- packages/react-router/lib/router/router.ts | 18 +- .../react-router/lib/server-runtime/build.ts | 12 +- .../react-router/lib/server-runtime/server.ts | 7 +- 8 files changed, 339 insertions(+), 28 deletions(-) diff --git a/packages/react-router/__tests__/router/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts index 7ba36c33da..7b0781e4ac 100644 --- a/packages/react-router/__tests__/router/instrumentation-test.ts +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -1,9 +1,12 @@ import { createMemoryRouter } from "../../lib/components"; +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"; @@ -1102,6 +1105,7 @@ describe("instrumentation", () => { loaderData: { page: "PAGE" }, }); }); + it("allows instrumentation of navigations", async () => { let spy = jest.fn(); let router = createMemoryRouter( @@ -1181,4 +1185,230 @@ describe("instrumentation", () => { }); expect(data).toBe("PAGE"); }); + + describe("static handler", () => { + // TODO: Middleware instrumentation will have to happen at the server level, + // outside of the static handler + 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_instrumentRoute: (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_instrumentRoute: (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_instrumentHandler: (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 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_instrumentRoute: (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: {}, + pattern: "/", + context: { + get: expect.any(Function), + }, + }, + ], + ["loader"], + [ + "end", + { + request: { + method: "GET", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + params: {}, + pattern: "/", + context: { + get: expect.any(Function), + }, + }, + ], + ]); + }); + }); }); diff --git a/packages/react-router/__tests__/server-runtime/utils.ts b/packages/react-router/__tests__/server-runtime/utils.ts index ec5634255a..115374d2f8 100644 --- a/packages/react-router/__tests__/server-runtime/utils.ts +++ b/packages/react-router/__tests__/server-runtime/utils.ts @@ -11,6 +11,10 @@ import type { 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 { + unstable_InstrumentHandlerFunction, + unstable_InstrumentRouteFunction, +} from "../../lib/router/instrumentation"; export function mockServerBuild( routes: Record< @@ -30,6 +34,8 @@ export function mockServerBuild( future?: Partial; handleError?: HandleErrorFunction; handleDocumentRequest?: HandleDocumentRequestFunction; + unstable_instrumentRoute?: unstable_InstrumentRouteFunction; + unstable_instrumentHandler?: unstable_InstrumentHandlerFunction; } = {}, ): ServerBuild { return { @@ -91,6 +97,8 @@ export function mockServerBuild( ), handleDataRequest: jest.fn(async (response) => response), handleError: opts.handleError, + unstable_instrumentRoute: opts.unstable_instrumentRoute, + unstable_instrumentHandler: opts.unstable_instrumentHandler, }, }, routes: Object.entries(routes).reduce( diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index e8cedfaa75..57c7e06d0a 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import type { - History, InitialEntry, Location, MemoryHistory, @@ -226,19 +225,19 @@ export function createMemoryRouter( ): DataRouter { let router = createRouter({ basename: opts?.basename, - dataStrategy: opts?.dataStrategy, - future: opts?.future, getContext: opts?.getContext, + future: opts?.future, history: createMemoryHistory({ initialEntries: opts?.initialEntries, initialIndex: opts?.initialIndex, }), hydrationData: opts?.hydrationData, + routes, hydrationRouteProperties, - unstable_instrumentRoute: opts?.unstable_instrumentRoute, mapRouteProperties, + dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, - routes, + unstable_instrumentRoute: opts?.unstable_instrumentRoute, }); if (opts?.unstable_instrumentRouter) { diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 0668107127..f6871660f1 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -775,13 +775,13 @@ export function createBrowserRouter( future: opts?.future, history: createBrowserHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), - unstable_instrumentRoute: opts?.unstable_instrumentRoute, routes, mapRouteProperties, hydrationRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, + unstable_instrumentRoute: opts?.unstable_instrumentRoute, }); if (opts?.unstable_instrumentRouter) { @@ -821,13 +821,13 @@ export function createHashRouter( future: opts?.future, history: createHashHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), - unstable_instrumentRoute: opts?.unstable_instrumentRoute, routes, mapRouteProperties, hydrationRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, + unstable_instrumentRoute: opts?.unstable_instrumentRoute, }); if (opts?.unstable_instrumentRouter) { diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index ed0d7577c7..24aa68169b 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -1,3 +1,4 @@ +import { RequestHandler } from "../../dist/development"; import { createPath } from "./history"; import type { Router } from "./router"; import type { @@ -9,6 +10,7 @@ import type { LoaderFunctionArgs, MaybePromise, MiddlewareFunction, + RouterContext, RouterContextProvider, } from "./utils"; @@ -70,6 +72,13 @@ interface InstrumentFetchFunction extends GenericInstrumentFunction { ): MaybePromise; } +interface InstrumentRequestFunction extends GenericInstrumentFunction { + ( + handler: () => Promise, + info: HandlerRequestInstrumentationInfo, + ): MaybePromise; +} + type RouterNavigationInstrumentationInfo = Readonly<{ to: string | number; currentUrl: string; @@ -89,21 +98,43 @@ type RouterFetchInstrumentationInfo = Readonly<{ body?: any; }>; +type HandlerRequestInstrumentationInfo = Readonly<{ + request: { + method: string; + url: string; + headers: Pick; + }; + // TODO: Fix for non-middleware + context: Pick; +}>; + type RouterInstrumentations = { navigate?: InstrumentNavigateFunction; fetch?: InstrumentFetchFunction; }; +type HandlerInstrumentations = { + request?: InstrumentRequestFunction; +}; + type InstrumentableRouter = { instrument(instrumentations: RouterInstrumentations): void; }; +type InstrumentableHandler = { + instrument(instrumentations: HandlerInstrumentations): void; +}; + export type unstable_InstrumentRouteFunction = ( route: InstrumentableRoute, ) => void; export type unstable_InstrumentRouterFunction = ( - route: InstrumentableRouter, + router: InstrumentableRouter, +) => void; + +export type unstable_InstrumentHandlerFunction = ( + handler: InstrumentableHandler, ) => void; const UninstrumentedSymbol = Symbol("Uninstrumented"); @@ -171,7 +202,10 @@ function getInstrumentationInfo( } function getInstrumentationsByType< - T extends RouteInstrumentations | RouterInstrumentations, + T extends + | RouteInstrumentations + | RouterInstrumentations + | HandlerInstrumentations, K extends keyof T, >(instrumentations: T[], key: K): GenericInstrumentFunction[] { let value: GenericInstrumentFunction[] = []; @@ -344,3 +378,44 @@ export function instrumentClientSideRouter( return router; } + +export function instrumentHandler( + handler: RequestHandler, + unstable_instrumentHandler: unstable_InstrumentHandlerFunction, +): RequestHandler { + let instrumentations: HandlerInstrumentations[] = []; + unstable_instrumentHandler({ + instrument(i) { + instrumentations.push(i); + }, + }); + + if (instrumentations.length === 0) { + return handler; + } + let instrumentedHandler = getInstrumentedImplementation( + getInstrumentationsByType(instrumentations, "request"), + handler, + (...args) => { + let [request, context] = args as Parameters; + let info: HandlerRequestInstrumentationInfo = { + request: { + method: request.method, + url: request.url, + headers: { + get: (...args) => request.headers.get(...args), + }, + }, + context: { + get: (ctx: RouterContext) => + context + ? (context as unknown as RouterContextProvider).get(ctx) + : (undefined as T), + }, + }; + return info; + }, + ) as RequestHandler; + + return instrumentedHandler ?? handler; +} diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index d7e70d7bb5..fe51bfd440 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -3536,9 +3536,7 @@ export function createRouter(init: RouterInit): Router { export interface CreateStaticHandlerOptions { basename?: string; mapRouteProperties?: MapRoutePropertiesFunction; - unstable_instrumentRoute?: ( - r: AgnosticDataRouteObject, - ) => AgnosticDataRouteObject; + unstable_instrumentRoute?: unstable_InstrumentRouteFunction; future?: {}; } @@ -3560,14 +3558,12 @@ export function createStaticHandler( // Leverage the existing mapRouteProperties logic to execute instrumentRoute // (if it exists) on all routes in the application if (opts?.unstable_instrumentRoute) { - let instrument = opts?.unstable_instrumentRoute; - // TODO: Clean up these types - mapRouteProperties = (r: AgnosticDataRouteObject) => { - return instrument({ - ...r, - ..._mapRouteProperties(r), - } as AgnosticDataRouteObject) as AgnosticDataRouteObject & { - hasErrorBoundary: boolean; + let instrument = opts.unstable_instrumentRoute; + + mapRouteProperties = (route: AgnosticDataRouteObject) => { + return { + ..._mapRouteProperties(route), + ...getInstrumentationUpdates(instrument, route), }; }; } diff --git a/packages/react-router/lib/server-runtime/build.ts b/packages/react-router/lib/server-runtime/build.ts index 3ca14141aa..013b6cb430 100644 --- a/packages/react-router/lib/server-runtime/build.ts +++ b/packages/react-router/lib/server-runtime/build.ts @@ -1,6 +1,5 @@ import type { ActionFunctionArgs, - AgnosticDataRouteObject, LoaderFunctionArgs, RouterContextProvider, } from "../router/utils"; @@ -13,7 +12,10 @@ import type { import type { ServerRouteManifest } from "./routes"; import type { AppLoadContext } from "./data"; import type { MiddlewareEnabled } from "../types/future"; -import type { RequestHandler } from "./server"; +import type { + unstable_InstrumentHandlerFunction, + unstable_InstrumentRouteFunction, +} from "../router/instrumentation"; type OptionalCriticalCss = CriticalCss | undefined; @@ -87,9 +89,7 @@ export interface ServerEntryModule { default: HandleDocumentRequestFunction; handleDataRequest?: HandleDataRequestFunction; handleError?: HandleErrorFunction; - unstable_instrumentHandler?: (handler: RequestHandler) => RequestHandler; - unstable_instrumentRoute?: ( - route: AgnosticDataRouteObject, - ) => AgnosticDataRouteObject; + unstable_instrumentHandler?: unstable_InstrumentHandlerFunction; + unstable_instrumentRoute?: unstable_InstrumentRouteFunction; streamTimeout?: number; } diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index dd495b4755..01775dd21a 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -36,6 +36,7 @@ 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 { instrumentHandler } from "../router/instrumentation"; export type RequestHandler = ( request: Request, @@ -305,8 +306,10 @@ function derive(build: ServerBuild, mode?: string) { }; if (build.entry.module.unstable_instrumentHandler) { - requestHandler = - build.entry.module.unstable_instrumentHandler(requestHandler); + requestHandler = instrumentHandler( + requestHandler, + build.entry.module.unstable_instrumentHandler, + ); } return { From ce4fc8be49ee677b706da011a4492be9375da55b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 1 Oct 2025 11:26:55 -0400 Subject: [PATCH 15/22] Update tests for SSR flows --- .../__tests__/router/instrumentation-test.ts | 2444 ++++++++++------- .../__tests__/server-runtime/utils.ts | 9 +- .../lib/router/instrumentation.ts | 5 +- 3 files changed, 1388 insertions(+), 1070 deletions(-) diff --git a/packages/react-router/__tests__/router/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts index 7b0781e4ac..18ec20a29b 100644 --- a/packages/react-router/__tests__/router/instrumentation-test.ts +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -1,4 +1,5 @@ import { createMemoryRouter } from "../../lib/components"; +import type { StaticHandlerContext } from "../../lib/router/router"; import { createStaticHandler } from "../../lib/router/router"; import { type ActionFunction, @@ -16,1179 +17,1336 @@ afterEach(() => { }); describe("instrumentation", () => { - 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"); + 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_instrumentRoute: (route) => { + route.instrument({ + async middleware(middleware) { + spy("start"); + await middleware(); + spy("end"); }, - ], - loader: true, + }); }, - ], - unstable_instrumentRoute: (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" }, + 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, + it("allows instrumentation of loaders", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + ], + unstable_instrumentRoute: (route) => { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); }, - ], - unstable_instrumentRoute: (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" }, + 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, + it("allows instrumentation of actions", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + action: true, + }, + ], + unstable_instrumentRoute: (route) => { + route.instrument({ + async action(action) { + spy("start"); + await action(); + spy("end"); + }, + }); }, - ], - unstable_instrumentRoute: (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" }, + 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, + 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_instrumentRoute: (route) => { + route.instrument({ + async lazy(lazy) { + spy("start"); + await lazy(); + spy("end"); + }, + }); }, - ], - unstable_instrumentRoute: (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" }, + 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, + 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_instrumentRoute: (route) => { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); }, - ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async loader(loader) { - spy("start"); - await loader(); - spy("end"); - }, - }); - }, - }); + }); - await t.navigate("/page"); - expect(spy).not.toHaveBeenCalled(); + await t.navigate("/page"); + expect(spy).not.toHaveBeenCalled(); - await lazyDfd.resolve({ loader: () => loaderDfd.promise }); - expect(spy.mock.calls).toEqual([["start"]]); + 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 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" }, + 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, + 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_instrumentRoute: (route) => { + route.instrument({ + async action(action) { + spy("start"); + await action(); + spy("end"); + }, + }); }, - ], - unstable_instrumentRoute: (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 t.navigate("/page", { + formMethod: "POST", + formData: createFormData({}), + }); + expect(spy).not.toHaveBeenCalled(); - await lazyDfd.resolve({ action: () => actionDfd.promise }); - expect(spy.mock.calls).toEqual([["start"]]); + 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 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" }, + 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, + 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_instrumentRoute: (route) => { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); }, - ], - unstable_instrumentRoute: (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(); + 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(); - warnSpy.mockRestore(); - }); + await t.navigate("/"); - 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_instrumentRoute: (route) => { - route.instrument({ - async action(action) { - spy("start"); - await action(); - spy("end"); - }, - }); - }, - }); + 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(); - 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" }, + warnSpy.mockRestore(); }); - 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" }, + 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_instrumentRoute: (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(); }); - 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_instrumentRoute: (route) => { + route.instrument({ + "lazy.middleware": async (middleware) => { + spy("start"); + await middleware(); + spy("end"); + }, + }); + }, + }); - it("allows instrumentation of lazy object middleware", async () => { - let spy = jest.fn(); - let middlewareDfd = createDeferred(); - let loaderDfd = createDeferred(); - let t = setup({ - routes: [ - { - index: true, + await t.navigate("/page"); + expect(spy.mock.calls).toEqual([["start"]]); + + await middlewareDfd.resolve([ + async (_, next) => { + spy("middleware start"); + await next(); + spy("middleware end"); }, - { - id: "page", - path: "/page", - lazy: { - middleware: () => middlewareDfd.promise, - loader: () => Promise.resolve(() => loaderDfd.promise), + ]); + 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_instrumentRoute: (route) => { + route.instrument({ + "lazy.loader": async (load) => { + spy("start"); + await load(); + spy("end"); + }, + loader: async (loader) => { + spy("loader start"); + await loader(); + spy("loader end"); + }, + }); }, - ], - unstable_instrumentRoute: (route) => { - route.instrument({ - "lazy.middleware": async (middleware) => { - spy("start"); - await middleware(); - spy("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 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" }, + await tick(); + 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, + 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_instrumentRoute: (route) => { + route.instrument({ + "lazy.action": async (load) => { + spy("start"); + await load(); + spy("end"); + }, + action: async (action) => { + spy("action start"); + await action(); + spy("action end"); + }, + }); }, - ], - unstable_instrumentRoute: (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", { + 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 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" }, + await tick(); + expect(t.router.state).toMatchObject({ + navigation: { state: "idle" }, + location: { pathname: "/page" }, + actionData: { 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, + 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_instrumentRoute: (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"); + }, + }); }, - ], - unstable_instrumentRoute: (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" }, + 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 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(); + 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_instrumentRoute: (route) => { + route.instrument({ + middleware: async (impl) => { + spy("start middleware"); + await impl(); + spy("end middleware"); }, - ], - loader: true, - action: true, + action: async (impl) => { + spy("start action"); + await impl(); + spy("end action"); + }, + loader: async (impl) => { + spy("start loader"); + await impl(); + spy("end loader"); + }, + }); }, - ], - unstable_instrumentRoute: (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" }, + 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 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, + 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_instrumentRoute: (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"); + }, + }); }, - ], - unstable_instrumentRoute: (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 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" }, + 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 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, + 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_instrumentRoute: (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"); - }, - }); - }, - }); + unstable_instrumentRoute: (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" }, + 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 statically defined route via patchRoutesOnNavigation", async () => { - let spy = jest.fn(); - let middlewareDfd = createDeferred(); - let t = setup({ - routes: [ - { - index: true, + 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, + }, + ]); + } }, - ], - 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_instrumentRoute: (route) => { + route.instrument({ + middleware: async (impl) => { + spy("start middleware"); + await impl(); + spy("end middleware"); }, - ]); - } - }, - unstable_instrumentRoute: (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"); - }, - }); - }, - }); + 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" }, + 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 function route via patchRoutesOnNavigation", async () => { - let spy = jest.fn(); - let lazyDfd = createDeferred(); - let t = setup({ - routes: [ - { - index: true, + 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, + }, + }, + ]); + } }, - ], - 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_instrumentRoute: (route) => { + route.instrument({ + middleware: async (impl) => { + spy("start middleware"); + await impl(); + spy("end middleware"); }, - ]); - } - }, - unstable_instrumentRoute: (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"); - }, - }); - }, + 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" }, + }); }); - await t.navigate("/page", { - formMethod: "POST", - formData: createFormData({}), + 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_instrumentRoute: (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.pattern).toBe("/:slug"); + expect(args.context.get).toBeDefined(); + expect(args.context.set).not.toBeDefined(); + expect(t.router.state.matches[0].params).toEqual({ slug: "a" }); }); - expect(spy.mock.calls).toEqual([]); - await lazyDfd.resolve({ - loader: () => "PAGE", - action: () => "ACTION", + it("allows composition of multiple instrumentations", async () => { + let spy = jest.fn(); + let t = setup({ + routes: [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: true, + }, + ], + unstable_instrumentRoute: (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" }, + }); }); - 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 navigations", async () => { + let spy = jest.fn(); + let router = createMemoryRouter( + [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: () => "PAGE", + }, + ], + { + unstable_instrumentRouter: (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 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: [ + it("allows instrumentation of fetchers", async () => { + let spy = jest.fn(); + let router = createMemoryRouter( + [ + { + index: true, + }, + { + id: "page", + path: "/page", + loader: () => "PAGE", + }, + ], { - index: true, + unstable_instrumentRouter: (router) => { + router.instrument({ + async fetch(fetch, info) { + spy("start", info); + await fetch(); + spy("end", info); + }, + }); + }, }, - ], - patchRoutesOnNavigation: ({ path, patch }) => { - if (path === "/page") { - patch(null, [ - { - id: "page", - path: "/page", - lazy: { - middleware: () => middlewareDfd.promise, - action: () => actionDfd.promise, - loader: () => loaderDfd.promise, - }, - }, - ]); - } - }, - unstable_instrumentRoute: (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" }, + 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"); }); }); - it("provides read-only information to instrumentation wrappers", async () => { - let spy = jest.fn(); - let t = setup({ - routes: [ - { - index: true, - }, + 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"); + }, + }; + }, + }, + ], { - id: "slug", - path: "/:slug", - loader: true, + unstable_instrumentRoute: (route) => { + route.instrument({ + async lazy(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); + }, }, - ], - unstable_instrumentRoute: (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.pattern).toBe("/:slug"); - expect(args.context.get).toBeDefined(); - expect(args.context.set).not.toBeDefined(); - expect(t.router.state.matches[0].params).toEqual({ slug: "a" }); - }); + 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(); - it("allows composition of multiple instrumentations", async () => { - let spy = jest.fn(); - let t = setup({ - routes: [ - { - index: true, - }, + // 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"); + }, + }; + }, + }, + ], { - id: "page", - path: "/page", - loader: true, + unstable_instrumentRoute: (route) => { + route.instrument({ + async lazy(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); + }, }, - ], - unstable_instrumentRoute: (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" }, + ); + 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 navigations", async () => { - let spy = jest.fn(); - let router = createMemoryRouter( - [ - { - index: true, - }, - { - id: "page", - path: "/page", - loader: () => "PAGE", - }, - ], - { - unstable_instrumentRouter: (router) => { - router.instrument({ - async navigate(navigate, info) { - spy("start", info); - await navigate(); - spy("end", info); + 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"); }, - }); - }, - }, - ); - - 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_instrumentRoute: (route) => { + route.instrument({ + async middleware(middleware) { + spy("start"); + await middleware(); + spy("end"); + }, + }); + }, }, - ], - { - unstable_instrumentRouter: (router) => { - router.instrument({ - async fetch(fetch, info) { - spy("start", info); - await fetch(); - spy("end", info); - }, - }); + ); + + 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(); - 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: "/" }, + 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"); }); - expect(data).toBe("PAGE"); - }); - describe("static handler", () => { - // TODO: Middleware instrumentation will have to happen at the server level, - // outside of the static handler it("allows instrumentation of loaders", async () => { let spy = jest.fn(); let { query, queryRoute } = createStaticHandler( @@ -1340,6 +1498,87 @@ describe("instrumentation", () => { ]); }); + 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_instrumentRoute: (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: {}, + pattern: "/", + context: { + get: expect.any(Function), + }, + }, + ], + ["middleware"], + ["loader"], + [ + "end", + { + request: { + method: "GET", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + params: {}, + pattern: "/", + context: { + get: expect.any(Function), + }, + }, + ], + ]); + }); + it("allows instrumentation of loaders", async () => { let spy = jest.fn(); let build = mockServerBuild( @@ -1410,5 +1649,78 @@ describe("instrumentation", () => { ], ]); }); + + 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_instrumentRoute: (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: {}, + pattern: "/", + context: { + get: expect.any(Function), + }, + }, + ], + ["action"], + [ + "end", + { + request: { + method: "POST", + url: "http://localhost/", + headers: { + get: expect.any(Function), + }, + }, + params: {}, + pattern: "/", + context: { + get: expect.any(Function), + }, + }, + ], + ]); + }); }); }); diff --git a/packages/react-router/__tests__/server-runtime/utils.ts b/packages/react-router/__tests__/server-runtime/utils.ts index 115374d2f8..12d3260de8 100644 --- a/packages/react-router/__tests__/server-runtime/utils.ts +++ b/packages/react-router/__tests__/server-runtime/utils.ts @@ -10,7 +10,11 @@ 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_InstrumentHandlerFunction, unstable_InstrumentRouteFunction, @@ -28,6 +32,7 @@ export function mockServerBuild( action?: ActionFunction; headers?: HeadersFunction; loader?: LoaderFunction; + middleware?: MiddlewareFunction[]; } >, opts: { @@ -112,8 +117,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/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index 24aa68169b..1617eec196 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -220,7 +220,7 @@ function getInstrumentationsByType< export function getInstrumentationUpdates( unstable_instrumentRoute: unstable_InstrumentRouteFunction, - route: AgnosticDataRouteObject, + route: Readonly, ) { let instrumentations: RouteInstrumentations[] = []; unstable_instrumentRoute({ @@ -233,6 +233,7 @@ export function getInstrumentationUpdates( }); let updates: { + middleware?: AgnosticDataRouteObject["middleware"]; loader?: AgnosticDataRouteObject["loader"]; action?: AgnosticDataRouteObject["action"]; lazy?: AgnosticDataRouteObject["lazy"]; @@ -265,7 +266,7 @@ export function getInstrumentationUpdates( // Instrument middleware functions if (route.middleware && route.middleware.length > 0) { - route.middleware = route.middleware.map((middleware) => { + updates.middleware = route.middleware.map((middleware) => { // @ts-expect-error let original = middleware[UninstrumentedSymbol] ?? middleware; let instrumented = getInstrumentedImplementation( From f430a5d6026f2de702d21dd9b5a09a812404713a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 1 Oct 2025 14:54:53 -0400 Subject: [PATCH 16/22] Switch to instrumentatons API --- integration/browser-entry-test.ts | 62 +- .../__tests__/router/instrumentation-test.ts | 754 ++++++++++-------- .../__tests__/server-runtime/utils.ts | 11 +- packages/react-router/lib/components.tsx | 77 +- .../lib/dom-export/hydrated-router.tsx | 83 +- packages/react-router/lib/dom/lib.tsx | 92 ++- .../lib/router/instrumentation.ts | 208 ++--- packages/react-router/lib/router/router.ts | 47 +- .../react-router/lib/server-runtime/build.ts | 6 +- .../react-router/lib/server-runtime/server.ts | 9 +- 10 files changed, 804 insertions(+), 545 deletions(-) diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts index 95d0361a7b..ac3a915bbb 100644 --- a/integration/browser-entry-test.ts +++ b/integration/browser-entry-test.ts @@ -212,36 +212,38 @@ test("allows users to instrument the client side router via HydratedRouter", asy document, { - router.instrument({ - async navigate(impl, info) { - console.log("start navigate", JSON.stringify(info)); - await impl(); - console.log("end navigate", JSON.stringify(info)); - }, - async fetch(impl, info) { - console.log("start fetch", JSON.stringify(info)); - await impl(); - console.log("end fetch", JSON.stringify(info)); - } - }) - }} - unstable_instrumentRoute={(route) => { - route.instrument({ - async loader(impl, info) { - let path = new URL(info.request.url).pathname; - console.log("start loader", route.id, path); - await impl(); - console.log("end loader", route.id, path); - }, - async action(impl, info) { - let path = new URL(info.request.url).pathname; - console.log("start action", route.id, path); - await impl(); - console.log("end action", route.id, path); - } - }) - }} + unstable_instrumentations={[{ + router(router) { + router.instrument({ + async navigate(impl, info) { + console.log("start navigate", JSON.stringify(info)); + await impl(); + console.log("end navigate", JSON.stringify(info)); + }, + async fetch(impl, info) { + console.log("start fetch", JSON.stringify(info)); + await impl(); + console.log("end fetch", JSON.stringify(info)); + } + }) + }, + route(route) { + route.instrument({ + async loader(impl, info) { + let path = new URL(info.request.url).pathname; + console.log("start loader", route.id, path); + await impl(); + console.log("end loader", route.id, path); + }, + async action(impl, info) { + let path = new URL(info.request.url).pathname; + console.log("start action", route.id, path); + await impl(); + console.log("end action", route.id, path); + } + }) + } + }]} /> ); diff --git a/packages/react-router/__tests__/router/instrumentation-test.ts b/packages/react-router/__tests__/router/instrumentation-test.ts index 18ec20a29b..749e85ad59 100644 --- a/packages/react-router/__tests__/router/instrumentation-test.ts +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -38,15 +38,19 @@ describe("instrumentation", () => { loader: true, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async middleware(middleware) { - spy("start"); - await middleware(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async middleware(middleware) { + spy("start"); + await middleware(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }); let A = await t.navigate("/page"); @@ -78,15 +82,19 @@ describe("instrumentation", () => { loader: true, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async loader(loader) { - spy("start"); - await loader(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }); let A = await t.navigate("/page"); @@ -113,15 +121,19 @@ describe("instrumentation", () => { action: true, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async action(action) { - spy("start"); - await action(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async action(action) { + spy("start"); + await action(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }); let A = await t.navigate("/page", { @@ -152,15 +164,19 @@ describe("instrumentation", () => { lazy: () => lazyDfd.promise, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async lazy(lazy) { - spy("start"); - await lazy(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async lazy(lazy) { + spy("start"); + await lazy(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }); await t.navigate("/page"); @@ -192,15 +208,19 @@ describe("instrumentation", () => { lazy: () => lazyDfd.promise, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async loader(loader) { - spy("start"); - await loader(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }); await t.navigate("/page"); @@ -236,15 +256,19 @@ describe("instrumentation", () => { lazy: () => lazyDfd.promise, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async action(action) { - spy("start"); - await action(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async action(action) { + spy("start"); + await action(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }); await t.navigate("/page", { @@ -284,15 +308,19 @@ describe("instrumentation", () => { lazy: () => lazyDfd.promise, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async loader(loader) { - spy("start"); - await loader(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }); let A = await t.navigate("/page"); @@ -339,15 +367,19 @@ describe("instrumentation", () => { lazy: () => lazyDfd.promise, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async action(action) { - spy("start"); - await action(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async action(action) { + spy("start"); + await action(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }); let A = await t.navigate("/page", { @@ -400,15 +432,19 @@ describe("instrumentation", () => { }, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - "lazy.middleware": async (middleware) => { - spy("start"); - await middleware(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + "lazy.middleware": async (middleware) => { + spy("start"); + await middleware(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }); await t.navigate("/page"); @@ -460,20 +496,24 @@ describe("instrumentation", () => { }, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - "lazy.loader": async (load) => { - spy("start"); - await load(); - spy("end"); - }, - loader: async (loader) => { - spy("loader start"); - await loader(); - spy("loader end"); + 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"); @@ -518,20 +558,24 @@ describe("instrumentation", () => { }, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - "lazy.action": async (load) => { - spy("start"); - await load(); - spy("end"); - }, - action: async (action) => { - spy("action start"); - await action(); - spy("action end"); + 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", { @@ -583,25 +627,29 @@ describe("instrumentation", () => { action: true, }, ], - unstable_instrumentRoute: (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"); + 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", { @@ -660,25 +708,29 @@ describe("instrumentation", () => { lazy: () => lazyDfd.promise, }, ], - unstable_instrumentRoute: (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"); + 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", { @@ -731,25 +783,29 @@ describe("instrumentation", () => { }, }, ], - unstable_instrumentRoute: (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"); + 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", { @@ -818,25 +874,29 @@ describe("instrumentation", () => { ]); } }, - unstable_instrumentRoute: (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"); + 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", { @@ -890,25 +950,29 @@ describe("instrumentation", () => { ]); } }, - unstable_instrumentRoute: (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"); + 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", { @@ -967,25 +1031,29 @@ describe("instrumentation", () => { ]); } }, - unstable_instrumentRoute: (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"); + 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", { @@ -1040,15 +1108,19 @@ describe("instrumentation", () => { loader: true, }, ], - unstable_instrumentRoute: (route) => { - route.instrument({ - async loader(loader, info) { - spy(info); - Object.assign(info.params, { extra: "extra" }); - await loader(); + 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"); @@ -1079,22 +1151,26 @@ describe("instrumentation", () => { loader: true, }, ], - unstable_instrumentRoute: (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"); + 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"); @@ -1126,15 +1202,19 @@ describe("instrumentation", () => { }, ], { - unstable_instrumentRouter: (router) => { - router.instrument({ - async navigate(navigate, info) { - spy("start", info); - await navigate(); - spy("end", info); + unstable_instrumentations: [ + { + router(router) { + router.instrument({ + async navigate(navigate, info) { + spy("start", info); + await navigate(); + spy("end", info); + }, + }); }, - }); - }, + }, + ], }, ); @@ -1164,15 +1244,19 @@ describe("instrumentation", () => { }, ], { - unstable_instrumentRouter: (router) => { - router.instrument({ - async fetch(fetch, info) { - spy("start", info); - await fetch(); - spy("end", info); + unstable_instrumentations: [ + { + router(router) { + router.instrument({ + async fetch(fetch, info) { + spy("start", info); + await fetch(); + spy("end", info); + }, + }); }, - }); - }, + }, + ], }, ); @@ -1213,15 +1297,19 @@ describe("instrumentation", () => { }, ], { - unstable_instrumentRoute: (route) => { - route.instrument({ - async lazy(loader) { - spy("start"); - await loader(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async lazy(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }, ); @@ -1256,15 +1344,19 @@ describe("instrumentation", () => { }, ], { - unstable_instrumentRoute: (route) => { - route.instrument({ - async lazy(loader) { - spy("start"); - await loader(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async lazy(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }, ); let response = await queryRoute(new Request("http://localhost/")); @@ -1297,15 +1389,19 @@ describe("instrumentation", () => { }, ], { - unstable_instrumentRoute: (route) => { - route.instrument({ - async middleware(middleware) { - spy("start"); - await middleware(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async middleware(middleware) { + spy("start"); + await middleware(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }, ); @@ -1361,15 +1457,19 @@ describe("instrumentation", () => { }, ], { - unstable_instrumentRoute: (route) => { - route.instrument({ - async loader(loader) { - spy("start"); - await loader(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader) { + spy("start"); + await loader(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }, ); @@ -1400,15 +1500,19 @@ describe("instrumentation", () => { }, ], { - unstable_instrumentRoute: (route) => { - route.instrument({ - async action(action) { - spy("start"); - await action(); - spy("end"); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async action(action) { + spy("start"); + await action(); + spy("end"); + }, + }); }, - }); - }, + }, + ], }, ); @@ -1448,15 +1552,19 @@ describe("instrumentation", () => { handleDocumentRequest(request) { return new Response(`${request.method} ${request.url} COMPONENT`); }, - unstable_instrumentHandler: (handler) => { - handler.instrument({ - async request(handler, info) { - spy("start", info); - await handler(); - spy("end", info); + unstable_instrumentations: [ + { + handler(handler) { + handler.instrument({ + async request(handler, info) { + spy("start", info); + await handler(); + spy("end", info); + }, + }); }, - }); - }, + }, + ], }, ); let handler = createRequestHandler(build); @@ -1524,15 +1632,19 @@ describe("instrumentation", () => { handleDocumentRequest(request) { return new Response(`${request.method} ${request.url} COMPONENT`); }, - unstable_instrumentRoute: (route) => { - route.instrument({ - async middleware(middleware, info) { - spy("start", info); - await middleware(); - spy("end", info); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async middleware(middleware, info) { + spy("start", info); + await middleware(); + spy("end", info); + }, + }); }, - }); - }, + }, + ], }, ); let handler = createRequestHandler(build); @@ -1596,15 +1708,19 @@ describe("instrumentation", () => { handleDocumentRequest(request) { return new Response(`${request.method} ${request.url} COMPONENT`); }, - unstable_instrumentRoute: (route) => { - route.instrument({ - async loader(loader, info) { - spy("start", info); - await loader(); - spy("end", info); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async loader(loader, info) { + spy("start", info); + await loader(); + spy("end", info); + }, + }); }, - }); - }, + }, + ], }, ); let handler = createRequestHandler(build); @@ -1667,15 +1783,19 @@ describe("instrumentation", () => { handleDocumentRequest(request) { return new Response(`${request.method} ${request.url} COMPONENT`); }, - unstable_instrumentRoute: (route) => { - route.instrument({ - async action(action, info) { - spy("start", info); - await action(); - spy("end", info); + unstable_instrumentations: [ + { + route(route) { + route.instrument({ + async action(action, info) { + spy("start", info); + await action(); + spy("end", info); + }, + }); }, - }); - }, + }, + ], }, ); let handler = createRequestHandler(build); diff --git a/packages/react-router/__tests__/server-runtime/utils.ts b/packages/react-router/__tests__/server-runtime/utils.ts index 12d3260de8..10771ca1dc 100644 --- a/packages/react-router/__tests__/server-runtime/utils.ts +++ b/packages/react-router/__tests__/server-runtime/utils.ts @@ -15,10 +15,7 @@ import type { LoaderFunction, MiddlewareFunction, } from "../../lib/router/utils"; -import type { - unstable_InstrumentHandlerFunction, - unstable_InstrumentRouteFunction, -} from "../../lib/router/instrumentation"; +import type { unstable_ServerInstrumentation } from "../../lib/router/instrumentation"; export function mockServerBuild( routes: Record< @@ -39,8 +36,7 @@ export function mockServerBuild( future?: Partial; handleError?: HandleErrorFunction; handleDocumentRequest?: HandleDocumentRequestFunction; - unstable_instrumentRoute?: unstable_InstrumentRouteFunction; - unstable_instrumentHandler?: unstable_InstrumentHandlerFunction; + unstable_instrumentations?: unstable_ServerInstrumentation[]; } = {}, ): ServerBuild { return { @@ -102,8 +98,7 @@ export function mockServerBuild( ), handleDataRequest: jest.fn(async (response) => response), handleError: opts.handleError, - unstable_instrumentRoute: opts.unstable_instrumentRoute, - unstable_instrumentHandler: opts.unstable_instrumentHandler, + unstable_instrumentations: opts.unstable_instrumentations, }, }, routes: Object.entries(routes).reduce( diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 57c7e06d0a..67921f6f91 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -69,6 +69,7 @@ import { import type { ViewTransition } from "./dom/global"; import { warnOnce } from "./server-runtime/warnings"; import type { + unstable_ClientInstrumentation, unstable_InstrumentRouteFunction, unstable_InstrumentRouterFunction, } from "./router/instrumentation"; @@ -174,18 +175,57 @@ export interface MemoryRouterOpts { */ initialIndex?: number; /** - * Function allowing you to instrument a route object prior to creating the - * client-side router (and on any subsequently added routes via `route.lazy` or - * `patchRoutesOnNavigation`). This is mostly useful for observability such - * as wrapping loaders/actions/middlewares with logging and/or performance tracing. - */ - unstable_instrumentRoute?: unstable_InstrumentRouteFunction; - /** - * Function allowing you to instrument the client-side router. This is mostly - * useful for observability such as wrapping `router.navigate`/`router.fetch` - * with logging and/or performance tracing. + * 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_instrumentRouter?: unstable_InstrumentRouterFunction; + unstable_instrumentations?: unstable_ClientInstrumentation[]; /** * Override the default data strategy of loading in parallel. * Only intended for advanced usage. @@ -214,8 +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_instrumentRoute} opts.unstable_instrumentRoute n/a - * @param {MemoryRouterOpts.unstable_instrumentRouter} opts.unstable_instrumentRouter 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 | ``} */ @@ -223,7 +262,7 @@ export function createMemoryRouter( routes: RouteObject[], opts?: MemoryRouterOpts, ): DataRouter { - let router = createRouter({ + return createRouter({ basename: opts?.basename, getContext: opts?.getContext, future: opts?.future, @@ -237,14 +276,8 @@ export function createMemoryRouter( mapRouteProperties, dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, - unstable_instrumentRoute: opts?.unstable_instrumentRoute, - }); - - if (opts?.unstable_instrumentRouter) { - router = instrumentClientSideRouter(router, opts.unstable_instrumentRouter); - } - - return router.initialize(); + unstable_instrumentations: opts?.unstable_instrumentations, + }).initialize(); } class Deferred { diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index e8d70a5765..57b52c96a1 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -27,11 +27,7 @@ import { } from "react-router"; import { CRITICAL_CSS_DATA_ATTRIBUTE } from "../dom/ssr/components"; import { RouterProvider } from "./dom-router-provider"; -import { - instrumentClientSideRouter, - type unstable_InstrumentRouteFunction, - type unstable_InstrumentRouterFunction, -} from "../router/instrumentation"; +import { unstable_ClientInstrumentation } from "../router/instrumentation"; type SSRInfo = { context: NonNullable<(typeof window)["__reactRouterContext"]>; @@ -83,12 +79,10 @@ function initSsrInfo(): void { function createHydratedRouter({ getContext, - unstable_instrumentRoute, - unstable_instrumentRouter, + unstable_instrumentations, }: { getContext?: RouterInit["getContext"]; - unstable_instrumentRoute?: unstable_InstrumentRouteFunction; - unstable_instrumentRouter?: unstable_InstrumentRouterFunction; + unstable_instrumentations?: unstable_ClientInstrumentation[]; }): DataRouter { initSsrInfo(); @@ -181,7 +175,7 @@ function createHydratedRouter({ getContext, hydrationData, hydrationRouteProperties, - unstable_instrumentRoute, + unstable_instrumentations, mapRouteProperties, future: { middleware: ssrInfo.context.future.v8_middleware, @@ -203,10 +197,6 @@ function createHydratedRouter({ ), }); - if (unstable_instrumentRouter) { - router = instrumentClientSideRouter(router, unstable_instrumentRouter); - } - ssrInfo.router = router; // We can call initialize() immediately if the router doesn't have any @@ -240,17 +230,59 @@ export interface HydratedRouterProps { */ getContext?: RouterInit["getContext"]; /** - * Function allowing you to instrument a route object prior to creating the - * client-side router. This is mostly useful for observability such as wrapping - * loaders/actions/middlewares with logging and/or performance tracing. - */ - unstable_instrumentRoute?: unstable_InstrumentRouteFunction; - /** - * Function allowing you to instrument the client-side router. This is mostly - * useful for observability such as wrapping `router.navigate`/`router.fetch` - * with logging and/or performance tracing. + * 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_instrumentRouter?: unstable_InstrumentRouterFunction; + 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 @@ -287,8 +319,7 @@ export function HydratedRouter(props: HydratedRouterProps) { if (!router) { router = createHydratedRouter({ getContext: props.getContext, - unstable_instrumentRoute: props.unstable_instrumentRoute, - unstable_instrumentRouter: props.unstable_instrumentRouter, + unstable_instrumentations: props.unstable_instrumentations, }); } diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index f6871660f1..372ca2e342 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -97,6 +97,7 @@ import { import type { SerializeFrom } from "../types/route-data"; import { instrumentClientSideRouter, + unstable_ClientInstrumentation, type unstable_InstrumentRouteFunction, type unstable_InstrumentRouterFunction, } from "../router/instrumentation"; @@ -241,18 +242,57 @@ export interface DOMRouterOpts { */ hydrationData?: HydrationState; /** - * Function allowing you to instrument a route object prior to creating the - * client-side router (and on any subsequently added routes via `route.lazy` or - * `patchRoutesOnNavigation`). This is mostly useful for observability such - * as wrapping loaders/actions/middlewares with logging and/or performance tracing. - */ - unstable_instrumentRoute?: unstable_InstrumentRouteFunction; - /** - * Function allowing you to instrument the client-side router. This is mostly - * useful for observability such as wrapping `router.navigate`/`router.fetch` - * with logging and/or performance tracing. + * 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_instrumentRouter?: unstable_InstrumentRouterFunction; + unstable_instrumentations?: unstable_ClientInstrumentation[]; /** * Override the default data strategy of running loaders in parallel. * See {@link DataStrategyFunction}. @@ -759,8 +799,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_instrumentRoute} opts.unstable_instrumentRoute n/a - * @param {DOMRouterOpts.unstable_instrumentRouter} opts.unstable_instrumentRouter 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 | ``} @@ -769,7 +808,7 @@ export function createBrowserRouter( routes: RouteObject[], opts?: DOMRouterOpts, ): DataRouter { - let router = createRouter({ + return createRouter({ basename: opts?.basename, getContext: opts?.getContext, future: opts?.future, @@ -781,14 +820,8 @@ export function createBrowserRouter( dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, - unstable_instrumentRoute: opts?.unstable_instrumentRoute, - }); - - if (opts?.unstable_instrumentRouter) { - router = instrumentClientSideRouter(router, opts.unstable_instrumentRouter); - } - - return router.initialize(); + unstable_instrumentations: opts?.unstable_instrumentations, + }).initialize(); } /** @@ -804,8 +837,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_instrumentRoute} opts.unstable_instrumentRoute n/a - * @param {DOMRouterOpts.unstable_instrumentRouter} opts.unstable_instrumentRouter 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 @@ -815,7 +847,7 @@ export function createHashRouter( routes: RouteObject[], opts?: DOMRouterOpts, ): DataRouter { - let router = createRouter({ + return createRouter({ basename: opts?.basename, getContext: opts?.getContext, future: opts?.future, @@ -827,14 +859,8 @@ export function createHashRouter( dataStrategy: opts?.dataStrategy, patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, - unstable_instrumentRoute: opts?.unstable_instrumentRoute, - }); - - if (opts?.unstable_instrumentRouter) { - router = instrumentClientSideRouter(router, opts.unstable_instrumentRouter); - } - - return router.initialize(); + unstable_instrumentations: opts?.unstable_instrumentations, + }).initialize(); } function parseHydrationData(): HydrationState | undefined { diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index 1617eec196..4c391e2128 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -1,4 +1,4 @@ -import { RequestHandler } from "../../dist/development"; +import type { RequestHandler } from "../server-runtime/server"; import { createPath } from "./history"; import type { Router } from "./router"; import type { @@ -14,6 +14,52 @@ import type { 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 GenericInstrumentFunction { + (handler: () => Promise, info: unknown): Promise; +} + +// Route Instrumentation +type InstrumentableRoute = { + id: string; + index: boolean | undefined; + path: string | undefined; + instrument(instrumentations: RouteInstrumentations): void; +}; + +type RouteInstrumentations = { + lazy?: InstrumentLazyFunction; + "lazy.loader"?: InstrumentLazyFunction; + "lazy.action"?: InstrumentLazyFunction; + "lazy.middleware"?: InstrumentLazyFunction; + middleware?: InstrumentRouteHandlerFunction; + loader?: InstrumentRouteHandlerFunction; + action?: InstrumentRouteHandlerFunction; +}; + type RouteHandlerInstrumentationInfo = Readonly<{ request: { method: string; @@ -26,59 +72,27 @@ type RouteHandlerInstrumentationInfo = Readonly<{ context: Pick; }>; -interface GenericInstrumentFunction { - (handler: () => Promise, info: unknown): Promise; -} - interface InstrumentLazyFunction extends GenericInstrumentFunction { (handler: () => Promise): Promise; } -interface InstrumentHandlerFunction extends GenericInstrumentFunction { +interface InstrumentRouteHandlerFunction extends GenericInstrumentFunction { ( handler: () => Promise, info: RouteHandlerInstrumentationInfo, ): Promise; } -type RouteInstrumentations = { - lazy?: InstrumentLazyFunction; - "lazy.loader"?: InstrumentLazyFunction; - "lazy.action"?: InstrumentLazyFunction; - "lazy.middleware"?: InstrumentLazyFunction; - middleware?: InstrumentHandlerFunction; - loader?: InstrumentHandlerFunction; - action?: InstrumentHandlerFunction; +// Router Instrumentation +type InstrumentableRouter = { + instrument(instrumentations: RouterInstrumentations): void; }; -type InstrumentableRoute = { - id: string; - index: boolean | undefined; - path: string | undefined; - instrument(instrumentations: RouteInstrumentations): void; +type RouterInstrumentations = { + navigate?: InstrumentNavigateFunction; + fetch?: InstrumentFetchFunction; }; -interface InstrumentNavigateFunction extends GenericInstrumentFunction { - ( - handler: () => Promise, - info: RouterNavigationInstrumentationInfo, - ): MaybePromise; -} - -interface InstrumentFetchFunction extends GenericInstrumentFunction { - ( - handler: () => Promise, - info: RouterFetchInstrumentationInfo, - ): MaybePromise; -} - -interface InstrumentRequestFunction extends GenericInstrumentFunction { - ( - handler: () => Promise, - info: HandlerRequestInstrumentationInfo, - ): MaybePromise; -} - type RouterNavigationInstrumentationInfo = Readonly<{ to: string | number; currentUrl: string; @@ -98,7 +112,30 @@ type RouterFetchInstrumentationInfo = Readonly<{ body?: any; }>; -type HandlerRequestInstrumentationInfo = Readonly<{ +interface InstrumentNavigateFunction extends GenericInstrumentFunction { + ( + handler: () => Promise, + info: RouterNavigationInstrumentationInfo, + ): MaybePromise; +} + +interface InstrumentFetchFunction extends GenericInstrumentFunction { + ( + handler: () => Promise, + info: RouterFetchInstrumentationInfo, + ): MaybePromise; +} + +// Request Handler Instrumentation +type InstrumentableRequestHandler = { + instrument(instrumentations: RequestHandlerInstrumentations): void; +}; + +type RequestHandlerInstrumentations = { + request?: InstrumentRequestHandlerFunction; +}; + +type RequestHandlerInstrumentationInfo = Readonly<{ request: { method: string; url: string; @@ -108,34 +145,12 @@ type HandlerRequestInstrumentationInfo = Readonly<{ context: Pick; }>; -type RouterInstrumentations = { - navigate?: InstrumentNavigateFunction; - fetch?: InstrumentFetchFunction; -}; - -type HandlerInstrumentations = { - request?: InstrumentRequestFunction; -}; - -type InstrumentableRouter = { - instrument(instrumentations: RouterInstrumentations): void; -}; - -type InstrumentableHandler = { - instrument(instrumentations: HandlerInstrumentations): void; -}; - -export type unstable_InstrumentRouteFunction = ( - route: InstrumentableRoute, -) => void; - -export type unstable_InstrumentRouterFunction = ( - router: InstrumentableRouter, -) => void; - -export type unstable_InstrumentHandlerFunction = ( - handler: InstrumentableHandler, -) => void; +interface InstrumentRequestHandlerFunction extends GenericInstrumentFunction { + ( + handler: () => Promise, + info: RequestHandlerInstrumentationInfo, + ): MaybePromise; +} const UninstrumentedSymbol = Symbol("Uninstrumented"); @@ -205,7 +220,7 @@ function getInstrumentationsByType< T extends | RouteInstrumentations | RouterInstrumentations - | HandlerInstrumentations, + | RequestHandlerInstrumentations, K extends keyof T, >(instrumentations: T[], key: K): GenericInstrumentFunction[] { let value: GenericInstrumentFunction[] = []; @@ -219,18 +234,20 @@ function getInstrumentationsByType< } export function getInstrumentationUpdates( - unstable_instrumentRoute: unstable_InstrumentRouteFunction, + fns: unstable_InstrumentRouteFunction[], route: Readonly, ) { let instrumentations: RouteInstrumentations[] = []; - unstable_instrumentRoute({ - id: route.id, - index: route.index, - path: route.path, - instrument(i) { - instrumentations.push(i); - }, - }); + fns.forEach((fn) => + fn({ + id: route.id, + index: route.index, + path: route.path, + instrument(i) { + instrumentations.push(i); + }, + }), + ); let updates: { middleware?: AgnosticDataRouteObject["middleware"]; @@ -310,14 +327,16 @@ export function getInstrumentationUpdates( export function instrumentClientSideRouter( router: Router, - unstable_instrumentRouter: unstable_InstrumentRouterFunction, + fns: unstable_InstrumentRouterFunction[], ): Router { let instrumentations: RouterInstrumentations[] = []; - unstable_instrumentRouter({ - instrument(i) { - instrumentations.push(i); - }, - }); + fns.forEach((fn) => + fn({ + instrument(i) { + instrumentations.push(i); + }, + }), + ); if (instrumentations.length > 0) { // @ts-expect-error @@ -382,24 +401,27 @@ export function instrumentClientSideRouter( export function instrumentHandler( handler: RequestHandler, - unstable_instrumentHandler: unstable_InstrumentHandlerFunction, + fns: unstable_InstrumentRequestHandlerFunction[], ): RequestHandler { - let instrumentations: HandlerInstrumentations[] = []; - unstable_instrumentHandler({ - instrument(i) { - instrumentations.push(i); - }, - }); + let instrumentations: RequestHandlerInstrumentations[] = []; + fns.forEach((fn) => + fn({ + instrument(i) { + instrumentations.push(i); + }, + }), + ); if (instrumentations.length === 0) { return handler; } + let instrumentedHandler = getInstrumentedImplementation( getInstrumentationsByType(instrumentations, "request"), handler, (...args) => { let [request, context] = args as Parameters; - let info: HandlerRequestInstrumentationInfo = { + let info: RequestHandlerInstrumentationInfo = { request: { method: request.method, url: request.url, diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index fe51bfd440..285d729cdb 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -9,8 +9,16 @@ import { parsePath, warning, } from "./history"; -import type { unstable_InstrumentRouteFunction } from "./instrumentation"; -import { getInstrumentationUpdates } from "./instrumentation"; +import type { + unstable_ClientInstrumentation, + unstable_InstrumentRouteFunction, + unstable_InstrumentRouterFunction, + unstable_ServerInstrumentation, +} from "./instrumentation"; +import { + getInstrumentationUpdates, + instrumentClientSideRouter, +} from "./instrumentation"; import type { AgnosticDataRouteMatch, AgnosticDataRouteObject, @@ -405,7 +413,7 @@ export interface RouterInit { history: History; basename?: string; getContext?: () => MaybePromise; - unstable_instrumentRoute?: unstable_InstrumentRouteFunction; + unstable_instrumentations?: unstable_ClientInstrumentation[]; mapRouteProperties?: MapRoutePropertiesFunction; future?: Partial; hydrationRouteProperties?: string[]; @@ -875,13 +883,18 @@ export function createRouter(init: RouterInit): Router { // Leverage the existing mapRouteProperties logic to execute instrumentRoute // (if it exists) on all routes in the application - if (init.unstable_instrumentRoute) { - let instrument = init.unstable_instrumentRoute; + if (init.unstable_instrumentations) { + let instrumentations = init.unstable_instrumentations; mapRouteProperties = (route: AgnosticDataRouteObject) => { return { ..._mapRouteProperties(route), - ...getInstrumentationUpdates(instrument, route), + ...getInstrumentationUpdates( + instrumentations + .map((i) => i.route) + .filter(Boolean) as unstable_InstrumentRouteFunction[], + route, + ), }; }; } @@ -3525,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 @@ -3536,7 +3558,7 @@ export function createRouter(init: RouterInit): Router { export interface CreateStaticHandlerOptions { basename?: string; mapRouteProperties?: MapRoutePropertiesFunction; - unstable_instrumentRoute?: unstable_InstrumentRouteFunction; + unstable_instrumentations?: Pick[]; future?: {}; } @@ -3557,13 +3579,18 @@ export function createStaticHandler( // Leverage the existing mapRouteProperties logic to execute instrumentRoute // (if it exists) on all routes in the application - if (opts?.unstable_instrumentRoute) { - let instrument = opts.unstable_instrumentRoute; + if (opts?.unstable_instrumentations) { + let instrumentations = opts.unstable_instrumentations; mapRouteProperties = (route: AgnosticDataRouteObject) => { return { ..._mapRouteProperties(route), - ...getInstrumentationUpdates(instrument, route), + ...getInstrumentationUpdates( + instrumentations + .map((i) => i.route) + .filter(Boolean) as unstable_InstrumentRouteFunction[], + route, + ), }; }; } diff --git a/packages/react-router/lib/server-runtime/build.ts b/packages/react-router/lib/server-runtime/build.ts index 013b6cb430..c18e7e3d85 100644 --- a/packages/react-router/lib/server-runtime/build.ts +++ b/packages/react-router/lib/server-runtime/build.ts @@ -13,8 +13,9 @@ import type { ServerRouteManifest } from "./routes"; import type { AppLoadContext } from "./data"; import type { MiddlewareEnabled } from "../types/future"; import type { - unstable_InstrumentHandlerFunction, + unstable_InstrumentRequestHandlerFunction, unstable_InstrumentRouteFunction, + unstable_ServerInstrumentation, } from "../router/instrumentation"; type OptionalCriticalCss = CriticalCss | undefined; @@ -89,7 +90,6 @@ export interface ServerEntryModule { default: HandleDocumentRequestFunction; handleDataRequest?: HandleDataRequestFunction; handleError?: HandleErrorFunction; - unstable_instrumentHandler?: unstable_InstrumentHandlerFunction; - unstable_instrumentRoute?: unstable_InstrumentRouteFunction; + unstable_instrumentations?: unstable_ServerInstrumentation[]; streamTimeout?: number; } diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 01775dd21a..6a8091cf25 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -36,6 +36,7 @@ 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 = ( @@ -56,7 +57,7 @@ function derive(build: ServerBuild, mode?: string) { let serverMode = isServerMode(mode) ? mode : ServerMode.Production; let staticHandler = createStaticHandler(dataRoutes, { basename: build.basename, - unstable_instrumentRoute: build.entry.module.unstable_instrumentRoute, + unstable_instrumentations: build.entry.module.unstable_instrumentations, }); let errorHandler = @@ -305,10 +306,12 @@ function derive(build: ServerBuild, mode?: string) { return response; }; - if (build.entry.module.unstable_instrumentHandler) { + if (build.entry.module.unstable_instrumentations) { requestHandler = instrumentHandler( requestHandler, - build.entry.module.unstable_instrumentHandler, + build.entry.module.unstable_instrumentations + .map((i) => i.handler) + .filter(Boolean) as unstable_InstrumentRequestHandlerFunction[], ); } From 8676b345dff03f7027ebea7a588ef834a0ea28d1 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Oct 2025 10:54:32 -0400 Subject: [PATCH 17/22] Updates to decision doc --- decisions/0015-observability.md | 220 +++++++++++++++++++++++--------- 1 file changed, 161 insertions(+), 59 deletions(-) diff --git a/decisions/0015-observability.md b/decisions/0015-observability.md index e4aa369ac0..adddd4e425 100644 --- a/decisions/0015-observability.md +++ b/decisions/0015-observability.md @@ -24,64 +24,71 @@ Adopt instrumentation as a first class API and the recommended way to implement There are 2 levels in which we want to instrument: -- router level - ability to track the start and end of a router operation +- "router" level - ability to track the start and end of a router operation - requests on the server handler - - initialization, navigations, and fetchers on the client router + - navigations and fetchers on the client router - route level - - loaders, actions, middlewares + - 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 2 new exports from `entry.server`. These 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. +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 -// Wrap incoming request handlers. Currently applies to _all_ requests handled -// by the RR handler, including: -// - manifest reqeusts -// - document requests -// - `.data` requests -// - resource route requests -export function instrumentHandler(handler: RequestHandler): RequestHandler { - return (...args) => { - let [request] = args; - let path = new URL(request.url).pathname; - let start = Date.now(); - console.log(`Request start: ${request.method} ${path}`); - - try { - return await handler(...args); - } finally { - let duration = Date.now() - start; - console.log(`Request end: ${request.method} ${path} (${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 -export function instrumentRoute(route: RouteModule): RequestHandler { - let { loader } = route; - let newRoute = { ...route }; - if (loader) { - newRoute.loader = (args) => { - let { request } = args; - let path = new URL(request.url).pathname; - let start = Date.now(); - console.log(`Loader start: ${request.method} ${path}`); - - try { - return await loader(...args); - } finally { - let duration = Date.now() - start; - console.log(`Loader end: ${request.method} ${path} (${duration}ms)`); - } - }; - } - return newRoute; -} +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: @@ -94,26 +101,121 @@ Client-side, it's a similar story. You could do this today at the route level in I think we can open up APIs similar to those in `entry.server` but do them on `createBrowserRouter` and `HydratedRouter`: ```tsx -function instrumentRouter(router: DataRouter): DataRouter { /* ... */ } - -function instrumentRoute(route: RouteObject): RouteObject { /* ... */ } +// 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, { - instrumentRouter, - instrumentRoute, -}) +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; +} +``` From 4e44d47be3b99e35cbe170a2525e9bae45231f75 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Oct 2025 11:02:21 -0400 Subject: [PATCH 18/22] Remove playground --- playground/observability/.gitignore | 6 - playground/observability/app/entry.client.tsx | 99 --------------- playground/observability/app/entry.server.tsx | 119 ------------------ playground/observability/app/o11y.ts | 49 -------- playground/observability/app/root.tsx | 56 --------- playground/observability/app/routes.ts | 6 - playground/observability/app/routes/index.tsx | 24 ---- playground/observability/app/routes/slug.tsx | 46 ------- playground/observability/package.json | 39 ------ playground/observability/public/favicon.ico | Bin 15086 -> 0 bytes .../observability/react-router.config.ts | 7 -- playground/observability/server.js | 43 ------- playground/observability/tsconfig.json | 31 ----- playground/observability/vite.config.ts | 7 -- 14 files changed, 532 deletions(-) delete mode 100644 playground/observability/.gitignore delete mode 100644 playground/observability/app/entry.client.tsx delete mode 100644 playground/observability/app/entry.server.tsx delete mode 100644 playground/observability/app/o11y.ts delete mode 100644 playground/observability/app/root.tsx delete mode 100644 playground/observability/app/routes.ts delete mode 100644 playground/observability/app/routes/index.tsx delete mode 100644 playground/observability/app/routes/slug.tsx delete mode 100644 playground/observability/package.json delete mode 100644 playground/observability/public/favicon.ico delete mode 100644 playground/observability/react-router.config.ts delete mode 100644 playground/observability/server.js delete mode 100644 playground/observability/tsconfig.json delete mode 100644 playground/observability/vite.config.ts diff --git a/playground/observability/.gitignore b/playground/observability/.gitignore deleted file mode 100644 index 752e5fe866..0000000000 --- a/playground/observability/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules - -/build -.env - -.react-router/ diff --git a/playground/observability/app/entry.client.tsx b/playground/observability/app/entry.client.tsx deleted file mode 100644 index c778e9fcb6..0000000000 --- a/playground/observability/app/entry.client.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { startTransition, StrictMode } from "react"; -import { hydrateRoot } from "react-dom/client"; -import type { - DataRouteObject, - DataRouter, - RouterNavigateOptions, -} from "react-router"; -import { HydratedRouter } from "react-router/dom"; -import { getPattern, measure, startMeasure } from "./o11y"; - -startTransition(() => { - hydrateRoot( - document, - - - , - ); -}); - -function instrumentRouter(router: DataRouter) { - let initialize = router.initialize; - router.initialize = () => { - let pattern = getPattern(router.routes, router.state.location.pathname); - let end = startMeasure(["initialize", pattern]); - - if (router.state.initialized) { - end(); - } else { - let unsubscribe = router.subscribe((state) => { - if (state.initialized) { - end(); - unsubscribe(); - } - }); - } - return initialize(); - }; - - let navigate = router.navigate; - router.navigate = async (to, opts?: RouterNavigateOptions) => { - let path = - typeof to === "string" - ? to - : typeof to === "number" - ? String(to) - : (to?.pathname ?? "unknown"); - await measure([`navigate`, getPattern(router.routes, path)], () => - typeof to === "number" ? navigate(to) : navigate(to, opts), - ); - }; - - return router; -} - -function instrumentRoute(route: DataRouteObject): DataRouteObject { - if (typeof route.lazy === "function") { - let lazy = route.lazy; - route.lazy = () => measure(["lazy", route.id], () => lazy()); - } - - if ( - route.middleware && - route.middleware.length > 0 && - // @ts-expect-error - route.middleware.instrumented !== true - ) { - route.middleware = route.middleware.map((mw, i) => { - return ({ request, params, pattern, context }, next) => - measure(["middleware", route.id, i.toString(), pattern], async () => - mw({ request, params, pattern, context }, next), - ); - }); - // When `route.lazy` is used alongside a statically defined `loader`, make - // sure we don't double-instrument the `loader` after `route.lazy` completes - // and we re-call `instrumentRoute` via `mapRouteProperties` - // @ts-expect-error - route.middleware.instrumented = true; - } - - // @ts-expect-error - if (typeof route.loader === "function" && !route.loader.instrumented) { - let loader = route.loader; - route.loader = (...args) => { - return measure([`loader:${route.id}`, args[0].pattern], async () => - loader(...args), - ); - }; - // When `route.lazy` is used alongside a statically defined `loader`, make - // sure we don't double-instrument the `loader` after `route.lazy` completes - // and we re-call `instrumentRoute` via `mapRouteProperties` - // @ts-expect-error - route.loader.instrumented = true; - } - - return route; -} diff --git a/playground/observability/app/entry.server.tsx b/playground/observability/app/entry.server.tsx deleted file mode 100644 index f40c00afe1..0000000000 --- a/playground/observability/app/entry.server.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { PassThrough } from "node:stream"; - -import type { AppLoadContext, EntryContext } from "react-router"; -import { createReadableStreamFromReadable } from "@react-router/node"; -import { ServerRouter } from "react-router"; -import { isbot } from "isbot"; -import type { RenderToPipeableStreamOptions } from "react-dom/server"; -import { renderToPipeableStream } from "react-dom/server"; -import type { RequestHandler } from "react-router"; -import { log } from "./o11y"; -import type { ServerBuild } from "react-router"; -import type { DataRouteObject } from "react-router"; -import type { MiddlewareFunction } from "react-router"; - -export const streamTimeout = 5_000; - -export function unstable_instrumentHandler( - handler: RequestHandler, -): RequestHandler { - let instrumented: RequestHandler = async (request, context) => { - let pattern = new URL(request.url).pathname; - return await log([`request`, pattern], () => handler(request, context)); - }; - return instrumented; -} - -export function unstable_instrumentRoute( - route: DataRouteObject, -): DataRouteObject { - if (route.middleware && route.middleware.length > 0) { - route.middleware = route.middleware.map((mw, i) => { - return (...args: Parameters>) => - log(["middleware", route.id, i.toString(), args[0].pattern], async () => - mw(...args), - ); - }) as MiddlewareFunction[]; - } - - if (typeof route.loader === "function") { - let loader = route.loader; - route.loader = (...args) => { - return log([`loader:${route.id}`, args[0].pattern], async () => - loader(...args), - ); - }; - } - - return route; -} - -export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - routerContext: EntryContext, - loadContext: AppLoadContext, - // If you have middleware enabled: - // loadContext: RouterContextProvider -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - let userAgent = request.headers.get("user-agent"); - - // Ensure requests from bots and SPA Mode renders wait for all content to load before responding - // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation - let readyOption: keyof RenderToPipeableStreamOptions = - (userAgent && isbot(userAgent)) || routerContext.isSpaMode - ? "onAllReady" - : "onShellReady"; - - // Abort the rendering stream after the `streamTimeout` so it has time to - // flush down the rejected boundaries - let timeoutId: ReturnType | undefined = setTimeout( - () => abort(), - streamTimeout + 1000, - ); - - const { pipe, abort } = renderToPipeableStream( - , - { - [readyOption]() { - shellRendered = true; - const body = new PassThrough({ - final(callback) { - // Clear the timeout to prevent retaining the closure and memory leak - clearTimeout(timeoutId); - timeoutId = undefined; - callback(); - }, - }); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set("Content-Type", "text/html"); - - pipe(body); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - }, - ); - }); -} diff --git a/playground/observability/app/o11y.ts b/playground/observability/app/o11y.ts deleted file mode 100644 index 83038496fa..0000000000 --- a/playground/observability/app/o11y.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { matchRoutes, type DataRouteObject } from "react-router"; - -export function getPattern(routes: DataRouteObject[], path: string) { - let matches = matchRoutes(routes, path); - if (matches && matches.length > 0) { - return matches - ?.map((m) => m.route.path) - .filter(Boolean) - .join("/") - .replace(/\/\/+/g, "/"); - } - return "unknown-pattern"; -} - -export function startMeasure(label: string[]) { - let strLabel = label.join("--"); - let now = Date.now().toString(); - let start = `start:${strLabel}:${now}`; - performance.mark(start); - return () => { - let end = `end:${strLabel}:${now}`; - performance.mark(end); - performance.measure(strLabel, start, end); - }; -} - -export async function measure( - label: string[], - cb: () => Promise, -): Promise { - let end = startMeasure(label); - try { - return await cb(); - } finally { - end(); - } -} - -export async function log( - label: string[], - cb: () => Promise, -): Promise { - console.log(new Date().toISOString(), "start", label.join("--")); - try { - return await cb(); - } finally { - console.log(new Date().toISOString(), "end", label.join("--")); - } -} diff --git a/playground/observability/app/root.tsx b/playground/observability/app/root.tsx deleted file mode 100644 index 52a3684e85..0000000000 --- a/playground/observability/app/root.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { - Link, - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, - type MiddlewareFunction, -} from "react-router"; - -let sleep = (ms: number = Math.max(100, Math.round(Math.random() * 500))) => - new Promise((r) => setTimeout(r, ms)); - -export const middleware = [ - async (_: unknown, next: Parameters>[1]) => { - await sleep(); - await next(); - await sleep(); - }, -]; - -export async function loader() { - await sleep(); -} - -export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - - {children} - - - - - ); -} - -export default function App() { - return ; -} diff --git a/playground/observability/app/routes.ts b/playground/observability/app/routes.ts deleted file mode 100644 index 3d0c769294..0000000000 --- a/playground/observability/app/routes.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type RouteConfig, index, route } from "@react-router/dev/routes"; - -export default [ - index("routes/index.tsx"), - route(":slug", "routes/slug.tsx"), -] satisfies RouteConfig; diff --git a/playground/observability/app/routes/index.tsx b/playground/observability/app/routes/index.tsx deleted file mode 100644 index 7e934a0a6c..0000000000 --- a/playground/observability/app/routes/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { MiddlewareFunction } from "react-router"; - -let sleep = (ms: number = Math.max(100, Math.round(Math.random() * 500))) => - new Promise((r) => setTimeout(r, ms)); - -export const middleware = [ - async (_: unknown, next: Parameters>[1]) => { - await sleep(); - await next(); - await sleep(); - }, -]; - -export async function loader() { - await sleep(); -} - -export default function Index() { - return ( -
-

Welcome to React Router

-
- ); -} diff --git a/playground/observability/app/routes/slug.tsx b/playground/observability/app/routes/slug.tsx deleted file mode 100644 index ce58440893..0000000000 --- a/playground/observability/app/routes/slug.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { startMeasure } from "~/o11y"; -import { type Route } from "../../.react-router/types/app/routes/+types/slug"; - -let sleep = (ms: number = Math.max(100, Math.round(Math.random() * 500))) => - new Promise((r) => setTimeout(r, ms)); - -export const middleware: Route.MiddlewareFunction[] = [ - async (_, next) => { - await sleep(); - await next(); - await sleep(); - }, -]; - -export const clientMiddleware: Route.ClientMiddlewareFunction[] = [ - async (_, next) => { - await sleep(); - await next(); - await sleep(); - }, -]; - -export async function loader({ params }: Route.LoaderArgs) { - await sleep(); - return params.slug; -} - -export async function clientLoader({ - serverLoader, - pattern, -}: Route.ClientLoaderArgs) { - await sleep(); - let end = startMeasure(["serverLoader", pattern]); - let value = await serverLoader(); - end(); - await sleep(); - return value; -} - -export default function Slug({ loaderData }: Route.ComponentProps) { - return ( -
-

Slug: {loaderData}

-
- ); -} diff --git a/playground/observability/package.json b/playground/observability/package.json deleted file mode 100644 index c0288d54a4..0000000000 --- a/playground/observability/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@playground/framework-express", - "version": "0.0.0", - "private": true, - "sideEffects": false, - "type": "module", - "scripts": { - "build": "react-router build", - "dev": "node ./server.js", - "start": "cross-env NODE_ENV=production node ./server.js", - "typecheck": "react-router typegen && tsc" - }, - "dependencies": { - "@react-router/express": "workspace:*", - "@react-router/node": "workspace:*", - "compression": "^1.7.4", - "express": "^4.19.2", - "isbot": "^5.1.11", - "morgan": "^1.10.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-router": "workspace:*" - }, - "devDependencies": { - "@react-router/dev": "workspace:*", - "@types/compression": "^1.7.5", - "@types/express": "^4.17.20", - "@types/morgan": "^1.9.9", - "@types/react": "^18.2.20", - "@types/react-dom": "^18.2.7", - "cross-env": "^7.0.3", - "typescript": "^5.1.6", - "vite": "^6.1.0", - "vite-tsconfig-paths": "^4.2.1" - }, - "engines": { - "node": ">=20.0.0" - } -} diff --git a/playground/observability/public/favicon.ico b/playground/observability/public/favicon.ico deleted file mode 100644 index 5dbdfcddcb14182535f6d32d1c900681321b1aa3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j diff --git a/playground/observability/react-router.config.ts b/playground/observability/react-router.config.ts deleted file mode 100644 index 039108a6a7..0000000000 --- a/playground/observability/react-router.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Config } from "@react-router/dev/config"; - -export default { - future: { - v8_middleware: true, - }, -} satisfies Config; diff --git a/playground/observability/server.js b/playground/observability/server.js deleted file mode 100644 index fa5048f32c..0000000000 --- a/playground/observability/server.js +++ /dev/null @@ -1,43 +0,0 @@ -import { createRequestHandler } from "@react-router/express"; -import compression from "compression"; -import express from "express"; -import morgan from "morgan"; - -const viteDevServer = - process.env.NODE_ENV === "production" - ? undefined - : await import("vite").then((vite) => - vite.createServer({ - server: { middlewareMode: true }, - }) - ); - -const reactRouterHandler = createRequestHandler({ - build: viteDevServer - ? () => viteDevServer.ssrLoadModule("virtual:react-router/server-build") - : await import("./build/server/index.js"), -}); - -const app = express(); - -app.use(compression()); -app.disable("x-powered-by"); - -if (viteDevServer) { - app.use(viteDevServer.middlewares); -} else { - app.use( - "/assets", - express.static("build/client/assets", { immutable: true, maxAge: "1y" }) - ); -} - -app.use(express.static("build/client", { maxAge: "1h" })); -app.use(morgan("tiny")); - -app.all("*", reactRouterHandler); - -const port = process.env.PORT || 3000; -app.listen(port, () => - console.log(`Express server listening at http://localhost:${port}`) -); diff --git a/playground/observability/tsconfig.json b/playground/observability/tsconfig.json deleted file mode 100644 index 79cf7b5af6..0000000000 --- a/playground/observability/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "include": [ - "**/*.ts", - "**/*.tsx", - "**/.server/**/*.ts", - "**/.server/**/*.tsx", - "**/.client/**/*.ts", - "**/.client/**/*.tsx", - "./.react-router/types/**/*" - ], - "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["@react-router/node", "vite/client"], - "verbatimModuleSyntax": true, - "esModuleInterop": true, - "jsx": "react-jsx", - "module": "ESNext", - "moduleResolution": "Bundler", - "resolveJsonModule": true, - "target": "ES2022", - "strict": true, - "allowJs": true, - "skipLibCheck": true, - "baseUrl": ".", - "paths": { - "~/*": ["./app/*"] - }, - "noEmit": true, - "rootDirs": [".", "./.react-router/types"] - } -} diff --git a/playground/observability/vite.config.ts b/playground/observability/vite.config.ts deleted file mode 100644 index f910ad4c18..0000000000 --- a/playground/observability/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { reactRouter } from "@react-router/dev/vite"; -import { defineConfig } from "vite"; -import tsconfigPaths from "vite-tsconfig-paths"; - -export default defineConfig({ - plugins: [reactRouter(), tsconfigPaths()], -}); From c5aa46e85bea6e2b51be664cac0225bde6d9ccf1 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Oct 2025 11:17:13 -0400 Subject: [PATCH 19/22] Add changeset --- .changeset/serious-garlics-push.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/serious-garlics-push.md 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 From 3df673e1d5c58fda5a7e58214ae66749d5390583 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Oct 2025 11:29:16 -0400 Subject: [PATCH 20/22] Rename pattern -> unstable_pattern --- .../__tests__/router/fetchers-test.ts | 16 ++++++------ .../__tests__/router/instrumentation-test.ts | 14 +++++------ .../__tests__/router/router-test.ts | 14 +++++------ .../__tests__/router/submission-test.ts | 12 ++++----- packages/react-router/lib/dom/ssr/routes.tsx | 8 +++--- .../lib/router/instrumentation.ts | 6 ++--- packages/react-router/lib/router/router.ts | 25 +++++++++++-------- packages/react-router/lib/router/utils.ts | 2 +- .../react-router/lib/server-runtime/data.ts | 2 +- packages/react-router/lib/types/route-data.ts | 4 +-- 10 files changed, 53 insertions(+), 50 deletions(-) diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 4d516b16ae..0fdf16c762 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -373,7 +373,7 @@ describe("fetchers", () => { request: new Request("http://localhost/foo", { signal: A.loaders.root.stub.mock.calls[0][0].request.signal, }), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); }); @@ -3374,7 +3374,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -3404,7 +3404,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -3432,7 +3432,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -3460,7 +3460,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -3489,7 +3489,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -3520,7 +3520,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -3550,7 +3550,7 @@ describe("fetchers", () => { expect(F.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + 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 index 749e85ad59..9ef2eeba36 100644 --- a/packages/react-router/__tests__/router/instrumentation-test.ts +++ b/packages/react-router/__tests__/router/instrumentation-test.ts @@ -1132,7 +1132,7 @@ describe("instrumentation", () => { expect(args.request.headers.get).toBeDefined(); expect(args.request.headers.set).not.toBeDefined(); expect(args.params).toEqual({ slug: "a", extra: "extra" }); - expect(args.pattern).toBe("/:slug"); + 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" }); @@ -1663,7 +1663,7 @@ describe("instrumentation", () => { }, }, params: {}, - pattern: "/", + unstable_pattern: "/", context: { get: expect.any(Function), }, @@ -1682,7 +1682,7 @@ describe("instrumentation", () => { }, }, params: {}, - pattern: "/", + unstable_pattern: "/", context: { get: expect.any(Function), }, @@ -1739,7 +1739,7 @@ describe("instrumentation", () => { }, }, params: {}, - pattern: "/", + unstable_pattern: "/", context: { get: expect.any(Function), }, @@ -1757,7 +1757,7 @@ describe("instrumentation", () => { }, }, params: {}, - pattern: "/", + unstable_pattern: "/", context: { get: expect.any(Function), }, @@ -1816,7 +1816,7 @@ describe("instrumentation", () => { }, }, params: {}, - pattern: "/", + unstable_pattern: "/", context: { get: expect.any(Function), }, @@ -1834,7 +1834,7 @@ describe("instrumentation", () => { }, }, params: {}, - pattern: "/", + 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 3580b2d176..df63df71b3 100644 --- a/packages/react-router/__tests__/router/router-test.ts +++ b/packages/react-router/__tests__/router/router-test.ts @@ -1503,7 +1503,7 @@ describe("a router", () => { request: new Request("http://localhost/tasks", { signal: nav.loaders.tasks.stub.mock.calls[0][0].request.signal, }), - pattern: "/tasks", + unstable_pattern: "/tasks", context: {}, }); @@ -1513,7 +1513,7 @@ describe("a router", () => { request: new Request("http://localhost/tasks/1", { signal: nav2.loaders.tasksId.stub.mock.calls[0][0].request.signal, }), - pattern: "/tasks/:id", + unstable_pattern: "/tasks/:id", context: {}, }); @@ -1523,7 +1523,7 @@ describe("a router", () => { request: new Request("http://localhost/tasks?foo=bar", { signal: nav3.loaders.tasks.stub.mock.calls[0][0].request.signal, }), - pattern: "/tasks", + unstable_pattern: "/tasks", context: {}, }); @@ -1535,7 +1535,7 @@ describe("a router", () => { request: new Request("http://localhost/tasks?foo=bar", { signal: nav4.loaders.tasks.stub.mock.calls[0][0].request.signal, }), - pattern: "/tasks", + unstable_pattern: "/tasks", context: {}, }); @@ -1934,7 +1934,7 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: "/tasks", + unstable_pattern: "/tasks", context: {}, }); @@ -1979,7 +1979,7 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); // Assert request internals, cannot do a deep comparison above since some @@ -2013,7 +2013,7 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + 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 75e296227e..7cc38b1c31 100644 --- a/packages/react-router/__tests__/router/submission-test.ts +++ b/packages/react-router/__tests__/router/submission-test.ts @@ -948,7 +948,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -983,7 +983,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -1016,7 +1016,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -1121,7 +1121,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -1160,7 +1160,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); @@ -1196,7 +1196,7 @@ describe("submissions", () => { expect(nav.actions.root.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - pattern: expect.any(String), + unstable_pattern: expect.any(String), context: {}, }); diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 4ffaf4fe36..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, pattern }: LoaderFunctionArgs, + { request, params, context, unstable_pattern }: LoaderFunctionArgs, singleFetch?: unknown, ) => { try { @@ -358,7 +358,7 @@ export function createClientRoutes( request, params, context, - pattern, + unstable_pattern, async serverLoader() { preventInvalidServerHandlerCall("loader", route); @@ -394,7 +394,7 @@ export function createClientRoutes( ); dataRoute.action = ( - { request, params, context, pattern }: ActionFunctionArgs, + { request, params, context, unstable_pattern }: ActionFunctionArgs, singleFetch?: unknown, ) => { return prefetchStylesAndCallHandler(async () => { @@ -413,7 +413,7 @@ export function createClientRoutes( request, params, context, - pattern, + 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 index 4c391e2128..f54564b7bf 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -67,7 +67,7 @@ type RouteHandlerInstrumentationInfo = Readonly<{ headers: Pick; }; params: LoaderFunctionArgs["params"]; - pattern: string; + unstable_pattern: string; // TODO: Fix for non-middleware context: Pick; }>; @@ -196,7 +196,7 @@ async function recurseRight( function getInstrumentationInfo( args: LoaderFunctionArgs, ): RouteHandlerInstrumentationInfo { - let { request, context, params, pattern } = args; + let { request, context, params, unstable_pattern } = args; return { // pseudo "Request" with the info they may want to read from request: { @@ -208,7 +208,7 @@ function getInstrumentationInfo( }, }, params: { ...params }, - pattern, + unstable_pattern, context: { get: (...args: Parameters) => context.get(...args), diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 285d729cdb..74f9e6c320 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -3720,7 +3720,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, - pattern: getRoutePattern(matches.map((m) => m.route.path)), + 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 @@ -3952,7 +3952,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, - pattern: getRoutePattern(matches.map((m) => m.route.path)), + 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 @@ -4818,6 +4818,9 @@ function getMatchesToLoad( actionStatus, }; + let pattern = getRoutePattern(matches.map((m) => m.route.path)); + console.log(pattern, matches); + let dsMatches: DataStrategyMatch[] = matches.map((match, index) => { let { route } = match; @@ -4851,7 +4854,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, - getRoutePattern(matches.map((m) => m.route.path)), + pattern, match, lazyRoutePropertiesToSkip, scopedContext, @@ -4881,7 +4884,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, - getRoutePattern(matches.map((m) => m.route.path)), + pattern, match, lazyRoutePropertiesToSkip, scopedContext, @@ -5653,7 +5656,7 @@ async function runMiddlewarePipeline( request, params, context, - pattern: getRoutePattern(matches.map((m) => m.route.path)), + unstable_pattern: getRoutePattern(matches.map((m) => m.route.path)), }, tuples, handler, @@ -5776,7 +5779,7 @@ function getDataStrategyMatch( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, - pattern: string, + unstable_pattern: string, match: DataRouteMatch, lazyRoutePropertiesToSkip: string[], scopedContext: unknown, @@ -5834,7 +5837,7 @@ function getDataStrategyMatch( if (callHandler && !isMiddlewareOnlyRoute) { return callLoaderOrAction({ request, - pattern, + unstable_pattern, match, lazyHandlerPromise: _lazyPromises?.handler, lazyRoutePromise: _lazyPromises?.route, @@ -5909,7 +5912,7 @@ async function callDataStrategyImpl( // back out below. let dataStrategyArgs = { request, - pattern: getRoutePattern(matches.map((m) => m.route.path)), + unstable_pattern: getRoutePattern(matches.map((m) => m.route.path)), params: matches[0].params, context: scopedContext, matches, @@ -5967,7 +5970,7 @@ async function callDataStrategyImpl( // Default logic for calling a loader/action is the user has no specified a dataStrategy async function callLoaderOrAction({ request, - pattern, + unstable_pattern, match, lazyHandlerPromise, lazyRoutePromise, @@ -5975,7 +5978,7 @@ async function callLoaderOrAction({ scopedContext, }: { request: Request; - pattern: string; + unstable_pattern: string; match: AgnosticDataRouteMatch; lazyHandlerPromise: Promise | undefined; lazyRoutePromise: Promise | undefined; @@ -6009,7 +6012,7 @@ async function callLoaderOrAction({ return handler( { request, - pattern, + 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 0ae2d2e45c..9d3fbcd864 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -273,7 +273,7 @@ interface DataFunctionArgs { * 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. */ - pattern: string; + unstable_pattern: string; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example diff --git a/packages/react-router/lib/server-runtime/data.ts b/packages/react-router/lib/server-runtime/data.ts index 17df22d468..db680dfd78 100644 --- a/packages/react-router/lib/server-runtime/data.ts +++ b/packages/react-router/lib/server-runtime/data.ts @@ -26,7 +26,7 @@ export async function callRouteHandler( request: stripRoutesParam(stripIndexParam(args.request)), params: args.params, context: args.context, - pattern: args.pattern, + 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/types/route-data.ts b/packages/react-router/lib/types/route-data.ts index 9efebafa8e..52eefee088 100644 --- a/packages/react-router/lib/types/route-data.ts +++ b/packages/react-router/lib/types/route-data.ts @@ -96,7 +96,7 @@ export type ClientDataFunctionArgs = { * 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. */ - pattern: string; + unstable_pattern: string; /** * When `future.v8_middleware` is not enabled, this is undefined. * @@ -130,7 +130,7 @@ export type ServerDataFunctionArgs = { * 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. */ - pattern: string; + 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 From a65d6f52a0bdbf4123412b2eefa5062d0ddcf687 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Oct 2025 12:22:07 -0400 Subject: [PATCH 21/22] Exports and lint fixes --- packages/react-router/index.ts | 7 +++++++ packages/react-router/lib/dom-export/hydrated-router.tsx | 2 +- packages/react-router/lib/dom/lib.tsx | 7 +------ 3 files changed, 9 insertions(+), 7 deletions(-) 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/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 57b52c96a1..d962eecc4a 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -27,7 +27,7 @@ import { } from "react-router"; import { CRITICAL_CSS_DATA_ATTRIBUTE } from "../dom/ssr/components"; import { RouterProvider } from "./dom-router-provider"; -import { unstable_ClientInstrumentation } from "../router/instrumentation"; +import type { unstable_ClientInstrumentation } from "../router/instrumentation"; type SSRInfo = { context: NonNullable<(typeof window)["__reactRouterContext"]>; diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 372ca2e342..3a57a0d34e 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -95,12 +95,7 @@ import { useRouteId, } from "../hooks"; import type { SerializeFrom } from "../types/route-data"; -import { - instrumentClientSideRouter, - unstable_ClientInstrumentation, - type unstable_InstrumentRouteFunction, - type unstable_InstrumentRouterFunction, -} from "../router/instrumentation"; +import type { unstable_ClientInstrumentation } from "../router/instrumentation"; //////////////////////////////////////////////////////////////////////////////// //#region Global Stuff From e0b6ce9ab9c02a66aafc856d5f0ed1ac3f0df01c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Oct 2025 15:59:08 -0400 Subject: [PATCH 22/22] Update implementaion for better typings --- .../lib/router/instrumentation.ts | 516 +++++++++--------- packages/react-router/lib/router/router.ts | 17 +- 2 files changed, 267 insertions(+), 266 deletions(-) diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index f54564b7bf..668bc5f02c 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -38,10 +38,26 @@ export type unstable_InstrumentRouteFunction = ( ) => void; // Shared -interface GenericInstrumentFunction { - (handler: () => Promise, info: unknown): Promise; +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; @@ -51,46 +67,32 @@ type InstrumentableRoute = { }; type RouteInstrumentations = { - lazy?: InstrumentLazyFunction; - "lazy.loader"?: InstrumentLazyFunction; - "lazy.action"?: InstrumentLazyFunction; - "lazy.middleware"?: InstrumentLazyFunction; - middleware?: InstrumentRouteHandlerFunction; - loader?: InstrumentRouteHandlerFunction; - action?: InstrumentRouteHandlerFunction; + lazy?: InstrumentFunction; + "lazy.loader"?: InstrumentFunction; + "lazy.action"?: InstrumentFunction; + "lazy.middleware"?: InstrumentFunction; + middleware?: InstrumentFunction; + loader?: InstrumentFunction; + action?: InstrumentFunction; }; +type RouteLazyInstrumentationInfo = undefined; + type RouteHandlerInstrumentationInfo = Readonly<{ - request: { - method: string; - url: string; - headers: Pick; - }; + request: ReadonlyRequest; params: LoaderFunctionArgs["params"]; unstable_pattern: string; - // TODO: Fix for non-middleware - context: Pick; + context: ReadonlyContext; }>; -interface InstrumentLazyFunction extends GenericInstrumentFunction { - (handler: () => Promise): Promise; -} - -interface InstrumentRouteHandlerFunction extends GenericInstrumentFunction { - ( - handler: () => Promise, - info: RouteHandlerInstrumentationInfo, - ): Promise; -} - // Router Instrumentation type InstrumentableRouter = { instrument(instrumentations: RouterInstrumentations): void; }; type RouterInstrumentations = { - navigate?: InstrumentNavigateFunction; - fetch?: InstrumentFetchFunction; + navigate?: InstrumentFunction; + fetch?: InstrumentFunction; }; type RouterNavigationInstrumentationInfo = Readonly<{ @@ -112,139 +114,56 @@ type RouterFetchInstrumentationInfo = Readonly<{ body?: any; }>; -interface InstrumentNavigateFunction extends GenericInstrumentFunction { - ( - handler: () => Promise, - info: RouterNavigationInstrumentationInfo, - ): MaybePromise; -} - -interface InstrumentFetchFunction extends GenericInstrumentFunction { - ( - handler: () => Promise, - info: RouterFetchInstrumentationInfo, - ): MaybePromise; -} - // Request Handler Instrumentation type InstrumentableRequestHandler = { instrument(instrumentations: RequestHandlerInstrumentations): void; }; type RequestHandlerInstrumentations = { - request?: InstrumentRequestHandlerFunction; + request?: InstrumentFunction; }; type RequestHandlerInstrumentationInfo = Readonly<{ - request: { - method: string; - url: string; - headers: Pick; - }; - // TODO: Fix for non-middleware - context: Pick; + request: ReadonlyRequest; + context: ReadonlyContext; }>; -interface InstrumentRequestHandlerFunction extends GenericInstrumentFunction { - ( - handler: () => Promise, - info: RequestHandlerInstrumentationInfo, - ): MaybePromise; -} - const UninstrumentedSymbol = Symbol("Uninstrumented"); -function getInstrumentedImplementation( - impls: GenericInstrumentFunction[], - handler: (...args: any[]) => Promise, - getInfo: (...args: unknown[]) => unknown = () => undefined, -) { - 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: GenericInstrumentFunction[], - info: Parameters[1] | undefined, - 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 getInstrumentationInfo( - args: LoaderFunctionArgs, -): RouteHandlerInstrumentationInfo { - let { request, context, params, unstable_pattern } = args; - return { - // pseudo "Request" with the info they may want to read from - request: { - method: request.method, - url: request.url, - // Maybe make this a proxy that only supports `get`? - headers: { - get: (...args) => request.headers.get(...args), - }, - }, - params: { ...params }, - unstable_pattern, - context: { - get: (...args: Parameters) => - context.get(...args), - }, - }; -} - -function getInstrumentationsByType< - T extends - | RouteInstrumentations - | RouterInstrumentations - | RequestHandlerInstrumentations, - K extends keyof T, ->(instrumentations: T[], key: K): GenericInstrumentFunction[] { - let value: GenericInstrumentFunction[] = []; - for (let i in instrumentations) { - let instrumentation = instrumentations[i]; - if (key in instrumentation && instrumentation[key] != null) { - value.push(instrumentation[key] as GenericInstrumentFunction); - } - } - return value; -} - -export function getInstrumentationUpdates( +export function getRouteInstrumentationUpdates( fns: unstable_InstrumentRouteFunction[], route: Readonly, ) { - let instrumentations: RouteInstrumentations[] = []; + 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) { - instrumentations.push(i); + let keys = Object.keys(aggregated) as Array; + for (let key of keys) { + if (i[key]) { + aggregated[key].push(i[key] as any); + } + } }, }), ); @@ -256,72 +175,69 @@ export function getInstrumentationUpdates( lazy?: AgnosticDataRouteObject["lazy"]; } = {}; - if (instrumentations.length > 0) { - // Instrument lazy, loader, and action functions - (["lazy", "loader", "action"] as const).forEach((key) => { - let func = route[key]; - if (typeof func === "function") { - // @ts-expect-error - let original = func[UninstrumentedSymbol] ?? func; - let instrumented = getInstrumentedImplementation( - getInstrumentationsByType(instrumentations, key), - original, - key === "lazy" - ? () => undefined - : (...args) => - getInstrumentationInfo( - args[0] as LoaderFunctionArgs | ActionFunctionArgs, - ), - ); + // 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) { - // @ts-expect-error - instrumented[UninstrumentedSymbol] = original; - updates[key] = instrumented; + updates.lazy = Object.assign(updates.lazy || {}, { + [key]: instrumented, + }); } } }); + } - // Instrument middleware functions - if (route.middleware && route.middleware.length > 0) { - updates.middleware = route.middleware.map((middleware) => { + // 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 - let original = middleware[UninstrumentedSymbol] ?? middleware; - let instrumented = getInstrumentedImplementation( - getInstrumentationsByType(instrumentations, "middleware"), - original, - (...args) => - getInstrumentationInfo( - args[0] as Parameters[0], - ), - ); - if (instrumented) { - // @ts-expect-error - instrumented[UninstrumentedSymbol] = original; - return instrumented; - } - return middleware; - }); - } - - // Instrument the lazy object format - if (typeof route.lazy === "object") { - let lazyObject: LazyRouteObject = route.lazy; - (["middleware", "loader", "action"] as const).forEach((key) => { - let func = lazyObject[key]; - if (typeof func === "function") { - let instrumented = getInstrumentedImplementation( - getInstrumentationsByType(instrumentations, `lazy.${key}`), - func, - ); - if (instrumented) { - updates.lazy = Object.assign(updates.lazy || {}, { - [key]: instrumented, - }); - } - } - }); + 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; } @@ -329,38 +245,44 @@ export function instrumentClientSideRouter( router: Router, fns: unstable_InstrumentRouterFunction[], ): Router { - let instrumentations: RouterInstrumentations[] = []; + let aggregated: { + navigate: InstrumentFunction[]; + fetch: InstrumentFunction[]; + } = { + navigate: [], + fetch: [], + }; + fns.forEach((fn) => fn({ instrument(i) { - instrumentations.push(i); + let keys = Object.keys(i) as Array; + for (let key of keys) { + if (i[key]) { + aggregated[key].push(i[key] as any); + } + } }, }), ); - if (instrumentations.length > 0) { + if (aggregated.navigate.length > 0) { // @ts-expect-error let navigate = router.navigate[UninstrumentedSymbol] ?? router.navigate; - let instrumentedNavigate = getInstrumentedImplementation( - getInstrumentationsByType(instrumentations, "navigate"), + let instrumentedNavigate = wrapImpl( + aggregated.navigate, navigate, (...args) => { let [to, opts] = args as Parameters; - opts = opts ?? {}; - let info: RouterNavigationInstrumentationInfo = { + return { to: typeof to === "number" || typeof to === "string" ? to : to ? createPath(to) : ".", - 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 info; + ...getRouterInfo(router, opts ?? {}), + } satisfies RouterNavigationInstrumentationInfo; }, ) as Router["navigate"]; if (instrumentedNavigate) { @@ -368,27 +290,19 @@ export function instrumentClientSideRouter( instrumentedNavigate[UninstrumentedSymbol] = navigate; router.navigate = instrumentedNavigate; } + } + if (aggregated.fetch.length > 0) { // @ts-expect-error let fetch = router.fetch[UninstrumentedSymbol] ?? router.fetch; - let instrumentedFetch = getInstrumentedImplementation( - getInstrumentationsByType(instrumentations, "fetch"), - fetch, - (...args) => { - let [key, , href, opts] = args as Parameters; - opts = opts ?? {}; - let info: RouterFetchInstrumentationInfo = { - href: href ?? ".", - currentUrl: createPath(router.state.location), - fetcherKey: key, - ...("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 info; - }, - ) as 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; @@ -403,42 +317,130 @@ export function instrumentHandler( handler: RequestHandler, fns: unstable_InstrumentRequestHandlerFunction[], ): RequestHandler { - let instrumentations: RequestHandlerInstrumentations[] = []; + let aggregated: { + request: InstrumentFunction[]; + } = { + request: [], + }; + fns.forEach((fn) => fn({ instrument(i) { - instrumentations.push(i); + let keys = Object.keys(i) as Array; + for (let key of keys) { + if (i[key]) { + aggregated[key].push(i[key] as any); + } + } }, }), ); - if (instrumentations.length === 0) { - return handler; - } + let instrumentedHandler = handler; - let instrumentedHandler = getInstrumentedImplementation( - getInstrumentationsByType(instrumentations, "request"), - handler, - (...args) => { + if (aggregated.request.length > 0) { + instrumentedHandler = wrapImpl(aggregated.request, handler, (...args) => { let [request, context] = args as Parameters; - let info: RequestHandlerInstrumentationInfo = { - request: { - method: request.method, - url: request.url, - headers: { - get: (...args) => request.headers.get(...args), - }, - }, - context: { - get: (ctx: RouterContext) => - context - ? (context as unknown as RouterContextProvider).get(ctx) - : (undefined as T), - }, - }; - return info; + 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), }, - ) as RequestHandler; + }; +} - return instrumentedHandler ?? handler; +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 74f9e6c320..b2531f9a35 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -16,7 +16,7 @@ import type { unstable_ServerInstrumentation, } from "./instrumentation"; import { - getInstrumentationUpdates, + getRouteInstrumentationUpdates, instrumentClientSideRouter, } from "./instrumentation"; import type { @@ -889,7 +889,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties = (route: AgnosticDataRouteObject) => { return { ..._mapRouteProperties(route), - ...getInstrumentationUpdates( + ...getRouteInstrumentationUpdates( instrumentations .map((i) => i.route) .filter(Boolean) as unstable_InstrumentRouteFunction[], @@ -3585,7 +3585,7 @@ export function createStaticHandler( mapRouteProperties = (route: AgnosticDataRouteObject) => { return { ..._mapRouteProperties(route), - ...getInstrumentationUpdates( + ...getRouteInstrumentationUpdates( instrumentations .map((i) => i.route) .filter(Boolean) as unstable_InstrumentRouteFunction[], @@ -4345,13 +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, - getRoutePattern(matches.map((m) => m.route.path)), + pattern, match, [], requestContext, @@ -4363,7 +4364,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, - getRoutePattern(matches.map((m) => m.route.path)), + pattern, match, [], requestContext, @@ -4819,8 +4820,6 @@ function getMatchesToLoad( }; let pattern = getRoutePattern(matches.map((m) => m.route.path)); - console.log(pattern, matches); - let dsMatches: DataStrategyMatch[] = matches.map((match, index) => { let { route } = match; @@ -5646,7 +5645,7 @@ 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][]; @@ -5656,7 +5655,7 @@ async function runMiddlewarePipeline( request, params, context, - unstable_pattern: getRoutePattern(matches.map((m) => m.route.path)), + unstable_pattern, }, tuples, handler,