From 60dc958b5bf2d0b3064233d13f912932ce8a373b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 3 Feb 2026 14:32:47 -0500 Subject: [PATCH 1/6] Add future.unstable_passThroughRequests flag --- .changeset/stupid-lamps-confess.md | 6 ++++ packages/react-router-dev/config/config.ts | 3 ++ .../lib/dom-export/hydrated-router.tsx | 3 +- packages/react-router/lib/dom/server.tsx | 1 + packages/react-router/lib/dom/ssr/entry.ts | 1 + .../lib/dom/ssr/routes-test-stub.tsx | 2 ++ packages/react-router/lib/router/router.ts | 30 ++++++++++++++----- packages/react-router/lib/rsc/browser.tsx | 1 + packages/react-router/lib/rsc/server.ssr.tsx | 1 + .../react-router/lib/server-runtime/data.ts | 11 ++++--- .../react-router/lib/server-runtime/routes.ts | 8 +++-- .../react-router/lib/server-runtime/server.ts | 12 ++++---- .../lib/server-runtime/single-fetch.ts | 27 ++++++++++------- 13 files changed, 73 insertions(+), 33 deletions(-) create mode 100644 .changeset/stupid-lamps-confess.md diff --git a/.changeset/stupid-lamps-confess.md b/.changeset/stupid-lamps-confess.md new file mode 100644 index 0000000000..8fb22dd8ee --- /dev/null +++ b/.changeset/stupid-lamps-confess.md @@ -0,0 +1,6 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +Add future.unstable_passThroughRequests flag diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 0052aeb646..7caf7ed27d 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -86,6 +86,7 @@ type ValidateConfigFunction = (config: ReactRouterConfig) => string | void; interface FutureConfig { unstable_optimizeDeps: boolean; + unstable_passThroughRequests: boolean; unstable_subResourceIntegrity: boolean; unstable_trailingSlashAwareDataRequests: boolean; /** @@ -684,6 +685,8 @@ async function resolveConfig({ let future: FutureConfig = { unstable_optimizeDeps: userAndPresetConfigs.future?.unstable_optimizeDeps ?? false, + unstable_passThroughRequests: + userAndPresetConfigs.future?.unstable_passThroughRequests ?? false, unstable_subResourceIntegrity: userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? false, unstable_trailingSlashAwareDataRequests: diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index e2dc320796..50e614e574 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -178,7 +178,8 @@ function createHydratedRouter({ unstable_instrumentations, mapRouteProperties, future: { - middleware: ssrInfo.context.future.v8_middleware, + unstable_passThroughRequests: + ssrInfo.context.future.unstable_passThroughRequests, }, dataStrategy: getTurboStreamSingleFetchDataStrategy( () => router, diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx index b054950bc2..b2e2cae21a 100644 --- a/packages/react-router/lib/dom/server.tsx +++ b/packages/react-router/lib/dom/server.tsx @@ -423,6 +423,7 @@ export function createStaticRouter( get future() { return { v8_middleware: false, + unstable_passThroughRequests: false, ...opts?.future, }; }, diff --git a/packages/react-router/lib/dom/ssr/entry.ts b/packages/react-router/lib/dom/ssr/entry.ts index f965b43444..c8eaf06c74 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -44,6 +44,7 @@ export interface EntryContext extends FrameworkContextObject { } export interface FutureConfig { + unstable_passThroughRequests: boolean; unstable_subResourceIntegrity: boolean; unstable_trailingSlashAwareDataRequests: boolean; v8_middleware: boolean; diff --git a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx index b9038287cc..b54f740b89 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -132,6 +132,8 @@ export function createRoutesStub( if (routerRef.current == null) { frameworkContextRef.current = { future: { + unstable_passThroughRequests: + future?.unstable_passThroughRequests === true, unstable_subResourceIntegrity: future?.unstable_subResourceIntegrity === true, v8_middleware: future?.v8_middleware === true, diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 34c74cc28d..61148bc124 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -404,7 +404,9 @@ export type HydrationState = Partial< /** * Future flags to toggle new feature behavior */ -export interface FutureConfig {} +export interface FutureConfig { + unstable_passThroughRequests: boolean; +} /** * Initialization options for createRouter @@ -917,6 +919,7 @@ export function createRouter(init: RouterInit): Router { // Config driven behavior flags let future: FutureConfig = { + unstable_passThroughRequests: false, ...init.future, }; // Cleanup function for history @@ -3688,7 +3691,7 @@ export interface CreateStaticHandlerOptions { basename?: string; mapRouteProperties?: MapRoutePropertiesFunction; unstable_instrumentations?: Pick[]; - future?: {}; + future?: Partial; } export function createStaticHandler( @@ -3705,6 +3708,10 @@ export function createStaticHandler( let _mapRouteProperties = opts?.mapRouteProperties || defaultMapRouteProperties; let mapRouteProperties = _mapRouteProperties; + let future: FutureConfig = { + unstable_passThroughRequests: false, + ...opts?.future, + }; // Leverage the existing mapRouteProperties logic to execute instrumentRoute // (if it exists) on all routes in the application @@ -4363,11 +4370,20 @@ export function createStaticHandler( } // Create a GET request for the loaders - let loaderRequest = new Request(request.url, { - headers: request.headers, - redirect: request.redirect, - signal: request.signal, - }); + let loaderRequest: Request; + if (future.unstable_passThroughRequests) { + // Don't permit loaders to read from POST request bodies + if (!request.bodyUsed) { + request.body?.cancel(); + } + loaderRequest = request; + } else { + loaderRequest = new Request(request.url, { + headers: request.headers, + redirect: request.redirect, + signal: request.signal, + }); + } if (isErrorResult(result)) { // Store off the pending error - we use it to determine which loaders diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 865ef8832f..bc779e48b4 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -824,6 +824,7 @@ export function RSCHydratedRouter({ v8_middleware: false, unstable_subResourceIntegrity: false, unstable_trailingSlashAwareDataRequests: true, // always on for RSC + unstable_passThroughRequests: true, // always on for RSC }, isSpaMode: false, ssr: true, diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 845d04544f..f4b871ea30 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -581,6 +581,7 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { v8_middleware: false, unstable_subResourceIntegrity: false, unstable_trailingSlashAwareDataRequests: true, // always on for RSC + unstable_passThroughRequests: true, // always on for RSC }, isSpaMode: false, ssr: true, diff --git a/packages/react-router/lib/server-runtime/data.ts b/packages/react-router/lib/server-runtime/data.ts index db680dfd78..ffb2306891 100644 --- a/packages/react-router/lib/server-runtime/data.ts +++ b/packages/react-router/lib/server-runtime/data.ts @@ -4,6 +4,7 @@ import type { LoaderFunctionArgs, ActionFunctionArgs, } from "../router/utils"; +import type { FutureConfig } from "../router/router"; import { isDataWithResponseInit, isRedirectStatusCode } from "../router/router"; /** @@ -21,9 +22,12 @@ export interface AppLoadContext { export async function callRouteHandler( handler: LoaderFunction | ActionFunction, args: LoaderFunctionArgs | ActionFunctionArgs, + future: FutureConfig, ) { let result = await handler({ - request: stripRoutesParam(stripIndexParam(args.request)), + request: future.unstable_passThroughRequests + ? args.request + : stripRoutesParam(stripIndexParam(args.request)), params: args.params, context: args.context, unstable_pattern: args.unstable_pattern, @@ -42,11 +46,6 @@ export async function callRouteHandler( return result; } -// TODO: Document these search params better -// and stop stripping these in V2. These break -// support for running in a SW and also expose -// valuable info to data funcs that is being asked -// for such as "is this a data request?". function stripIndexParam(request: Request) { let url = new URL(request.url); let indexValues = url.searchParams.getAll("index"); diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index a445101e44..32097d2749 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -131,13 +131,17 @@ export function createStaticHandlerDataRoutes( return result.data; } } - let val = await callRouteHandler(route.module.loader!, args); + let val = await callRouteHandler( + route.module.loader!, + args, + future, + ); return val; } : undefined, action: route.module.action ? (args: RRActionFunctionArgs) => - callRouteHandler(route.module.action!, args) + callRouteHandler(route.module.action!, args, future) : undefined, handle: route.module.handle, }; diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index ea1df0ddad..8a77ced2c3 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -221,12 +221,9 @@ function derive(build: ServerBuild, mode?: string) { let response: Response; if (url.pathname.endsWith(".data")) { - let handlerUrl = new URL(request.url); - handlerUrl.pathname = normalizedPath; - let singleFetchMatches = matchServerRoutes( routes, - handlerUrl.pathname, + normalizedPath, build.basename, ); @@ -235,7 +232,7 @@ function derive(build: ServerBuild, mode?: string) { build, staticHandler, request, - handlerUrl, + normalizedPath, loadContext, handleError, ); @@ -443,10 +440,13 @@ async function handleSingleFetchRequest( build: ServerBuild, staticHandler: StaticHandler, request: Request, - handlerUrl: URL, + normalizedPath: string, loadContext: AppLoadContext | RouterContextProvider, handleError: (err: unknown) => void, ): Promise { + let handlerUrl = new URL(request.url); + handlerUrl.pathname = normalizedPath; + let response = request.method !== "GET" ? await singleFetchAction( diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 9785c5176e..613e809272 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -24,6 +24,7 @@ import { ServerMode } from "./mode"; import { getDocumentHeaders } from "./headers"; import type { ServerBuild } from "./build"; import { throwIfPotentialCSRFAttack } from "../actions"; +import { FutureConfig } from "../dom/ssr/entry"; // Add 304 for server side - that is not included in the client side logic // because the browser should fill those responses with the cached data @@ -54,13 +55,15 @@ export async function singleFetchAction( return handleQueryError(new Error("Bad Request"), 400); } - let handlerRequest = new Request(handlerUrl, { - method: request.method, - body: request.body, - headers: request.headers, - signal: request.signal, - ...(request.body ? { duplex: "half" } : undefined), - }); + let handlerRequest = build.future.unstable_passThroughRequests + ? request + : new Request(handlerUrl, { + method: request.method, + body: request.body, + headers: request.headers, + signal: request.signal, + ...(request.body ? { duplex: "half" } : undefined), + }); let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, @@ -147,10 +150,12 @@ export async function singleFetchLoaders( let loadRouteIds = routesParam ? new Set(routesParam.split(",")) : null; try { - let handlerRequest = new Request(handlerUrl, { - headers: request.headers, - signal: request.signal, - }); + let handlerRequest = build.future.unstable_passThroughRequests + ? request + : new Request(handlerUrl, { + headers: request.headers, + signal: request.signal, + }); let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, From a2745d939dd14e4cd8afa7d9e81cc6e38dc21b82 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 4 Feb 2026 11:14:47 -0500 Subject: [PATCH 2/6] Add unstable_url param --- packages/react-router/lib/dom/ssr/routes.tsx | 18 ++- packages/react-router/lib/router/router.ts | 125 +++++++++++++++--- packages/react-router/lib/router/utils.ts | 8 ++ packages/react-router/lib/rsc/server.rsc.ts | 5 + .../react-router/lib/server-runtime/data.ts | 1 + .../react-router/lib/server-runtime/server.ts | 41 ++---- .../lib/server-runtime/single-fetch.ts | 5 + .../react-router/lib/server-runtime/urls.ts | 55 ++++++++ packages/react-router/lib/types/route-data.ts | 16 +++ 9 files changed, 228 insertions(+), 46 deletions(-) create mode 100644 packages/react-router/lib/server-runtime/urls.ts diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 0c1682c250..ebe2a02715 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -340,7 +340,13 @@ export function createClientRoutes( (routeModule.clientLoader?.hydrate === true || !route.hasLoader); dataRoute.loader = async ( - { request, params, context, unstable_pattern }: LoaderFunctionArgs, + { + request, + params, + context, + unstable_pattern, + unstable_url, + }: LoaderFunctionArgs, singleFetch?: unknown, ) => { try { @@ -359,6 +365,7 @@ export function createClientRoutes( params, context, unstable_pattern, + unstable_url, async serverLoader() { preventInvalidServerHandlerCall("loader", route); @@ -394,7 +401,13 @@ export function createClientRoutes( ); dataRoute.action = ( - { request, params, context, unstable_pattern }: ActionFunctionArgs, + { + request, + params, + context, + unstable_pattern, + unstable_url, + }: ActionFunctionArgs, singleFetch?: unknown, ) => { return prefetchStylesAndCallHandler(async () => { @@ -414,6 +427,7 @@ export function createClientRoutes( params, context, unstable_pattern, + unstable_url, 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 61148bc124..f73690392e 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -463,6 +463,7 @@ export interface StaticHandler { }, ) => Promise, ) => MaybePromise; + unstable_normalizeUrl?: (r: Request) => URL; }, ): Promise; queryRoute( @@ -474,6 +475,7 @@ export interface StaticHandler { generateMiddlewareResponse?: ( queryRoute: (r: Request) => Promise, ) => MaybePromise; + unstable_normalizeUrl?: (r: Request) => URL; }, ): Promise; } @@ -1753,6 +1755,7 @@ export function createRouter(init: RouterInit): Router { pendingNavigationController.signal, opts && opts.submission, ); + let normalizedUrl = createNormalizedUrl(request); // Create a new context per navigation let scopedContext = init.getContext ? await init.getContext() @@ -1776,6 +1779,7 @@ export function createRouter(init: RouterInit): Router { // Call action if we received an action submission let actionResult = await handleAction( request, + normalizedUrl, location, opts.submission, matches, @@ -1819,11 +1823,18 @@ export function createRouter(init: RouterInit): Router { fogOfWar.active = false; // Create a GET request for the loaders - request = createClientSideRequest( - init.history, - request.url, - request.signal, - ); + if (future.unstable_passThroughRequests) { + // Don't let loaders consume any request bodies + if (!request.bodyUsed) { + request.body?.cancel(); + } + } else { + request = createClientSideRequest( + init.history, + request.url, + request.signal, + ); + } } // Call loaders @@ -1834,6 +1845,7 @@ export function createRouter(init: RouterInit): Router { errors, } = await handleLoaders( request, + normalizedUrl, location, matches, scopedContext, @@ -1869,6 +1881,7 @@ export function createRouter(init: RouterInit): Router { // redirects/errors async function handleAction( request: Request, + normalizedUrl: URL, location: Location, submission: Submission, matches: AgnosticDataRouteMatch[], @@ -1955,6 +1968,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, request, + normalizedUrl, matches, actionMatch, initialHydration ? [] : hydrationRouteProperties, @@ -2041,6 +2055,7 @@ export function createRouter(init: RouterInit): Router { // errors, etc. async function handleLoaders( request: Request, + normalizedUrl: URL, location: Location, matches: AgnosticDataRouteMatch[], scopedContext: RouterContextProvider, @@ -2141,6 +2156,7 @@ export function createRouter(init: RouterInit): Router { let routesToUse = inFlightDataRoutes || dataRoutes; let { dsMatches, revalidatingFetchers } = getMatchesToLoad( request, + normalizedUrl, scopedContext, mapRouteProperties, manifest, @@ -2453,6 +2469,7 @@ export function createRouter(init: RouterInit): Router { abortController.signal, submission, ); + let normalizedFetcherUrl = createNormalizedUrl(fetchRequest); if (isFogOfWar) { let discoverResult = await discoverRoutes( @@ -2500,6 +2517,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, fetchRequest, + normalizedFetcherUrl, requestMatches, match, hydrationRouteProperties, @@ -2576,6 +2594,7 @@ export function createRouter(init: RouterInit): Router { nextLocation, abortController.signal, ); + let normalizedUrl = createNormalizedUrl(revalidationRequest); let routesToUse = inFlightDataRoutes || dataRoutes; let matches = state.navigation.state !== "idle" @@ -2592,6 +2611,7 @@ export function createRouter(init: RouterInit): Router { let { dsMatches, revalidatingFetchers } = getMatchesToLoad( revalidationRequest, + normalizedUrl, scopedContext, mapRouteProperties, manifest, @@ -2769,6 +2789,7 @@ export function createRouter(init: RouterInit): Router { path, abortController.signal, ); + let normalizedFetcherUrl = createNormalizedUrl(fetchRequest); if (isFogOfWar) { let discoverResult = await discoverRoutes( @@ -2806,6 +2827,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, fetchRequest, + normalizedFetcherUrl, matches, match, hydrationRouteProperties, @@ -3024,6 +3046,7 @@ export function createRouter(init: RouterInit): Router { results = await callDataStrategyImpl( dataStrategyImpl as DataStrategyFunction, request, + createNormalizedUrl(request), matches, fetcherKey, scopedContext, @@ -3773,11 +3796,19 @@ export function createStaticHandler( skipRevalidation, dataStrategy, generateMiddlewareResponse, + unstable_normalizeUrl, }: Parameters[1] = {}, ): Promise { - let url = new URL(request.url); + let normalizedUrl = unstable_normalizeUrl + ? unstable_normalizeUrl(request) + : new URL(request.url); let method = request.method; - let location = createLocation("", createPath(url), null, "default"); + let location = createLocation( + "", + createPath(normalizedUrl), + null, + "default", + ); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -3856,6 +3887,9 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + unstable_url: unstable_normalizeUrl + ? unstable_normalizeUrl(request) + : createNormalizedUrl(request), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -3875,6 +3909,7 @@ export function createStaticHandler( ) => { let result = await queryImpl( revalidationRequest, + normalizedUrl, location, matches!, requestContext, @@ -3995,6 +4030,7 @@ export function createStaticHandler( let result = await queryImpl( request, + normalizedUrl, location, matches, requestContext, @@ -4048,11 +4084,19 @@ export function createStaticHandler( requestContext, dataStrategy, generateMiddlewareResponse, + unstable_normalizeUrl, }: Parameters[1] = {}, ): Promise { - let url = new URL(request.url); + let normalizedUrl = unstable_normalizeUrl + ? unstable_normalizeUrl(request) + : new URL(request.url); let method = request.method; - let location = createLocation("", createPath(url), null, "default"); + let location = createLocation( + "", + createPath(normalizedUrl), + null, + "default", + ); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -4088,6 +4132,9 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + unstable_url: unstable_normalizeUrl + ? unstable_normalizeUrl(request) + : createNormalizedUrl(request), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -4100,6 +4147,7 @@ export function createStaticHandler( async (innerRequest: Request) => { let result = await queryImpl( innerRequest, + normalizedUrl, location, matches!, requestContext, @@ -4137,6 +4185,7 @@ export function createStaticHandler( let result = await queryImpl( request, + normalizedUrl, location, matches, requestContext, @@ -4178,6 +4227,7 @@ export function createStaticHandler( async function queryImpl( request: Request, + normalizedUrl: URL, location: Location, matches: AgnosticDataRouteMatch[], requestContext: unknown, @@ -4196,6 +4246,7 @@ export function createStaticHandler( if (isMutationMethod(request.method)) { let result = await submit( request, + normalizedUrl, matches, routeMatch || getTargetMatch(matches, location), requestContext, @@ -4210,6 +4261,7 @@ export function createStaticHandler( let result = await loadRouteData( request, + normalizedUrl, matches, requestContext, dataStrategy, @@ -4245,6 +4297,7 @@ export function createStaticHandler( async function submit( request: Request, + normalizedUrl: URL, matches: AgnosticDataRouteMatch[], actionMatch: AgnosticDataRouteMatch, requestContext: unknown, @@ -4274,6 +4327,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + normalizedUrl, matches, actionMatch, [], @@ -4282,6 +4336,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, + normalizedUrl, dsMatches, isRouteRequest, requestContext, @@ -4394,6 +4449,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, + normalizedUrl, matches, requestContext, dataStrategy, @@ -4420,6 +4476,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, + normalizedUrl, matches, requestContext, dataStrategy, @@ -4443,6 +4500,7 @@ export function createStaticHandler( async function loadRouteData( request: Request, + normalizedUrl: URL, matches: AgnosticDataRouteMatch[], requestContext: unknown, dataStrategy: DataStrategyFunction | null, @@ -4478,6 +4536,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + normalizedUrl, matches, routeMatch, [], @@ -4497,6 +4556,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + normalizedUrl, pattern, match, [], @@ -4509,6 +4569,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + normalizedUrl, pattern, match, [], @@ -4538,6 +4599,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, + normalizedUrl, dsMatches, isRouteRequest, requestContext, @@ -4567,6 +4629,7 @@ export function createStaticHandler( // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( request: Request, + normalizedUrl: URL, matches: DataStrategyMatch[], isRouteRequest: boolean, requestContext: unknown, @@ -4575,6 +4638,7 @@ export function createStaticHandler( let results = await callDataStrategyImpl( dataStrategy || defaultDataStrategy, request, + normalizedUrl, matches, null, requestContext, @@ -4898,6 +4962,7 @@ function normalizeNavigateOptions( function getMatchesToLoad( request: Request, + normalizedUrl: URL, scopedContext: unknown, mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, @@ -4999,6 +5064,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + normalizedUrl, pattern, match, lazyRoutePropertiesToSkip, @@ -5043,6 +5109,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + normalizedUrl, pattern, match, lazyRoutePropertiesToSkip, @@ -5114,6 +5181,7 @@ function getMatchesToLoad( f.path, fetchController.signal, ); + let fetchUrl = createNormalizedUrl(fetchRequest); let fetcherDsMatches: DataStrategyMatch[] | null = null; @@ -5124,6 +5192,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + fetchUrl, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5138,6 +5207,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + fetchUrl, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5166,6 +5236,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + fetchUrl, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5820,18 +5891,13 @@ async function runMiddlewarePipeline( nextResult: { value: Result } | undefined, ) => Promise, ): Promise { - let { matches, request, params, context, unstable_pattern } = args; + let { matches, ...dataFnArgs } = args; let tuples = matches.flatMap((m) => m.route.middleware ? m.route.middleware.map((fn) => [m.route.id, fn]) : [], ) as [string, MiddlewareFunction][]; let result = await callRouteMiddleware( - { - request, - params, - context, - unstable_pattern, - }, + dataFnArgs, tuples, handler, processResult, @@ -5953,6 +6019,7 @@ function getDataStrategyMatch( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, + normalizedUrl: URL, unstable_pattern: string, match: DataRouteMatch, lazyRoutePropertiesToSkip: string[], @@ -6023,6 +6090,7 @@ function getDataStrategyMatch( ) { return callLoaderOrAction({ request, + normalizedUrl, unstable_pattern, match, lazyHandlerPromise: _lazyPromises?.handler, @@ -6040,6 +6108,7 @@ function getTargetedDataStrategyMatches( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, + normalizedUrl: URL, matches: AgnosticDataRouteMatch[], targetMatch: AgnosticDataRouteMatch, lazyRoutePropertiesToSkip: string[], @@ -6070,6 +6139,7 @@ function getTargetedDataStrategyMatches( mapRouteProperties, manifest, request, + normalizedUrl, getRoutePattern(matches), match, lazyRoutePropertiesToSkip, @@ -6083,6 +6153,7 @@ function getTargetedDataStrategyMatches( async function callDataStrategyImpl( dataStrategyImpl: DataStrategyFunction, request: Request, + normalizedUrl: URL, matches: DataStrategyMatch[], fetcherKey: string | null, scopedContext: unknown, @@ -6096,8 +6167,12 @@ async function callDataStrategyImpl( // Send all matches here to allow for a middleware-type implementation. // handler will be a no-op for unneeded routes and we filter those results // back out below. - let dataStrategyArgs = { + let dataStrategyArgs: Omit< + DataStrategyFunctionArgs, + "fetcherKey" | "runClientMiddleware" + > = { request, + unstable_url: normalizedUrl, unstable_pattern: getRoutePattern(matches), params: matches[0].params, context: scopedContext, @@ -6156,6 +6231,7 @@ async function callDataStrategyImpl( // Default logic for calling a loader/action is the user has no specified a dataStrategy async function callLoaderOrAction({ request, + normalizedUrl, unstable_pattern, match, lazyHandlerPromise, @@ -6164,6 +6240,7 @@ async function callLoaderOrAction({ scopedContext, }: { request: Request; + normalizedUrl: URL; unstable_pattern: string; match: AgnosticDataRouteMatch; lazyHandlerPromise: Promise | undefined; @@ -6198,6 +6275,7 @@ async function callLoaderOrAction({ return handler( { request, + unstable_url: normalizedUrl, unstable_pattern, params: match.params, context: scopedContext, @@ -6497,6 +6575,19 @@ function createClientSideRequest( return new Request(url, init); } +function createNormalizedUrl(request: Request): URL { + let url = new URL(request.url); + + // Strip naked index param, preserve any other index params with values + let indexValues = url.searchParams.getAll("index"); + url.searchParams.delete("index"); + for (let value of indexValues.filter(Boolean)) { + url.searchParams.append("index", value); + } + + return url; +} + function convertFormDataToSearchParams(formData: FormData): URLSearchParams { let searchParams = new URLSearchParams(); diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 7a5c10b7c7..847e4faecb 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -269,6 +269,14 @@ 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; + /** + * The URL of the application location being navigated to or fetched. + * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. + * With `future.unstable_passThroughRequests` enabled, this is a normalized + * version of `request.url` with React-Router-specific implementation details + * removed (`.data` pathnames, `index`/`_routes` search params) + */ + unstable_url: URL; /** * 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. diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index e19c12326d..3edc96193c 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -67,6 +67,7 @@ import { createRedirectErrorDigest, createRouteErrorResponseDigest, } from "../errors"; +import { normalizeUrl } from "../server-runtime/urls"; const Outlet: typeof OutletType = UNTYPED_Outlet; const WithComponentProps: typeof WithComponentPropsType = @@ -714,6 +715,8 @@ async function generateResourceResponse( return generateErrorResponse(error); } }, + unstable_normalizeUrl: (r) => + normalizeUrl(new URL(r.url), basename, null), }); return response; } catch (error) { @@ -805,6 +808,8 @@ async function generateRenderResponse( ...(routeIdsToLoad ? { filterMatchesToLoad: (m) => routeIdsToLoad!.includes(m.route.id) } : {}), + unstable_normalizeUrl: (r) => + normalizeUrl(new URL(r.url), basename, null), async generateMiddlewareResponse(query) { // If this is an RSC server action, process that and then call query as a // revalidation. If this is a RR Form/Fetcher submission, diff --git a/packages/react-router/lib/server-runtime/data.ts b/packages/react-router/lib/server-runtime/data.ts index ffb2306891..3d75870aaa 100644 --- a/packages/react-router/lib/server-runtime/data.ts +++ b/packages/react-router/lib/server-runtime/data.ts @@ -28,6 +28,7 @@ export async function callRouteHandler( request: future.unstable_passThroughRequests ? args.request : stripRoutesParam(stripIndexParam(args.request)), + unstable_url: args.unstable_url, params: args.params, context: args.context, unstable_pattern: args.unstable_pattern, diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 8a77ced2c3..7fdc9a980b 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -39,6 +39,7 @@ import { getManifestPath } from "../dom/ssr/fog-of-war"; import type { unstable_InstrumentRequestHandlerFunction } from "../router/instrumentation"; import { instrumentHandler } from "../router/instrumentation"; import { throwIfPotentialCSRFAttack } from "../actions"; +import { normalizePath, normalizeUrl } from "./urls"; export type RequestHandler = ( request: Request, @@ -108,29 +109,11 @@ function derive(build: ServerBuild, mode?: string) { let url = new URL(request.url); - let normalizedBasename = build.basename || "/"; - let normalizedPath = url.pathname; - if (build.future.unstable_trailingSlashAwareDataRequests) { - if (normalizedPath.endsWith("/_.data")) { - // Handle trailing slash URLs: /about/_.data -> /about/ - normalizedPath = normalizedPath.replace(/_.data$/, ""); - } else { - normalizedPath = normalizedPath.replace(/\.data$/, ""); - } - } else { - if (stripBasename(normalizedPath, normalizedBasename) === "/_root.data") { - normalizedPath = normalizedBasename; - } else if (normalizedPath.endsWith(".data")) { - normalizedPath = normalizedPath.replace(/\.data$/, ""); - } - - if ( - stripBasename(normalizedPath, normalizedBasename) !== "/" && - normalizedPath.endsWith("/") - ) { - normalizedPath = normalizedPath.slice(0, -1); - } - } + let normalizedPath = normalizePath( + url.pathname, + build.basename || "/", + build.future, + ); let isSpaMode = getBuildTimeHeader(request, "X-React-Router-SPA-Mode") === "yes"; @@ -141,15 +124,15 @@ function derive(build: ServerBuild, mode?: string) { // Decode the URL path before checking against the prerender config let decodedPath = decodeURI(normalizedPath); - if (normalizedBasename !== "/") { - let strippedPath = stripBasename(decodedPath, normalizedBasename); + if (build.basename && build.basename !== "/") { + let strippedPath = stripBasename(decodedPath, build.basename); if (strippedPath == null) { errorHandler( new ErrorResponseImpl( 404, "Not Found", `Refusing to prerender the \`${decodedPath}\` path because it does ` + - `not start with the basename \`${normalizedBasename}\``, + `not start with the basename \`${build.basename}\``, ), { context: loadContext, @@ -202,7 +185,7 @@ function derive(build: ServerBuild, mode?: string) { // Manifest request for fog of war let manifestUrl = getManifestPath( build.routeDiscovery.manifestPath, - normalizedBasename, + build.basename, ); if (url.pathname === manifestUrl) { try { @@ -511,6 +494,8 @@ async function handleDocumentRequest( } } : undefined, + unstable_normalizeUrl: (r) => + normalizeUrl(new URL(r.url), build.basename, build.future), }); if (!isResponse(result)) { @@ -688,6 +673,8 @@ async function handleResourceRequest( } } : undefined, + unstable_normalizeUrl: (r) => + normalizeUrl(new URL(r.url), build.basename, build.future), }); return handleQueryRouteResult(result); diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 613e809272..7f9d091d04 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -25,6 +25,7 @@ import { getDocumentHeaders } from "./headers"; import type { ServerBuild } from "./build"; import { throwIfPotentialCSRFAttack } from "../actions"; import { FutureConfig } from "../dom/ssr/entry"; +import { normalizeUrl } from "./urls"; // Add 304 for server side - that is not included in the client side logic // because the browser should fill those responses with the cached data @@ -79,6 +80,8 @@ export async function singleFetchAction( } } : undefined, + unstable_normalizeUrl: (r) => + normalizeUrl(new URL(r.url), build.basename, build.future), }); return handleQueryResult(result); @@ -171,6 +174,8 @@ export async function singleFetchLoaders( } } : undefined, + unstable_normalizeUrl: (r) => + normalizeUrl(new URL(r.url), build.basename, build.future), }); return handleQueryResult(result); diff --git a/packages/react-router/lib/server-runtime/urls.ts b/packages/react-router/lib/server-runtime/urls.ts new file mode 100644 index 0000000000..9436baf4b6 --- /dev/null +++ b/packages/react-router/lib/server-runtime/urls.ts @@ -0,0 +1,55 @@ +import type { FutureConfig } from "../dom/ssr/entry"; +import { stripBasename } from "../router/utils"; + +export function normalizeUrl( + url: URL, + basename: string | undefined, + future: FutureConfig | null, +) { + // Strip .data suffix + url.pathname = normalizePath(url.pathname, basename || "/", future); + + // Strip _routes param + url.searchParams.delete("_routes"); + + // Strip index param + let indexValues = url.searchParams.getAll("index"); + url.searchParams.delete("index"); + for (let value of indexValues.filter(Boolean)) { + url.searchParams.append("index", value); + } + + return url; +} + +export function normalizePath( + pathname: string, + basename: string | undefined, + future: FutureConfig, +) { + let normalizedBasename = basename || "/"; + let normalizedPath = pathname; + if (future.unstable_trailingSlashAwareDataRequests) { + if (normalizedPath.endsWith("/_.data")) { + // Handle trailing slash URLs: /about/_.data -> /about/ + normalizedPath = normalizedPath.replace(/_.data$/, ""); + } else { + normalizedPath = normalizedPath.replace(/\.data$/, ""); + } + } else { + if (stripBasename(normalizedPath, normalizedBasename) === "/_root.data") { + normalizedPath = normalizedBasename; + } else if (normalizedPath.endsWith(".data")) { + normalizedPath = normalizedPath.replace(/\.data$/, ""); + } + + if ( + stripBasename(normalizedPath, normalizedBasename) !== "/" && + normalizedPath.endsWith("/") + ) { + normalizedPath = normalizedPath.slice(0, -1); + } + } + + return normalizedPath; +} diff --git a/packages/react-router/lib/types/route-data.ts b/packages/react-router/lib/types/route-data.ts index 52eefee088..deddcffacc 100644 --- a/packages/react-router/lib/types/route-data.ts +++ b/packages/react-router/lib/types/route-data.ts @@ -77,6 +77,14 @@ export type ClientDataFunctionArgs = { * @note Because client data functions are called before a network request is made, the Request object does not include the headers which the browser automatically adds. React Router infers the "content-type" header from the enc-type of the form that performed the submission. **/ request: Request; + /** + * The URL of the application location being navigated to or fetched. + * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. + * With `future.unstable_passThroughRequests` enabled, this is a normalized + * version of `request.url` with React-Router-specific implementation details + * removed (`.data` pathnames, `index`/`_routes` search params) + */ + unstable_url: URL; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example @@ -111,6 +119,14 @@ export type ClientDataFunctionArgs = { export type ServerDataFunctionArgs = { /** A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read the url, method, headers (such as cookies), and request body from the request. */ request: Request; + /** + * The URL of the application location being navigated to or fetched. + * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. + * With `future.unstable_passThroughRequests` enabled, this is a normalized + * version of `request.url` with React-Router-specific implementation details + * removed (`.data` pathnames, `index`/`_routes` search params) + */ + unstable_url: URL; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example From b77f08a0e7ee1b0fb7dd03e5dd6607e082be7e6f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 4 Feb 2026 13:16:40 -0500 Subject: [PATCH 3/6] derive from location --- packages/react-router/lib/router/router.ts | 130 +++++++++------------ 1 file changed, 58 insertions(+), 72 deletions(-) diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index f73690392e..4fd0e1014c 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1755,7 +1755,6 @@ export function createRouter(init: RouterInit): Router { pendingNavigationController.signal, opts && opts.submission, ); - let normalizedUrl = createNormalizedUrl(request); // Create a new context per navigation let scopedContext = init.getContext ? await init.getContext() @@ -1779,7 +1778,6 @@ export function createRouter(init: RouterInit): Router { // Call action if we received an action submission let actionResult = await handleAction( request, - normalizedUrl, location, opts.submission, matches, @@ -1845,7 +1843,6 @@ export function createRouter(init: RouterInit): Router { errors, } = await handleLoaders( request, - normalizedUrl, location, matches, scopedContext, @@ -1881,7 +1878,6 @@ export function createRouter(init: RouterInit): Router { // redirects/errors async function handleAction( request: Request, - normalizedUrl: URL, location: Location, submission: Submission, matches: AgnosticDataRouteMatch[], @@ -1968,7 +1964,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, request, - normalizedUrl, + location, matches, actionMatch, initialHydration ? [] : hydrationRouteProperties, @@ -1976,6 +1972,7 @@ export function createRouter(init: RouterInit): Router { ); let results = await callDataStrategy( request, + location, dsMatches, scopedContext, null, @@ -2055,7 +2052,6 @@ export function createRouter(init: RouterInit): Router { // errors, etc. async function handleLoaders( request: Request, - normalizedUrl: URL, location: Location, matches: AgnosticDataRouteMatch[], scopedContext: RouterContextProvider, @@ -2156,7 +2152,6 @@ export function createRouter(init: RouterInit): Router { let routesToUse = inFlightDataRoutes || dataRoutes; let { dsMatches, revalidatingFetchers } = getMatchesToLoad( request, - normalizedUrl, scopedContext, mapRouteProperties, manifest, @@ -2252,6 +2247,7 @@ export function createRouter(init: RouterInit): Router { dsMatches, revalidatingFetchers, request, + location, scopedContext, ); @@ -2469,7 +2465,6 @@ export function createRouter(init: RouterInit): Router { abortController.signal, submission, ); - let normalizedFetcherUrl = createNormalizedUrl(fetchRequest); if (isFogOfWar) { let discoverResult = await discoverRoutes( @@ -2517,7 +2512,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, fetchRequest, - normalizedFetcherUrl, + path, requestMatches, match, hydrationRouteProperties, @@ -2525,6 +2520,7 @@ export function createRouter(init: RouterInit): Router { ); let actionResults = await callDataStrategy( fetchRequest, + path, fetchMatches, scopedContext, key, @@ -2594,7 +2590,6 @@ export function createRouter(init: RouterInit): Router { nextLocation, abortController.signal, ); - let normalizedUrl = createNormalizedUrl(revalidationRequest); let routesToUse = inFlightDataRoutes || dataRoutes; let matches = state.navigation.state !== "idle" @@ -2611,7 +2606,6 @@ export function createRouter(init: RouterInit): Router { let { dsMatches, revalidatingFetchers } = getMatchesToLoad( revalidationRequest, - normalizedUrl, scopedContext, mapRouteProperties, manifest, @@ -2668,6 +2662,7 @@ export function createRouter(init: RouterInit): Router { dsMatches, revalidatingFetchers, revalidationRequest, + nextLocation, scopedContext, ); @@ -2789,7 +2784,6 @@ export function createRouter(init: RouterInit): Router { path, abortController.signal, ); - let normalizedFetcherUrl = createNormalizedUrl(fetchRequest); if (isFogOfWar) { let discoverResult = await discoverRoutes( @@ -2827,7 +2821,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, fetchRequest, - normalizedFetcherUrl, + path, matches, match, hydrationRouteProperties, @@ -2835,6 +2829,7 @@ export function createRouter(init: RouterInit): Router { ); let results = await callDataStrategy( fetchRequest, + path, dsMatches, scopedContext, key, @@ -3036,6 +3031,7 @@ export function createRouter(init: RouterInit): Router { // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( request: Request, + path: To, matches: DataStrategyMatch[], scopedContext: RouterContextProvider, fetcherKey: string | null, @@ -3046,7 +3042,7 @@ export function createRouter(init: RouterInit): Router { results = await callDataStrategyImpl( dataStrategyImpl as DataStrategyFunction, request, - createNormalizedUrl(request), + path, matches, fetcherKey, scopedContext, @@ -3122,11 +3118,13 @@ export function createRouter(init: RouterInit): Router { matches: DataStrategyMatch[], fetchersToLoad: RevalidatingFetcher[], request: Request, + path: To, scopedContext: RouterContextProvider, ) { // Kick off loaders and fetchers in parallel let loaderResultsPromise = callDataStrategy( request, + path, matches, scopedContext, null, @@ -3137,6 +3135,7 @@ export function createRouter(init: RouterInit): Router { if (f.matches && f.match && f.request && f.controller) { let results = await callDataStrategy( f.request, + f.path, f.matches, scopedContext, f.key, @@ -3803,12 +3802,7 @@ export function createStaticHandler( ? unstable_normalizeUrl(request) : new URL(request.url); let method = request.method; - let location = createLocation( - "", - createPath(normalizedUrl), - null, - "default", - ); + let location = createLocation("", normalizedUrl, null, "default"); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -3887,9 +3881,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, - unstable_url: unstable_normalizeUrl - ? unstable_normalizeUrl(request) - : createNormalizedUrl(request), + unstable_url: createNormalizedUrlFromLocation(request, location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -3909,7 +3901,6 @@ export function createStaticHandler( ) => { let result = await queryImpl( revalidationRequest, - normalizedUrl, location, matches!, requestContext, @@ -4030,7 +4021,6 @@ export function createStaticHandler( let result = await queryImpl( request, - normalizedUrl, location, matches, requestContext, @@ -4090,13 +4080,8 @@ export function createStaticHandler( let normalizedUrl = unstable_normalizeUrl ? unstable_normalizeUrl(request) : new URL(request.url); + let location = createLocation("", normalizedUrl, null, "default"); let method = request.method; - let location = createLocation( - "", - createPath(normalizedUrl), - null, - "default", - ); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -4132,9 +4117,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, - unstable_url: unstable_normalizeUrl - ? unstable_normalizeUrl(request) - : createNormalizedUrl(request), + unstable_url: normalizedUrl, unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -4147,7 +4130,6 @@ export function createStaticHandler( async (innerRequest: Request) => { let result = await queryImpl( innerRequest, - normalizedUrl, location, matches!, requestContext, @@ -4185,7 +4167,6 @@ export function createStaticHandler( let result = await queryImpl( request, - normalizedUrl, location, matches, requestContext, @@ -4227,8 +4208,7 @@ export function createStaticHandler( async function queryImpl( request: Request, - normalizedUrl: URL, - location: Location, + path: To, matches: AgnosticDataRouteMatch[], requestContext: unknown, dataStrategy: DataStrategyFunction | null, @@ -4246,9 +4226,9 @@ export function createStaticHandler( if (isMutationMethod(request.method)) { let result = await submit( request, - normalizedUrl, + path, matches, - routeMatch || getTargetMatch(matches, location), + routeMatch || getTargetMatch(matches, new URL(request.url)), requestContext, dataStrategy, skipLoaderErrorBubbling, @@ -4261,7 +4241,7 @@ export function createStaticHandler( let result = await loadRouteData( request, - normalizedUrl, + path, matches, requestContext, dataStrategy, @@ -4297,7 +4277,7 @@ export function createStaticHandler( async function submit( request: Request, - normalizedUrl: URL, + path: To, matches: AgnosticDataRouteMatch[], actionMatch: AgnosticDataRouteMatch, requestContext: unknown, @@ -4327,7 +4307,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, - normalizedUrl, + path, matches, actionMatch, [], @@ -4336,7 +4316,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, - normalizedUrl, + path, dsMatches, isRouteRequest, requestContext, @@ -4449,7 +4429,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, - normalizedUrl, + path, matches, requestContext, dataStrategy, @@ -4476,7 +4456,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, - normalizedUrl, + path, matches, requestContext, dataStrategy, @@ -4500,7 +4480,7 @@ export function createStaticHandler( async function loadRouteData( request: Request, - normalizedUrl: URL, + path: To, matches: AgnosticDataRouteMatch[], requestContext: unknown, dataStrategy: DataStrategyFunction | null, @@ -4536,7 +4516,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, - normalizedUrl, + path, matches, routeMatch, [], @@ -4556,7 +4536,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, - normalizedUrl, + path, pattern, match, [], @@ -4569,7 +4549,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, - normalizedUrl, + path, pattern, match, [], @@ -4599,7 +4579,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, - normalizedUrl, + path, dsMatches, isRouteRequest, requestContext, @@ -4629,7 +4609,7 @@ export function createStaticHandler( // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( request: Request, - normalizedUrl: URL, + path: To, matches: DataStrategyMatch[], isRouteRequest: boolean, requestContext: unknown, @@ -4638,7 +4618,7 @@ export function createStaticHandler( let results = await callDataStrategyImpl( dataStrategy || defaultDataStrategy, request, - normalizedUrl, + path, matches, null, requestContext, @@ -4962,7 +4942,6 @@ function normalizeNavigateOptions( function getMatchesToLoad( request: Request, - normalizedUrl: URL, scopedContext: unknown, mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, @@ -5064,7 +5043,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, - normalizedUrl, + location, pattern, match, lazyRoutePropertiesToSkip, @@ -5109,7 +5088,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, - normalizedUrl, + location, pattern, match, lazyRoutePropertiesToSkip, @@ -5181,7 +5160,6 @@ function getMatchesToLoad( f.path, fetchController.signal, ); - let fetchUrl = createNormalizedUrl(fetchRequest); let fetcherDsMatches: DataStrategyMatch[] | null = null; @@ -5192,7 +5170,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, - fetchUrl, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5207,7 +5185,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, - fetchUrl, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5236,7 +5214,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, - fetchUrl, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -6019,7 +5997,7 @@ function getDataStrategyMatch( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, - normalizedUrl: URL, + path: To, unstable_pattern: string, match: DataRouteMatch, lazyRoutePropertiesToSkip: string[], @@ -6090,7 +6068,7 @@ function getDataStrategyMatch( ) { return callLoaderOrAction({ request, - normalizedUrl, + path, unstable_pattern, match, lazyHandlerPromise: _lazyPromises?.handler, @@ -6108,7 +6086,7 @@ function getTargetedDataStrategyMatches( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, - normalizedUrl: URL, + path: To, matches: AgnosticDataRouteMatch[], targetMatch: AgnosticDataRouteMatch, lazyRoutePropertiesToSkip: string[], @@ -6139,7 +6117,7 @@ function getTargetedDataStrategyMatches( mapRouteProperties, manifest, request, - normalizedUrl, + path, getRoutePattern(matches), match, lazyRoutePropertiesToSkip, @@ -6153,7 +6131,7 @@ function getTargetedDataStrategyMatches( async function callDataStrategyImpl( dataStrategyImpl: DataStrategyFunction, request: Request, - normalizedUrl: URL, + path: To, matches: DataStrategyMatch[], fetcherKey: string | null, scopedContext: unknown, @@ -6172,7 +6150,12 @@ async function callDataStrategyImpl( "fetcherKey" | "runClientMiddleware" > = { request, - unstable_url: normalizedUrl, + unstable_url: createNormalizedUrlFromLocation(request, { + pathname: "", + search: "", + hash: "", + ...(typeof path === "string" ? parsePath(path) : path), + }), unstable_pattern: getRoutePattern(matches), params: matches[0].params, context: scopedContext, @@ -6231,7 +6214,7 @@ async function callDataStrategyImpl( // Default logic for calling a loader/action is the user has no specified a dataStrategy async function callLoaderOrAction({ request, - normalizedUrl, + path, unstable_pattern, match, lazyHandlerPromise, @@ -6240,7 +6223,7 @@ async function callLoaderOrAction({ scopedContext, }: { request: Request; - normalizedUrl: URL; + path: To; unstable_pattern: string; match: AgnosticDataRouteMatch; lazyHandlerPromise: Promise | undefined; @@ -6275,7 +6258,7 @@ async function callLoaderOrAction({ return handler( { request, - unstable_url: normalizedUrl, + unstable_url: createNormalizedUrlFromLocation(request, path), unstable_pattern, params: match.params, context: scopedContext, @@ -6575,8 +6558,11 @@ function createClientSideRequest( return new Request(url, init); } -function createNormalizedUrl(request: Request): URL { - let url = new URL(request.url); +function createNormalizedUrlFromLocation(request: Request, path: To): URL { + let url = new URL( + new URL(request.url).origin + + createPath(typeof path === "string" ? parsePath(path) : path), + ); // Strip naked index param, preserve any other index params with values let indexValues = url.searchParams.getAll("index"); @@ -7059,7 +7045,7 @@ function hasNakedIndexQuery(search: string): boolean { function getTargetMatch( matches: AgnosticDataRouteMatch[], - location: Location | string, + location: Path | string, ) { let search = typeof location === "string" ? parsePath(location).search : location.search; From 497224a244977fe4e8dfa2be78c66426caa3e650 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 4 Feb 2026 14:52:39 -0500 Subject: [PATCH 4/6] Switch to unstable_path and add back loaderRequests --- packages/react-router/lib/dom/ssr/routes.tsx | 8 +- packages/react-router/lib/router/router.ts | 128 ++++++++---------- packages/react-router/lib/router/utils.ts | 4 +- packages/react-router/lib/rsc/server.rsc.ts | 6 +- .../react-router/lib/server-runtime/data.ts | 2 +- .../react-router/lib/server-runtime/server.ts | 8 +- .../lib/server-runtime/single-fetch.ts | 8 +- .../react-router/lib/server-runtime/urls.ts | 12 +- packages/react-router/lib/types/route-data.ts | 9 +- 9 files changed, 82 insertions(+), 103 deletions(-) diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index ebe2a02715..8a7db11992 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -345,7 +345,7 @@ export function createClientRoutes( params, context, unstable_pattern, - unstable_url, + unstable_path, }: LoaderFunctionArgs, singleFetch?: unknown, ) => { @@ -365,7 +365,7 @@ export function createClientRoutes( params, context, unstable_pattern, - unstable_url, + unstable_path, async serverLoader() { preventInvalidServerHandlerCall("loader", route); @@ -406,7 +406,7 @@ export function createClientRoutes( params, context, unstable_pattern, - unstable_url, + unstable_path, }: ActionFunctionArgs, singleFetch?: unknown, ) => { @@ -427,7 +427,7 @@ export function createClientRoutes( params, context, unstable_pattern, - unstable_url, + unstable_path, 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 4fd0e1014c..55abb29e29 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -463,7 +463,7 @@ export interface StaticHandler { }, ) => Promise, ) => MaybePromise; - unstable_normalizeUrl?: (r: Request) => URL; + unstable_normalizeUrl?: (url: URL) => URL; }, ): Promise; queryRoute( @@ -475,7 +475,7 @@ export interface StaticHandler { generateMiddlewareResponse?: ( queryRoute: (r: Request) => Promise, ) => MaybePromise; - unstable_normalizeUrl?: (r: Request) => URL; + unstable_normalizeUrl?: (url: URL) => URL; }, ): Promise; } @@ -1821,18 +1821,11 @@ export function createRouter(init: RouterInit): Router { fogOfWar.active = false; // Create a GET request for the loaders - if (future.unstable_passThroughRequests) { - // Don't let loaders consume any request bodies - if (!request.bodyUsed) { - request.body?.cancel(); - } - } else { - request = createClientSideRequest( - init.history, - request.url, - request.signal, - ); - } + request = createClientSideRequest( + init.history, + request.url, + request.signal, + ); } // Call loaders @@ -3118,13 +3111,13 @@ export function createRouter(init: RouterInit): Router { matches: DataStrategyMatch[], fetchersToLoad: RevalidatingFetcher[], request: Request, - path: To, + location: Location, scopedContext: RouterContextProvider, ) { // Kick off loaders and fetchers in parallel let loaderResultsPromise = callDataStrategy( request, - path, + location, matches, scopedContext, null, @@ -3798,11 +3791,12 @@ export function createStaticHandler( unstable_normalizeUrl, }: Parameters[1] = {}, ): Promise { - let normalizedUrl = unstable_normalizeUrl - ? unstable_normalizeUrl(request) - : new URL(request.url); + let url = new URL(request.url); + if (unstable_normalizeUrl) { + url = unstable_normalizeUrl(url); + } let method = request.method; - let location = createLocation("", normalizedUrl, null, "default"); + let location = createLocation("", createPath(url), null, "default"); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -3881,7 +3875,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, - unstable_url: createNormalizedUrlFromLocation(request, location), + unstable_path: stripIndexParam(location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -4077,11 +4071,12 @@ export function createStaticHandler( unstable_normalizeUrl, }: Parameters[1] = {}, ): Promise { - let normalizedUrl = unstable_normalizeUrl - ? unstable_normalizeUrl(request) - : new URL(request.url); - let location = createLocation("", normalizedUrl, null, "default"); + let url = new URL(request.url); + if (unstable_normalizeUrl) { + url = unstable_normalizeUrl(url); + } let method = request.method; + let location = createLocation("", createPath(url), null, "default"); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -4117,7 +4112,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, - unstable_url: normalizedUrl, + unstable_path: stripIndexParam(location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -4208,7 +4203,7 @@ export function createStaticHandler( async function queryImpl( request: Request, - path: To, + location: Location, matches: AgnosticDataRouteMatch[], requestContext: unknown, dataStrategy: DataStrategyFunction | null, @@ -4226,9 +4221,9 @@ export function createStaticHandler( if (isMutationMethod(request.method)) { let result = await submit( request, - path, + location, matches, - routeMatch || getTargetMatch(matches, new URL(request.url)), + routeMatch || getTargetMatch(matches, location), requestContext, dataStrategy, skipLoaderErrorBubbling, @@ -4241,7 +4236,7 @@ export function createStaticHandler( let result = await loadRouteData( request, - path, + location, matches, requestContext, dataStrategy, @@ -4277,7 +4272,7 @@ export function createStaticHandler( async function submit( request: Request, - path: To, + location: Location, matches: AgnosticDataRouteMatch[], actionMatch: AgnosticDataRouteMatch, requestContext: unknown, @@ -4307,7 +4302,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, - path, + location, matches, actionMatch, [], @@ -4316,7 +4311,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, - path, + location, dsMatches, isRouteRequest, requestContext, @@ -4405,20 +4400,11 @@ export function createStaticHandler( } // Create a GET request for the loaders - let loaderRequest: Request; - if (future.unstable_passThroughRequests) { - // Don't permit loaders to read from POST request bodies - if (!request.bodyUsed) { - request.body?.cancel(); - } - loaderRequest = request; - } else { - loaderRequest = new Request(request.url, { - headers: request.headers, - redirect: request.redirect, - signal: request.signal, - }); - } + let loaderRequest = new Request(request.url, { + headers: request.headers, + redirect: request.redirect, + signal: request.signal, + }); if (isErrorResult(result)) { // Store off the pending error - we use it to determine which loaders @@ -4429,7 +4415,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, - path, + location, matches, requestContext, dataStrategy, @@ -4456,7 +4442,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, - path, + location, matches, requestContext, dataStrategy, @@ -4480,7 +4466,7 @@ export function createStaticHandler( async function loadRouteData( request: Request, - path: To, + location: Location, matches: AgnosticDataRouteMatch[], requestContext: unknown, dataStrategy: DataStrategyFunction | null, @@ -4516,7 +4502,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, - path, + location, matches, routeMatch, [], @@ -4536,7 +4522,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, - path, + location, pattern, match, [], @@ -4549,7 +4535,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, - path, + location, pattern, match, [], @@ -4579,7 +4565,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, - path, + location, dsMatches, isRouteRequest, requestContext, @@ -4609,7 +4595,7 @@ export function createStaticHandler( // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( request: Request, - path: To, + location: Location, matches: DataStrategyMatch[], isRouteRequest: boolean, requestContext: unknown, @@ -4618,7 +4604,7 @@ export function createStaticHandler( let results = await callDataStrategyImpl( dataStrategy || defaultDataStrategy, request, - path, + location, matches, null, requestContext, @@ -6150,12 +6136,7 @@ async function callDataStrategyImpl( "fetcherKey" | "runClientMiddleware" > = { request, - unstable_url: createNormalizedUrlFromLocation(request, { - pathname: "", - search: "", - hash: "", - ...(typeof path === "string" ? parsePath(path) : path), - }), + unstable_path: stripIndexParam(path), unstable_pattern: getRoutePattern(matches), params: matches[0].params, context: scopedContext, @@ -6258,7 +6239,7 @@ async function callLoaderOrAction({ return handler( { request, - unstable_url: createNormalizedUrlFromLocation(request, path), + unstable_path: stripIndexParam(path), unstable_pattern, params: match.params, context: scopedContext, @@ -6558,20 +6539,23 @@ function createClientSideRequest( return new Request(url, init); } -function createNormalizedUrlFromLocation(request: Request, path: To): URL { - let url = new URL( - new URL(request.url).origin + - createPath(typeof path === "string" ? parsePath(path) : path), - ); +function stripIndexParam(path: To): Path { + let parsed = typeof path === "string" ? parsePath(path) : path; + let searchParams = new URLSearchParams(parsed.search); // Strip naked index param, preserve any other index params with values - let indexValues = url.searchParams.getAll("index"); - url.searchParams.delete("index"); + let indexValues = searchParams.getAll("index"); + searchParams.delete("index"); for (let value of indexValues.filter(Boolean)) { - url.searchParams.append("index", value); + searchParams.append("index", value); } - return url; + return { + pathname: "", + hash: "", + ...parsed, + search: searchParams.size ? `?${searchParams.toString()}` : "", + }; } function convertFormDataToSearchParams(formData: FormData): URLSearchParams { diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 847e4faecb..415d832c53 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -270,13 +270,13 @@ 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; /** - * The URL of the application location being navigated to or fetched. + * The application location being navigated to or fetched. * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. * With `future.unstable_passThroughRequests` enabled, this is a normalized * version of `request.url` with React-Router-specific implementation details * removed (`.data` pathnames, `index`/`_routes` search params) */ - unstable_url: URL; + unstable_path: Path; /** * 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. diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 3edc96193c..8844030d37 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -715,8 +715,7 @@ async function generateResourceResponse( return generateErrorResponse(error); } }, - unstable_normalizeUrl: (r) => - normalizeUrl(new URL(r.url), basename, null), + unstable_normalizeUrl: (url) => normalizeUrl(url, basename, null), }); return response; } catch (error) { @@ -808,8 +807,7 @@ async function generateRenderResponse( ...(routeIdsToLoad ? { filterMatchesToLoad: (m) => routeIdsToLoad!.includes(m.route.id) } : {}), - unstable_normalizeUrl: (r) => - normalizeUrl(new URL(r.url), basename, null), + unstable_normalizeUrl: (url) => normalizeUrl(url, basename, null), async generateMiddlewareResponse(query) { // If this is an RSC server action, process that and then call query as a // revalidation. If this is a RR Form/Fetcher submission, diff --git a/packages/react-router/lib/server-runtime/data.ts b/packages/react-router/lib/server-runtime/data.ts index 3d75870aaa..ecfef6e2d1 100644 --- a/packages/react-router/lib/server-runtime/data.ts +++ b/packages/react-router/lib/server-runtime/data.ts @@ -28,7 +28,7 @@ export async function callRouteHandler( request: future.unstable_passThroughRequests ? args.request : stripRoutesParam(stripIndexParam(args.request)), - unstable_url: args.unstable_url, + unstable_path: args.unstable_path, params: args.params, context: args.context, unstable_pattern: args.unstable_pattern, diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 7fdc9a980b..96d30bc47b 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -494,8 +494,8 @@ async function handleDocumentRequest( } } : undefined, - unstable_normalizeUrl: (r) => - normalizeUrl(new URL(r.url), build.basename, build.future), + unstable_normalizeUrl: (url) => + normalizeUrl(url, build.basename, build.future), }); if (!isResponse(result)) { @@ -673,8 +673,8 @@ async function handleResourceRequest( } } : undefined, - unstable_normalizeUrl: (r) => - normalizeUrl(new URL(r.url), build.basename, build.future), + unstable_normalizeUrl: (url) => + normalizeUrl(url, build.basename, build.future), }); return handleQueryRouteResult(result); diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 7f9d091d04..488ecc0da6 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -80,8 +80,8 @@ export async function singleFetchAction( } } : undefined, - unstable_normalizeUrl: (r) => - normalizeUrl(new URL(r.url), build.basename, build.future), + unstable_normalizeUrl: (url) => + normalizeUrl(url, build.basename, build.future), }); return handleQueryResult(result); @@ -174,8 +174,8 @@ export async function singleFetchLoaders( } } : undefined, - unstable_normalizeUrl: (r) => - normalizeUrl(new URL(r.url), build.basename, build.future), + unstable_normalizeUrl: (url) => + normalizeUrl(url, build.basename, build.future), }); return handleQueryResult(result); diff --git a/packages/react-router/lib/server-runtime/urls.ts b/packages/react-router/lib/server-runtime/urls.ts index 9436baf4b6..53b8536ffa 100644 --- a/packages/react-router/lib/server-runtime/urls.ts +++ b/packages/react-router/lib/server-runtime/urls.ts @@ -12,12 +12,8 @@ export function normalizeUrl( // Strip _routes param url.searchParams.delete("_routes"); - // Strip index param - let indexValues = url.searchParams.getAll("index"); - url.searchParams.delete("index"); - for (let value of indexValues.filter(Boolean)) { - url.searchParams.append("index", value); - } + // Don't touch index params here - they're needed for router matching and are + // stripped when creating the loader/action args return url; } @@ -25,11 +21,11 @@ export function normalizeUrl( export function normalizePath( pathname: string, basename: string | undefined, - future: FutureConfig, + future: FutureConfig | null, ) { let normalizedBasename = basename || "/"; let normalizedPath = pathname; - if (future.unstable_trailingSlashAwareDataRequests) { + if (future?.unstable_trailingSlashAwareDataRequests) { if (normalizedPath.endsWith("/_.data")) { // Handle trailing slash URLs: /about/_.data -> /about/ normalizedPath = normalizedPath.replace(/_.data$/, ""); diff --git a/packages/react-router/lib/types/route-data.ts b/packages/react-router/lib/types/route-data.ts index deddcffacc..051f812c1f 100644 --- a/packages/react-router/lib/types/route-data.ts +++ b/packages/react-router/lib/types/route-data.ts @@ -2,6 +2,7 @@ import type { ClientLoaderFunctionArgs, ClientActionFunctionArgs, } from "../dom/ssr/routeModules"; +import type { Path } from "../router/history"; import type { DataWithResponseInit, RouterContextProvider, @@ -78,13 +79,13 @@ export type ClientDataFunctionArgs = { **/ request: Request; /** - * The URL of the application location being navigated to or fetched. + * The application location being navigated to or fetched. * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. * With `future.unstable_passThroughRequests` enabled, this is a normalized * version of `request.url` with React-Router-specific implementation details * removed (`.data` pathnames, `index`/`_routes` search params) */ - unstable_url: URL; + unstable_path: Path; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example @@ -120,13 +121,13 @@ export type ServerDataFunctionArgs = { /** A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read the url, method, headers (such as cookies), and request body from the request. */ request: Request; /** - * The URL of the application location being navigated to or fetched. + * The application location being navigated to or fetched. * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. * With `future.unstable_passThroughRequests` enabled, this is a normalized * version of `request.url` with React-Router-specific implementation details * removed (`.data` pathnames, `index`/`_routes` search params) */ - unstable_url: URL; + unstable_path: Path; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example From d0bf9422020c434c5608b98987fc53fded71aaa3 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 4 Feb 2026 17:02:34 -0500 Subject: [PATCH 5/6] Fix tests --- CLAUDE.md | 1 + .../__tests__/router/fetchers-test.ts | 50 +++++++++++++++++-- .../__tests__/router/router-test.ts | 17 ++++++- .../react-router/__tests__/router/ssr-test.ts | 50 +++++++++++++++++-- .../__tests__/router/submission-test.ts | 6 +++ packages/react-router/lib/router/router.ts | 22 ++++---- 6 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..4bb4db343e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [./AGENTS.md] diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 0fdf16c762..45d6b84628 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -172,6 +172,19 @@ describe("fetchers", () => { await A.loaders.foo.resolve("A DATA"); expect(A.fetcher.state).toBe("idle"); expect(A.fetcher.data).toBe("A DATA"); + expect(A.loaders.foo.stub).toHaveBeenCalledWith({ + params: {}, + request: new Request("http://localhost/foo", { + signal: A.loaders.foo.stub.mock.calls[0][0].request.signal, + }), + unstable_pattern: "/foo", + unstable_path: { + pathname: "/foo", + search: "", + hash: "", + }, + context: {}, + }); }); it("loader re-fetch", async () => { @@ -212,11 +225,19 @@ describe("fetchers", () => { expect(A.fetcher.formAction).toBe("/foo"); expect(A.fetcher.formData).toEqual(createFormData({ key: "value" })); expect(A.fetcher.formEncType).toBe("application/x-www-form-urlencoded"); - expect( - new URL( - A.loaders.foo.stub.mock.calls[0][0].request.url, - ).searchParams.toString(), - ).toBe("key=value"); + expect(A.loaders.foo.stub).toHaveBeenCalledWith({ + params: {}, + request: new Request("http://localhost/foo?key=value", { + signal: A.loaders.foo.stub.mock.calls[0][0].request.signal, + }), + unstable_pattern: "/foo", + unstable_path: { + pathname: "/foo", + search: "?key=value", + hash: "", + }, + context: {}, + }); await A.loaders.foo.resolve("A DATA"); expect(A.fetcher.state).toBe("idle"); @@ -264,6 +285,17 @@ describe("fetchers", () => { formData: createFormData({ key: "value" }), }); expect(A.fetcher.state).toBe("submitting"); + expect(A.actions.foo.stub).toHaveBeenCalledWith({ + params: {}, + request: expect.any(Request), + unstable_pattern: "/foo", + unstable_path: { + pathname: "/foo", + search: "", + hash: "", + }, + context: {}, + }); await A.actions.foo.resolve("A ACTION"); expect(A.fetcher.state).toBe("loading"); @@ -374,6 +406,7 @@ describe("fetchers", () => { signal: A.loaders.root.stub.mock.calls[0][0].request.signal, }), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); }); @@ -3375,6 +3408,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -3405,6 +3439,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -3433,6 +3468,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -3461,6 +3497,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -3490,6 +3527,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -3521,6 +3559,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -3551,6 +3590,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); diff --git a/packages/react-router/__tests__/router/router-test.ts b/packages/react-router/__tests__/router/router-test.ts index c5f9bbee35..6da986d11f 100644 --- a/packages/react-router/__tests__/router/router-test.ts +++ b/packages/react-router/__tests__/router/router-test.ts @@ -1752,6 +1752,7 @@ describe("a router", () => { signal: nav.loaders.tasks.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks", + unstable_path: { pathname: "/tasks", search: "", hash: "" }, context: {}, }); @@ -1762,6 +1763,7 @@ describe("a router", () => { signal: nav2.loaders.tasksId.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks/:id", + unstable_path: { pathname: "/tasks/1", search: "", hash: "" }, context: {}, }); @@ -1772,6 +1774,11 @@ describe("a router", () => { signal: nav3.loaders.tasks.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks", + unstable_path: { + pathname: "/tasks", + search: "?foo=bar", + hash: "#hash", + }, context: {}, }); @@ -1784,6 +1791,11 @@ describe("a router", () => { signal: nav4.loaders.tasks.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks", + unstable_path: { + pathname: "/tasks", + search: "?foo=bar", + hash: "#hash", + }, context: {}, }); @@ -2210,6 +2222,7 @@ describe("a router", () => { params: {}, request: expect.any(Request), unstable_pattern: "/tasks", + unstable_path: { pathname: "/tasks", search: "", hash: "" }, context: {}, }); @@ -2254,7 +2267,8 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - unstable_pattern: expect.any(String), + unstable_pattern: "/tasks", + unstable_path: { pathname: "/tasks", search: "?foo=bar", hash: "" }, context: {}, }); // Assert request internals, cannot do a deep comparison above since some @@ -2289,6 +2303,7 @@ describe("a router", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); diff --git a/packages/react-router/__tests__/router/ssr-test.ts b/packages/react-router/__tests__/router/ssr-test.ts index 317bbe50d8..faeed9bfbb 100644 --- a/packages/react-router/__tests__/router/ssr-test.ts +++ b/packages/react-router/__tests__/router/ssr-test.ts @@ -837,12 +837,29 @@ describe("ssr", () => { ]); await query(createRequest("/child")); + expect(rootLoaderStub).toHaveBeenCalledTimes(1); + expect(rootLoaderStub).toHaveBeenCalledWith({ + request: new Request("http://localhost/child"), + unstable_pattern: "/child", + unstable_path: { pathname: "/child", search: "", hash: "" }, + params: {}, + context: {}, + }); // @ts-expect-error let rootLoaderRequest = rootLoaderStub.mock.calls[0][0]?.request; - // @ts-expect-error - let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(rootLoaderRequest.method).toBe("GET"); expect(rootLoaderRequest.url).toBe("http://localhost/child"); + + expect(childLoaderStub).toHaveBeenCalledTimes(1); + expect(childLoaderStub).toHaveBeenCalledWith({ + request: new Request("http://localhost/child"), + unstable_pattern: "/child", + unstable_path: { pathname: "/child", search: "", hash: "" }, + params: {}, + context: {}, + }); + // @ts-expect-error + let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(childLoaderRequest.method).toBe("GET"); expect(childLoaderRequest.url).toBe("http://localhost/child"); }); @@ -874,6 +891,14 @@ describe("ssr", () => { }), ); + expect(actionStub).toHaveBeenCalledTimes(1); + expect(actionStub).toHaveBeenCalledWith({ + request: expect.any(Request), + unstable_pattern: "/child", + unstable_path: { pathname: "/child", search: "", hash: "" }, + params: {}, + context: {}, + }); // @ts-expect-error let actionRequest = actionStub.mock.calls[0][0]?.request; expect(actionRequest.method).toBe("POST"); @@ -883,14 +908,31 @@ describe("ssr", () => { ); expect((await actionRequest.formData()).get("key")).toBe("value"); + expect(rootLoaderStub).toHaveBeenCalledTimes(1); + expect(rootLoaderStub).toHaveBeenCalledWith({ + request: expect.any(Request), + unstable_pattern: "/child", + unstable_path: { pathname: "/child", search: "", hash: "" }, + params: {}, + context: {}, + }); // @ts-expect-error let rootLoaderRequest = rootLoaderStub.mock.calls[0][0]?.request; - // @ts-expect-error - let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(rootLoaderRequest.method).toBe("GET"); expect(rootLoaderRequest.url).toBe("http://localhost/child"); expect(rootLoaderRequest.headers.get("test")).toBe("value"); expect(await rootLoaderRequest.text()).toBe(""); + + expect(childLoaderStub).toHaveBeenCalledTimes(1); + expect(childLoaderStub).toHaveBeenCalledWith({ + request: expect.any(Request), + unstable_pattern: "/child", + unstable_path: { pathname: "/child", search: "", hash: "" }, + params: {}, + context: {}, + }); + // @ts-expect-error + let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(childLoaderRequest.method).toBe("GET"); expect(childLoaderRequest.url).toBe("http://localhost/child"); expect(childLoaderRequest.headers.get("test")).toBe("value"); diff --git a/packages/react-router/__tests__/router/submission-test.ts b/packages/react-router/__tests__/router/submission-test.ts index 7cc38b1c31..902485ca1a 100644 --- a/packages/react-router/__tests__/router/submission-test.ts +++ b/packages/react-router/__tests__/router/submission-test.ts @@ -949,6 +949,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -984,6 +985,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -1017,6 +1019,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -1122,6 +1125,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -1161,6 +1165,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); @@ -1197,6 +1202,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_path: expect.any(Object), context: {}, }); diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 55abb29e29..d9ec93e1a4 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -3875,7 +3875,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, - unstable_path: stripIndexParam(location), + unstable_path: createDataFunctionPath(location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -4112,7 +4112,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, - unstable_path: stripIndexParam(location), + unstable_path: createDataFunctionPath(location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -6136,7 +6136,7 @@ async function callDataStrategyImpl( "fetcherKey" | "runClientMiddleware" > = { request, - unstable_path: stripIndexParam(path), + unstable_path: createDataFunctionPath(path), unstable_pattern: getRoutePattern(matches), params: matches[0].params, context: scopedContext, @@ -6239,7 +6239,7 @@ async function callLoaderOrAction({ return handler( { request, - unstable_path: stripIndexParam(path), + unstable_path: createDataFunctionPath(path), unstable_pattern, params: match.params, context: scopedContext, @@ -6539,7 +6539,9 @@ function createClientSideRequest( return new Request(url, init); } -function stripIndexParam(path: To): Path { +// Create the unstable_path object to pass to loaders/actions/middleware, +// we strip the `?index` param becuase that is a React Router implementation detail +function createDataFunctionPath(path: To): Path { let parsed = typeof path === "string" ? parsePath(path) : path; let searchParams = new URLSearchParams(parsed.search); @@ -6550,11 +6552,13 @@ function stripIndexParam(path: To): Path { searchParams.append("index", value); } + // Create fresh here to strip any `state`/`key` fields from `Location` instances + // coming in (which satisfy the `To` interface) + let search = searchParams.toString(); return { - pathname: "", - hash: "", - ...parsed, - search: searchParams.size ? `?${searchParams.toString()}` : "", + pathname: parsed.pathname || "/", + search: search ? `?${search}` : "", + hash: parsed.hash || "", }; } From 3d17cf508c6d0ca480fb3c5e3470401fed4ba981 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 5 Feb 2026 10:46:01 -0500 Subject: [PATCH 6/6] Switch from urls to Path objects --- packages/react-router/lib/router/router.ts | 31 +++++---- packages/react-router/lib/rsc/server.rsc.ts | 6 +- .../react-router/lib/server-runtime/server.ts | 41 ++++++------ .../lib/server-runtime/single-fetch.ts | 11 ++- .../react-router/lib/server-runtime/urls.ts | 67 ++++++++++--------- 5 files changed, 81 insertions(+), 75 deletions(-) diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index d9ec93e1a4..bba6f4af39 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -463,7 +463,7 @@ export interface StaticHandler { }, ) => Promise, ) => MaybePromise; - unstable_normalizeUrl?: (url: URL) => URL; + unstable_normalizePath?: (request: Request) => Path; }, ): Promise; queryRoute( @@ -475,7 +475,7 @@ export interface StaticHandler { generateMiddlewareResponse?: ( queryRoute: (r: Request) => Promise, ) => MaybePromise; - unstable_normalizeUrl?: (url: URL) => URL; + unstable_normalizePath?: (request: Request) => Path; }, ): Promise; } @@ -3788,15 +3788,12 @@ export function createStaticHandler( skipRevalidation, dataStrategy, generateMiddlewareResponse, - unstable_normalizeUrl, + unstable_normalizePath, }: Parameters[1] = {}, ): Promise { - let url = new URL(request.url); - if (unstable_normalizeUrl) { - url = unstable_normalizeUrl(url); - } + let normalizePath = unstable_normalizePath || defaultNormalizePath; let method = request.method; - let location = createLocation("", createPath(url), null, "default"); + let location = createLocation("", normalizePath(request), null, "default"); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -4068,15 +4065,12 @@ export function createStaticHandler( requestContext, dataStrategy, generateMiddlewareResponse, - unstable_normalizeUrl, + unstable_normalizePath, }: Parameters[1] = {}, ): Promise { - let url = new URL(request.url); - if (unstable_normalizeUrl) { - url = unstable_normalizeUrl(url); - } + let normalizePath = unstable_normalizePath || defaultNormalizePath; let method = request.method; - let location = createLocation("", createPath(url), null, "default"); + let location = createLocation("", normalizePath(request), null, "default"); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -4711,6 +4705,15 @@ function isSubmissionNavigation( ); } +function defaultNormalizePath(request: Request): Path { + let url = new URL(request.url); + return { + pathname: url.pathname, + search: url.search, + hash: url.hash, + }; +} + function normalizeTo( location: Path, matches: AgnosticDataRouteMatch[], diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 8844030d37..60e64bca33 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -67,7 +67,7 @@ import { createRedirectErrorDigest, createRouteErrorResponseDigest, } from "../errors"; -import { normalizeUrl } from "../server-runtime/urls"; +import { getNormalizedPath } from "../server-runtime/urls"; const Outlet: typeof OutletType = UNTYPED_Outlet; const WithComponentProps: typeof WithComponentPropsType = @@ -715,7 +715,7 @@ async function generateResourceResponse( return generateErrorResponse(error); } }, - unstable_normalizeUrl: (url) => normalizeUrl(url, basename, null), + unstable_normalizePath: (r) => getNormalizedPath(r, basename, null), }); return response; } catch (error) { @@ -807,7 +807,7 @@ async function generateRenderResponse( ...(routeIdsToLoad ? { filterMatchesToLoad: (m) => routeIdsToLoad!.includes(m.route.id) } : {}), - unstable_normalizeUrl: (url) => normalizeUrl(url, basename, null), + unstable_normalizePath: (r) => getNormalizedPath(r, basename, null), async generateMiddlewareResponse(query) { // If this is an RSC server action, process that and then call query as a // revalidation. If this is a RR Form/Fetcher submission, diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index 96d30bc47b..277332a35f 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -39,7 +39,7 @@ import { getManifestPath } from "../dom/ssr/fog-of-war"; import type { unstable_InstrumentRequestHandlerFunction } from "../router/instrumentation"; import { instrumentHandler } from "../router/instrumentation"; import { throwIfPotentialCSRFAttack } from "../actions"; -import { normalizePath, normalizeUrl } from "./urls"; +import { getNormalizedPath } from "./urls"; export type RequestHandler = ( request: Request, @@ -107,11 +107,10 @@ function derive(build: ServerBuild, mode?: string) { loadContext = initialContext || {}; } - let url = new URL(request.url); - - let normalizedPath = normalizePath( - url.pathname, - build.basename || "/", + let requestUrl = new URL(request.url); + let normalizedPath = getNormalizedPath( + request, + build.basename, build.future, ); @@ -122,7 +121,7 @@ function derive(build: ServerBuild, mode?: string) { // pre-rendered site would if (!build.ssr) { // Decode the URL path before checking against the prerender config - let decodedPath = decodeURI(normalizedPath); + let decodedPath = decodeURI(normalizedPath.pathname); if (build.basename && build.basename !== "/") { let strippedPath = stripBasename(decodedPath, build.basename); @@ -157,7 +156,7 @@ function derive(build: ServerBuild, mode?: string) { !build.prerender.includes(decodedPath) && !build.prerender.includes(decodedPath + "/") ) { - if (url.pathname.endsWith(".data")) { + if (requestUrl.pathname.endsWith(".data")) { // 404 on non-pre-rendered `.data` requests errorHandler( new ErrorResponseImpl( @@ -187,9 +186,9 @@ function derive(build: ServerBuild, mode?: string) { build.routeDiscovery.manifestPath, build.basename, ); - if (url.pathname === manifestUrl) { + if (requestUrl.pathname === manifestUrl) { try { - let res = await handleManifestRequest(build, routes, url); + let res = await handleManifestRequest(build, routes, requestUrl); return res; } catch (e) { handleError(e); @@ -197,16 +196,20 @@ function derive(build: ServerBuild, mode?: string) { } } - let matches = matchServerRoutes(routes, normalizedPath, build.basename); + let matches = matchServerRoutes( + routes, + normalizedPath.pathname, + build.basename, + ); if (matches && matches.length > 0) { Object.assign(params, matches[0].params); } let response: Response; - if (url.pathname.endsWith(".data")) { + if (requestUrl.pathname.endsWith(".data")) { let singleFetchMatches = matchServerRoutes( routes, - normalizedPath, + normalizedPath.pathname, build.basename, ); @@ -215,7 +218,7 @@ function derive(build: ServerBuild, mode?: string) { build, staticHandler, request, - normalizedPath, + normalizedPath.pathname, loadContext, handleError, ); @@ -261,7 +264,7 @@ function derive(build: ServerBuild, mode?: string) { handleError, ); } else { - let { pathname } = url; + let { pathname } = requestUrl; let criticalCss: CriticalCss | undefined = undefined; if (build.unstable_getCriticalCss) { @@ -494,8 +497,8 @@ async function handleDocumentRequest( } } : undefined, - unstable_normalizeUrl: (url) => - normalizeUrl(url, build.basename, build.future), + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); if (!isResponse(result)) { @@ -673,8 +676,8 @@ async function handleResourceRequest( } } : undefined, - unstable_normalizeUrl: (url) => - normalizeUrl(url, build.basename, build.future), + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); return handleQueryRouteResult(result); diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 488ecc0da6..a27e73fc7e 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -24,8 +24,7 @@ import { ServerMode } from "./mode"; import { getDocumentHeaders } from "./headers"; import type { ServerBuild } from "./build"; import { throwIfPotentialCSRFAttack } from "../actions"; -import { FutureConfig } from "../dom/ssr/entry"; -import { normalizeUrl } from "./urls"; +import { getNormalizedPath } from "./urls"; // Add 304 for server side - that is not included in the client side logic // because the browser should fill those responses with the cached data @@ -80,8 +79,8 @@ export async function singleFetchAction( } } : undefined, - unstable_normalizeUrl: (url) => - normalizeUrl(url, build.basename, build.future), + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); return handleQueryResult(result); @@ -174,8 +173,8 @@ export async function singleFetchLoaders( } } : undefined, - unstable_normalizeUrl: (url) => - normalizeUrl(url, build.basename, build.future), + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); return handleQueryResult(result); diff --git a/packages/react-router/lib/server-runtime/urls.ts b/packages/react-router/lib/server-runtime/urls.ts index 53b8536ffa..26338821cd 100644 --- a/packages/react-router/lib/server-runtime/urls.ts +++ b/packages/react-router/lib/server-runtime/urls.ts @@ -1,51 +1,52 @@ import type { FutureConfig } from "../dom/ssr/entry"; +import type { Path } from "../router/history"; import { stripBasename } from "../router/utils"; -export function normalizeUrl( - url: URL, +export function getNormalizedPath( + request: Request, basename: string | undefined, future: FutureConfig | null, -) { - // Strip .data suffix - url.pathname = normalizePath(url.pathname, basename || "/", future); - - // Strip _routes param - url.searchParams.delete("_routes"); - - // Don't touch index params here - they're needed for router matching and are - // stripped when creating the loader/action args +): Path { + basename = basename || "/"; - return url; -} + let url = new URL(request.url); + let pathname = url.pathname; -export function normalizePath( - pathname: string, - basename: string | undefined, - future: FutureConfig | null, -) { - let normalizedBasename = basename || "/"; - let normalizedPath = pathname; + // Strip .data suffix if (future?.unstable_trailingSlashAwareDataRequests) { - if (normalizedPath.endsWith("/_.data")) { + if (pathname.endsWith("/_.data")) { // Handle trailing slash URLs: /about/_.data -> /about/ - normalizedPath = normalizedPath.replace(/_.data$/, ""); + pathname = pathname.replace(/_.data$/, ""); } else { - normalizedPath = normalizedPath.replace(/\.data$/, ""); + pathname = pathname.replace(/\.data$/, ""); } } else { - if (stripBasename(normalizedPath, normalizedBasename) === "/_root.data") { - normalizedPath = normalizedBasename; - } else if (normalizedPath.endsWith(".data")) { - normalizedPath = normalizedPath.replace(/\.data$/, ""); + if (stripBasename(pathname, basename) === "/_root.data") { + pathname = basename; + } else if (pathname.endsWith(".data")) { + pathname = pathname.replace(/\.data$/, ""); } - if ( - stripBasename(normalizedPath, normalizedBasename) !== "/" && - normalizedPath.endsWith("/") - ) { - normalizedPath = normalizedPath.slice(0, -1); + if (stripBasename(pathname, basename) !== "/" && pathname.endsWith("/")) { + pathname = pathname.slice(0, -1); } } - return normalizedPath; + // Strip _routes param + let searchParams = new URLSearchParams(url.search); + searchParams.delete("_routes"); + let search = searchParams.toString(); + if (search) { + search = `?${search}`; + } + + // Don't touch index params here - they're needed for router matching and are + // stripped when creating the loader/action args + + return { + pathname, + search, + // No hashes on the server + hash: "", + }; }