diff --git a/.changeset/violet-apes-pay.md b/.changeset/violet-apes-pay.md new file mode 100644 index 0000000000..031444d5e9 --- /dev/null +++ b/.changeset/violet-apes-pay.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": patch +--- + +Fix partial object (search or hash only) pathnames losing current path value diff --git a/packages/react-router-dom/__tests__/link-click-test.tsx b/packages/react-router-dom/__tests__/link-click-test.tsx index b5eacee4f8..d3a8facd4c 100644 --- a/packages/react-router-dom/__tests__/link-click-test.tsx +++ b/packages/react-router-dom/__tests__/link-click-test.tsx @@ -61,6 +61,116 @@ describe("A click", () => { expect(h1?.textContent).toEqual("About"); }); + it("navigates to the new page when using an absolute URL on the same origin", () => { + function Home() { + return ( +
+

Home

+ About +
+ ); + } + + act(() => { + ReactDOM.createRoot(node).render( + + + } /> + About} /> + + + ); + }); + + let anchor = node.querySelector("a"); + expect(anchor).not.toBeNull(); + + let event: MouseEvent; + act(() => { + event = click(anchor); + }); + + expect(event.defaultPrevented).toBe(true); + let h1 = node.querySelector("h1"); + expect(h1).not.toBeNull(); + expect(h1?.textContent).toEqual("About"); + }); + + describe("when an external absolute URL is specified", () => { + it("does not prevent default", () => { + function Home() { + return ( +
+

Home

+ About +
+ ); + } + + act(() => { + ReactDOM.createRoot(node).render( + + + } /> + About} /> + + + ); + }); + + let anchor = node.querySelector("a"); + expect(anchor).not.toBeNull(); + + let event: MouseEvent; + act(() => { + event = click(anchor); + }); + + expect(event.defaultPrevented).toBe(false); + }); + + it("calls provided listener", () => { + let handlerCalled; + let defaultPrevented; + + function Home() { + return ( +
+

Home

+ { + handlerCalled = true; + defaultPrevented = e.defaultPrevented; + }} + > + About + +
+ ); + } + + act(() => { + ReactDOM.createRoot(node).render( + + + } /> + About} /> + + + ); + }); + + act(() => { + click(node.querySelector("a")); + }); + + expect(handlerCalled).toBe(true); + expect(defaultPrevented).toBe(false); + }); + }); + describe("when reloadDocument is specified", () => { it("does not prevent default", () => { function Home() { diff --git a/packages/react-router-dom/__tests__/link-href-test.tsx b/packages/react-router-dom/__tests__/link-href-test.tsx index a37d7a7b7d..386fb7d9b2 100644 --- a/packages/react-router-dom/__tests__/link-href-test.tsx +++ b/packages/react-router-dom/__tests__/link-href-test.tsx @@ -172,6 +172,72 @@ describe(" href", () => { "web+remix://somepath" ); }); + + test(' is treated as an absolute link', () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } + /> + + + + ); + }); + + expect(renderer.root.findByType("a").props.href).toEqual( + "http://localhost/inbox" + ); + }); + + test(" is handled with the current pathname", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } + /> + + + + ); + }); + + expect(renderer.root.findByType("a").props.href).toEqual( + "/inbox/messages?key=value" + ); + }); + + test(" is handled with the current pathname", () => { + let renderer: TestRenderer.ReactTestRenderer; + TestRenderer.act(() => { + renderer = TestRenderer.create( + + + + } + /> + + + + ); + }); + + expect(renderer.root.findByType("a").props.href).toEqual( + "/inbox/messages#hash" + ); + }); }); describe("in a dynamic route", () => { diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index a2adb57978..882159e6cc 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -418,31 +418,32 @@ export const Link = React.forwardRef( }, ref ) { - // `location` is the unaltered href we will render in the tag for absolute URLs - let location = typeof to === "string" ? to : createPath(to); - let isAbsolute = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(location); - - // Location to use in the click handler - let navigationLocation = location; + // Rendered into for absolute URLs + let absoluteHref; let isExternal = false; - if (isBrowser && isAbsolute) { + + if ( + isBrowser && + typeof to === "string" && + /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(to) + ) { + absoluteHref = to; let currentUrl = new URL(window.location.href); - let targetUrl = location.startsWith("//") - ? new URL(currentUrl.protocol + location) - : new URL(location); + let targetUrl = to.startsWith("//") + ? new URL(currentUrl.protocol + to) + : new URL(to); if (targetUrl.origin === currentUrl.origin) { // Strip the protocol/origin for same-origin absolute URLs - navigationLocation = - targetUrl.pathname + targetUrl.search + targetUrl.hash; + to = targetUrl.pathname + targetUrl.search + targetUrl.hash; } else { isExternal = true; } } - // `href` is what we render in the tag for relative URLs - let href = useHref(navigationLocation, { relative }); + // Rendered into for relative URLs + let href = useHref(to, { relative }); - let internalOnClick = useLinkClickHandler(navigationLocation, { + let internalOnClick = useLinkClickHandler(to, { replace, state, target, @@ -462,7 +463,7 @@ export const Link = React.forwardRef( // eslint-disable-next-line jsx-a11y/anchor-has-content