diff --git a/docs/api/rsc/matchRSCServerRequest.md b/docs/api/rsc/matchRSCServerRequest.md
index 1feebccc5f..a61c8ef425 100644
--- a/docs/api/rsc/matchRSCServerRequest.md
+++ b/docs/api/rsc/matchRSCServerRequest.md
@@ -13,6 +13,10 @@ Matches the given routes to a Request and returns a RSC Response encoding an `RS
## Options
+### basename
+
+The basename to use when matching the request.
+
### decodeAction
Your `react-server-dom-xyz/server`'s `decodeAction` function, responsible for loading a server action.
diff --git a/integration/helpers/rsc-parcel/src/config/basename.ts b/integration/helpers/rsc-parcel/src/config/basename.ts
new file mode 100644
index 0000000000..17b384ae41
--- /dev/null
+++ b/integration/helpers/rsc-parcel/src/config/basename.ts
@@ -0,0 +1,2 @@
+// THIS FILE IS DESIGNED TO BE OVERRIDDEN IN TESTS IF NEEDED
+export const basename = undefined;
diff --git a/integration/helpers/rsc-parcel/src/server.tsx b/integration/helpers/rsc-parcel/src/server.tsx
index 5c3017029b..119cd3d520 100644
--- a/integration/helpers/rsc-parcel/src/server.tsx
+++ b/integration/helpers/rsc-parcel/src/server.tsx
@@ -14,7 +14,8 @@ import {
// Import the prerender function from the client environment
import { prerender } from "./prerender" with { env: "react-client" };
import { routes } from "./routes";
-import { assets } from "./parcel-entry-wrapper"
+import { assets } from "./parcel-entry-wrapper";
+import { basename } from "./config/basename";
function fetchServer(request: Request) {
return matchRSCServerRequest({
@@ -28,6 +29,7 @@ function fetchServer(request: Request) {
request,
// The app routes.
routes,
+ basename,
// Encode the match with the React Server implementation.
generateResponse(match, options) {
return new Response(renderToReadableStream(match.payload, options), {
diff --git a/integration/helpers/rsc-vite/src/config/basename.ts b/integration/helpers/rsc-vite/src/config/basename.ts
new file mode 100644
index 0000000000..17b384ae41
--- /dev/null
+++ b/integration/helpers/rsc-vite/src/config/basename.ts
@@ -0,0 +1,2 @@
+// THIS FILE IS DESIGNED TO BE OVERRIDDEN IN TESTS IF NEEDED
+export const basename = undefined;
diff --git a/integration/helpers/rsc-vite/src/entry.rsc.tsx b/integration/helpers/rsc-vite/src/entry.rsc.tsx
index 834c9dc47e..de5105feec 100644
--- a/integration/helpers/rsc-vite/src/entry.rsc.tsx
+++ b/integration/helpers/rsc-vite/src/entry.rsc.tsx
@@ -6,6 +6,7 @@ import {
renderToReadableStream,
} from "@hiogawa/vite-rsc/rsc";
import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router";
+import { basename } from "./config/basename";
import { routes } from "./routes";
@@ -17,6 +18,7 @@ export async function fetchServer(request: Request) {
loadServerAction,
request,
routes,
+ basename,
generateResponse(match, options) {
return new Response(renderToReadableStream(match.payload, options), {
status: match.statusCode,
diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts
index 148c947292..003b37bb20 100644
--- a/integration/rsc/rsc-test.ts
+++ b/integration/rsc/rsc-test.ts
@@ -939,6 +939,708 @@ implementations.forEach((implementation) => {
});
});
+ test.describe("Basename", () => {
+ test("Renders a page with a custom basename", async ({ page }) => {
+ let port = await getPort();
+ let basename = "/custom/basename/";
+ stop = await setupRscTest({
+ implementation,
+ port,
+ files: {
+ "src/config/basename.ts": js`
+ // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION
+ export const basename = ${JSON.stringify(basename)};
+ `,
+ "src/routes/home.tsx": js`
+ export function loader() {
+ return { message: "Loader Data" };
+ }
+ export default function HomeRoute({ loaderData }) {
+ return
Home: {loaderData.message}
;
+ }
+ `,
+ },
+ });
+
+ await page.goto(`http://localhost:${port}${basename}`);
+ await page.waitForSelector("[data-home]");
+ expect(await page.locator("[data-home]").textContent()).toBe(
+ "Home: Loader Data"
+ );
+
+ // Ensure this is using RSC
+ validateRSCHtml(await page.content());
+ });
+
+ test("Handles server-side redirects with basename", async ({ page }) => {
+ let port = await getPort();
+ let basename = "/custom/basename/";
+ stop = await setupRscTest({
+ implementation,
+ port,
+ files: {
+ "src/config/basename.ts": js`
+ // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION
+ export const basename = ${JSON.stringify(basename)};
+ `,
+ "src/routes.ts": js`
+ import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router";
+
+ export const routes = [
+ {
+ id: "root",
+ path: "",
+ lazy: () => import("./routes/root"),
+ children: [
+ {
+ id: "home",
+ index: true,
+ lazy: () => import("./routes/home"),
+ },
+ {
+ id: "redirect",
+ path: "redirect",
+ lazy: () => import("./routes/redirect"),
+ },
+ {
+ id: "target",
+ path: "target",
+ lazy: () => import("./routes/target"),
+ },
+ ],
+ },
+ ] satisfies RSCRouteConfig;
+ `,
+ "src/routes/home.tsx": js`
+ import { Link } from "react-router";
+
+ export default function HomeRoute() {
+ return (
+
+
Home Route
+
+ Go to redirect route
+
+
+ );
+ }
+ `,
+ "src/routes/redirect.tsx": js`
+ import { redirect } from "react-router";
+
+ export function loader() {
+ throw redirect("/target");
+ }
+
+ export default function RedirectRoute() {
+ return This should not be rendered
;
+ }
+ `,
+ "src/routes/target.tsx": js`
+ export default function TargetRoute() {
+ return Target Route
;
+ }
+ `,
+ },
+ });
+
+ // Navigate directly to redirect route with basename
+ await page.goto(`http://localhost:${port}${basename}redirect`);
+
+ // Should be redirected to target route
+ await page.waitForURL(`http://localhost:${port}${basename}target`);
+ await page.waitForSelector("[data-target]");
+ expect(await page.locator("[data-target]").textContent()).toBe(
+ "Target Route"
+ );
+
+ // Ensure this is using RSC
+ validateRSCHtml(await page.content());
+ });
+
+ test("Handles server-side redirects in route actions with basename", async ({
+ page,
+ }) => {
+ let port = await getPort();
+ let basename = "/custom/basename/";
+ stop = await setupRscTest({
+ implementation,
+ port,
+ files: {
+ "src/config/basename.ts": js`
+ // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION
+ export const basename = ${JSON.stringify(basename)};
+ `,
+ "src/routes.ts": js`
+ import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router";
+
+ export const routes = [
+ {
+ id: "root",
+ path: "",
+ lazy: () => import("./routes/root"),
+ children: [
+ {
+ id: "home",
+ index: true,
+ lazy: () => import("./routes/home"),
+ },
+ {
+ id: "action-redirect",
+ path: "action-redirect",
+ lazy: () => import("./routes/action-redirect"),
+ },
+ {
+ id: "target",
+ path: "target",
+ lazy: () => import("./routes/target"),
+ },
+ ],
+ },
+ ] satisfies RSCRouteConfig;
+ `,
+ "src/routes/home.tsx": js`
+ import { Link } from "react-router";
+
+ export default function HomeRoute() {
+ return (
+
+
Home Route
+ Go to action redirect route
+
+ );
+ }
+ `,
+ "src/routes/action-redirect.tsx": js`
+ import { redirect } from "react-router";
+
+ export async function action({ request }) {
+ // Redirect to target when form is submitted
+ throw redirect("/target");
+ }
+
+ export default function ActionRedirectRoute() {
+ return (
+
+
Action Redirect Route
+
+
+ );
+ }
+ `,
+ "src/routes/target.tsx": js`
+ export default function TargetRoute() {
+ return Target Route
;
+ }
+ `,
+ },
+ });
+
+ // Navigate to action redirect route with basename
+ await page.goto(`http://localhost:${port}${basename}action-redirect`);
+ await page.waitForSelector("[data-action-redirect]");
+ expect(await page.locator("[data-action-redirect]").textContent()).toBe(
+ "Action Redirect Route"
+ );
+
+ // Mutate the window object so we can check if the navigation occurred
+ // within the same browser context
+ await page.evaluate(() => {
+ // @ts-expect-error
+ window.__isWithinSameBrowserContext = true;
+ });
+
+ // Submit the form to trigger the action redirect
+ await page.click("[data-submit-action]");
+
+ // Should be redirected to target route
+ await page.waitForURL(`http://localhost:${port}${basename}target`);
+ await page.waitForSelector("[data-target]");
+ expect(await page.locator("[data-target]").textContent()).toBe(
+ "Target Route"
+ );
+
+ // Ensure a document navigation occurred
+ expect(
+ await page.evaluate(() => {
+ // @ts-expect-error
+ return window.__isWithinSameBrowserContext;
+ })
+ ).not.toBe(true);
+
+ // Ensure this is using RSC
+ validateRSCHtml(await page.content());
+ });
+
+ test("Supports redirects on client navigations with basename", async ({
+ page,
+ }) => {
+ let port = await getPort();
+ let basename = "/custom/basename/";
+ stop = await setupRscTest({
+ implementation,
+ port,
+ files: {
+ "src/config/basename.ts": js`
+ // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION
+ export const basename = ${JSON.stringify(basename)};
+ `,
+ "src/routes.ts": js`
+ import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router";
+
+ export const routes = [
+ {
+ id: "root",
+ path: "",
+ lazy: () => import("./routes/root"),
+ children: [
+ {
+ id: "home",
+ index: true,
+ lazy: () => import("./routes/home"),
+ },
+ {
+ id: "redirect",
+ path: "redirect",
+ lazy: () => import("./routes/redirect"),
+ },
+ {
+ id: "target",
+ path: "target",
+ lazy: () => import("./routes/target"),
+ },
+ ],
+ },
+ ] satisfies RSCRouteConfig;
+ `,
+ "src/routes/home.tsx": js`
+ import { Link } from "react-router";
+
+ export default function HomeRoute() {
+ return (
+
+
Home Route
+
+ Go to redirect route
+
+
+ );
+ }
+ `,
+ "src/routes/redirect.tsx": js`
+ import { redirect } from "react-router";
+
+ export function loader() {
+ throw redirect("/target");
+ }
+
+ export default function RedirectRoute() {
+ return This should not be rendered
;
+ }
+ `,
+ "src/routes/target.tsx": js`
+ export default function TargetRoute() {
+ return Target Route
;
+ }
+ `,
+ },
+ });
+
+ // Navigate to home route with basename
+ await page.goto(`http://localhost:${port}${basename}`);
+ await page.waitForSelector("[data-home]");
+ expect(await page.locator("[data-home]").textContent()).toBe(
+ "Home Route"
+ );
+
+ // Click link to redirect route
+ await page.click("[data-link-to-redirect]");
+
+ // Should be redirected to target route
+ await page.waitForURL(`http://localhost:${port}${basename}target`);
+ await page.waitForSelector("[data-target]");
+ expect(await page.locator("[data-target]").textContent()).toBe(
+ "Target Route"
+ );
+
+ // Ensure this is using RSC
+ validateRSCHtml(await page.content());
+ });
+
+ test("Supports redirects in route actions on client navigations with basename", async ({
+ page,
+ }) => {
+ let port = await getPort();
+ let basename = "/custom/basename/";
+ stop = await setupRscTest({
+ implementation,
+ port,
+ files: {
+ "src/config/basename.ts": js`
+ // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION
+ export const basename = ${JSON.stringify(basename)};
+ `,
+ "src/routes.ts": js`
+ import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router";
+
+ export const routes = [
+ {
+ id: "root",
+ path: "",
+ lazy: () => import("./routes/root"),
+ children: [
+ {
+ id: "home",
+ index: true,
+ lazy: () => import("./routes/home"),
+ },
+ {
+ id: "action-redirect",
+ path: "action-redirect",
+ lazy: () => import("./routes/action-redirect"),
+ },
+ {
+ id: "target",
+ path: "target",
+ lazy: () => import("./routes/target"),
+ },
+ ],
+ },
+ ] satisfies RSCRouteConfig;
+ `,
+ "src/routes/home.tsx": js`
+ import { Link } from "react-router";
+
+ export default function HomeRoute() {
+ return (
+
+
Home Route
+ Go to action redirect route
+
+ );
+ }
+ `,
+ "src/routes/action-redirect.tsx": js`
+ import { Form, redirect } from "react-router";
+
+ export async function action({ request }) {
+ // Redirect to target when form is submitted
+ throw redirect("/target");
+ }
+
+ export default function ActionRedirectRoute() {
+ return (
+
+
Action Redirect Route
+
+
+ );
+ }
+ `,
+ "src/routes/target.tsx": js`
+ export default function TargetRoute() {
+ return Target Route
;
+ }
+ `,
+ },
+ });
+
+ // Navigate to action redirect route with basename
+ await page.goto(`http://localhost:${port}${basename}action-redirect`);
+ await page.waitForSelector("[data-action-redirect]");
+ expect(await page.locator("[data-action-redirect]").textContent()).toBe(
+ "Action Redirect Route"
+ );
+
+ // Mutate the window object so we can check if the navigation occurred
+ // within the same browser context
+ await page.evaluate(() => {
+ // @ts-expect-error
+ window.__isWithinSameBrowserContext = true;
+ });
+
+ // Submit the form to trigger the action redirect
+ await page.click("[data-submit-action]");
+
+ // Should be redirected to target route
+ await page.waitForURL(`http://localhost:${port}${basename}target`);
+ await page.waitForSelector("[data-target]");
+ expect(await page.locator("[data-target]").textContent()).toBe(
+ "Target Route"
+ );
+
+ // Ensure a client-side navigation occurred
+ expect(
+ await page.evaluate(() => {
+ // @ts-expect-error
+ return window.__isWithinSameBrowserContext;
+ })
+ ).toBe(true);
+
+ // Ensure this is using RSC
+ validateRSCHtml(await page.content());
+ });
+
+ test("Supports redirects in server actions with basename", async ({
+ page,
+ }) => {
+ let port = await getPort();
+ let basename = "/custom/basename/";
+ stop = await setupRscTest({
+ implementation,
+ port,
+ files: {
+ "src/config/basename.ts": js`
+ // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION
+ export const basename = ${JSON.stringify(basename)};
+ `,
+ "src/routes.ts": js`
+ import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router";
+
+ export const routes = [
+ {
+ id: "root",
+ path: "",
+ lazy: () => import("./routes/root"),
+ children: [
+ {
+ id: "home",
+ index: true,
+ lazy: () => import("./routes/home"),
+ },
+ {
+ id: "redirect",
+ path: "redirect",
+ lazy: () => import("./routes/redirect"),
+ },
+ {
+ id: "target",
+ path: "target",
+ lazy: () => import("./routes/target"),
+ },
+ ],
+ },
+ ] satisfies RSCRouteConfig;
+ `,
+ "src/routes/home.tsx": js`
+ import { Link } from "react-router";
+
+ export default function HomeRoute() {
+ return (
+
+
Home Route
+
+ Go to server action redirect route
+
+
+ );
+ }
+ `,
+ "src/routes/redirect.actions.ts": js`
+ "use server";
+ import { redirect } from "react-router";
+
+ export async function redirectAction(formData: FormData) {
+ throw redirect("/target");
+ }
+ `,
+ "src/routes/redirect.tsx": js`
+ export { default } from "./redirect.client";
+ `,
+ "src/routes/redirect.client.tsx": js`
+ "use client";
+
+ import { useActionState } from "react";
+ import { redirectAction } from "./redirect.actions";
+
+ export default function RedirectRoute() {
+ const [state, formAction, isPending] = useActionState(redirectAction, null);
+
+ return (
+
+
Server Action Redirect Route
+
+
+ );
+ }
+ `,
+ "src/routes/target.tsx": js`
+ export default function TargetRoute() {
+ return Target Route
;
+ }
+ `,
+ },
+ });
+
+ // Start on home route
+ await page.goto(`http://localhost:${port}${basename}`);
+ await page.waitForSelector("[data-home]");
+ expect(await page.locator("[data-home]").textContent()).toBe(
+ "Home Route"
+ );
+
+ // Navigate to redirect route via client navigation
+ await page.click("[data-link-to-redirect]");
+ await page.waitForSelector("[data-redirect]");
+ expect(await page.locator("[data-redirect]").textContent()).toBe(
+ "Server Action Redirect Route"
+ );
+
+ // Submit the form to trigger server action redirect
+ await page.click("[data-submit-action]");
+
+ // Should be redirected to target route
+ await page.waitForURL(`http://localhost:${port}${basename}target`);
+ await page.waitForSelector("[data-target]");
+ expect(await page.locator("[data-target]").textContent()).toBe(
+ "Target Route"
+ );
+
+ // Ensure this is using RSC
+ validateRSCHtml(await page.content());
+ });
+
+ test.describe("Without JavaScript", () => {
+ test.use({ javaScriptEnabled: false });
+ test("Supports redirects in server actions without JavaScript with basename", async ({
+ page,
+ }) => {
+ test.skip(implementation.name === "parcel", "Not working in parcel?");
+
+ let port = await getPort();
+ let basename = "/custom/basename/";
+ stop = await setupRscTest({
+ implementation,
+ port,
+ files: {
+ "src/config/basename.ts": js`
+ // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION
+ export const basename = ${JSON.stringify(basename)};
+ `,
+ "src/routes.ts": js`
+ import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router";
+
+ export const routes = [
+ {
+ id: "root",
+ path: "",
+ lazy: () => import("./routes/root"),
+ children: [
+ {
+ id: "home",
+ index: true,
+ lazy: () => import("./routes/home"),
+ },
+ {
+ id: "redirect",
+ path: "redirect",
+ lazy: () => import("./routes/redirect"),
+ },
+ {
+ id: "target",
+ path: "target",
+ lazy: () => import("./routes/target"),
+ },
+ ],
+ },
+ ] satisfies RSCRouteConfig;
+ `,
+ "src/routes/home.tsx": js`
+ import { Link } from "react-router";
+
+ export default function HomeRoute() {
+ return (
+
+
Home Route
+
+ Go to server action redirect route
+
+
+ );
+ }
+ `,
+ "src/routes/redirect.actions.ts": js`
+ "use server";
+ import { redirect } from "react-router";
+
+ export async function redirectAction(formData: FormData) {
+ throw redirect("/target");
+ }
+ `,
+ "src/routes/redirect.tsx": js`
+ export { default } from "./redirect.client";
+ `,
+ "src/routes/redirect.client.tsx": js`
+ "use client";
+
+ import { useActionState } from "react";
+ import { redirectAction } from "./redirect.actions";
+
+ export default function RedirectRoute() {
+ const [state, formAction, isPending] = useActionState(redirectAction, null);
+
+ return (
+
+
Server Action Redirect Route
+
+
+ );
+ }
+ `,
+ "src/routes/target.tsx": js`
+ export default function TargetRoute() {
+ return Target Route
;
+ }
+ `,
+ },
+ });
+
+ // Start on home route
+ await page.goto(`http://localhost:${port}${basename}`);
+ await page.waitForSelector("[data-home]");
+ expect(await page.locator("[data-home]").textContent()).toBe(
+ "Home Route"
+ );
+
+ // Navigate to redirect route
+ await page.click("[data-link-to-redirect]");
+ await page.waitForSelector("[data-redirect]");
+ expect(await page.locator("[data-redirect]").textContent()).toBe(
+ "Server Action Redirect Route"
+ );
+
+ // Submit the form to trigger server action redirect
+ await page.click("[data-submit-action]");
+
+ // Should be redirected to target route
+ await page.waitForURL(`http://localhost:${port}${basename}target`);
+ await page.waitForSelector("[data-target]");
+ expect(await page.locator("[data-target]").textContent()).toBe(
+ "Target Route"
+ );
+
+ // Ensure this is using RSC
+ validateRSCHtml(await page.content());
+ });
+ });
+ });
+
test.describe("Errors", () => {
test("Handles errors in server components correctly", async ({
page,
@@ -955,7 +1657,7 @@ implementations.forEach((implementation) => {
}
export default function HomeRoute() {
- return This shouldn't render
;
+ return This should not be rendered
;
}
export { ErrorBoundary } from "./home.client";
diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts
index 2cc27c8ef4..f98da1a5c1 100644
--- a/packages/react-router/lib/router/router.ts
+++ b/packages/react-router/lib/router/router.ts
@@ -814,6 +814,7 @@ export const IDLE_BLOCKER: BlockerUnblocked = {
};
const ABSOLUTE_URL_REGEX = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
+export const isAbsoluteUrl = (url: string) => ABSOLUTE_URL_REGEX.test(url);
const defaultMapRouteProperties: MapRoutePropertiesFunction = (route) => ({
hasErrorBoundary: Boolean(route.hasErrorBoundary),
@@ -2779,7 +2780,7 @@ export function createRouter(init: RouterInit): Router {
if (redirect.response.headers.has("X-Remix-Reload-Document")) {
// Hard reload if the response contained X-Remix-Reload-Document
isDocumentReload = true;
- } else if (ABSOLUTE_URL_REGEX.test(location)) {
+ } else if (isAbsoluteUrl(location)) {
// We skip `history.createURL` here for absolute URLs because we don't
// want to inherit the current `window.location` base URL
const url = createBrowserURLImpl(location, true);
@@ -4469,6 +4470,19 @@ function isSubmissionNavigation(
);
}
+export function prependBasename({
+ basename,
+ pathname,
+}: {
+ basename: string;
+ pathname: string;
+}): string {
+ // If this is a root navigation, then just use the raw basename which allows
+ // the basename to have full control over the presence of a trailing slash on
+ // root actions
+ return pathname === "/" ? basename : joinPaths([basename, pathname]);
+}
+
function normalizeTo(
location: Path,
matches: AgnosticDataRouteMatch[],
@@ -4530,13 +4544,9 @@ function normalizeTo(
}
}
- // If we're operating within a basename, prepend it to the pathname. If
- // this is a root navigation, then just use the raw basename which allows
- // the basename to have full control over the presence of a trailing slash
- // on root actions
+ // If we're operating within a basename, prepend it to the pathname.
if (basename !== "/") {
- path.pathname =
- path.pathname === "/" ? basename : joinPaths([basename, path.pathname]);
+ path.pathname = prependBasename({ basename, pathname: path.pathname });
}
return createPath(path);
@@ -6063,7 +6073,7 @@ function normalizeRelativeRoutingRedirectResponse(
"Redirects returned/thrown from loaders/actions must have a Location header"
);
- if (!ABSOLUTE_URL_REGEX.test(location)) {
+ if (!isAbsoluteUrl(location)) {
let trimmedMatches = matches.slice(
0,
matches.findIndex((m) => m.route.id === routeId) + 1
@@ -6085,7 +6095,7 @@ function normalizeRedirectLocation(
currentUrl: URL,
basename: string
): string {
- if (ABSOLUTE_URL_REGEX.test(location)) {
+ if (isAbsoluteUrl(location)) {
// Strip off the protocol+origin for same-origin + same-basename absolute redirects
let normalizedLocation = location;
let url = normalizedLocation.startsWith("//")
diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx
index c7675b2c2c..12a80fc70f 100644
--- a/packages/react-router/lib/rsc/browser.tsx
+++ b/packages/react-router/lib/rsc/browser.tsx
@@ -240,11 +240,11 @@ function createRouterFromPayload({
signal
);
},
- // FIXME: Pass `build.ssr` and `build.basename` into this function
+ // FIXME: Pass `build.ssr` into this function
dataStrategy: getRSCSingleFetchDataStrategy(
() => window.__router,
true,
- undefined,
+ payload.basename,
createFromReadableStream,
fetchImplementation
),
diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts
index 21362593f5..05d54dd10b 100644
--- a/packages/react-router/lib/rsc/server.rsc.ts
+++ b/packages/react-router/lib/rsc/server.rsc.ts
@@ -12,9 +12,11 @@ import type {
import { type Location } from "../router/history";
import {
createStaticHandler,
+ isAbsoluteUrl,
isMutationMethod,
isResponse,
isRedirectResponse,
+ prependBasename,
type StaticHandlerContext,
} from "../router/router";
import {
@@ -29,6 +31,7 @@ import {
redirect as baseRedirect,
redirectDocument as baseRedirectDocument,
replace as baseReplace,
+ stripBasename,
} from "../router/utils";
import { getDocumentHeadersImpl } from "../server-runtime/headers";
import { SINGLE_FETCH_REDIRECT_STATUS } from "../dom/ssr/single-fetch";
@@ -155,7 +158,7 @@ export type RSCRouteMatch = RSCRouteManifest & {
export type RSCRenderPayload = {
type: "render";
actionData: Record | null;
- basename?: string;
+ basename: string | undefined;
errors: Record | null;
loaderData: Record;
location: Location;
@@ -204,7 +207,7 @@ export type RSCMatch = {
export type DecodeActionFunction = (
formData: FormData
-) => Promise<() => Promise>;
+) => Promise<() => Promise>;
export type DecodeFormStateFunction = (
result: unknown,
@@ -220,6 +223,7 @@ export type LoadServerActionFunction = (id: string) => Promise;
export async function matchRSCServerRequest({
createTemporaryReferenceSet,
+ basename,
decodeReply,
loadServerAction,
decodeAction,
@@ -230,6 +234,7 @@ export async function matchRSCServerRequest({
generateResponse,
}: {
createTemporaryReferenceSet: () => unknown;
+ basename?: string;
decodeReply?: DecodeReplyFunction;
decodeAction?: DecodeActionFunction;
decodeFormState?: DecodeFormStateFunction;
@@ -253,6 +258,7 @@ export async function matchRSCServerRequest({
if (isManifestRequest(requestUrl)) {
let response = await generateManifestResponse(
routes,
+ basename,
request,
generateResponse,
temporaryReferences
@@ -279,7 +285,7 @@ export async function matchRSCServerRequest({
// TODO: This isn't ideal but we can't do it through `lazy()` in the router,
// and if we move to `lazy: {}` then we lose all the other things from the
// `RSCRouteConfigEntry` like `Layout` etc.
- let matches = matchRoutes(routes, url.pathname);
+ let matches = matchRoutes(routes, url.pathname, basename);
if (matches) {
await Promise.all(matches.map((m) => explodeLazyRoute(m.route)));
}
@@ -294,6 +300,7 @@ export async function matchRSCServerRequest({
return generateResourceResponse(
routerRequest,
routes,
+ basename,
leafMatch.route.id,
onError
);
@@ -302,6 +309,7 @@ export async function matchRSCServerRequest({
let response = await generateRenderResponse(
routerRequest,
routes,
+ basename,
isDataRequest,
decodeReply,
loadServerAction,
@@ -319,6 +327,7 @@ export async function matchRSCServerRequest({
async function generateManifestResponse(
routes: RSCRouteConfigEntry[],
+ basename: string | undefined,
request: Request,
generateResponse: (
match: RSCMatch,
@@ -334,7 +343,7 @@ async function generateManifestResponse(
let routeIds = new Set();
let matchedRoutes = pathnames
.flatMap((pathname) => {
- let pathnameMatches = matchRoutes(routes, pathname);
+ let pathnameMatches = matchRoutes(routes, pathname, basename);
return (
pathnameMatches?.map((m, i) => ({
...m.route,
@@ -354,7 +363,12 @@ async function generateManifestResponse(
patches: (
await Promise.all([
...matchedRoutes.map((route) => getManifestRoute(route)),
- getAdditionalRoutePatches(pathnames, routes, Array.from(routeIds)),
+ getAdditionalRoutePatches(
+ pathnames,
+ routes,
+ basename,
+ Array.from(routeIds)
+ ),
])
).flat(1),
};
@@ -372,8 +386,29 @@ async function generateManifestResponse(
);
}
+function prependBasenameToRedirectResponse(
+ response: Response,
+ basename: string | undefined = "/"
+): Response {
+ if (basename === "/") {
+ return response;
+ }
+
+ let redirect = response.headers.get("Location");
+ if (!redirect || isAbsoluteUrl(redirect)) {
+ return response;
+ }
+
+ response.headers.set(
+ "Location",
+ prependBasename({ basename, pathname: redirect })
+ );
+ return response;
+}
+
async function processServerAction(
request: Request,
+ basename: string | undefined,
decodeReply: DecodeReplyFunction | undefined,
loadServerAction: LoadServerActionFunction | undefined,
decodeAction: DecodeActionFunction | undefined,
@@ -441,9 +476,15 @@ async function processServerAction(
const action = await decodeAction(formData);
let formState = undefined;
try {
- const result = await action();
+ let result = await action();
+ if (isRedirectResponse(result)) {
+ result = prependBasenameToRedirectResponse(result, basename);
+ }
formState = decodeFormState?.(result, formData);
} catch (error) {
+ if (isRedirectResponse(error)) {
+ return prependBasenameToRedirectResponse(error, basename);
+ }
if (isResponse(error)) {
return error;
}
@@ -460,14 +501,14 @@ async function processServerAction(
async function generateResourceResponse(
request: Request,
routes: RSCRouteConfigEntry[],
+ basename: string | undefined,
routeId: string,
onError: ((error: unknown) => void) | undefined
) {
let result: Response;
try {
const staticHandler = createStaticHandler(routes, {
- // TODO: Support basename
- // basename
+ basename,
});
let response = await staticHandler.queryRoute(request, {
@@ -511,6 +552,7 @@ async function generateResourceResponse(
async function generateRenderResponse(
request: Request,
routes: RSCRouteConfigEntry[],
+ basename: string | undefined,
isDataRequest: boolean,
decodeReply: DecodeReplyFunction | undefined,
loadServerAction: LoadServerActionFunction | undefined,
@@ -540,6 +582,7 @@ async function generateRenderResponse(
// Create the handler here with exploded routes
const handler = createStaticHandler(routes, {
+ basename,
mapRouteProperties: (r) => ({
hasErrorBoundary: (r as RouteObject).ErrorBoundary != null,
}),
@@ -563,6 +606,7 @@ async function generateRenderResponse(
if (request.method === "POST") {
let result = await processServerAction(
request,
+ basename,
decodeReply,
loadServerAction,
decodeAction,
@@ -574,6 +618,8 @@ async function generateRenderResponse(
return generateRedirectResponse(
result,
actionResult,
+ basename,
+ isDataRequest,
generateResponse,
temporaryReferences
);
@@ -587,6 +633,8 @@ async function generateRenderResponse(
return generateRedirectResponse(
ctx.redirect,
actionResult,
+ basename,
+ isDataRequest,
generateResponse,
temporaryReferences
);
@@ -598,6 +646,8 @@ async function generateRenderResponse(
return generateRedirectResponse(
staticContext,
actionResult,
+ basename,
+ isDataRequest,
generateResponse,
temporaryReferences
);
@@ -605,6 +655,7 @@ async function generateRenderResponse(
return generateStaticContextResponse(
routes,
+ basename,
generateResponse,
statusCode,
routeIdsToLoad,
@@ -623,6 +674,8 @@ async function generateRenderResponse(
return generateRedirectResponse(
result,
actionResult,
+ basename,
+ isDataRequest,
generateResponse,
temporaryReferences
);
@@ -635,15 +688,23 @@ async function generateRenderResponse(
function generateRedirectResponse(
response: Response,
actionResult: Promise | undefined,
+ basename: string | undefined,
+ isDataRequest: boolean,
generateResponse: (
match: RSCMatch,
{ temporaryReferences }: { temporaryReferences: unknown }
) => Response,
temporaryReferences: unknown
) {
+ let redirect = response.headers.get("Location")!;
+
+ if (isDataRequest && basename) {
+ redirect = stripBasename(redirect, basename) || redirect;
+ }
+
let payload: RSCRedirectPayload = {
type: "redirect",
- location: response.headers.get("Location") || "",
+ location: redirect,
reload: response.headers.get("X-Remix-Reload-Document") === "true",
replace: response.headers.get("X-Remix-Replace") === "true",
status: response.status,
@@ -664,6 +725,7 @@ function generateRedirectResponse(
async function generateStaticContextResponse(
routes: RSCRouteConfigEntry[],
+ basename: string | undefined,
generateResponse: (
match: RSCMatch,
{ temporaryReferences }: { temporaryReferences: unknown }
@@ -710,6 +772,7 @@ async function generateStaticContextResponse(
const baseRenderPayload: Omit = {
type: "render",
+ basename,
actionData: staticContext.actionData,
errors: staticContext.errors,
loaderData: staticContext.loaderData,
@@ -721,6 +784,7 @@ async function generateStaticContextResponse(
getRenderPayload(
baseRenderPayload,
routes,
+ basename,
routeIdsToLoad,
isDataRequest,
staticContext
@@ -761,6 +825,7 @@ async function generateStaticContextResponse(
async function getRenderPayload(
baseRenderPayload: Omit,
routes: RSCRouteConfigEntry[],
+ basename: string | undefined,
routeIdsToLoad: string[] | null,
isDataRequest: boolean,
staticContext: StaticHandlerContext
@@ -809,6 +874,7 @@ async function getRenderPayload(
? getAdditionalRoutePatches(
[staticContext.location.pathname],
routes,
+ basename,
staticContext.matches.map((m) => m.route.id)
)
: undefined;
@@ -978,6 +1044,7 @@ async function explodeLazyRoute(route: RSCRouteConfigEntry) {
async function getAdditionalRoutePatches(
pathnames: string[],
routes: RSCRouteConfigEntry[],
+ basename: string | undefined,
matchedRouteIds: string[]
): Promise {
let patchRouteMatches = new Map<
@@ -1005,7 +1072,7 @@ async function getAdditionalRoutePatches(
return;
}
matchedPaths.add(path);
- let matches = matchRoutes(routes, path) || [];
+ let matches = matchRoutes(routes, path, basename) || [];
matches.forEach((m, i) => {
if (patchRouteMatches.get(m.route.id)) {
return;