From 0eaac5a3c7491ab04677750f0df05019baf2cc9f Mon Sep 17 00:00:00 2001 From: Bryan Ross Date: Fri, 4 Sep 2026 22:43:55 -0600 Subject: [PATCH 1/2] feat: implement custom fetch for HydratedRouter --- integration/browser-entry-test.ts | 56 ++++++ .../lib/dom-export/hydrated-router.tsx | 10 ++ .../react-router/lib/dom/ssr/single-fetch.tsx | 160 ++++++++++-------- 3 files changed, 151 insertions(+), 75 deletions(-) diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts index 070e789e95..1093872ddd 100644 --- a/integration/browser-entry-test.ts +++ b/integration/browser-entry-test.ts @@ -131,6 +131,62 @@ test("allows users to pass a client side context to HydratedRouter", async ({ appFixture.close(); }); +test("allows users to pass a custom fetch implementation to HydratedRouter", async ({ + page, +}) => { + let fixture = await createFixture({ + files: { + "app/entry.client.tsx": js` + import { HydratedRouter } from "react-router/dom"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + + startTransition(() => { + hydrateRoot( + document, + + { + let request = new Request(input, init); + request.headers.set("X-Custom-Fetch", "true"); + return window.fetch(request); + }} + /> + + ); + }); + `, + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + + export default function Index() { + return Go to Page; + } + `, + "app/routes/page.tsx": js` + export function loader({ request }) { + return request.headers.get("X-Custom-Fetch"); + } + + export default function Page({ loaderData }) { + return

{loaderData}

; + } + `, + }, + }); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + await page.click('a[href="/page"]'); + await page.waitForSelector("[data-custom-fetch]"); + + await expect(page.locator("[data-custom-fetch]")).toHaveText("true"); + + appFixture.close(); +}); + test("allows users to pass an onError function to HydratedRouter", async ({ page, browserName, diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 2631fab372..20d852d3be 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -27,6 +27,7 @@ import { import { CRITICAL_CSS_DATA_ATTRIBUTE } from "../dom/ssr/components"; import { RouterProvider } from "./dom-router-provider"; import type { ClientInstrumentation } from "../router/instrumentation"; +import type { FetchFunction } from "../dom/ssr/single-fetch"; type SSRInfo = { context: NonNullable<(typeof window)["__reactRouterContext"]>; @@ -79,9 +80,11 @@ function initSsrInfo(): void { function createHydratedRouter({ getContext, instrumentations, + fetch: fetchImplementation = fetch, }: { getContext?: RouterInit["getContext"]; instrumentations?: ClientInstrumentation[]; + fetch: FetchFunction; }): DataRouter { initSsrInfo(); @@ -186,6 +189,7 @@ function createHydratedRouter({ ssrInfo.manifest, ssrInfo.routeModules, ssrInfo.context.ssr, + fetchImplementation, ), patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction( () => router, @@ -319,6 +323,11 @@ export interface HydratedRouterProps { * For more information, please see the [docs](../../explanation/react-transitions). */ useTransitions?: boolean; + /** + * Provide a custom implementation for `fetch`, which will be used to perform + * data requests for navigations and fetchers. Defaults to `window.fetch` + */ + fetch?: FetchFunction; } /** @@ -338,6 +347,7 @@ export function HydratedRouter(props: HydratedRouterProps) { router = createHydratedRouter({ getContext: props.getContext, instrumentations: props.instrumentations, + fetch: props.fetch ?? window.fetch, }); } diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 9cfbef89e2..bbfae68cb6 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -172,11 +172,17 @@ export type FetchAndDecodeFunction = ( shouldAllowOptOut?: ShouldAllowOptOutFunction, ) => Promise<{ status: number; data: DecodedSingleFetchResults }>; +export type FetchFunction = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + export function getTurboStreamSingleFetchDataStrategy( getRouter: () => DataRouter, manifest: AssetsManifest, routeModules: RouteModules, ssr: boolean, + fetchImplementation: FetchFunction, ): DataStrategyFunction { let dataStrategy = getSingleFetchDataStrategyImpl( getRouter, @@ -188,7 +194,7 @@ export function getTurboStreamSingleFetchDataStrategy( hasClientLoader: manifestRoute.hasClientLoader, }; }, - fetchAndDecodeViaTurboStream, + fetchAndDecodeViaTurboStream(fetchImplementation), ssr, ); return async (args) => args.runClientMiddleware(dataStrategy); @@ -577,92 +583,96 @@ export function singleFetchUrl( return url; } -async function fetchAndDecodeViaTurboStream( - args: DataStrategyFunctionArgs, - targetRoutes?: string[], -): Promise<{ status: number; data: DecodedSingleFetchResults }> { - let { request } = args; - let url = singleFetchUrl(request.url, "data"); - if (request.method === "GET") { - url = stripIndexParam(url); - if (targetRoutes) { - url.searchParams.set("_routes", targetRoutes.join(",")); +function fetchAndDecodeViaTurboStream( + fetchImplementation: FetchFunction, +): FetchAndDecodeFunction { + return async ( + args: DataStrategyFunctionArgs, + targetRoutes?: string[], + ): Promise<{ status: number; data: DecodedSingleFetchResults }> => { + let { request } = args; + let url = singleFetchUrl(request.url, "data"); + if (request.method === "GET") { + url = stripIndexParam(url); + if (targetRoutes) { + url.searchParams.set("_routes", targetRoutes.join(",")); + } } - } - let res = await fetch(url, await createRequestInit(request)); + let res = await fetchImplementation(url, await createRequestInit(request)); - // If this error'd without hitting the running server, then bubble a normal - // `ErrorResponse` and don't try to decode the body with `turbo-stream`. - // - // This could be triggered by a few scenarios: - // - `.data` request 404 on a pre-rendered app using a CDN - // - 429 error returned from a CDN on a SSR app - if (res.status >= 400 && !res.headers.has("X-Remix-Response")) { - throw new ErrorResponseImpl(res.status, res.statusText, await res.text()); - } + // If this error'd without hitting the running server, then bubble a normal + // `ErrorResponse` and don't try to decode the body with `turbo-stream`. + // + // This could be triggered by a few scenarios: + // - `.data` request 404 on a pre-rendered app using a CDN + // - 429 error returned from a CDN on a SSR app + if (res.status >= 400 && !res.headers.has("X-Remix-Response")) { + throw new ErrorResponseImpl(res.status, res.statusText, await res.text()); + } - // Handle non-RR redirects (i.e., from express middleware) - if (res.status === 204 && res.headers.has("X-Remix-Redirect")) { - return { - status: SINGLE_FETCH_REDIRECT_STATUS, - data: { - redirect: { - redirect: res.headers.get("X-Remix-Redirect")!, - status: Number(res.headers.get("X-Remix-Status") || "302"), - revalidate: res.headers.get("X-Remix-Revalidate") === "true", - reload: res.headers.get("X-Remix-Reload-Document") === "true", - replace: res.headers.get("X-Remix-Replace") === "true", + // Handle non-RR redirects (i.e., from express middleware) + if (res.status === 204 && res.headers.has("X-Remix-Redirect")) { + return { + status: SINGLE_FETCH_REDIRECT_STATUS, + data: { + redirect: { + redirect: res.headers.get("X-Remix-Redirect")!, + status: Number(res.headers.get("X-Remix-Status") || "302"), + revalidate: res.headers.get("X-Remix-Revalidate") === "true", + reload: res.headers.get("X-Remix-Reload-Document") === "true", + replace: res.headers.get("X-Remix-Replace") === "true", + }, }, - }, - }; - } + }; + } - if (NO_BODY_STATUS_CODES.has(res.status)) { - let routes: { [key: string]: SingleFetchResult } = {}; - // We get back just a single result for action requests - normalize that - // to a DecodedSingleFetchResults shape here - if (targetRoutes && request.method !== "GET") { - routes[targetRoutes[0]] = { data: undefined }; + if (NO_BODY_STATUS_CODES.has(res.status)) { + let routes: { [key: string]: SingleFetchResult } = {}; + // We get back just a single result for action requests - normalize that + // to a DecodedSingleFetchResults shape here + if (targetRoutes && request.method !== "GET") { + routes[targetRoutes[0]] = { data: undefined }; + } + return { + status: res.status, + data: { routes }, + }; } - return { - status: res.status, - data: { routes }, - }; - } - invariant(res.body, "No response body to decode"); + invariant(res.body, "No response body to decode"); - try { - let decoded = await decodeViaTurboStream(res.body, window); - let data: DecodedSingleFetchResults; - if (request.method === "GET") { - let typed = decoded.value as SingleFetchResults; - if (SingleFetchRedirectSymbol in typed) { - data = { redirect: typed[SingleFetchRedirectSymbol] }; - } else { - data = { routes: typed }; - } - } else { - let typed = decoded.value as SingleFetchResult; - let routeId = targetRoutes?.[0]; - invariant(routeId, "No routeId found for single fetch call decoding"); - if ("redirect" in typed) { - data = { redirect: typed }; + try { + let decoded = await decodeViaTurboStream(res.body, window); + let data: DecodedSingleFetchResults; + if (request.method === "GET") { + let typed = decoded.value as SingleFetchResults; + if (SingleFetchRedirectSymbol in typed) { + data = { redirect: typed[SingleFetchRedirectSymbol] }; + } else { + data = { routes: typed }; + } } else { - data = { routes: { [routeId]: typed } }; + let typed = decoded.value as SingleFetchResult; + let routeId = targetRoutes?.[0]; + invariant(routeId, "No routeId found for single fetch call decoding"); + if ("redirect" in typed) { + data = { redirect: typed }; + } else { + data = { routes: { [routeId]: typed } }; + } } + return { status: res.status, data }; + } catch { + // Can't clone after consuming the body via turbo-stream so we can't + // include the body here. In an ideal world we'd look for a turbo-stream + // content type here, or even X-Remix-Response but then folks can't + // statically deploy their prerendered .data files to a CDN unless they can + // tell that CDN to add special headers to those certain files - which is a + // bit restrictive. + throw new Error("Unable to decode turbo-stream response"); } - return { status: res.status, data }; - } catch { - // Can't clone after consuming the body via turbo-stream so we can't - // include the body here. In an ideal world we'd look for a turbo-stream - // content type here, or even X-Remix-Response but then folks can't - // statically deploy their prerendered .data files to a CDN unless they can - // tell that CDN to add special headers to those certain files - which is a - // bit restrictive. - throw new Error("Unable to decode turbo-stream response"); - } + }; } // Note: If you change this function please change the corresponding From b82141c71b759986fb8748ae373bca383373c3a3 Mon Sep 17 00:00:00 2001 From: Bryan Ross Date: Fri, 4 Sep 2026 22:47:26 -0600 Subject: [PATCH 2/2] add changes file --- .../minor.adds-fetch-prop-hydratedrouter-which-provides.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/react-router/.changes/minor.adds-fetch-prop-hydratedrouter-which-provides.md diff --git a/packages/react-router/.changes/minor.adds-fetch-prop-hydratedrouter-which-provides.md b/packages/react-router/.changes/minor.adds-fetch-prop-hydratedrouter-which-provides.md new file mode 100644 index 0000000000..be72faf411 --- /dev/null +++ b/packages/react-router/.changes/minor.adds-fetch-prop-hydratedrouter-which-provides.md @@ -0,0 +1 @@ +Adds `fetch` prop to `HydratedRouter`, which provides a custom fetch implementation for data requests.