Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions integration/browser-entry-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
<StrictMode>
<HydratedRouter
fetch={(input, init) => {
let request = new Request(input, init);
request.headers.set("X-Custom-Fetch", "true");
return window.fetch(request);
}}
/>
</StrictMode>
);
});
`,
"app/routes/_index.tsx": js`
import { Link } from "react-router";
export default function Index() {
return <Link to="/page">Go to Page</Link>;
}
`,
"app/routes/page.tsx": js`
export function loader({ request }) {
return request.headers.get("X-Custom-Fetch");
}
export default function Page({ loaderData }) {
return <h1 data-custom-fetch>{loaderData}</h1>;
}
`,
},
});

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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adds `fetch` prop to `HydratedRouter`, which provides a custom fetch implementation for data requests.
10 changes: 10 additions & 0 deletions packages/react-router/lib/dom-export/hydrated-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]>;
Expand Down Expand Up @@ -79,9 +80,11 @@ function initSsrInfo(): void {
function createHydratedRouter({
getContext,
instrumentations,
fetch: fetchImplementation = fetch,
}: {
getContext?: RouterInit["getContext"];
instrumentations?: ClientInstrumentation[];
fetch: FetchFunction;
}): DataRouter {
initSsrInfo();

Expand Down Expand Up @@ -186,6 +189,7 @@ function createHydratedRouter({
ssrInfo.manifest,
ssrInfo.routeModules,
ssrInfo.context.ssr,
fetchImplementation,
),
patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction(
() => router,
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -338,6 +347,7 @@ export function HydratedRouter(props: HydratedRouterProps) {
router = createHydratedRouter({
getContext: props.getContext,
instrumentations: props.instrumentations,
fetch: props.fetch ?? window.fetch,
});
}

Expand Down
160 changes: 85 additions & 75 deletions packages/react-router/lib/dom/ssr/single-fetch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,17 @@ export type FetchAndDecodeFunction = (
shouldAllowOptOut?: ShouldAllowOptOutFunction,
) => Promise<{ status: number; data: DecodedSingleFetchResults }>;

export type FetchFunction = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;

export function getTurboStreamSingleFetchDataStrategy(
getRouter: () => DataRouter,
manifest: AssetsManifest,
routeModules: RouteModules,
ssr: boolean,
fetchImplementation: FetchFunction,
): DataStrategyFunction {
let dataStrategy = getSingleFetchDataStrategyImpl(
getRouter,
Expand All @@ -188,7 +194,7 @@ export function getTurboStreamSingleFetchDataStrategy(
hasClientLoader: manifestRoute.hasClientLoader,
};
},
fetchAndDecodeViaTurboStream,
fetchAndDecodeViaTurboStream(fetchImplementation),
ssr,
);
return async (args) => args.runClientMiddleware(dataStrategy);
Expand Down Expand Up @@ -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
Expand Down