Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/silent-oranges-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"react-router": patch
"react-router-dom": patch
---

allow using `<Link>` with external URLs

```tsx
<Link to="//example.com/some/path">
<Link to="https://www.currentorigin.com/path">
```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
"none": "11 kB"
},
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
"none": "16.5 kB"
"none": "16.7 kB"
}
}
}
39 changes: 39 additions & 0 deletions packages/react-router-dom/__tests__/link-href-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,45 @@ describe("<Link> href", () => {
["/about", "/about"]
);
});

test('<Link to="https://remix.run"> is treated as external link', () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/inbox/messages"]}>
<Routes>
<Route path="inbox">
<Route
path="messages"
element={<Link to="https://remix.run" />}
/>
</Route>
</Routes>
</MemoryRouter>
);
});

expect(renderer.root.findByType("a").props.href).toEqual(
"https://remix.run"
);
});

test('<Link to="//remix.run"> is treated as external link', () => {
let renderer: TestRenderer.ReactTestRenderer;
TestRenderer.act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/inbox/messages"]}>
<Routes>
<Route path="inbox">
<Route path="messages" element={<Link to="//remix.run" />} />
</Route>
</Routes>
</MemoryRouter>
);
});

expect(renderer.root.findByType("a").props.href).toEqual("//remix.run");
});
});

describe("in a dynamic route", () => {
Expand Down
37 changes: 33 additions & 4 deletions packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,11 @@ export interface LinkProps
to: To;
}

const isBrowser =
typeof window !== "undefined" &&
typeof window.document !== "undefined" &&
typeof window.document.createElement !== "undefined";

/**
* The public API for rendering a history-aware <a>.
*/
Expand All @@ -409,8 +414,32 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
},
ref
) {
let href = useHref(to, { relative });
let internalOnClick = useLinkClickHandler(to, {
// `location` is the unaltered href we will render in the <a> tag for absolute URLs
let location = typeof to === "string" ? to : createPath(to);
let isAbsolute =
/^[a-z+]+:\/\//i.test(location) || location.startsWith("//");

// Location to use in the click handler
let navigationLocation = location;
let isExternal = false;
if (isBrowser && isAbsolute) {
let currentUrl = new URL(window.location.href);
let targetUrl = location.startsWith("//")
? new URL(currentUrl.protocol + location)
: new URL(location);
if (targetUrl.origin === currentUrl.origin) {
// Strip the protocol/origin for same-origin absolute URLs
navigationLocation =
targetUrl.pathname + targetUrl.search + targetUrl.hash;
} else {
isExternal = true;
}
}

// `href` is what we render in the <a> tag for relative URLs
let href = useHref(navigationLocation, { relative });

let internalOnClick = useLinkClickHandler(navigationLocation, {
replace,
state,
target,
Expand All @@ -430,8 +459,8 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
{...rest}
href={href}
onClick={reloadDocument ? onClick : handleClick}
href={isAbsolute ? location : href}
onClick={isExternal || reloadDocument ? onClick : handleClick}
ref={ref}
target={target}
/>
Expand Down