diff --git a/.changeset/fetcher-basename.md b/.changeset/fetcher-basename.md new file mode 100644 index 0000000000..5c6ded420c --- /dev/null +++ b/.changeset/fetcher-basename.md @@ -0,0 +1,13 @@ +--- +"react-router-dom": minor +"@remix-run/router": minor +--- + +- Enable relative routing in the `@remix-run/router` when providing a source route ID from which the path is relative to: + + - Example: `router.navigate("../path", { fromRouteId: "some-route" })`. + - This also applies to `router.fetch` which already receives a source route ID + +- Introduce a new `@remix-run/router` `future.v7_prependBasename` flag to enable `basename` prefixing to all paths coming into `router.navigate` and `router.fetch`. + - Previously the `basename` was prepended in the React Router layer, but now that relative routing is being handled by the router we need prepend the `basename` _after_ resolving any relative paths + - This also enables `basename` support in `useFetcher` as well diff --git a/.changeset/stable-navigate-submit.md b/.changeset/stable-navigate-submit.md new file mode 100644 index 0000000000..3ef42d8254 --- /dev/null +++ b/.changeset/stable-navigate-submit.md @@ -0,0 +1,6 @@ +--- +"react-router": patch +"react-router-dom": patch +--- + +When using a `RouterProvider`, `useNavigate`/`useSubmit`/`fetcher.submit` are now stable across location changes, since we can handle relative routing via the `@remix-run/router` instance and get rid of our dependence on `useLocation()`. When using `BrowserRouter`, these hooks remain unstable across location changes because they still rely on `useLocation()`. diff --git a/package.json b/package.json index 1310d4da63..8b95a2d15f 100644 --- a/package.json +++ b/package.json @@ -105,19 +105,19 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "44.2 kB" + "none": "45 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "13.1 kB" + "none": "13.3 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "15.3 kB" + "none": "15.6 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "11.6 kB" + "none": "11.8 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "17.5 kB" + "none": "17.7 kB" } } } diff --git a/packages/react-router-dom-v5-compat/index.ts b/packages/react-router-dom-v5-compat/index.ts index c9c0ee0034..d38c15c840 100644 --- a/packages/react-router-dom-v5-compat/index.ts +++ b/packages/react-router-dom-v5-compat/index.ts @@ -177,6 +177,7 @@ export { UNSAFE_DataRouterContext, UNSAFE_DataRouterStateContext, UNSAFE_useScrollRestoration, + UNSAFE_useRouteId, } from "./react-router-dom"; export type { StaticRouterProps } from "./lib/components"; diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index b50aee31b7..efc4f23f2e 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -33,6 +33,7 @@ import { defer, useLocation, useMatches, + useSearchParams, createRoutesFromElements, } from "react-router-dom"; @@ -2283,6 +2284,69 @@ function testDomRouter( `); }); + it("allows a button to override the
", async () => { + let router = createTestRouter( + createRoutesFromElements( + + { + throw new Error("No"); + }} + > + "Yes"} + Component={() => { + let actionData = useActionData() as string | undefined; + return ( + +

{actionData || "No"}

+ + + ); + }} + /> +
+
+ ), + { + window: getWindow("/foo/bar"), + } + ); + let { container } = render(); + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo" + ); + expect( + container.querySelector("button")?.getAttribute("formaction") + ).toBe("/foo/bar"); + + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => screen.getByText("Yes")); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+
+

+ Yes +

+ +
+
" + `); + }); + it("supports uppercase form method attributes", async () => { let loaderDefer = createDeferred(); let actionDefer = createDeferred(); @@ -2631,6 +2695,39 @@ function testDomRouter( "/foo/bar?index&a=1#hash" ); }); + + // eslint-disable-next-line jest/expect-expect + it('does not put ?index param in final URL for
{ + let testWindow = getWindow("/form"); + let router = createTestRouter( + createRoutesFromElements( + + + + + + } + /> + + + ), + { + window: testWindow, + } + ); + render(); + + assertLocation(testWindow, "/form", ""); + + fireEvent.click(screen.getByText("Submit")); + await new Promise((r) => setTimeout(r, 0)); + assertLocation(testWindow, "/form", "?name=value"); + }); }); describe("dynamic routes", () => { @@ -3394,6 +3491,7 @@ function testDomRouter( async function clickAndAssert(btnText: string, expectedOutput: string) { fireEvent.click(screen.getByText(btnText)); + await new Promise((r) => setTimeout(r, 1)); await waitFor(() => screen.getByText(new RegExp(expectedOutput))); expect(getHtml(container.querySelector("#output")!)).toContain( expectedOutput @@ -4411,6 +4509,286 @@ function testDomRouter( " `); }); + + it("useFetcher is stable across across location changes", async () => { + let router = createBrowserRouter( + [ + { + path: "/", + Component() { + const [, setSearchParams] = useSearchParams(); + let [count, setCount] = React.useState(0); + let fetcherCount = React.useRef(0); + let fetcher = useFetcher(); + React.useEffect(() => { + fetcherCount.current++; + }, [fetcher.submit]); + return ( + <> + +

+ {`render count:${count}`} + {`fetcher count:${fetcherCount.current}`} +

+ + ); + }, + }, + ], + { + window: getWindow("/"), + } + ); + + let { container } = render(); + + let html = getHtml(container); + expect(html).toContain("render count:0"); + expect(html).toContain("fetcher count:0"); + + fireEvent.click(screen.getByText("Click")); + fireEvent.click(screen.getByText("Click")); + fireEvent.click(screen.getByText("Click")); + await waitFor(() => screen.getByText(/render count:3/)); + + html = getHtml(container); + expect(html).toContain("render count:3"); + expect(html).toContain("fetcher count:1"); + }); + + describe("with a basename", () => { + it("prepends the basename to fetcher.load paths", async () => { + let router = createTestRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + window: getWindow("/base"), + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+ +
" + `); + + fireEvent.click(screen.getByText("load")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+ +
" + `); + }); + + it('prepends the basename to fetcher.submit({ method: "get" }) paths', async () => { + let router = createTestRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + window: getWindow("/base"), + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+ +
" + `); + + fireEvent.click(screen.getByText("load")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+ +
" + `); + }); + + it('prepends the basename to fetcher.submit({ method: "post" }) paths', async () => { + let router = createTestRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + window: getWindow("/base"), + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+ +
" + `); + + fireEvent.click(screen.getByText("submit")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+ +
" + `); + }); + it("prepends the basename to fetcher.Form paths", async () => { + let router = createTestRouter( + createRoutesFromElements( + }> + "FETCH"} /> + + ), + { + basename: "/base", + window: getWindow("/base"), + } + ); + let { container } = render(); + + function Comp() { + let fetcher = useFetcher(); + return ( + <> +

{`data:${fetcher.data}`}

+ + + + + ); + } + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:undefined +

+
+ +
+
" + `); + + fireEvent.click(screen.getByText("submit")); + await waitFor(() => screen.getByText(/FETCH/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ data:FETCH +

+
+ +
+
" + `); + }); + }); }); describe("errors", () => { diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index 6a01100aff..4be6d69c7c 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -1,5 +1,9 @@ -import type { FormEncType, HTMLFormMethod } from "@remix-run/router"; -import type { RelativeRoutingType } from "react-router"; +import type { + FormEncType, + HTMLFormMethod, + RelativeRoutingType, +} from "@remix-run/router"; +import { stripBasename } from "@remix-run/router"; export const defaultMethod: HTMLFormMethod = "get"; const defaultEncType: FormEncType = "application/x-www-form-urlencoded"; @@ -115,9 +119,6 @@ export interface SubmitOptions { /** * The action URL path used to submit the form. Overrides `
`. * Defaults to the path of the current route. - * - * Note: It is assumed the path is already resolved. If you need to resolve a - * relative path, use `useFormAction`. */ action?: string; @@ -157,16 +158,16 @@ export function getFormSubmissionInfo( | URLSearchParams | { [name: string]: string } | null, - defaultAction: string, - options: SubmitOptions + options: SubmitOptions, + basename: string ): { - url: URL; + action: string | null; method: string; encType: string; formData: FormData; } { let method: string; - let action: string; + let action: string | null = null; let encType: string; let formData: FormData; @@ -175,8 +176,16 @@ export function getFormSubmissionInfo( options as any ).submissionTrigger; + if (options.action) { + action = options.action; + } else { + // When grabbing the action from the element, it will have had the basename + // prefixed to ensure non-JS scenarios work, so strip it since we'll + // re-prefix in the router + let attr = target.getAttribute("action"); + action = attr ? stripBasename(attr, basename) : null; + } method = options.method || target.getAttribute("method") || defaultMethod; - action = options.action || target.getAttribute("action") || defaultAction; encType = options.encType || target.getAttribute("enctype") || defaultEncType; @@ -200,16 +209,22 @@ export function getFormSubmissionInfo( // + +

{`count:${count.current}`}

+ + ); + } + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ + , +

+ Home +

, + ] + `); + + // @ts-expect-error + let buttons = renderer.root.findAllByType("button"); + TestRenderer.act(() => { + buttons[1].props.onClick(); // link to /about + }); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ + , +

+ About +

, + ] + `); + + // @ts-expect-error + buttons = renderer.root.findAllByType("button"); + TestRenderer.act(() => { + buttons[0].props.onClick(); // link back to /home + }); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ + , +

+ Home +

, + ] + `); + }); + }); + + describe("when relative navigation is handled via @remix-run/router", () => { + describe("with an absolute href", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } /> + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + }); + + describe("with a relative href (relative=route)", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } + /> + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from an index routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + } /> + + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> + } + /> + + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + }> + }> + }> + } + /> + + + + + About} /> + + ), + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> + }> + }> + }> + } + /> + + + + + About} /> + + ), + { initialEntries: ["/home/page"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ About +

+ `); + }); + + it("handles parent navigation from inside multiple pathless layout routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + +

Home

+ + + } + > + }> + }> + }> + +

Page

+ + + } + /> +
+
+
+
+ About} /> + + ), + { initialEntries: ["/home/page"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Home +

+ `); + }); + + it("handles relative navigation from nested index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } /> + Destination} /> + + + + ), + { initialEntries: ["/layout/thing"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); + }); + + describe("with a relative href (relative=path)", () => { + it("navigates to the correct URL", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + } + /> + + ), + { initialEntries: ["/contacts/1"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from an index routes", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + + } + /> + + + ), + { initialEntries: ["/contacts/1"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside a pathless layout route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + }> + } + /> + + + ), + { initialEntries: ["/contacts/1"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + Contacts} /> + + }> + }> + }> + } + /> + + + + + + ), + { initialEntries: ["/contacts/1"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + }> + Contacts} /> + }> + }> + }> + } + /> + + + + + + ), + { initialEntries: ["/contacts/1"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Contacts +

+ `); + }); + + it("handles relative navigation from nested index route", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + + + {/* redirect /layout/:param/ index routes to /layout/:param/dest */} + } + /> + Destination} /> + + + + ), + { initialEntries: ["/layout/thing"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` +

+ Destination +

+ `); + }); + + it("preserves search params and hash", () => { + let router = createMemoryRouter( + createRoutesFromElements( + <> + } /> + + } + /> + + ), + { initialEntries: ["/contacts/1"] } + ); + + function Contacts() { + let { search, hash } = useLocation(); + return ( + <> +

Contacts

+

+ {search} + {hash} +

+ + ); + } + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + // @ts-expect-error + let button = renderer.root.findByType("button"); + TestRenderer.act(() => button.props.onClick()); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ +

+ Contacts +

, +

+ ?foo=bar + #hash +

, + ] + `); + }); + }); + + it("is stable across location changes", () => { + let router = createMemoryRouter( + [ + { + path: "/", + Component: () => ( + <> + + + + ), + children: [ + { + path: "home", + element:

Home

, + }, + { + path: "about", + element:

About

, + }, + ], + }, + ], + { initialEntries: ["/home"] } + ); + + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create(); + }); + + function NavBar() { + let count = React.useRef(0); + let navigate = useNavigate(); + React.useEffect(() => { + count.current++; + }, [navigate]); + return ( + + ); + } + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ + , +

+ Home +

, + ] + `); + + // @ts-expect-error + let buttons = renderer.root.findAllByType("button"); + TestRenderer.act(() => { + buttons[1].props.onClick(); // link to /about + }); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ + , +

+ About +

, + ] + `); + + // @ts-expect-error + buttons = renderer.root.findAllByType("button"); + TestRenderer.act(() => { + buttons[0].props.onClick(); // link back to /home + }); + + // @ts-expect-error + expect(renderer.toJSON()).toMatchInlineSnapshot(` + [ + , +

+ Home +

, + ] + `); + }); + }); }); + +function UseNavigateButton({ + to, + relative, +}: { + to: To; + relative?: RelativeRoutingType; +}) { + let navigate = useNavigate(); + return ; +} diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 992e0f556e..cf1705bda2 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -17,6 +17,7 @@ import type { PathMatch, PathPattern, RedirectFunction, + RelativeRoutingType, Router as RemixRouter, ShouldRevalidateFunction, To, @@ -76,7 +77,6 @@ import type { NonIndexRouteObject, RouteMatch, RouteObject, - RelativeRoutingType, } from "./lib/context"; import { DataRouterContext, @@ -102,6 +102,7 @@ import { useActionData, useAsyncError, useAsyncValue, + useRouteId, useLoaderData, useMatches, useNavigation, @@ -254,7 +255,7 @@ export function createMemoryRouter( routes: RouteObject[], opts?: { basename?: string; - future?: FutureConfig; + future?: Partial>; hydrationData?: HydrationState; initialEntries?: InitialEntry[]; initialIndex?: number; @@ -262,7 +263,10 @@ export function createMemoryRouter( ): RemixRouter { return createRouter({ basename: opts?.basename, - future: opts?.future, + future: { + ...opts?.future, + v7_prependBasename: true, + }, history: createMemoryHistory({ initialEntries: opts?.initialEntries, initialIndex: opts?.initialIndex, @@ -294,4 +298,5 @@ export { DataRouterContext as UNSAFE_DataRouterContext, DataRouterStateContext as UNSAFE_DataRouterStateContext, mapRouteProperties as UNSAFE_mapRouteProperties, + useRouteId as UNSAFE_useRouteId, }; diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index efa75820be..7cdd4c083b 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -8,6 +8,7 @@ import type { RouterState, To, LazyRouteFunction, + RelativeRoutingType, } from "@remix-run/router"; import { Action as NavigationType, @@ -27,7 +28,6 @@ import type { RouteObject, Navigator, NonIndexRouteObject, - RelativeRoutingType, } from "./context"; import { LocationContext, diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index da1340c7aa..d8660ebd1e 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -5,6 +5,7 @@ import type { AgnosticNonIndexRouteObject, History, Location, + RelativeRoutingType, Router, StaticHandlerContext, To, @@ -88,8 +89,6 @@ if (__DEV__) { AwaitContext.displayName = "Await"; } -export type RelativeRoutingType = "route" | "path"; - export interface NavigateOptions { replace?: boolean; state?: any; diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index c054436e63..4568e09880 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -8,6 +8,7 @@ import type { Path, PathMatch, PathPattern, + RelativeRoutingType, Router as RemixRouter, To, } from "@remix-run/router"; @@ -30,7 +31,6 @@ import type { RouteMatch, RouteObject, DataRouteMatch, - RelativeRoutingType, } from "./context"; import { DataRouterContext, @@ -156,6 +156,13 @@ export interface NavigateFunction { * @see https://reactrouter.com/hooks/use-navigate */ export function useNavigate(): NavigateFunction { + let isDataRouter = React.useContext(DataRouterContext) != null; + // Conditional usage is OK here because the usage of a data router is static + // eslint-disable-next-line react-hooks/rules-of-hooks + return isDataRouter ? useNavigateStable() : useNavigateUnstable(); +} + +function useNavigateUnstable(): NavigateFunction { invariant( useInRouterContext(), // TODO: This error is probably because they somehow have 2 versions of the @@ -682,6 +689,7 @@ export function _renderMatches( enum DataRouterHook { UseBlocker = "useBlocker", UseRevalidator = "useRevalidator", + UseNavigateStable = "useNavigate", } enum DataRouterStateHook { @@ -693,6 +701,8 @@ enum DataRouterStateHook { UseRouteLoaderData = "useRouteLoaderData", UseMatches = "useMatches", UseRevalidator = "useRevalidator", + UseNavigateStable = "useNavigate", + UseRouteId = "useRouteId", } function getDataRouterConsoleError( @@ -719,6 +729,7 @@ function useRouteContext(hookName: DataRouterStateHook) { return route; } +// Internal version with hookName-aware debugging function useCurrentRouteId(hookName: DataRouterStateHook) { let route = useRouteContext(hookName); let thisRoute = route.matches[route.matches.length - 1]; @@ -729,6 +740,13 @@ function useCurrentRouteId(hookName: DataRouterStateHook) { return thisRoute.route.id; } +/** + * Returns the ID for the nearest contextual route + */ +export function useRouteId() { + return useCurrentRouteId(DataRouterStateHook.UseRouteId); +} + /** * Returns the current navigation, defaulting to an "idle" navigation when * no navigation is in progress @@ -885,6 +903,28 @@ export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker { return state.blockers.get(blockerKey) || blocker; } +/** + * Stable version of useNavigate that is used when we are in the context of + * a RouterProvider. + */ +function useNavigateStable(): NavigateFunction { + let { router } = useDataRouterContext(DataRouterHook.UseNavigateStable); + let id = useCurrentRouteId(DataRouterStateHook.UseNavigateStable); + + let navigate: NavigateFunction = React.useCallback( + (to: To | number, options: NavigateOptions = {}) => { + if (typeof to === "number") { + router.navigate(to); + } else { + router.navigate(to, { fromRouteId: id, ...options }); + } + }, + [router, id] + ); + + return navigate; +} + const alreadyWarned: Record = {}; function warningOnce(key: string, cond: boolean, message: string) { diff --git a/packages/router/README.md b/packages/router/README.md index 2c8c83c58e..bd931f830c 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -17,12 +17,19 @@ A Router instance can be created using `createRouter`: // including history listeners and kicking off the initial data fetch let router = createRouter({ // Required properties - routes, // Routes array - history, // History instance + routes: [{ + path: '/', + loader: ({ request, params }) => { /* ... */ }, + children: [{ + path: 'home', + loader: ({ request, params }) => { /* ... */ }, + }] + }, + history: createBrowserHistory(), // Optional properties basename, // Base path - mapRouteProperties, // Map function framework-agnostic routes to framework-aware routes + mapRouteProperties, // Map framework-agnostic routes to framework-aware routes future, // Future flags hydrationData, // Hydration data if using server-side-rendering }).initialize(); @@ -83,6 +90,11 @@ router.navigate("/page", { formMethod: "post", formData, }); + +// Relative routing from a source routeId +router.navigate("../../somewhere", { + fromRouteId: "active-route-id", +}); ``` ### Fetchers @@ -106,7 +118,17 @@ router.fetch("key", "/page", { By default, active loaders will revalidate after any navigation or fetcher mutation. If you need to kick off a revalidation for other use-cases, you can use `router.revalidate()` to re-execute all active loaders. +### Future Flags + +We use _Future Flags_ in the router to help us introduce breaking changes in an opt-in fashion ahead of major releases. Please check out the [blog post][future-flags-post] and [React Router Docs][api-development-strategy] for more information on this process. The currently available future flags in `@remix-run/router` are: + +| Flag | Description | +| `v7_normalizeFormMethod` | Normalize `useNavigation().formMethod` to be an uppercase HTTP Method | +| `v7_prependBasename` | Prepend the `basename` to incoming `router.navigate`/`router.fetch` paths | + [react-router]: https://reactrouter.com [remix]: https://remix.run [react-router-repo]: https://github.com/remix-run/react-router [remix-routers-repo]: https://github.com/brophdawg11/remix-routers +[api-development-strategy]: https://reactrouter.com/en/main/guides/api-development-strategy +[future-flags-post]: https://remix.run/blog/future-flags diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index d5d3723156..fb27c190d0 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -14,6 +14,7 @@ import type { } from "../index"; import { createMemoryHistory, + createPath, createRouter, createStaticHandler, defer, @@ -3706,6 +3707,7 @@ describe("a router", () => { ], future: { v7_normalizeFormMethod: true, + v7_prependBasename: false, }, }); let A = await t.navigate("/child", { @@ -15543,4 +15545,496 @@ describe("a router", () => { }); }); }); + + describe("path resolution", () => { + describe("routing to self", () => { + // Utility that accepts children of /foo routes and executes the same + // routing tests starting at /foo/bar/?a=b#hash + function assertRoutingToSelf(fooChildren, expectedPath, expectIndex) { + const getRouter = () => + createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: fooChildren, + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }).initialize(); + + // Null should preserve the search/hash + let router = getRouter(); + router.navigate(null, { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe( + expectedPath + (expectIndex ? "?index&a=1#hash" : "?a=1#hash") + ); + router.dispose(); + + // "." and "" should not preserve the search and hash + router = getRouter(); + router.navigate(".", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe( + expectedPath + (expectIndex ? "?index" : "") + ); + router.dispose(); + + router = getRouter(); + router.navigate("", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe( + expectedPath + (expectIndex ? "?index" : "") + ); + router.dispose(); + } + + /* eslint-disable jest/expect-expect */ + it("from a static route", () => { + assertRoutingToSelf( + [ + { + id: "activeRoute", + path: "bar", + }, + ], + "/foo/bar", + false + ); + }); + + it("from a layout route", () => { + assertRoutingToSelf( + [ + { + id: "activeRoute", + path: "bar", + children: [ + { + index: true, + }, + ], + }, + ], + "/foo/bar", + false + ); + }); + + it("from an index route", () => { + assertRoutingToSelf( + [ + { + path: "bar", + children: [ + { + id: "activeRoute", + index: true, + }, + ], + }, + ], + "/foo/bar", + true + ); + }); + + it("from an index route with a path", () => { + assertRoutingToSelf( + [ + { + id: "activeRoute", + path: "bar", + index: true, + }, + ], + "/foo/bar", + true + ); + }); + + it("from a dynamic param route", () => { + assertRoutingToSelf( + [ + { + id: "activeRoute", + path: ":param", + }, + ], + "/foo/bar", + false + ); + }); + + it("from a splat route", () => { + assertRoutingToSelf( + [ + { + id: "activeRoute", + path: "*", + }, + ], + "/foo", + false + ); + }); + /* eslint-enable jest/expect-expect */ + }); + + describe("routing to parent", () => { + function assertRoutingToParent(fooChildren) { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: fooChildren, + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }).initialize(); + + // Null should preserve the search/hash + router.navigate("..", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe("/foo"); + } + + /* eslint-disable jest/expect-expect */ + it("from a static route", () => { + assertRoutingToParent([ + { + id: "activeRoute", + path: "bar", + }, + ]); + }); + + it("from a layout route", () => { + assertRoutingToParent([ + { + id: "activeRoute", + path: "bar", + children: [ + { + index: true, + }, + ], + }, + ]); + }); + + it("from an index route", () => { + assertRoutingToParent([ + { + path: "bar", + children: [ + { + id: "activeRoute", + index: true, + }, + ], + }, + ]); + }); + + it("from an index route with a path", () => { + assertRoutingToParent([ + { + id: "activeRoute", + path: "bar", + index: true, + }, + ]); + }); + + it("from a dynamic param route", () => { + assertRoutingToParent([ + { + id: "activeRoute", + path: ":param", + }, + ]); + }); + + it("from a splat route", () => { + assertRoutingToParent([ + { + id: "activeRoute", + path: "*", + }, + ]); + }); + /* eslint-enable jest/expect-expect */ + }); + + describe("routing to sibling", () => { + function assertRoutingToSibling(fooChildren) { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: [ + ...fooChildren, + { + path: "bar-sibling", + }, + ], + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }).initialize(); + + // Null should preserve the search/hash + router.navigate("../bar-sibling", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe("/foo/bar-sibling"); + } + + /* eslint-disable jest/expect-expect */ + it("from a static route", () => { + assertRoutingToSibling([ + { + id: "activeRoute", + path: "bar", + }, + ]); + }); + + it("from a layout route", () => { + assertRoutingToSibling([ + { + id: "activeRoute", + path: "bar", + children: [ + { + index: true, + }, + ], + }, + ]); + }); + + it("from an index route", () => { + assertRoutingToSibling([ + { + path: "bar", + children: [ + { + id: "activeRoute", + index: true, + }, + ], + }, + ]); + }); + + it("from an index route with a path", () => { + assertRoutingToSibling([ + { + id: "activeRoute", + path: "bar", + index: true, + }, + ]); + }); + + it("from a dynamic param route", () => { + assertRoutingToSibling([ + { + id: "activeRoute", + path: ":param", + }, + ]); + }); + + it("from a splat route", () => { + assertRoutingToSibling([ + { + id: "activeRoute", + path: "*", + }, + ]); + }); + /* eslint-enable jest/expect-expect */ + }); + + describe("routing to child", () => { + function assertRoutingToChild(fooChildren) { + const getRouter = () => + createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "foo", + children: [...fooChildren], + }, + ], + }, + ], + history: createMemoryHistory({ + initialEntries: ["/foo/bar?a=1#hash"], + }), + }).initialize(); + + let router = getRouter(); + router.navigate("baz", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe("/foo/bar/baz"); + router.dispose(); + + router = getRouter(); + router.navigate("./baz", { fromRouteId: "activeRoute" }); + expect(createPath(router.state.location)).toBe("/foo/bar/baz"); + router.dispose(); + } + + /* eslint-disable jest/expect-expect */ + it("from a static route", () => { + assertRoutingToChild([ + { + id: "activeRoute", + path: "bar", + children: [{ path: "baz" }], + }, + ]); + }); + + it("from a layout route", () => { + assertRoutingToChild([ + { + id: "activeRoute", + path: "bar", + children: [ + { + index: true, + }, + { path: "baz" }, + ], + }, + ]); + }); + + it("from a dynamic param route", () => { + assertRoutingToChild([ + { + id: "activeRoute", + path: ":param", + children: [{ path: "baz" }], + }, + ]); + }); + /* eslint-enable jest/expect-expect */ + }); + + it("resolves relative routes when using relative:path", () => { + let history = createMemoryHistory({ + initialEntries: ["/a/b/c/d/e/f"], + }); + let routes = [ + { + id: "a", + path: "/a", + children: [ + { + id: "bc", + path: "b/c", + children: [ + { + id: "de", + path: "d/e", + children: [ + { + id: "f", + path: "f", + }, + ], + }, + ], + }, + ], + }, + ]; + + // Navigating without relative:path + let router = createRouter({ routes, history }).initialize(); + router.navigate(".."); + expect(router.state.location.pathname).toBe("/a/b/c/d/e"); + router.navigate("/a/b/c/d/e/f"); + + router.navigate("../.."); + expect(router.state.location.pathname).toBe("/a/b/c"); + router.navigate("/a/b/c/d/e/f"); + + // Navigating with relative:path + router.navigate("..", { relative: "path" }); + expect(router.state.location.pathname).toBe("/a/b/c/d/e"); + router.navigate("/a/b/c/d/e/f"); + + router.navigate("../..", { relative: "path" }); + expect(router.state.location.pathname).toBe("/a/b/c/d"); + router.navigate("/a/b/c/d/e/f"); + + router.dispose(); + }); + + it("should not append ?index to get submission navigations to self from index route", () => { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + path: "path", + children: [ + { + id: "activeRouteId", + index: true, + }, + ], + }, + ], + }, + ], + history: createMemoryHistory({ initialEntries: ["/path"] }), + }).initialize(); + + router.navigate(null, { + fromRouteId: "activeRouteId", + formData: createFormData({}), + }); + expect(createPath(router.state.location)).toBe("/path"); + expect(router.state.matches[2].route.index).toBe(true); + + router.navigate(".", { + fromRouteId: "activeRouteId", + formData: createFormData({}), + }); + expect(createPath(router.state.location)).toBe("/path"); + expect(router.state.matches[2].route.index).toBe(true); + + router.navigate("", { + fromRouteId: "activeRouteId", + formData: createFormData({}), + }); + expect(createPath(router.state.location)).toBe("/path"); + expect(router.state.matches[2].route.index).toBe(true); + }); + }); }); diff --git a/packages/router/router.ts b/packages/router/router.ts index 4b8171fb51..7f57a75126 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -130,7 +130,7 @@ export interface Router { * @param to Path to navigate to * @param opts Navigation options (method, submission, etc.) */ - navigate(to: To, opts?: RouterNavigateOptions): Promise; + navigate(to: To | null, opts?: RouterNavigateOptions): Promise; /** * @internal @@ -146,7 +146,7 @@ export interface Router { fetch( key: string, routeId: string, - href: string, + href: string | null, opts?: RouterNavigateOptions ): void; @@ -335,6 +335,7 @@ export type HydrationState = Partial< */ export interface FutureConfig { v7_normalizeFormMethod: boolean; + v7_prependBasename: boolean; } /** @@ -349,7 +350,7 @@ export interface RouterInit { */ detectErrorBoundary?: DetectErrorBoundaryFunction; mapRouteProperties?: MapRoutePropertiesFunction; - future?: FutureConfig; + future?: Partial; hydrationData?: HydrationState; } @@ -415,22 +416,25 @@ export interface GetScrollPositionFunction { (): number; } -/** - * Options for a navigate() call for a Link navigation - */ -type LinkNavigateOptions = { +export type RelativeRoutingType = "route" | "path"; + +type BaseNavigateOptions = { replace?: boolean; state?: any; preventScrollReset?: boolean; + relative?: RelativeRoutingType; + fromRouteId?: string; }; +/** + * Options for a navigate() call for a Link navigation + */ +type LinkNavigateOptions = BaseNavigateOptions; + /** * Options for a navigate() call for a Form navigation */ -type SubmissionNavigateOptions = { - replace?: boolean; - state?: any; - preventScrollReset?: boolean; +type SubmissionNavigateOptions = BaseNavigateOptions & { formMethod?: HTMLFormMethod; formEncType?: FormEncType; formData: FormData; @@ -705,9 +709,11 @@ export function createRouter(init: RouterInit): Router { manifest ); let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined; + let basename = init.basename || "/"; // Config driven behavior flags let future: FutureConfig = { v7_normalizeFormMethod: false, + v7_prependBasename: false, ...init.future, }; // Cleanup function for history @@ -728,11 +734,7 @@ export function createRouter(init: RouterInit): Router { // SSR did the initial scroll restoration. let initialScrollRestored = init.hydrationData != null; - let initialMatches = matchRoutes( - dataRoutes, - init.history.location, - init.basename - ); + let initialMatches = matchRoutes(dataRoutes, init.history.location, basename); let initialErrors: RouteData | null = null; if (initialMatches == null) { @@ -1039,7 +1041,7 @@ export function createRouter(init: RouterInit): Router { // Trigger a navigation event, which can either be a numerical POP or a PUSH // replace with an optional submission async function navigate( - to: number | To, + to: number | To | null, opts?: RouterNavigateOptions ): Promise { if (typeof to === "number") { @@ -1047,9 +1049,19 @@ export function createRouter(init: RouterInit): Router { return; } - let { path, submission, error } = normalizeNavigateOptions( + let normalizedPath = normalizeTo( + state.location, + state.matches, + basename, + future.v7_prependBasename, to, - future, + opts?.fromRouteId, + opts?.relative + ); + let { path, submission, error } = normalizeNavigateOptions( + future.v7_normalizeFormMethod, + false, + normalizedPath, opts ); @@ -1194,7 +1206,7 @@ export function createRouter(init: RouterInit): Router { let routesToUse = inFlightDataRoutes || dataRoutes; let loadingNavigation = opts && opts.overrideNavigation; - let matches = matchRoutes(routesToUse, location, init.basename); + let matches = matchRoutes(routesToUse, location, basename); // Short circuit with a 404 on the root error boundary if we match nothing if (!matches) { @@ -1345,7 +1357,7 @@ export function createRouter(init: RouterInit): Router { matches, manifest, mapRouteProperties, - router.basename + basename ); if (request.signal.aborted) { @@ -1454,7 +1466,7 @@ export function createRouter(init: RouterInit): Router { cancelledFetcherLoads, fetchLoadMatches, routesToUse, - init.basename, + basename, pendingActionData, pendingError ); @@ -1609,7 +1621,7 @@ export function createRouter(init: RouterInit): Router { function fetch( key: string, routeId: string, - href: string, + href: string | null, opts?: RouterFetchOptions ) { if (isServer) { @@ -1623,21 +1635,31 @@ export function createRouter(init: RouterInit): Router { if (fetchControllers.has(key)) abortFetcher(key); let routesToUse = inFlightDataRoutes || dataRoutes; - let matches = matchRoutes(routesToUse, href, init.basename); + let normalizedPath = normalizeTo( + state.location, + state.matches, + basename, + future.v7_prependBasename, + href, + routeId, + opts?.relative + ); + let matches = matchRoutes(routesToUse, normalizedPath, basename); + if (!matches) { setFetcherError( key, routeId, - getInternalRouterError(404, { pathname: href }) + getInternalRouterError(404, { pathname: normalizedPath }) ); return; } let { path, submission } = normalizeNavigateOptions( - href, - future, - opts, - true + future.v7_normalizeFormMethod, + true, + normalizedPath, + opts ); let match = getTargetMatch(matches, path); @@ -1705,7 +1727,7 @@ export function createRouter(init: RouterInit): Router { requestMatches, manifest, mapRouteProperties, - router.basename + basename ); if (fetchRequest.signal.aborted) { @@ -1757,7 +1779,7 @@ export function createRouter(init: RouterInit): Router { let routesToUse = inFlightDataRoutes || dataRoutes; let matches = state.navigation.state !== "idle" - ? matchRoutes(routesToUse, state.navigation.location, init.basename) + ? matchRoutes(routesToUse, state.navigation.location, basename) : state.matches; invariant(matches, "Didn't find any matches after fetcher action"); @@ -1784,7 +1806,7 @@ export function createRouter(init: RouterInit): Router { cancelledFetcherLoads, fetchLoadMatches, routesToUse, - init.basename, + basename, { [match.route.id]: actionResult.data }, undefined // No need to send through errors since we short circuit above ); @@ -1948,7 +1970,7 @@ export function createRouter(init: RouterInit): Router { matches, manifest, mapRouteProperties, - router.basename + basename ); // Deferred isn't supported for fetcher loads, await everything and treat it @@ -2066,8 +2088,7 @@ export function createRouter(init: RouterInit): Router { typeof window?.location !== "undefined" ) { let url = init.history.createURL(redirect.location); - let isDifferentBasename = - stripBasename(url.pathname, init.basename || "/") == null; + let isDifferentBasename = stripBasename(url.pathname, basename) == null; if (window.location.origin !== url.origin || isDifferentBasename) { if (replace) { @@ -2167,7 +2188,7 @@ export function createRouter(init: RouterInit): Router { matches, manifest, mapRouteProperties, - router.basename + basename ) ), ...fetchersToLoad.map((f) => { @@ -2179,7 +2200,7 @@ export function createRouter(init: RouterInit): Router { f.matches, manifest, mapRouteProperties, - router.basename + basename ); } else { let error: ErrorResult = { @@ -2458,7 +2479,7 @@ export function createRouter(init: RouterInit): Router { router = { get basename() { - return init.basename; + return basename; }, get state() { return state; @@ -3040,20 +3061,87 @@ function isSubmissionNavigation( return opts != null && "formData" in opts; } +function normalizeTo( + location: Path, + matches: AgnosticDataRouteMatch[], + basename: string, + prependBasename: boolean, + to: To | null, + fromRouteId?: string, + relative?: RelativeRoutingType +) { + let contextualMatches: AgnosticDataRouteMatch[]; + let activeRouteMatch: AgnosticDataRouteMatch | undefined; + if (fromRouteId != null && relative !== "path") { + // Grab matches up to the calling route so our route-relative logic is + // relative to the correct source route. When using relative:path, + // fromRouteId is ignored since that is always relative to the current + // location path + contextualMatches = []; + for (let match of matches) { + contextualMatches.push(match); + if (match.route.id === fromRouteId) { + activeRouteMatch = match; + break; + } + } + } else { + contextualMatches = matches; + activeRouteMatch = matches[matches.length - 1]; + } + + // Resolve the relative path + let path = resolveTo( + to ? to : ".", + getPathContributingMatches(contextualMatches).map((m) => m.pathnameBase), + location.pathname, + relative === "path" + ); + + // When `to` is not specified we inherit search/hash from the current + // location, unlike when to="." and we just inherit the path. + // See https://github.com/remix-run/remix/issues/927 + if (to == null) { + path.search = location.search; + path.hash = location.hash; + } + + // Add an ?index param for matched index routes if we don't already have one + if ( + (to == null || to === "" || to === ".") && + activeRouteMatch && + activeRouteMatch.route.index && + !hasNakedIndexQuery(path.search) + ) { + path.search = path.search + ? path.search.replace(/^\?/, "?index&") + : "?index"; + } + + // 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 (prependBasename && basename !== "/") { + path.pathname = + path.pathname === "/" ? basename : joinPaths([basename, path.pathname]); + } + + return createPath(path); +} + // Normalize navigation options by converting formMethod=GET formData objects to // URLSearchParams so they behave identically to links with query params function normalizeNavigateOptions( - to: To, - future: FutureConfig, - opts?: RouterNavigateOptions, - isFetcher = false + normalizeFormMethod: boolean, + isFetcher: boolean, + path: string, + opts?: RouterNavigateOptions ): { path: string; submission?: Submission; error?: ErrorResponse; } { - let path = typeof to === "string" ? to : createPath(to); - // Return location verbatim on non-submission navigations if (!opts || !isSubmissionNavigation(opts)) { return { path }; @@ -3071,7 +3159,7 @@ function normalizeNavigateOptions( if (opts.formData) { let formMethod = opts.formMethod || "get"; submission = { - formMethod: future.v7_normalizeFormMethod + formMethod: normalizeFormMethod ? (formMethod.toUpperCase() as V7_FormMethod) : (formMethod.toLowerCase() as FormMethod), formAction: stripHashFromPath(path), @@ -3088,9 +3176,9 @@ function normalizeNavigateOptions( // Flatten submission onto URLSearchParams for GET submissions let parsedPath = parsePath(path); let searchParams = convertFormDataToSearchParams(opts.formData); - // Since fetcher GET submissions only run a single loader (as opposed to - // navigation GET submissions which run all loaders), we need to preserve - // any incoming ?index params + // On GET navigation submissions we can drop the ?index param from the + // resulting location since all loaders will run. But fetcher GET submissions + // only run a single loader so we need to preserve any incoming ?index params if (isFetcher && parsedPath.search && hasNakedIndexQuery(parsedPath.search)) { searchParams.append("index", ""); } @@ -3386,7 +3474,7 @@ async function callLoaderOrAction( matches: AgnosticDataRouteMatch[], manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, - basename = "/", + basename: string, isStaticRequest: boolean = false, isRouteRequest: boolean = false, requestContext?: unknown @@ -3477,28 +3565,13 @@ async function callLoaderOrAction( // Support relative routing in internal redirects if (!ABSOLUTE_URL_REGEX.test(location)) { - let activeMatches = matches.slice(0, matches.indexOf(match) + 1); - let routePathnames = getPathContributingMatches(activeMatches).map( - (match) => match.pathnameBase - ); - let resolvedLocation = resolveTo( - location, - routePathnames, - new URL(request.url).pathname - ); - invariant( - createPath(resolvedLocation), - `Unable to resolve redirect location: ${location}` + location = normalizeTo( + new URL(request.url), + matches.slice(0, matches.indexOf(match) + 1), + basename, + true, + location ); - - // Prepend the basename to the redirect location if we have one - if (basename) { - let path = resolvedLocation.pathname; - resolvedLocation.pathname = - path === "/" ? basename : joinPaths([basename, path]); - } - - location = createPath(resolvedLocation); } else if (!isStaticRequest) { // Strip off the protocol+origin for same-origin + same-basename absolute // redirects. If this is a static request, we can let it go back to the