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/.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.
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