diff --git a/.changeset/smart-ants-decide.md b/.changeset/smart-ants-decide.md new file mode 100644 index 0000000000..ea14d2c312 --- /dev/null +++ b/.changeset/smart-ants-decide.md @@ -0,0 +1,5 @@ +--- +"@remix-run/router": patch +--- + +Support basename and relative routing in loader/action redirects diff --git a/package.json b/package.json index 866c02ec24..710c09c033 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ }, "filesize": { "packages/router/dist/router.js": { - "none": "105 kB" + "none": "106 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "12.5 kB" diff --git a/packages/react-router/__tests__/data-memory-router-test.tsx b/packages/react-router/__tests__/data-memory-router-test.tsx index 55a809fdc4..e6e57ae87b 100644 --- a/packages/react-router/__tests__/data-memory-router-test.tsx +++ b/packages/react-router/__tests__/data-memory-router-test.tsx @@ -9,6 +9,7 @@ import { } from "@testing-library/react"; import "@testing-library/jest-dom"; import type { FormMethod, Router, RouterInit } from "@remix-run/router"; +import { joinPaths } from "@remix-run/router"; import type { RouteObject } from "react-router"; import { Await, @@ -20,6 +21,7 @@ import { createMemoryRouter, createRoutesFromElements, defer, + redirect, useActionData, useAsyncError, useAsyncValue, @@ -158,6 +160,116 @@ describe("", () => { `); }); + it("prepends basename to loader/action redirects", async () => { + let { container } = render( + + }> + redirect("/other")} /> + Other} /> + + + ); + + function Root() { + return ( + <> + Link to thing + + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ + Link to thing + +
" + `); + + fireEvent.click(screen.getByText("Link to thing")); + await waitFor(() => screen.getByText("Other")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ + Link to thing + +

+ Other +

+
" + `); + }); + + it("supports relative routing in loader/action redirects", async () => { + let { container } = render( + + }> + }> + redirect("../other")} /> + Other} /> + + + + ); + + function Root() { + return ( + <> + Link to child + + + ); + } + + function Parent() { + return ( + <> +

Parent

+ + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ + Link to child + +
" + `); + + fireEvent.click(screen.getByText("Link to child")); + await waitFor(() => screen.getByText("Parent")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+ + Link to child + +

+ Parent +

+

+ Other +

+
" + `); + }); + it("renders with hydration data", async () => { let { container } = render( { event.preventDefault(); diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index c159d04892..144a23f68e 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -32,7 +32,7 @@ import type { AgnosticRouteObject, TrackedPromise, } from "../utils"; -import { AbortedDeferredError } from "../utils"; +import { AbortedDeferredError, stripBasename } from "../utils"; /////////////////////////////////////////////////////////////////////////////// //#region Types and Utils @@ -588,10 +588,11 @@ function setup({ let promise = new Promise((r) => { invariant(currentRouter, "No currentRouter available"); let unsubscribe = currentRouter.subscribe(() => { - helpers = getNavigationHelpers( - history.createHref(history.location), - navigationId - ); + let popHref = history.createHref(history.location); + if (currentRouter?.basename) { + popHref = stripBasename(popHref, currentRouter.basename) as string; + } + helpers = getNavigationHelpers(popHref, navigationId); unsubscribe(); r(); }); @@ -602,7 +603,11 @@ function setup({ return helpers; } - helpers = getNavigationHelpers(href, navigationId); + let navHref = href; + if (currentRouter.basename) { + navHref = stripBasename(navHref, currentRouter.basename) as string; + } + helpers = getNavigationHelpers(navHref, navigationId); currentRouter.navigate(href, opts); return helpers; } @@ -5049,6 +5054,287 @@ describe("a router", () => { }); }); + describe("redirects", () => { + let REDIRECT_ROUTES: TestRouteObject[] = [ + { + id: "root", + path: "/", + children: [ + { + id: "parent", + path: "parent", + action: true, + loader: true, + children: [ + { + id: "child", + path: "child", + action: true, + loader: true, + children: [ + { + id: "index", + index: true, + action: true, + loader: true, + }, + ], + }, + ], + }, + ], + }, + ]; + + it("applies the basename to redirects returned from loaders", async () => { + let t = setup({ + routes: REDIRECT_ROUTES, + basename: "/base/name", + initialEntries: ["/base/name"], + }); + + let nav1 = await t.navigate("/base/name/parent"); + + let nav2 = await nav1.loaders.parent.redirectReturn("/parent/child"); + await nav2.loaders.parent.resolve("PARENT"); + await nav2.loaders.child.resolve("CHILD"); + await nav2.loaders.index.resolve("INDEX"); + expect(t.router.state).toMatchObject({ + historyAction: "PUSH", + location: { + pathname: "/base/name/parent/child", + }, + navigation: IDLE_NAVIGATION, + loaderData: { + parent: "PARENT", + child: "CHILD", + index: "INDEX", + }, + errors: null, + }); + expect(t.history.action).toEqual("PUSH"); + expect(t.history.location.pathname).toEqual("/base/name/parent/child"); + }); + + it("supports relative routing in redirects (from parent navigation loader)", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let nav1 = await t.navigate("/parent/child"); + + await nav1.loaders.child.resolve("CHILD"); + await nav1.loaders.index.resolve("INDEX"); + await nav1.loaders.parent.redirectReturn(".."); + // No root loader so redirect lands immediately + expect(t.router.state).toMatchObject({ + location: { + pathname: "/", + }, + navigation: IDLE_NAVIGATION, + loaderData: {}, + errors: null, + }); + }); + + it("supports relative routing in redirects (from child navigation loader)", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let nav1 = await t.navigate("/parent/child"); + + await nav1.loaders.parent.resolve("PARENT"); + await nav1.loaders.index.resolve("INDEX"); + let nav2 = await nav1.loaders.child.redirectReturn( + "..", + undefined, + undefined, + ["parent"] + ); + await nav2.loaders.parent.resolve("PARENT 2"); + expect(t.router.state).toMatchObject({ + location: { + pathname: "/parent", + }, + navigation: IDLE_NAVIGATION, + loaderData: { + parent: "PARENT 2", + }, + errors: null, + }); + }); + + it("supports relative routing in redirects (from index navigation loader)", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let nav1 = await t.navigate("/parent/child"); + + await nav1.loaders.parent.resolve("PARENT"); + await nav1.loaders.child.resolve("INDEX"); + let nav2 = await nav1.loaders.index.redirectReturn( + "..", + undefined, + undefined, + ["parent"] + ); + await nav2.loaders.parent.resolve("PARENT 2"); + expect(t.router.state).toMatchObject({ + location: { + pathname: "/parent", + }, + navigation: IDLE_NAVIGATION, + loaderData: { + parent: "PARENT 2", + }, + errors: null, + }); + }); + + it("supports relative routing in redirects (from parent fetch loader)", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let fetch = await t.fetch("/parent"); + + await fetch.loaders.parent.redirectReturn(".."); + // No root loader so redirect lands immediately + expect(t.router.state).toMatchObject({ + location: { + pathname: "/", + }, + navigation: IDLE_NAVIGATION, + loaderData: {}, + errors: null, + }); + }); + + it("supports relative routing in redirects (from child fetch loader)", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let fetch = await t.fetch("/parent/child"); + let nav = await fetch.loaders.child.redirectReturn( + "..", + undefined, + undefined, + ["parent"] + ); + + await nav.loaders.parent.resolve("PARENT"); + expect(t.router.state).toMatchObject({ + location: { + pathname: "/parent", + }, + navigation: IDLE_NAVIGATION, + loaderData: { + parent: "PARENT", + }, + errors: null, + }); + }); + + it("supports relative routing in redirects (from index fetch loader)", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let fetch = await t.fetch("/parent/child?index"); + let nav = await fetch.loaders.index.redirectReturn( + "..", + undefined, + undefined, + ["parent"] + ); + + await nav.loaders.parent.resolve("PARENT"); + expect(t.router.state).toMatchObject({ + location: { + pathname: "/parent", + }, + navigation: IDLE_NAVIGATION, + loaderData: { + parent: "PARENT", + }, + errors: null, + }); + }); + + it("supports . redirects", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let nav1 = await t.navigate("/parent"); + + let nav2 = await nav1.loaders.parent.redirectReturn( + "./child", + undefined, + undefined, + ["parent", "child", "index"] + ); + await nav2.loaders.parent.resolve("PARENT"); + await nav2.loaders.child.resolve("CHILD"); + await nav2.loaders.index.resolve("INDEX"); + expect(t.router.state).toMatchObject({ + location: { + pathname: "/parent/child", + }, + navigation: IDLE_NAVIGATION, + loaderData: { + parent: "PARENT", + child: "CHILD", + index: "INDEX", + }, + errors: null, + }); + }); + + it("supports relative routing in navigation action redirects", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let nav1 = await t.navigate("/parent/child", { + formMethod: "post", + formData: createFormData({}), + }); + + let nav2 = await nav1.actions.child.redirectReturn( + "..", + undefined, + undefined, + ["parent"] + ); + await nav2.loaders.parent.resolve("PARENT"); + expect(t.router.state).toMatchObject({ + location: { + pathname: "/parent", + }, + navigation: IDLE_NAVIGATION, + loaderData: { + parent: "PARENT", + }, + errors: null, + }); + }); + + it("supports relative routing in fetch action redirects", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let nav1 = await t.fetch("/parent/child", { + formMethod: "post", + formData: createFormData({}), + }); + + let nav2 = await nav1.actions.child.redirectReturn( + "..", + undefined, + undefined, + ["parent"] + ); + await nav2.loaders.parent.resolve("PARENT"); + expect(t.router.state).toMatchObject({ + location: { + pathname: "/parent", + }, + navigation: IDLE_NAVIGATION, + loaderData: { + parent: "PARENT", + }, + errors: null, + }); + }); + }); + describe("scroll restoration", () => { it("restores scroll on navigations", async () => { let t = setup({ @@ -9732,10 +10018,56 @@ describe("a router", () => { it("should handle redirect Responses", async () => { let { query } = createStaticHandler(SSR_ROUTES); - let redirect = await query(createRequest("/redirect")); - expect(redirect instanceof Response).toBe(true); - expect((redirect as Response).status).toBe(302); - expect((redirect as Response).headers.get("Location")).toBe("/"); + let response = await query(createRequest("/redirect")); + expect(response instanceof Response).toBe(true); + expect((response as Response).status).toBe(302); + expect((response as Response).headers.get("Location")).toBe("/"); + }); + + it("should handle relative redirect responses (loader)", async () => { + let { query } = createStaticHandler([ + { + path: "/", + children: [ + { + path: "parent", + children: [ + { + path: "child", + loader: () => redirect(".."), + }, + ], + }, + ], + }, + ]); + let response = await query(createRequest("/parent/child")); + expect(response instanceof Response).toBe(true); + expect((response as Response).status).toBe(302); + expect((response as Response).headers.get("Location")).toBe("/parent"); + }); + + it("should handle relative redirect responses (action)", async () => { + let { query } = createStaticHandler([ + { + path: "/", + children: [ + { + path: "parent", + children: [ + { + path: "child", + action: () => redirect(".."), + }, + ], + }, + ], + }, + ]); + let response = await query(createSubmitRequest("/parent/child")); + expect(response instanceof Response).toBe(true); + expect((response as Response).status).toBe(302); + expect((response as Response).headers.get("Location")).toBe("/parent"); }); it("should handle 404 navigations", async () => { @@ -10576,6 +10908,60 @@ describe("a router", () => { expect(data).toBe(""); }); + it("should handle relative redirect responses (loader)", async () => { + let { queryRoute } = createStaticHandler([ + { + path: "/", + children: [ + { + path: "parent", + children: [ + { + id: "child", + path: "child", + loader: () => redirect(".."), + }, + ], + }, + ], + }, + ]); + let response = await queryRoute( + createRequest("/parent/child"), + "child" + ); + expect(response instanceof Response).toBe(true); + expect((response as Response).status).toBe(302); + expect((response as Response).headers.get("Location")).toBe("/parent"); + }); + + it("should handle relative redirect responses (action)", async () => { + let { queryRoute } = createStaticHandler([ + { + path: "/", + children: [ + { + path: "parent", + children: [ + { + id: "child", + path: "child", + action: () => redirect(".."), + }, + ], + }, + ], + }, + ]); + let response = await queryRoute( + createSubmitRequest("/parent/child"), + "child" + ); + expect(response instanceof Response).toBe(true); + expect((response as Response).status).toBe(302); + expect((response as Response).headers.get("Location")).toBe("/parent"); + }); + it("should not unwrap responses returned from loaders", async () => { let response = json({ key: "value" }); let { queryRoute } = createStaticHandler([ diff --git a/packages/router/router.ts b/packages/router/router.ts index 73a3b538c4..f9cffb396c 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -28,7 +28,9 @@ import { getPathContributingMatches, invariant, isRouteErrorResponse, + joinPaths, matchRoutes, + resolveTo, } from "./utils"; //////////////////////////////////////////////////////////////////////////////// @@ -470,14 +472,25 @@ interface HandleLoadersResult extends ShortCircuitable { } /** - * Tuple of [key, href, DataRouterMatch] for a revalidating fetcher.load() + * Tuple of [key, href, DataRouteMatch, DataRouteMatch[]] for a revalidating + * fetcher.load() */ -type RevalidatingFetcher = [string, string, AgnosticDataRouteMatch]; +type RevalidatingFetcher = [ + string, + string, + AgnosticDataRouteMatch, + AgnosticDataRouteMatch[] +]; /** - * Tuple of [href, DataRouteMatch] for an active fetcher.load() + * Tuple of [href, DataRouteMatch, DataRouteMatch[]] for an active + * fetcher.load() */ -type FetchLoadMatch = [string, AgnosticDataRouteMatch]; +type FetchLoadMatch = [ + string, + AgnosticDataRouteMatch, + AgnosticDataRouteMatch[] +]; /** * Wrapper object to allow us to throw any response out from callLoaderOrAction @@ -956,7 +969,13 @@ export function createRouter(init: RouterInit): Router { if (!actionMatch.route.action) { result = getMethodNotAllowedResult(location); } else { - result = await callLoaderOrAction("action", request, actionMatch); + result = await callLoaderOrAction( + "action", + request, + actionMatch, + matches, + router.basename + ); if (request.signal.aborted) { return { shortCircuited: true }; @@ -1098,6 +1117,7 @@ export function createRouter(init: RouterInit): Router { let { results, loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( state.matches, + matches, matchesToLoad, revalidatingFetchers, request @@ -1187,14 +1207,14 @@ export function createRouter(init: RouterInit): Router { let match = getTargetMatch(matches, path); if (submission) { - handleFetcherAction(key, routeId, path, match, submission); + handleFetcherAction(key, routeId, path, match, matches, submission); return; } // Store off the match so we can call it's shouldRevalidate on subsequent // revalidations - fetchLoadMatches.set(key, [path, match]); - handleFetcherLoader(key, routeId, path, match); + fetchLoadMatches.set(key, [path, match, matches]); + handleFetcherLoader(key, routeId, path, match, matches); } // Call the action for the matched fetcher.submit(), and then handle redirects, @@ -1204,6 +1224,7 @@ export function createRouter(init: RouterInit): Router { routeId: string, path: string, match: AgnosticDataRouteMatch, + requestMatches: AgnosticDataRouteMatch[], submission: Submission ) { interruptActiveLoads(); @@ -1230,7 +1251,13 @@ export function createRouter(init: RouterInit): Router { let fetchRequest = createRequest(path, abortController.signal, submission); fetchControllers.set(key, abortController); - let actionResult = await callLoaderOrAction("action", fetchRequest, match); + let actionResult = await callLoaderOrAction( + "action", + fetchRequest, + match, + requestMatches, + router.basename + ); if (fetchRequest.signal.aborted) { // We can delete this so long as we weren't aborted by ou our own fetcher @@ -1332,6 +1359,7 @@ export function createRouter(init: RouterInit): Router { let { results, loaderResults, fetcherResults } = await callLoadersAndMaybeResolveData( state.matches, + matches, matchesToLoad, revalidatingFetchers, revalidationRequest @@ -1412,7 +1440,8 @@ export function createRouter(init: RouterInit): Router { key: string, routeId: string, path: string, - match: AgnosticDataRouteMatch + match: AgnosticDataRouteMatch, + matches: AgnosticDataRouteMatch[] ) { let existingFetcher = state.fetchers.get(key); // Put this fetcher into it's loading state @@ -1434,7 +1463,9 @@ export function createRouter(init: RouterInit): Router { let result: DataResult = await callLoaderOrAction( "loader", fetchRequest, - match + match, + matches, + router.basename ); // Deferred isn't supported or fetcher loads, await everything and treat it @@ -1532,6 +1563,7 @@ export function createRouter(init: RouterInit): Router { let redirectHistoryAction = replace === true ? HistoryAction.Replace : HistoryAction.Push; + await startNavigation(redirectHistoryAction, navigation.location, { overrideNavigation: navigation, }); @@ -1539,6 +1571,7 @@ export function createRouter(init: RouterInit): Router { async function callLoadersAndMaybeResolveData( currentMatches: AgnosticDataRouteMatch[], + matches: AgnosticDataRouteMatch[], matchesToLoad: AgnosticDataRouteMatch[], fetchersToLoad: RevalidatingFetcher[], request: Request @@ -1547,9 +1580,17 @@ export function createRouter(init: RouterInit): Router { // then slice off the results into separate arrays so we can handle them // accordingly let results = await Promise.all([ - ...matchesToLoad.map((m) => callLoaderOrAction("loader", request, m)), - ...fetchersToLoad.map(([, href, match]) => - callLoaderOrAction("loader", createRequest(href, request.signal), match) + ...matchesToLoad.map((match) => + callLoaderOrAction("loader", request, match, matches, router.basename) + ), + ...fetchersToLoad.map(([, href, match, fetchMatches]) => + callLoaderOrAction( + "loader", + createRequest(href, request.signal), + match, + fetchMatches, + router.basename + ) ), ]); let loaderResults = results.slice(0, matchesToLoad.length); @@ -1830,7 +1871,7 @@ export function unstable_createStaticHandler( }; } - let result = await queryImpl(request, location, matches, false); + let result = await queryImpl(request, location, matches); if (result instanceof Response) { return result; } @@ -1881,7 +1922,7 @@ export function unstable_createStaticHandler( }); } - let result = await queryImpl(request, location, [match], true); + let result = await queryImpl(request, location, matches, match); if (result instanceof Response) { return result; } @@ -1904,7 +1945,7 @@ export function unstable_createStaticHandler( request: Request, location: Location, matches: AgnosticDataRouteMatch[], - isRouteRequest: boolean + routeMatch?: AgnosticDataRouteMatch ): Promise | Response> { invariant( request.method !== "HEAD", @@ -1920,13 +1961,13 @@ export function unstable_createStaticHandler( let result = await submit( request, matches, - getTargetMatch(matches, location), - isRouteRequest + routeMatch || getTargetMatch(matches, location), + routeMatch != null ); return result; } - let result = await loadRouteData(request, matches, isRouteRequest); + let result = await loadRouteData(request, matches, routeMatch); return result instanceof Response ? result : { @@ -1974,6 +2015,8 @@ export function unstable_createStaticHandler( "action", request, actionMatch, + matches, + undefined, // Basename not currently supported in static handlers true, isRouteRequest ); @@ -1986,7 +2029,7 @@ export function unstable_createStaticHandler( if (isRedirectResult(result)) { // Uhhhh - this should never happen, we should always throw these from - // calLoaderOrAction, but the type narrowing here keeps TS happy and we + // callLoaderOrAction, but the type narrowing here keeps TS happy and we // can get back on the "throw all redirect responses" train here should // this ever happen :/ throw new Response(null, { @@ -2038,7 +2081,7 @@ export function unstable_createStaticHandler( // Store off the pending error - we use it to determine which loaders // to call and will commit it when we complete the navigation let boundaryMatch = findNearestBoundary(matches, actionMatch.route.id); - let context = await loadRouteData(request, matches, isRouteRequest, { + let context = await loadRouteData(request, matches, undefined, { [boundaryMatch.route.id]: result.error, }); @@ -2055,7 +2098,7 @@ export function unstable_createStaticHandler( }; } - let context = await loadRouteData(request, matches, isRouteRequest); + let context = await loadRouteData(request, matches); return { ...context, @@ -2073,16 +2116,20 @@ export function unstable_createStaticHandler( async function loadRouteData( request: Request, matches: AgnosticDataRouteMatch[], - isRouteRequest: boolean, + routeMatch?: AgnosticDataRouteMatch, pendingActionError?: RouteData ): Promise< | Omit | Response > { - let matchesToLoad = getLoaderMatchesUntilBoundary( - matches, - Object.keys(pendingActionError || {})[0] - ).filter((m) => m.route.loader); + let isRouteRequest = routeMatch != null; + let requestMatches = routeMatch + ? [routeMatch] + : getLoaderMatchesUntilBoundary( + matches, + Object.keys(pendingActionError || {})[0] + ); + let matchesToLoad = requestMatches.filter((m) => m.route.loader); // Short circuit if we have no loaders to run if (matchesToLoad.length === 0) { @@ -2096,8 +2143,16 @@ export function unstable_createStaticHandler( } let results = await Promise.all([ - ...matchesToLoad.map((m) => - callLoaderOrAction("loader", request, m, true, isRouteRequest) + ...matchesToLoad.map((match) => + callLoaderOrAction( + "loader", + request, + match, + matches, + undefined, // Basename not currently supported in static handlers + true, + isRouteRequest + ) ), ]); @@ -2312,10 +2367,10 @@ function getMatchesToLoad( // Pick fetcher.loads that need to be revalidated let revalidatingFetchers: RevalidatingFetcher[] = []; fetchLoadMatches && - fetchLoadMatches.forEach(([href, match], key) => { + fetchLoadMatches.forEach(([href, match, fetchMatches], key) => { // This fetcher was cancelled from a prior action submission - force reload if (cancelledFetcherLoads.includes(key)) { - revalidatingFetchers.push([key, href, match]); + revalidatingFetchers.push([key, href, match, fetchMatches]); } else if (isRevalidationRequired) { let shouldRevalidate = shouldRevalidateLoader( href, @@ -2327,7 +2382,7 @@ function getMatchesToLoad( actionResult ); if (shouldRevalidate) { - revalidatingFetchers.push([key, href, match]); + revalidatingFetchers.push([key, href, match, fetchMatches]); } } }); @@ -2421,7 +2476,9 @@ async function callLoaderOrAction( type: "loader" | "action", request: Request, match: AgnosticDataRouteMatch, - skipRedirects: boolean = false, + matches: AgnosticDataRouteMatch[], + basename: string | undefined, + isStaticRequest: boolean = false, isRouteRequest: boolean = false ): Promise { let resultType; @@ -2452,28 +2509,43 @@ async function callLoaderOrAction( } if (result instanceof Response) { - // Process redirects let status = result.status; - let location = result.headers.get("Location"); - // For SSR single-route requests, we want to hand Responses back directly - // without unwrapping. Wer do this with the QueryRouteResponse wrapper - // interface so we can know whether it was returned or thrown - if (isRouteRequest) { - // eslint-disable-next-line no-throw-literal - throw { - type: resultType || ResultType.data, - response: result, - }; - } + // Process redirects + if (status >= 300 && status <= 399) { + let location = result.headers.get("Location"); + invariant( + location, + "Redirects returned/thrown from loaders/actions must have a Location header" + ); + + // Support relative routing in redirects + let activeMatches = matches.slice(0, matches.indexOf(match) + 1); + let routePathnames = getPathContributingMatches(activeMatches).map( + (match) => match.pathnameBase + ); + let requestPath = createURL(request.url).pathname; + location = resolveTo(location, routePathnames, requestPath).pathname; + invariant( + location, + `Unable to resolve redirect location: ${result.headers.get("Location")}` + ); + + // Prepend the basename to the redirect location if we have one + if (basename) { + let path = createURL(location).pathname; + location = path === "/" ? basename : joinPaths([basename, path]); + } - if (status >= 300 && status <= 399 && location != null) { - // Don't process redirects in the router during SSR document requests. + // Don't process redirects in the router during static requests requests. // Instead, throw the Response and let the server handle it with an HTTP - // redirect - if (skipRedirects) { + // redirect. We also update the Location header in place in this flow so + // basename and relative routing is taken into account + if (isStaticRequest) { + result.headers.set("Location", location); throw result; } + return { type: ResultType.redirect, status, @@ -2482,6 +2554,17 @@ async function callLoaderOrAction( }; } + // For SSR single-route requests, we want to hand Responses back directly + // without unwrapping. We do this with the QueryRouteResponse wrapper + // interface so we can know whether it was returned or thrown + if (isRouteRequest) { + // eslint-disable-next-line no-throw-literal + throw { + type: resultType || ResultType.data, + response: result, + }; + } + let data: any; let contentType = result.headers.get("Content-Type"); if (contentType && contentType.startsWith("application/json")) {