Skip to content

Commit e9f271d

Browse files
mcanshbrophdawg11
andauthored
feat: allow <Link> to link to external urls (#9900)
* feat: allow Link's "to" prop to accept external urls Signed-off-by: Logan McAnsh <[email protected]> * chore: update external link detection to account for no protocol Signed-off-by: Logan McAnsh <[email protected]> * Update index.tsx * Update index.tsx * chore: update absolute url checking per #9900 (comment) Signed-off-by: Logan McAnsh <[email protected]> * Create silent-oranges-pay.md * chore: bump bundle size Signed-off-by: Logan McAnsh <[email protected]> * chore: add browser check Signed-off-by: Logan McAnsh <[email protected]> * Preserve absolute same origin and don't add listener on external links * chore: bump bundle size Signed-off-by: Logan McAnsh <[email protected]> Signed-off-by: Logan McAnsh <[email protected]> Co-authored-by: Matt Brophy <[email protected]>
1 parent 2cd8266 commit e9f271d

File tree

4 files changed

+84
-5
lines changed

4 files changed

+84
-5
lines changed

.changeset/silent-oranges-pay.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"react-router": patch
3+
"react-router-dom": patch
4+
---
5+
6+
allow using `<Link>` with external URLs
7+
8+
```tsx
9+
<Link to="//example.com/some/path">
10+
<Link to="https://www.currentorigin.com/path">
11+
```

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@
121121
"none": "11 kB"
122122
},
123123
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
124-
"none": "16.5 kB"
124+
"none": "16.7 kB"
125125
}
126126
}
127127
}

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,45 @@ describe("<Link> href", () => {
8989
["/about", "/about"]
9090
);
9191
});
92+
93+
test('<Link to="https://remix.run"> is treated as external link', () => {
94+
let renderer: TestRenderer.ReactTestRenderer;
95+
TestRenderer.act(() => {
96+
renderer = TestRenderer.create(
97+
<MemoryRouter initialEntries={["/inbox/messages"]}>
98+
<Routes>
99+
<Route path="inbox">
100+
<Route
101+
path="messages"
102+
element={<Link to="https://remix.run" />}
103+
/>
104+
</Route>
105+
</Routes>
106+
</MemoryRouter>
107+
);
108+
});
109+
110+
expect(renderer.root.findByType("a").props.href).toEqual(
111+
"https://remix.run"
112+
);
113+
});
114+
115+
test('<Link to="//remix.run"> is treated as external link', () => {
116+
let renderer: TestRenderer.ReactTestRenderer;
117+
TestRenderer.act(() => {
118+
renderer = TestRenderer.create(
119+
<MemoryRouter initialEntries={["/inbox/messages"]}>
120+
<Routes>
121+
<Route path="inbox">
122+
<Route path="messages" element={<Link to="//remix.run" />} />
123+
</Route>
124+
</Routes>
125+
</MemoryRouter>
126+
);
127+
});
128+
129+
expect(renderer.root.findByType("a").props.href).toEqual("//remix.run");
130+
});
92131
});
93132

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

packages/react-router-dom/index.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,11 @@ export interface LinkProps
394394
to: To;
395395
}
396396

397+
const isBrowser =
398+
typeof window !== "undefined" &&
399+
typeof window.document !== "undefined" &&
400+
typeof window.document.createElement !== "undefined";
401+
397402
/**
398403
* The public API for rendering a history-aware <a>.
399404
*/
@@ -412,8 +417,32 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
412417
},
413418
ref
414419
) {
415-
let href = useHref(to, { relative });
416-
let internalOnClick = useLinkClickHandler(to, {
420+
// `location` is the unaltered href we will render in the <a> tag for absolute URLs
421+
let location = typeof to === "string" ? to : createPath(to);
422+
let isAbsolute =
423+
/^[a-z+]+:\/\//i.test(location) || location.startsWith("//");
424+
425+
// Location to use in the click handler
426+
let navigationLocation = location;
427+
let isExternal = false;
428+
if (isBrowser && isAbsolute) {
429+
let currentUrl = new URL(window.location.href);
430+
let targetUrl = location.startsWith("//")
431+
? new URL(currentUrl.protocol + location)
432+
: new URL(location);
433+
if (targetUrl.origin === currentUrl.origin) {
434+
// Strip the protocol/origin for same-origin absolute URLs
435+
navigationLocation =
436+
targetUrl.pathname + targetUrl.search + targetUrl.hash;
437+
} else {
438+
isExternal = true;
439+
}
440+
}
441+
442+
// `href` is what we render in the <a> tag for relative URLs
443+
let href = useHref(navigationLocation, { relative });
444+
445+
let internalOnClick = useLinkClickHandler(navigationLocation, {
417446
replace,
418447
state,
419448
target,
@@ -433,8 +462,8 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
433462
// eslint-disable-next-line jsx-a11y/anchor-has-content
434463
<a
435464
{...rest}
436-
href={href}
437-
onClick={reloadDocument ? onClick : handleClick}
465+
href={isAbsolute ? location : href}
466+
onClick={isExternal || reloadDocument ? onClick : handleClick}
438467
ref={ref}
439468
target={target}
440469
/>

0 commit comments

Comments
 (0)