Skip to content

Commit

Permalink
Allow absolute urls in router and Link (#15792)
Browse files Browse the repository at this point in the history
Fixes #15639
Fixes #15820

To Do:
- [x] Doesn't work with `basePath` yet
  • Loading branch information
Janpot authored Aug 5, 2020
1 parent b6060fa commit 5dbe0d0
Show file tree
Hide file tree
Showing 15 changed files with 367 additions and 117 deletions.
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)) {
// 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
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:idk@idk.com">
<a id="mailto-link">mailto:idk@idk.com</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

0 comments on commit 5dbe0d0

Please sign in to comment.