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;