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

Allow absolute urls in router and Link #15792

Merged
merged 13 commits into from
Aug 5, 2020
57 changes: 29 additions & 28 deletions packages/next/client/link.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
declare const __NEXT_DATA__: any

import React, { Children } from 'react'
import { UrlObject } from 'url'
import { PrefetchOptions, NextRouter } from '../next-server/lib/router/router'
import { execOnce, getLocationOrigin } from '../next-server/lib/utils'
import {
PrefetchOptions,
NextRouter,
isLocalURL,
} from '../next-server/lib/router/router'
import { execOnce } from '../next-server/lib/utils'
import { useRouter } from './router'
import { addBasePath, resolveHref } from '../next-server/lib/router/router'

/**
* Detects whether a given url is from the same origin as the current page (browser only).
*/
function isLocal(url: string): boolean {
const locationOrigin = getLocationOrigin()
const resolved = new URL(url, locationOrigin)
return resolved.origin === locationOrigin
}

type Url = string | UrlObject

export type LinkProps = {
Expand Down Expand Up @@ -89,6 +82,7 @@ function prefetch(
options?: PrefetchOptions
): void {
if (typeof window === 'undefined') return
if (!isLocalURL(href)) return
// Prefetch the JSON page if asked (only in the client)
// We need to handle a prefetch error here since we may be
// loading with priority which can reject but we don't
Expand All @@ -103,6 +97,17 @@ function prefetch(
prefetched[href + '%' + as] = true
}

function isNewTabRequest(event: React.MouseEvent) {
const { target } = event.currentTarget as HTMLAnchorElement
return (
(target && target !== '_self') ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
(event.nativeEvent && event.nativeEvent.which === 2)
)
}

function linkClicked(
e: React.MouseEvent,
router: NextRouter,
Expand All @@ -112,21 +117,10 @@ function linkClicked(
shallow?: boolean,
scroll?: boolean
): void {
const { nodeName, target } = e.currentTarget as HTMLAnchorElement
if (
nodeName === 'A' &&
((target && target !== '_self') ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
(e.nativeEvent && e.nativeEvent.which === 2))
) {
// ignore click for new tab / new window behavior
return
}
const { nodeName } = e.currentTarget

if (!isLocal(href)) {
Janpot marked this conversation as resolved.
Show resolved Hide resolved
// ignore click if it's outside our scope (e.g. https://google.com)
if (nodeName === 'A' && (isNewTabRequest(e) || !isLocalURL(href))) {
// ignore click for new tab / new window behavior
return
}

Expand Down Expand Up @@ -177,7 +171,13 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
}, [pathname, props.href, props.as])

React.useEffect(() => {
if (p && IntersectionObserver && childElm && childElm.tagName) {
if (
p &&
IntersectionObserver &&
childElm &&
childElm.tagName &&
isLocalURL(href)
) {
// Join on an invalid URI character
const isPrefetched = prefetched[href + '%' + as]
if (!isPrefetched) {
Expand Down Expand Up @@ -224,6 +224,7 @@ function Link(props: React.PropsWithChildren<LinkProps>) {

if (p) {
childProps.onMouseEnter = (e: React.MouseEvent) => {
if (!isLocalURL(href)) return
if (child.props && typeof child.props.onMouseEnter === 'function') {
child.props.onMouseEnter(e)
}
Expand Down
48 changes: 38 additions & 10 deletions packages/next/next-server/lib/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
loadGetInitialProps,
NextPageContext,
ST,
getLocationOrigin,
} from '../utils'
import { isDynamicRoute } from './utils/is-dynamic'
import { getRouteMatcher } from './utils/route-matcher'
Expand Down Expand Up @@ -47,7 +48,8 @@ export function hasBasePath(path: string): boolean {
}

export function addBasePath(path: string): string {
return basePath
// we only add the basepath on relative urls
return basePath && path.startsWith('/')
? path === '/'
? normalizePathTrailingSlash(basePath)
: basePath + path
Expand All @@ -58,6 +60,21 @@ export function delBasePath(path: string): string {
return path.slice(basePath.length) || '/'
}

/**
* Detects whether a given url is routable by the Next.js router (browser only).
*/
export function isLocalURL(url: string): boolean {
if (url.startsWith('/')) return true
try {
// absolute urls can be local if they are on the same origin
const locationOrigin = getLocationOrigin()
const resolved = new URL(url, locationOrigin)
return resolved.origin === locationOrigin && hasBasePath(resolved.pathname)
} catch (_) {
return false
}
}

type Url = UrlObject | string

/**
Expand All @@ -69,12 +86,16 @@ export function resolveHref(currentPath: string, href: Url): string {
const base = new URL(currentPath, 'http://n')
const urlAsString =
typeof href === 'string' ? href : formatWithValidation(href)
const finalUrl = new URL(urlAsString, base)
finalUrl.pathname = normalizePathTrailingSlash(finalUrl.pathname)
// if the origin didn't change, it means we received a relative href
return finalUrl.origin === base.origin
? finalUrl.href.slice(finalUrl.origin.length)
: finalUrl.href
try {
const finalUrl = new URL(urlAsString, base)
finalUrl.pathname = normalizePathTrailingSlash(finalUrl.pathname)
// if the origin didn't change, it means we received a relative href
return finalUrl.origin === base.origin
? finalUrl.href.slice(finalUrl.origin.length)
: finalUrl.href
} catch (_) {
return urlAsString
}
}

function prepareUrlAs(router: NextRouter, url: Url, as: Url) {
Expand All @@ -93,9 +114,11 @@ function tryParseRelativeUrl(
return parseRelativeUrl(url)
} catch (err) {
if (process.env.NODE_ENV !== 'production') {
throw new Error(
`Invalid href passed to router: ${url} https://err.sh/vercel/next.js/invalid-href-passed`
)
setTimeout(() => {
throw new Error(
`Invalid href passed to router: ${url} https://err.sh/vercel/next.js/invalid-href-passed`
)
}, 0)
}
return null
}
Expand Down Expand Up @@ -434,6 +457,11 @@ export default class Router implements BaseRouter {
as: string,
options: TransitionOptions
): Promise<boolean> {
if (!isLocalURL(url)) {
window.location.href = url
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work with mailto: links and whatnot?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, didn't manage to test mailto handlers in selenium, but it's just treated as an external link

return false
}

if (!(options as any)._h) {
this.isSsr = false
}
Expand Down
27 changes: 20 additions & 7 deletions packages/next/next-server/lib/router/utils/parse-relative-url.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
const DUMMY_BASE = new URL('http://n')
import { getLocationOrigin } from '../../utils'

const DUMMY_BASE = new URL(
typeof window === 'undefined' ? 'http://n' : getLocationOrigin()
)

/**
* Parses path-relative urls (e.g. `/hello/world?foo=bar`). If url isn't path-relative
* (e.g. `./hello`) then at least base must be.
* Absolute urls are rejected.
* Absolute urls are rejected with one exception, in the browser, absolute urls that are on
* the current origin will be parsed as relative
*/
export function parseRelativeUrl(url: string, base?: string) {
const resolvedBase = base ? new URL(base, DUMMY_BASE) : DUMMY_BASE
const { pathname, searchParams, search, hash, href, origin } = new URL(
url,
resolvedBase
)
if (origin !== DUMMY_BASE.origin) {
const {
pathname,
searchParams,
search,
hash,
href,
origin,
protocol,
} = new URL(url, resolvedBase)
if (
origin !== DUMMY_BASE.origin ||
(protocol !== 'http:' && protocol !== 'https:')
) {
throw new Error('invariant: invalid relative URL')
}
return {
Expand Down
19 changes: 19 additions & 0 deletions test/integration/basepath/pages/absolute-url-basepath.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react'
import Link from 'next/link'

export async function getServerSideProps({ query: { port } }) {
if (!port) {
throw new Error('port required')
}
return { props: { port } }
}

export default function Page({ port }) {
return (
<>
<Link href={`http://localhost:${port}/docs/something-else`}>
<a id="absolute-link">http://localhost:{port}/docs/something-else</a>
</Link>
</>
)
}
19 changes: 19 additions & 0 deletions test/integration/basepath/pages/absolute-url-no-basepath.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react'
import Link from 'next/link'

export async function getServerSideProps({ query: { port } }) {
if (!port) {
throw new Error('port required')
}
return { props: { port } }
}

export default function Page({ port }) {
return (
<>
<Link href={`http://localhost:${port}/rewrite-no-basepath`}>
<a id="absolute-link">http://localhost:{port}/rewrite-no-basepath</a>
</Link>
</>
)
}
16 changes: 16 additions & 0 deletions test/integration/basepath/pages/absolute-url.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react'
import Link from 'next/link'

export default function Page() {
return (
<>
<Link href="https://vercel.com/">
<a id="absolute-link">https://vercel.com/</a>
</Link>
<br />
<Link href="mailto:[email protected]">
<a id="mailto-link">mailto:[email protected]</a>
</Link>
</>
)
}
39 changes: 39 additions & 0 deletions test/integration/basepath/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,45 @@ const runTests = (context, dev = false) => {
expect(pathname).toBe('/docs')
})

it('should navigate an absolute url', async () => {
const browser = await webdriver(context.appPort, `/docs/absolute-url`)
await browser.waitForElementByCss('#absolute-link').click()
await check(
() => browser.eval(() => window.location.origin),
'https://vercel.com'
)
})

it('should navigate an absolute local url with basePath', async () => {
const browser = await webdriver(
context.appPort,
`/docs/absolute-url-basepath?port=${context.appPort}`
)
await browser.eval(() => (window._didNotNavigate = true))
await browser.waitForElementByCss('#absolute-link').click()
const text = await browser
.waitForElementByCss('#something-else-page')
.text()

expect(text).toBe('something else')
expect(await browser.eval(() => window._didNotNavigate)).toBe(true)
})

it('should navigate an absolute local url without basePath', async () => {
const browser = await webdriver(
context.appPort,
`/docs/absolute-url-no-basepath?port=${context.appPort}`
)
await browser.waitForElementByCss('#absolute-link').click()
await check(
() => browser.eval(() => location.pathname),
'/rewrite-no-basepath'
)
const text = await browser.elementByCss('body').text()

expect(text).toBe('hello from external')
})

it('should 404 when manually adding basePath with <Link>', async () => {
const browser = await webdriver(
context.appPort,
Expand Down
2 changes: 1 addition & 1 deletion test/integration/build-output/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe('Build Output', () => {
expect(parseFloat(err404FirstLoad) - 63).toBeLessThanOrEqual(0)
expect(err404FirstLoad.endsWith('kB')).toBe(true)

expect(parseFloat(sharedByAll) - 59.3).toBeLessThanOrEqual(0)
expect(parseFloat(sharedByAll) - 59.4).toBeLessThanOrEqual(0)
expect(sharedByAll.endsWith('kB')).toBe(true)

if (_appSize.endsWith('kB')) {
Expand Down
Loading