diff --git a/.changeset/funny-oranges-arrive.md b/.changeset/funny-oranges-arrive.md new file mode 100644 index 0000000000..a7f52b3e48 --- /dev/null +++ b/.changeset/funny-oranges-arrive.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": patch +"@remix-run/router": patch +--- + +Support `basename` in static data routers diff --git a/package.json b/package.json index b29785bf16..1f29d2697a 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "34.5 kB" + "none": "35 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "12.5 kB" diff --git a/packages/react-router-dom/__tests__/data-static-router-test.tsx b/packages/react-router-dom/__tests__/data-static-router-test.tsx index fcaf04aec3..edebe0ddef 100644 --- a/packages/react-router-dom/__tests__/data-static-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-static-router-test.tsx @@ -3,6 +3,7 @@ import * as ReactDOMServer from "react-dom/server"; import type { StaticHandlerContext } from "@remix-run/router"; import { unstable_createStaticHandler as createStaticHandler } from "@remix-run/router"; import { + Link, Outlet, useLoaderData, useLocation, @@ -17,7 +18,7 @@ beforeEach(() => { jest.spyOn(console, "warn").mockImplementation(() => {}); }); -describe("A ", () => { +describe("A ", () => { it("renders an initialized router", async () => { let hooksData1: { location: ReturnType; @@ -45,7 +46,12 @@ describe("A ", () => { loaderData: useLoaderData(), matches: useMatches(), }; - return

👋

; + return ( + <> +

👋

+ Other + + ); } let routes = [ @@ -71,7 +77,7 @@ describe("A ", () => { let { query } = createStaticHandler(routes); let context = (await query( - new Request("http:/localhost/the/path?the=query#the-hash", { + new Request("http://localhost/the/path?the=query#the-hash", { signal: new AbortController().signal, }) )) as StaticHandlerContext; @@ -85,6 +91,7 @@ describe("A ", () => { ); expect(html).toMatch("

👋

"); + expect(html).toMatch(''); // @ts-expect-error expect(hooksData1.location).toEqual({ @@ -155,6 +162,59 @@ describe("A ", () => { ]); }); + it("renders an initialized router with a basename", async () => { + let location: ReturnType; + + function GetLocation() { + location = useLocation(); + return ( + <> +

👋

+ Other + + ); + } + + let routes = [ + { + path: "the", + children: [ + { + path: "path", + element: , + }, + ], + }, + ]; + let { query } = createStaticHandler(routes, { basename: "/base" }); + + let context = (await query( + new Request("http://localhost/base/the/path?the=query#the-hash", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + expect(html).toMatch("

👋

"); + expect(html).toMatch('
'); + + // @ts-expect-error + expect(location).toEqual({ + pathname: "/the/path", + search: "?the=query", + hash: "#the-hash", + state: null, + key: expect.any(String), + }); + }); + it("renders hydration data by default", async () => { let routes = [ { diff --git a/packages/react-router-dom/__tests__/static-link-test.tsx b/packages/react-router-dom/__tests__/static-link-test.tsx index 766628e6d1..f2bade5fdd 100644 --- a/packages/react-router-dom/__tests__/static-link-test.tsx +++ b/packages/react-router-dom/__tests__/static-link-test.tsx @@ -17,6 +17,21 @@ describe("A in a ", () => { expect(renderer.root.findByType("a").props.href).toEqual("/mjackson"); }); + + it("uses the right href with a basename", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + ); + }); + + expect(renderer.root.findByType("a").props.href).toEqual( + "/base/mjackson" + ); + }); }); describe("with an object", () => { diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index 67e7b7beb5..9f07923fdf 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -65,7 +65,6 @@ export function StaticRouter({ } export interface StaticRouterProviderProps { - basename?: string; context: StaticHandlerContext; router: RemixRouter; hydrate?: boolean; @@ -77,7 +76,6 @@ export interface StaticRouterProviderProps { * on the server where there is no stateful UI. */ export function unstable_StaticRouterProvider({ - basename, context, router, hydrate = true, @@ -92,7 +90,7 @@ export function unstable_StaticRouterProvider({ router, navigator: getStatelessNavigator(), static: true, - basename: basename || "/", + basename: context.basename || "/", }; let hydrateScript = ""; @@ -191,7 +189,7 @@ export function unstable_createStaticRouter( return { get basename() { - return "/"; + return context.basename; }, get state() { return { diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 0b19d09eaf..ca2804174f 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -10100,6 +10100,21 @@ describe("a router", () => { }); }); + it("should support document load navigations with a basename", async () => { + let { query } = createStaticHandler(SSR_ROUTES, { basename: "/base" }); + let context = await query(createRequest("/base/parent/child")); + expect(context).toMatchObject({ + actionData: null, + loaderData: { + parent: "PARENT LOADER", + child: "CHILD LOADER", + }, + errors: null, + location: { pathname: "/base/parent/child" }, + matches: [{ route: { id: "parent" } }, { route: { id: "child" } }], + }); + }); + it("should support document load navigations returning responses", async () => { let { query } = createStaticHandler(SSR_ROUTES); let context = await query(createRequest("/parent/json")); @@ -11020,6 +11035,38 @@ describe("a router", () => { expect(data).toBe(""); }); + it("should support singular route load navigations (with a basename)", async () => { + let { queryRoute } = createStaticHandler(SSR_ROUTES, { + basename: "/base", + }); + let data; + + // Layout route + data = await queryRoute(createRequest("/base/parent"), "parent"); + expect(data).toBe("PARENT LOADER"); + + // Index route + data = await queryRoute(createRequest("/base/parent"), "parentIndex"); + expect(data).toBe("PARENT INDEX LOADER"); + + // Parent in nested route + data = await queryRoute(createRequest("/base/parent/child"), "parent"); + expect(data).toBe("PARENT LOADER"); + + // Child in nested route + data = await queryRoute(createRequest("/base/parent/child"), "child"); + expect(data).toBe("CHILD LOADER"); + + // Non-undefined falsey values should count + let T = setupFlexRouteTest(); + data = await T.resolveLoader(null); + expect(data).toBeNull(); + data = await T.resolveLoader(false); + expect(data).toBe(false); + data = await T.resolveLoader(""); + expect(data).toBe(""); + }); + it("should support singular route submit navigations (primitives)", async () => { let { queryRoute } = createStaticHandler(SSR_ROUTES); let data; diff --git a/packages/router/router.ts b/packages/router/router.ts index 3f11274690..5cfcfea0f0 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -298,6 +298,7 @@ export interface RouterInit { * State returned from a server-side query() call */ export interface StaticHandlerContext { + basename: Router["basename"]; location: RouterState["location"]; matches: RouterState["matches"]; loaderData: RouterState["loaderData"]; @@ -1858,7 +1859,10 @@ const validActionMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); const validRequestMethods = new Set(["GET", "HEAD", ...validActionMethods]); export function unstable_createStaticHandler( - routes: AgnosticRouteObject[] + routes: AgnosticRouteObject[], + opts?: { + basename?: string; + } ): StaticHandler { invariant( routes.length > 0, @@ -1866,6 +1870,7 @@ export function unstable_createStaticHandler( ); let dataRoutes = convertRoutesToDataRoutes(routes); + let basename = (opts ? opts.basename : null) || "/"; /** * The query() method is intended for document requests, in which we want to @@ -1891,13 +1896,14 @@ export function unstable_createStaticHandler( ): Promise { let url = new URL(request.url); let location = createLocation("", createPath(url), null, "default"); - let matches = matchRoutes(dataRoutes, location); + let matches = matchRoutes(dataRoutes, location, basename); if (!validRequestMethods.has(request.method)) { let error = getInternalRouterError(405, { method: request.method }); let { matches: methodNotAllowedMatches, route } = getShortCircuitMatches(dataRoutes); return { + basename, location, matches: methodNotAllowedMatches, loaderData: {}, @@ -1914,6 +1920,7 @@ export function unstable_createStaticHandler( let { matches: notFoundMatches, route } = getShortCircuitMatches(dataRoutes); return { + basename, location, matches: notFoundMatches, loaderData: {}, @@ -1935,7 +1942,7 @@ export function unstable_createStaticHandler( // When returning StaticHandlerContext, we patch back in the location here // since we need it for React Context. But this helps keep our submit and // loadRouteData operating on a Request instead of a Location - return { location, ...result }; + return { location, basename, ...result }; } /** @@ -1961,7 +1968,7 @@ export function unstable_createStaticHandler( async function queryRoute(request: Request, routeId?: string): Promise { let url = new URL(request.url); let location = createLocation("", createPath(url), null, "default"); - let matches = matchRoutes(dataRoutes, location); + let matches = matchRoutes(dataRoutes, location, basename); if (!validRequestMethods.has(request.method)) { throw getInternalRouterError(405, { method: request.method }); @@ -2007,7 +2014,7 @@ export function unstable_createStaticHandler( location: Location, matches: AgnosticDataRouteMatch[], routeMatch?: AgnosticDataRouteMatch - ): Promise | Response> { + ): Promise | Response> { invariant( request.signal, "query()/queryRoute() requests must contain an AbortController signal" @@ -2056,7 +2063,7 @@ export function unstable_createStaticHandler( matches: AgnosticDataRouteMatch[], actionMatch: AgnosticDataRouteMatch, isRouteRequest: boolean - ): Promise | Response> { + ): Promise | Response> { let result: DataResult; if (!actionMatch.route.action) { @@ -2078,7 +2085,7 @@ export function unstable_createStaticHandler( request, actionMatch, matches, - undefined, // Basename not currently supported in static handlers + basename, true, isRouteRequest ); @@ -2168,7 +2175,10 @@ export function unstable_createStaticHandler( routeMatch?: AgnosticDataRouteMatch, pendingActionError?: RouteData ): Promise< - | Omit + | Omit< + StaticHandlerContext, + "location" | "basename" | "actionData" | "actionHeaders" + > | Response > { let isRouteRequest = routeMatch != null; @@ -2208,7 +2218,7 @@ export function unstable_createStaticHandler( request, match, matches, - undefined, // Basename not currently supported in static handlers + basename, true, isRouteRequest ) @@ -2519,7 +2529,7 @@ async function callLoaderOrAction( request: Request, match: AgnosticDataRouteMatch, matches: AgnosticDataRouteMatch[], - basename: string | undefined, + basename = "/", isStaticRequest: boolean = false, isRouteRequest: boolean = false ): Promise {