Skip to content

Commit

Permalink
fix(#33081): handle relative path correctly (#36823)
Browse files Browse the repository at this point in the history
## Bug

- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [ ] Errors have helpful link attached, see `contributing.md`

Fixes #36823 
Closes #33084

The issue is caused by the `isLocalURL` function only checks if a URL starts with `/`, `#` or `?`. So a URL that starts with `.` will not be considered a "local URL". The PR fixes that by introducing a new util function `isAbsoluteUrl` that is fully compliant with [RFC3986](https://tools.ietf.org/html/rfc3986#section-4.3).
  • Loading branch information
SukkaW authored and pull[bot] committed Jan 18, 2024
1 parent e7c7e36 commit 35a5f01
Show file tree
Hide file tree
Showing 8 changed files with 98 additions and 3 deletions.
4 changes: 2 additions & 2 deletions packages/next/shared/lib/router/router.ts
Expand Up @@ -30,6 +30,7 @@ import {
NextPageContext,
ST,
NEXT_DATA,
isAbsoluteUrl,
} from '../utils'
import { isDynamicRoute } from './utils/is-dynamic'
import { parseRelativeUrl } from './utils/parse-relative-url'
Expand Down Expand Up @@ -231,8 +232,7 @@ export function delBasePath(path: string): string {
*/
export function isLocalURL(url: string): boolean {
// prevent a hydration mismatch on href for url with anchor refs
if (url.startsWith('/') || url.startsWith('#') || url.startsWith('?'))
return true
if (!isAbsoluteUrl(url)) return true
try {
// absolute urls can be local if they are on the same origin
const locationOrigin = getLocationOrigin()
Expand Down
8 changes: 7 additions & 1 deletion packages/next/shared/lib/router/utils/parse-relative-url.ts
Expand Up @@ -11,7 +11,13 @@ export function parseRelativeUrl(url: string, base?: string) {
const globalBase = new URL(
typeof window === 'undefined' ? 'http://n' : getLocationOrigin()
)
const resolvedBase = base ? new URL(base, globalBase) : globalBase

const resolvedBase = base
? new URL(base, globalBase)
: url.startsWith('.')
? new URL(typeof window === 'undefined' ? 'http://n' : window.location.href)
: globalBase

const { pathname, searchParams, search, hash, href, origin } = new URL(
url,
resolvedBase
Expand Down
5 changes: 5 additions & 0 deletions packages/next/shared/lib/utils.ts
Expand Up @@ -290,6 +290,11 @@ export function execOnce<T extends (...args: any[]) => ReturnType<T>>(
}) as T
}

// Scheme: https://tools.ietf.org/html/rfc3986#section-3.1
// Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/
export const isAbsoluteUrl = (url: string) => ABSOLUTE_URL_REGEX.test(url)

export function getLocationOrigin() {
const { protocol, hostname, port } = window.location
return `${protocol}//${hostname}${port ? ':' + port : ''}`
Expand Down
12 changes: 12 additions & 0 deletions test/integration/client-navigation/pages/nav/relative-1.js
@@ -0,0 +1,12 @@
import Link from 'next/link'

export default function Relative1() {
return (
<div id="relative-1">
On relative 1
<Link href="./relative-2">
<a>To relative 2</a>
</Link>
</div>
)
}
18 changes: 18 additions & 0 deletions test/integration/client-navigation/pages/nav/relative-2.js
@@ -0,0 +1,18 @@
import { useRouter } from 'next/router'

export default function Relative2() {
const router = useRouter()
return (
<div id="relative-2">
On relative 2
<button
onClick={(e) => {
e.preventDefault()
router.push('./relative')
}}
>
To relative index
</button>
</div>
)
}
12 changes: 12 additions & 0 deletions test/integration/client-navigation/pages/nav/relative/index.js
@@ -0,0 +1,12 @@
import Link from 'next/link'

export default function Relative() {
return (
<div id="relative">
On relative index
<Link href="./relative-1" id="relative-1-link">
To relative 1
</Link>
</div>
)
}
24 changes: 24 additions & 0 deletions test/integration/client-navigation/test/index.test.js
Expand Up @@ -1714,6 +1714,30 @@ describe('Client Navigation', () => {
expect(value).toBe(false)
})

it('should navigate to paths relative to the current page', async () => {
const browser = await webdriver(context.appPort, '/nav/relative')
await browser.waitForElementByCss('body', 500)
let page

await browser.elementByCss('a').click()

browser.waitForElementByCss('#relative-1', 500)
page = await browser.elementByCss('body').text()
expect(page).toMatch(/On relative 1/)
await browser.elementByCss('a').click()

browser.waitForElementByCss('#relative-2', 500)
page = await browser.elementByCss('body').text()
expect(page).toMatch(/On relative 2/)

await browser.elementByCss('button').click()
browser.waitForElementByCss('#relative', 500)
page = await browser.elementByCss('body').text()
expect(page).toMatch(/On relative index/)

await browser.close()
})

renderingSuite(
(p, q) => renderViaHTTP(context.appPort, p, q),
(p, q) => fetchViaHTTP(context.appPort, p, q),
Expand Down
18 changes: 18 additions & 0 deletions test/unit/parse-relative-url.test.ts
Expand Up @@ -42,6 +42,24 @@ describe('parseRelativeUrl', () => {
check(
'http://example.com:3210/someA/pathB?fooC=barD#hashE',
'./someF/pathG?fooH=barI#hashJ',
{
pathname: '/someA/someF/pathG',
search: '?fooH=barI',
hash: '#hashJ',
}
)
check(
'http://example.com:3210/someA/pathB',
'../someF/pathG?fooH=barI#hashJ',
{
pathname: '/someF/pathG',
search: '?fooH=barI',
hash: '#hashJ',
}
)
check(
'http://example.com:3210/someA/pathB',
'../../someF/pathG?fooH=barI#hashJ',
{
pathname: '/someF/pathG',
search: '?fooH=barI',
Expand Down

0 comments on commit 35a5f01

Please sign in to comment.