Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow Link's "to" prop to accept external urls #9900

Merged
merged 10 commits into from
Jan 18, 2023
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.6 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}
mcansh marked this conversation as resolved.
Show resolved Hide resolved
ref={ref}
target={target}
/>
Expand Down