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/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-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/__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/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/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 0c1682c250..8a7db11992 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_path, + }: LoaderFunctionArgs, singleFetch?: unknown, ) => { try { @@ -359,6 +365,7 @@ export function createClientRoutes( params, context, unstable_pattern, + unstable_path, 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_path, + }: ActionFunctionArgs, singleFetch?: unknown, ) => { return prefetchStylesAndCallHandler(async () => { @@ -414,6 +427,7 @@ export function createClientRoutes( params, context, unstable_pattern, + 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 34c74cc28d..bba6f4af39 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 @@ -461,6 +463,7 @@ export interface StaticHandler { }, ) => Promise, ) => MaybePromise; + unstable_normalizePath?: (request: Request) => Path; }, ): Promise; queryRoute( @@ -472,6 +475,7 @@ export interface StaticHandler { generateMiddlewareResponse?: ( queryRoute: (r: Request) => Promise, ) => MaybePromise; + unstable_normalizePath?: (request: Request) => Path; }, ): Promise; } @@ -917,6 +921,7 @@ export function createRouter(init: RouterInit): Router { // Config driven behavior flags let future: FutureConfig = { + unstable_passThroughRequests: false, ...init.future, }; // Cleanup function for history @@ -1952,6 +1957,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, request, + location, matches, actionMatch, initialHydration ? [] : hydrationRouteProperties, @@ -1959,6 +1965,7 @@ export function createRouter(init: RouterInit): Router { ); let results = await callDataStrategy( request, + location, dsMatches, scopedContext, null, @@ -2233,6 +2240,7 @@ export function createRouter(init: RouterInit): Router { dsMatches, revalidatingFetchers, request, + location, scopedContext, ); @@ -2497,6 +2505,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, fetchRequest, + path, requestMatches, match, hydrationRouteProperties, @@ -2504,6 +2513,7 @@ export function createRouter(init: RouterInit): Router { ); let actionResults = await callDataStrategy( fetchRequest, + path, fetchMatches, scopedContext, key, @@ -2645,6 +2655,7 @@ export function createRouter(init: RouterInit): Router { dsMatches, revalidatingFetchers, revalidationRequest, + nextLocation, scopedContext, ); @@ -2803,6 +2814,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, fetchRequest, + path, matches, match, hydrationRouteProperties, @@ -2810,6 +2822,7 @@ export function createRouter(init: RouterInit): Router { ); let results = await callDataStrategy( fetchRequest, + path, dsMatches, scopedContext, key, @@ -3011,6 +3024,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, @@ -3021,6 +3035,7 @@ export function createRouter(init: RouterInit): Router { results = await callDataStrategyImpl( dataStrategyImpl as DataStrategyFunction, request, + path, matches, fetcherKey, scopedContext, @@ -3096,11 +3111,13 @@ export function createRouter(init: RouterInit): Router { matches: DataStrategyMatch[], fetchersToLoad: RevalidatingFetcher[], request: Request, + location: Location, scopedContext: RouterContextProvider, ) { // Kick off loaders and fetchers in parallel let loaderResultsPromise = callDataStrategy( request, + location, matches, scopedContext, null, @@ -3111,6 +3128,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, @@ -3688,7 +3706,7 @@ export interface CreateStaticHandlerOptions { basename?: string; mapRouteProperties?: MapRoutePropertiesFunction; unstable_instrumentations?: Pick[]; - future?: {}; + future?: Partial; } export function createStaticHandler( @@ -3705,6 +3723,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 @@ -3766,11 +3788,12 @@ export function createStaticHandler( skipRevalidation, dataStrategy, generateMiddlewareResponse, + unstable_normalizePath, }: Parameters[1] = {}, ): Promise { - let url = new URL(request.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(); @@ -3849,6 +3872,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + unstable_path: createDataFunctionPath(location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -4041,11 +4065,12 @@ export function createStaticHandler( requestContext, dataStrategy, generateMiddlewareResponse, + unstable_normalizePath, }: Parameters[1] = {}, ): Promise { - let url = new URL(request.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(); @@ -4081,6 +4106,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + unstable_path: createDataFunctionPath(location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -4189,6 +4215,7 @@ export function createStaticHandler( if (isMutationMethod(request.method)) { let result = await submit( request, + location, matches, routeMatch || getTargetMatch(matches, location), requestContext, @@ -4203,6 +4230,7 @@ export function createStaticHandler( let result = await loadRouteData( request, + location, matches, requestContext, dataStrategy, @@ -4238,6 +4266,7 @@ export function createStaticHandler( async function submit( request: Request, + location: Location, matches: AgnosticDataRouteMatch[], actionMatch: AgnosticDataRouteMatch, requestContext: unknown, @@ -4267,6 +4296,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, matches, actionMatch, [], @@ -4275,6 +4305,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, + location, dsMatches, isRouteRequest, requestContext, @@ -4378,6 +4409,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, + location, matches, requestContext, dataStrategy, @@ -4404,6 +4436,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, + location, matches, requestContext, dataStrategy, @@ -4427,6 +4460,7 @@ export function createStaticHandler( async function loadRouteData( request: Request, + location: Location, matches: AgnosticDataRouteMatch[], requestContext: unknown, dataStrategy: DataStrategyFunction | null, @@ -4462,6 +4496,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, matches, routeMatch, [], @@ -4481,6 +4516,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, pattern, match, [], @@ -4493,6 +4529,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, pattern, match, [], @@ -4522,6 +4559,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, + location, dsMatches, isRouteRequest, requestContext, @@ -4551,6 +4589,7 @@ export function createStaticHandler( // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( request: Request, + location: Location, matches: DataStrategyMatch[], isRouteRequest: boolean, requestContext: unknown, @@ -4559,6 +4598,7 @@ export function createStaticHandler( let results = await callDataStrategyImpl( dataStrategy || defaultDataStrategy, request, + location, matches, null, requestContext, @@ -4665,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[], @@ -4983,6 +5032,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + location, pattern, match, lazyRoutePropertiesToSkip, @@ -5027,6 +5077,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + location, pattern, match, lazyRoutePropertiesToSkip, @@ -5108,6 +5159,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5122,6 +5174,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5150,6 +5203,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5804,18 +5858,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, @@ -5937,6 +5986,7 @@ function getDataStrategyMatch( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, + path: To, unstable_pattern: string, match: DataRouteMatch, lazyRoutePropertiesToSkip: string[], @@ -6007,6 +6057,7 @@ function getDataStrategyMatch( ) { return callLoaderOrAction({ request, + path, unstable_pattern, match, lazyHandlerPromise: _lazyPromises?.handler, @@ -6024,6 +6075,7 @@ function getTargetedDataStrategyMatches( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, + path: To, matches: AgnosticDataRouteMatch[], targetMatch: AgnosticDataRouteMatch, lazyRoutePropertiesToSkip: string[], @@ -6054,6 +6106,7 @@ function getTargetedDataStrategyMatches( mapRouteProperties, manifest, request, + path, getRoutePattern(matches), match, lazyRoutePropertiesToSkip, @@ -6067,6 +6120,7 @@ function getTargetedDataStrategyMatches( async function callDataStrategyImpl( dataStrategyImpl: DataStrategyFunction, request: Request, + path: To, matches: DataStrategyMatch[], fetcherKey: string | null, scopedContext: unknown, @@ -6080,8 +6134,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_path: createDataFunctionPath(path), unstable_pattern: getRoutePattern(matches), params: matches[0].params, context: scopedContext, @@ -6140,6 +6198,7 @@ async function callDataStrategyImpl( // Default logic for calling a loader/action is the user has no specified a dataStrategy async function callLoaderOrAction({ request, + path, unstable_pattern, match, lazyHandlerPromise, @@ -6148,6 +6207,7 @@ async function callLoaderOrAction({ scopedContext, }: { request: Request; + path: To; unstable_pattern: string; match: AgnosticDataRouteMatch; lazyHandlerPromise: Promise | undefined; @@ -6182,6 +6242,7 @@ async function callLoaderOrAction({ return handler( { request, + unstable_path: createDataFunctionPath(path), unstable_pattern, params: match.params, context: scopedContext, @@ -6481,6 +6542,29 @@ function createClientSideRequest( return new Request(url, init); } +// 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); + + // Strip naked index param, preserve any other index params with values + let indexValues = searchParams.getAll("index"); + searchParams.delete("index"); + for (let value of indexValues.filter(Boolean)) { + 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: parsed.pathname || "/", + search: search ? `?${search}` : "", + hash: parsed.hash || "", + }; +} + function convertFormDataToSearchParams(formData: FormData): URLSearchParams { let searchParams = new URLSearchParams(); @@ -6952,7 +7036,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; diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 7a5c10b7c7..415d832c53 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 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_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/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.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index e19c12326d..60e64bca33 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 { getNormalizedPath } from "../server-runtime/urls"; const Outlet: typeof OutletType = UNTYPED_Outlet; const WithComponentProps: typeof WithComponentPropsType = @@ -714,6 +715,7 @@ async function generateResourceResponse( return generateErrorResponse(error); } }, + unstable_normalizePath: (r) => getNormalizedPath(r, basename, null), }); return response; } catch (error) { @@ -805,6 +807,7 @@ async function generateRenderResponse( ...(routeIdsToLoad ? { filterMatchesToLoad: (m) => routeIdsToLoad!.includes(m.route.id) } : {}), + 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/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..ecfef6e2d1 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,13 @@ 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)), + unstable_path: args.unstable_path, params: args.params, context: args.context, unstable_pattern: args.unstable_pattern, @@ -42,11 +47,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..277332a35f 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 { getNormalizedPath } from "./urls"; export type RequestHandler = ( request: Request, @@ -106,31 +107,12 @@ function derive(build: ServerBuild, mode?: string) { loadContext = initialContext || {}; } - 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 requestUrl = new URL(request.url); + let normalizedPath = getNormalizedPath( + request, + build.basename, + build.future, + ); let isSpaMode = getBuildTimeHeader(request, "X-React-Router-SPA-Mode") === "yes"; @@ -139,17 +121,17 @@ 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 (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, @@ -174,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( @@ -202,11 +184,11 @@ 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) { + 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); @@ -214,19 +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")) { - let handlerUrl = new URL(request.url); - handlerUrl.pathname = normalizedPath; - + if (requestUrl.pathname.endsWith(".data")) { let singleFetchMatches = matchServerRoutes( routes, - handlerUrl.pathname, + normalizedPath.pathname, build.basename, ); @@ -235,7 +218,7 @@ function derive(build: ServerBuild, mode?: string) { build, staticHandler, request, - handlerUrl, + normalizedPath.pathname, loadContext, handleError, ); @@ -281,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) { @@ -443,10 +426,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( @@ -511,6 +497,8 @@ async function handleDocumentRequest( } } : undefined, + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); if (!isResponse(result)) { @@ -688,6 +676,8 @@ async function handleResourceRequest( } } : undefined, + 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 9785c5176e..a27e73fc7e 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 { 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 @@ -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, @@ -76,6 +79,8 @@ export async function singleFetchAction( } } : undefined, + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); return handleQueryResult(result); @@ -147,10 +152,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, @@ -166,6 +173,8 @@ export async function singleFetchLoaders( } } : undefined, + 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 new file mode 100644 index 0000000000..26338821cd --- /dev/null +++ b/packages/react-router/lib/server-runtime/urls.ts @@ -0,0 +1,52 @@ +import type { FutureConfig } from "../dom/ssr/entry"; +import type { Path } from "../router/history"; +import { stripBasename } from "../router/utils"; + +export function getNormalizedPath( + request: Request, + basename: string | undefined, + future: FutureConfig | null, +): Path { + basename = basename || "/"; + + let url = new URL(request.url); + let pathname = url.pathname; + + // Strip .data suffix + if (future?.unstable_trailingSlashAwareDataRequests) { + if (pathname.endsWith("/_.data")) { + // Handle trailing slash URLs: /about/_.data -> /about/ + pathname = pathname.replace(/_.data$/, ""); + } else { + pathname = pathname.replace(/\.data$/, ""); + } + } else { + if (stripBasename(pathname, basename) === "/_root.data") { + pathname = basename; + } else if (pathname.endsWith(".data")) { + pathname = pathname.replace(/\.data$/, ""); + } + + if (stripBasename(pathname, basename) !== "/" && pathname.endsWith("/")) { + pathname = pathname.slice(0, -1); + } + } + + // 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: "", + }; +} diff --git a/packages/react-router/lib/types/route-data.ts b/packages/react-router/lib/types/route-data.ts index 52eefee088..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, @@ -77,6 +78,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 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_path: Path; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example @@ -111,6 +120,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 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_path: Path; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example