Skip to content

Commit b620be2

Browse files
brophdawg11mcansh
andauthored
Fix partial object (search or hash only) pathnames losing current path (#10029)
Co-authored-by: Logan McAnsh <logan@remix.run>
1 parent c92a99d commit b620be2

File tree

4 files changed

+198
-16
lines changed

4 files changed

+198
-16
lines changed

.changeset/violet-apes-pay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router-dom": patch
3+
---
4+
5+
Fix partial object (search or hash only) pathnames losing current path value

packages/react-router-dom/__tests__/link-click-test.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,116 @@ describe("A <Link> click", () => {
6161
expect(h1?.textContent).toEqual("About");
6262
});
6363

64+
it("navigates to the new page when using an absolute URL on the same origin", () => {
65+
function Home() {
66+
return (
67+
<div>
68+
<h1>Home</h1>
69+
<Link to="http://localhost/about">About</Link>
70+
</div>
71+
);
72+
}
73+
74+
act(() => {
75+
ReactDOM.createRoot(node).render(
76+
<MemoryRouter initialEntries={["/home"]}>
77+
<Routes>
78+
<Route path="home" element={<Home />} />
79+
<Route path="about" element={<h1>About</h1>} />
80+
</Routes>
81+
</MemoryRouter>
82+
);
83+
});
84+
85+
let anchor = node.querySelector("a");
86+
expect(anchor).not.toBeNull();
87+
88+
let event: MouseEvent;
89+
act(() => {
90+
event = click(anchor);
91+
});
92+
93+
expect(event.defaultPrevented).toBe(true);
94+
let h1 = node.querySelector("h1");
95+
expect(h1).not.toBeNull();
96+
expect(h1?.textContent).toEqual("About");
97+
});
98+
99+
describe("when an external absolute URL is specified", () => {
100+
it("does not prevent default", () => {
101+
function Home() {
102+
return (
103+
<div>
104+
<h1>Home</h1>
105+
<Link to="https://remix.run">About</Link>
106+
</div>
107+
);
108+
}
109+
110+
act(() => {
111+
ReactDOM.createRoot(node).render(
112+
<MemoryRouter initialEntries={["/home"]}>
113+
<Routes>
114+
<Route path="home" element={<Home />} />
115+
<Route path="about" element={<h1>About</h1>} />
116+
</Routes>
117+
</MemoryRouter>
118+
);
119+
});
120+
121+
let anchor = node.querySelector("a");
122+
expect(anchor).not.toBeNull();
123+
124+
let event: MouseEvent;
125+
act(() => {
126+
event = click(anchor);
127+
});
128+
129+
expect(event.defaultPrevented).toBe(false);
130+
});
131+
132+
it("calls provided listener", () => {
133+
let handlerCalled;
134+
let defaultPrevented;
135+
136+
function Home() {
137+
return (
138+
<div>
139+
<h1>Home</h1>
140+
<Link
141+
reloadDocument
142+
to="https://remix.run"
143+
onClick={(e) => {
144+
handlerCalled = true;
145+
defaultPrevented = e.defaultPrevented;
146+
}}
147+
>
148+
About
149+
</Link>
150+
</div>
151+
);
152+
}
153+
154+
act(() => {
155+
ReactDOM.createRoot(node).render(
156+
<MemoryRouter initialEntries={["/home"]}>
157+
<Routes>
158+
<Route path="home" element={<Home />} />
159+
<Route path="about" element={<h1>About</h1>} />
160+
</Routes>
161+
</MemoryRouter>
162+
);
163+
});
164+
165+
act(() => {
166+
click(node.querySelector("a"));
167+
});
168+
169+
expect(handlerCalled).toBe(true);
170+
expect(defaultPrevented).toBe(false);
171+
});
172+
});
173+
64174
describe("when reloadDocument is specified", () => {
65175
it("does not prevent default", () => {
66176
function Home() {

packages/react-router-dom/__tests__/link-href-test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,72 @@ describe("<Link> href", () => {
172172
"web+remix://somepath"
173173
);
174174
});
175+
176+
test('<Link to="http://localhost/inbox"> is treated as an absolute link', () => {
177+
let renderer: TestRenderer.ReactTestRenderer;
178+
TestRenderer.act(() => {
179+
renderer = TestRenderer.create(
180+
<MemoryRouter initialEntries={["/inbox/messages"]}>
181+
<Routes>
182+
<Route path="inbox">
183+
<Route
184+
path="messages"
185+
element={<Link to="http://localhost/inbox" />}
186+
/>
187+
</Route>
188+
</Routes>
189+
</MemoryRouter>
190+
);
191+
});
192+
193+
expect(renderer.root.findByType("a").props.href).toEqual(
194+
"http://localhost/inbox"
195+
);
196+
});
197+
198+
test("<Link to=\"{ search: 'key=value'\"> is handled with the current pathname", () => {
199+
let renderer: TestRenderer.ReactTestRenderer;
200+
TestRenderer.act(() => {
201+
renderer = TestRenderer.create(
202+
<MemoryRouter initialEntries={["/inbox/messages"]}>
203+
<Routes>
204+
<Route path="inbox">
205+
<Route
206+
path="messages"
207+
element={<Link to={{ search: "key=value" }} />}
208+
/>
209+
</Route>
210+
</Routes>
211+
</MemoryRouter>
212+
);
213+
});
214+
215+
expect(renderer.root.findByType("a").props.href).toEqual(
216+
"/inbox/messages?key=value"
217+
);
218+
});
219+
220+
test("<Link to=\"{ hash: 'hash'\"> is handled with the current pathname", () => {
221+
let renderer: TestRenderer.ReactTestRenderer;
222+
TestRenderer.act(() => {
223+
renderer = TestRenderer.create(
224+
<MemoryRouter initialEntries={["/inbox/messages"]}>
225+
<Routes>
226+
<Route path="inbox">
227+
<Route
228+
path="messages"
229+
element={<Link to={{ hash: "hash" }} />}
230+
/>
231+
</Route>
232+
</Routes>
233+
</MemoryRouter>
234+
);
235+
});
236+
237+
expect(renderer.root.findByType("a").props.href).toEqual(
238+
"/inbox/messages#hash"
239+
);
240+
});
175241
});
176242

177243
describe("in a dynamic route", () => {

packages/react-router-dom/index.tsx

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -418,31 +418,32 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
418418
},
419419
ref
420420
) {
421-
// `location` is the unaltered href we will render in the <a> tag for absolute URLs
422-
let location = typeof to === "string" ? to : createPath(to);
423-
let isAbsolute = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(location);
424-
425-
// Location to use in the click handler
426-
let navigationLocation = location;
421+
// Rendered into <a href> for absolute URLs
422+
let absoluteHref;
427423
let isExternal = false;
428-
if (isBrowser && isAbsolute) {
424+
425+
if (
426+
isBrowser &&
427+
typeof to === "string" &&
428+
/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(to)
429+
) {
430+
absoluteHref = to;
429431
let currentUrl = new URL(window.location.href);
430-
let targetUrl = location.startsWith("//")
431-
? new URL(currentUrl.protocol + location)
432-
: new URL(location);
432+
let targetUrl = to.startsWith("//")
433+
? new URL(currentUrl.protocol + to)
434+
: new URL(to);
433435
if (targetUrl.origin === currentUrl.origin) {
434436
// Strip the protocol/origin for same-origin absolute URLs
435-
navigationLocation =
436-
targetUrl.pathname + targetUrl.search + targetUrl.hash;
437+
to = targetUrl.pathname + targetUrl.search + targetUrl.hash;
437438
} else {
438439
isExternal = true;
439440
}
440441
}
441442

442-
// `href` is what we render in the <a> tag for relative URLs
443-
let href = useHref(navigationLocation, { relative });
443+
// Rendered into <a href> for relative URLs
444+
let href = useHref(to, { relative });
444445

445-
let internalOnClick = useLinkClickHandler(navigationLocation, {
446+
let internalOnClick = useLinkClickHandler(to, {
446447
replace,
447448
state,
448449
target,
@@ -462,7 +463,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
462463
// eslint-disable-next-line jsx-a11y/anchor-has-content
463464
<a
464465
{...rest}
465-
href={isAbsolute ? location : href}
466+
href={absoluteHref || href}
466467
onClick={isExternal || reloadDocument ? onClick : handleClick}
467468
ref={ref}
468469
target={target}

0 commit comments

Comments
 (0)