8e202610a4
## Problem Relative hash/query handling in `next/link` (e.g. `<Link href="#hello">`) is broken in App Router, especially if you're on a nested route. This wasn't a problem in `/pages` because the href always get fully resolved in `<NextLink>`; i.e. if you have `<Link href="#hash" />` on `/hello`, it'll resolve the href to `/hello#hash` and use that everywhere. However, in App Router, `<Link>` no longer uses the current location to resolve the href:5451564f36/packages/next/src/client/link.tsx (L450-L457)
Therefore navigating with `new URL(href, location.origin)` will skip the current path and always apply the relative hash/query to the _root_ route:5451564f36/packages/next/src/client/components/app-router.tsx (L208-L215)
## Solution Not 100% sure if this is the best solution, but since `app-router` is already reading `window.location`, I'm using `location.href` as the base URL to resolve the href. I grep'd for `location.origin` and checked other callsites; seems like only `app-router` deals with user specified hrefs. Fixes https://github.com/vercel/next.js/issues/42157 & https://github.com/vercel/next.js/issues/44139, and potentially https://github.com/vercel/next.js/issues/48554 and https://github.com/vercel/next.js/issues/22838 ## Test Plan ``` pnpm testheadless test/e2e/app-dir/navigation ``` ---------
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
import { createNextDescribe } from 'e2e-utils'
|
|
import webdriver from 'next-webdriver'
|
|
import { check } from 'next-test-utils'
|
|
|
|
createNextDescribe(
|
|
'app dir - navigation',
|
|
{
|
|
files: __dirname,
|
|
},
|
|
({ next, isNextDev, isNextDeploy }) => {
|
|
describe('query string', () => {
|
|
it('should set query correctly', async () => {
|
|
const browser = await webdriver(next.url, '/')
|
|
expect(await browser.elementById('query').text()).toMatchInlineSnapshot(
|
|
`""`
|
|
)
|
|
|
|
browser.elementById('set-query').click()
|
|
|
|
await check(
|
|
async () => await browser.elementById('query').text(),
|
|
'a=b&c=d'
|
|
)
|
|
|
|
const url = new URL(await browser.url())
|
|
expect(url.searchParams.toString()).toMatchInlineSnapshot(`"a=b&c=d"`)
|
|
})
|
|
})
|
|
|
|
describe('hash', () => {
|
|
it('should scroll to the specified hash', async () => {
|
|
const browser = await next.browser('/hash')
|
|
|
|
const checkLink = async (
|
|
val: number | string,
|
|
expectedScroll: number
|
|
) => {
|
|
await browser.elementByCss(`#link-to-${val.toString()}`).click()
|
|
await check(
|
|
async () => {
|
|
const val = await browser.eval('window.pageYOffset')
|
|
return val.toString()
|
|
},
|
|
expectedScroll.toString(),
|
|
true,
|
|
// Try maximum of 15 seconds
|
|
15
|
|
)
|
|
}
|
|
|
|
await checkLink(6, 114)
|
|
await checkLink(50, 730)
|
|
await checkLink(160, 2270)
|
|
await checkLink(300, 4230)
|
|
await checkLink(500, 7030) // this one is hash only (`href="#hash-500"`)
|
|
await checkLink('top', 0)
|
|
await checkLink('non-existent', 0)
|
|
})
|
|
})
|
|
|
|
describe('hash-link-back-to-same-page', () => {
|
|
it('should scroll to the specified hash', async () => {
|
|
const browser = await next.browser('/hash-link-back-to-same-page')
|
|
|
|
const checkLink = async (
|
|
val: number | string,
|
|
expectedScroll: number
|
|
) => {
|
|
await browser.elementByCss(`#link-to-${val.toString()}`).click()
|
|
await check(
|
|
async () => {
|
|
const val = await browser.eval('window.pageYOffset')
|
|
return val.toString()
|
|
},
|
|
expectedScroll.toString(),
|
|
true,
|
|
// Try maximum of 15 seconds
|
|
15
|
|
)
|
|
}
|
|
|
|
await checkLink(6, 114)
|
|
await checkLink(50, 730)
|
|
await checkLink(160, 2270)
|
|
|
|
await browser
|
|
.elementByCss('#to-other-page')
|
|
// Navigate to other
|
|
.click()
|
|
// Wait for other ot load
|
|
.waitForElementByCss('#link-to-home')
|
|
// Navigate back to hash-link-back-to-same-page
|
|
.click()
|
|
// Wait for hash-link-back-to-same-page to load
|
|
.waitForElementByCss('#to-other-page')
|
|
|
|
await check(
|
|
async () => {
|
|
const val = await browser.eval('window.pageYOffset')
|
|
return val.toString()
|
|
},
|
|
(0).toString(),
|
|
true,
|
|
// Try maximum of 15 seconds
|
|
15
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('relative hashes and queries', () => {
|
|
const pathname = '/nested-relative-query-and-hash'
|
|
|
|
it('should work with a hash-only href', async () => {
|
|
const browser = await next.browser(pathname)
|
|
await browser.elementByCss('#link-to-h1-hash-only').click()
|
|
|
|
await check(() => browser.url(), next.url + pathname + '#h1')
|
|
})
|
|
|
|
it('should work with a hash-only `router.push(...)`', async () => {
|
|
const browser = await next.browser(pathname)
|
|
await browser.elementByCss('#button-to-h3-hash-only').click()
|
|
|
|
await check(() => browser.url(), next.url + pathname + '#h3')
|
|
})
|
|
|
|
it('should work with a query-only href', async () => {
|
|
const browser = await next.browser(pathname)
|
|
await browser.elementByCss('#link-to-dummy-query').click()
|
|
|
|
await check(() => browser.url(), next.url + pathname + '?foo=1&bar=2')
|
|
})
|
|
|
|
it('should work with both relative hashes and queries', async () => {
|
|
const browser = await next.browser(pathname)
|
|
await browser.elementByCss('#link-to-h2-with-hash-and-query').click()
|
|
|
|
await check(() => browser.url(), next.url + pathname + '?here=ok#h2')
|
|
|
|
// Only update hash
|
|
await browser.elementByCss('#link-to-h1-hash-only').click()
|
|
await check(() => browser.url(), next.url + pathname + '?here=ok#h1')
|
|
|
|
// Replace all with new query
|
|
await browser.elementByCss('#link-to-dummy-query').click()
|
|
await check(() => browser.url(), next.url + pathname + '?foo=1&bar=2')
|
|
|
|
// Add hash to existing query
|
|
await browser.elementByCss('#link-to-h1-hash-only').click()
|
|
await check(
|
|
() => browser.url(),
|
|
next.url + pathname + '?foo=1&bar=2#h1'
|
|
)
|
|
|
|
// Update hash again via `router.push(...)`
|
|
await browser.elementByCss('#button-to-h3-hash-only').click()
|
|
await check(
|
|
() => browser.url(),
|
|
next.url + pathname + '?foo=1&bar=2#h3'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('not-found', () => {
|
|
it('should trigger not-found in a server component', async () => {
|
|
const browser = await next.browser('/not-found/servercomponent')
|
|
|
|
expect(
|
|
await browser.waitForElementByCss('#not-found-component').text()
|
|
).toBe('Not Found!')
|
|
expect(
|
|
await browser
|
|
.waitForElementByCss('meta[name="robots"]')
|
|
.getAttribute('content')
|
|
).toBe('noindex')
|
|
})
|
|
|
|
it('should trigger not-found in a client component', async () => {
|
|
const browser = await next.browser('/not-found/clientcomponent')
|
|
expect(
|
|
await browser.waitForElementByCss('#not-found-component').text()
|
|
).toBe('Not Found!')
|
|
expect(
|
|
await browser
|
|
.waitForElementByCss('meta[name="robots"]')
|
|
.getAttribute('content')
|
|
).toBe('noindex')
|
|
})
|
|
it('should trigger not-found client-side', async () => {
|
|
const browser = await next.browser('/not-found/client-side')
|
|
await browser
|
|
.elementByCss('button')
|
|
.click()
|
|
.waitForElementByCss('#not-found-component')
|
|
expect(await browser.elementByCss('#not-found-component').text()).toBe(
|
|
'Not Found!'
|
|
)
|
|
expect(
|
|
await browser
|
|
.waitForElementByCss('meta[name="robots"]')
|
|
.getAttribute('content')
|
|
).toBe('noindex')
|
|
})
|
|
it('should trigger not-found while streaming', async () => {
|
|
const browser = await next.browser('/not-found/suspense')
|
|
expect(
|
|
await browser.waitForElementByCss('#not-found-component').text()
|
|
).toBe('Not Found!')
|
|
expect(
|
|
await browser
|
|
.waitForElementByCss('meta[name="robots"]')
|
|
.getAttribute('content')
|
|
).toBe('noindex')
|
|
})
|
|
})
|
|
|
|
describe('bots', () => {
|
|
if (!isNextDeploy) {
|
|
it('should block rendering for bots and return 404 status', async () => {
|
|
const res = await next.fetch('/not-found/servercomponent', {
|
|
headers: {
|
|
'User-Agent': 'Googlebot',
|
|
},
|
|
})
|
|
|
|
expect(res.status).toBe(404)
|
|
expect(await res.text()).toInclude('"noindex"')
|
|
})
|
|
}
|
|
})
|
|
|
|
describe('redirect', () => {
|
|
describe('components', () => {
|
|
it('should redirect in a server component', async () => {
|
|
const browser = await next.browser('/redirect/servercomponent')
|
|
await browser.waitForElementByCss('#result-page')
|
|
expect(await browser.elementByCss('#result-page').text()).toBe(
|
|
'Result Page'
|
|
)
|
|
})
|
|
|
|
it('should redirect in a client component', async () => {
|
|
const browser = await next.browser('/redirect/clientcomponent')
|
|
await browser.waitForElementByCss('#result-page')
|
|
expect(await browser.elementByCss('#result-page').text()).toBe(
|
|
'Result Page'
|
|
)
|
|
})
|
|
|
|
it('should redirect client-side', async () => {
|
|
const browser = await next.browser('/redirect/client-side')
|
|
await browser
|
|
.elementByCss('button')
|
|
.click()
|
|
.waitForElementByCss('#result-page')
|
|
// eslint-disable-next-line jest/no-standalone-expect
|
|
expect(await browser.elementByCss('#result-page').text()).toBe(
|
|
'Result Page'
|
|
)
|
|
})
|
|
|
|
it('should redirect to external url', async () => {
|
|
const browser = await next.browser('/redirect/external')
|
|
expect(await browser.waitForElementByCss('h1').text()).toBe(
|
|
'Example Domain'
|
|
)
|
|
})
|
|
|
|
it('should redirect to external url, initiating only once', async () => {
|
|
const storageKey = Math.random()
|
|
const browser = await next.browser(
|
|
`/redirect/external-log/${storageKey}`
|
|
)
|
|
expect(await browser.waitForElementByCss('h1').text()).toBe(
|
|
'Example Domain'
|
|
)
|
|
|
|
// Now check the logs...
|
|
await browser.get(
|
|
`${next.url}/redirect/external-log/${storageKey}?read=1`
|
|
)
|
|
const stored = JSON.parse(await browser.elementByCss('pre').text())
|
|
|
|
if (stored['navigation-supported'] === 'false') {
|
|
// Old browser. Can't know how many times we navigated. Oh well.
|
|
return
|
|
}
|
|
|
|
expect(stored['navigation-supported']).toEqual('true')
|
|
|
|
// This one is a bit flaky during dev, original notes by @sophiebits:
|
|
// > Not actually sure why this is '2' in dev. Possibly something
|
|
// > related to an update triggered by <HotReload>?
|
|
expect(stored['navigate-https://example.vercel.sh/']).toBeOneOf(
|
|
isNextDev ? ['1', '2'] : ['1']
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('next.config.js redirects', () => {
|
|
it('should redirect from next.config.js', async () => {
|
|
const browser = await next.browser('/redirect/a')
|
|
expect(await browser.elementByCss('h1').text()).toBe('redirect-dest')
|
|
expect(await browser.url()).toBe(next.url + '/redirect-dest')
|
|
})
|
|
|
|
it('should redirect from next.config.js with link navigation', async () => {
|
|
const browser = await next.browser('/redirect/next-config-redirect')
|
|
await browser
|
|
.elementByCss('#redirect-a')
|
|
.click()
|
|
.waitForElementByCss('h1')
|
|
expect(await browser.elementByCss('h1').text()).toBe('redirect-dest')
|
|
expect(await browser.url()).toBe(next.url + '/redirect-dest')
|
|
})
|
|
})
|
|
|
|
describe('middleware redirects', () => {
|
|
it('should redirect from middleware', async () => {
|
|
const browser = await next.browser(
|
|
'/redirect-middleware-to-dashboard'
|
|
)
|
|
expect(await browser.elementByCss('h1').text()).toBe('redirect-dest')
|
|
expect(await browser.url()).toBe(next.url + '/redirect-dest')
|
|
})
|
|
|
|
it('should redirect from middleware with link navigation', async () => {
|
|
const browser = await next.browser(
|
|
'/redirect/next-middleware-redirect'
|
|
)
|
|
await browser
|
|
.elementByCss('#redirect-middleware')
|
|
.click()
|
|
.waitForElementByCss('h1')
|
|
expect(await browser.elementByCss('h1').text()).toBe('redirect-dest')
|
|
expect(await browser.url()).toBe(next.url + '/redirect-dest')
|
|
})
|
|
})
|
|
|
|
describe('status code', () => {
|
|
it('should respond with 307 status code in server component', async () => {
|
|
const res = await next.fetch('/redirect/servercomponent', {
|
|
redirect: 'manual',
|
|
})
|
|
expect(res.status).toBe(307)
|
|
})
|
|
it('should respond with 307 status code in client component', async () => {
|
|
const res = await next.fetch('/redirect/clientcomponent', {
|
|
redirect: 'manual',
|
|
})
|
|
expect(res.status).toBe(307)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('external push', () => {
|
|
it('should push external url without affecting hooks', async () => {
|
|
// Log with sessionStorage to persist across navigations
|
|
const storageKey = Math.random()
|
|
const browser = await next.browser(`/external-push/${storageKey}`)
|
|
await browser.elementByCss('#go').click()
|
|
await browser.waitForCondition(
|
|
'window.location.origin === "https://example.vercel.sh"'
|
|
)
|
|
|
|
// Now check the logs...
|
|
await browser.get(`${next.url}/external-push/${storageKey}`)
|
|
const stored = JSON.parse(await browser.elementByCss('pre').text())
|
|
let expected = {
|
|
// Only one navigation
|
|
'navigate-https://example.vercel.sh/stuff?abc=123': '1',
|
|
'navigation-supported': 'true',
|
|
// Make sure /stuff?abc=123 is not logged here
|
|
[`path-/external-push/${storageKey}`]: 'true',
|
|
// isPending should have been true until the page unloads
|
|
lastIsPending: 'true',
|
|
}
|
|
|
|
if (stored['navigation-supported'] !== 'true') {
|
|
// Old browser. Can't know how many times we navigated. Oh well.
|
|
expected['navigation-supported'] = 'false'
|
|
for (const key in expected) {
|
|
if (key.startsWith('navigate-')) {
|
|
delete expected[key]
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(stored).toEqual(expected)
|
|
})
|
|
})
|
|
|
|
describe('nested navigation', () => {
|
|
it('should navigate to nested pages', async () => {
|
|
const browser = await next.browser('/nested-navigation')
|
|
expect(await browser.elementByCss('h1').text()).toBe('Home')
|
|
|
|
const pages = [
|
|
['Electronics', ['Phones', 'Tablets', 'Laptops']],
|
|
['Clothing', ['Tops', 'Shorts', 'Shoes']],
|
|
['Books', ['Fiction', 'Biography', 'Education']],
|
|
] as const
|
|
|
|
for (const [category, subCategories] of pages) {
|
|
expect(
|
|
await browser
|
|
.elementByCss(
|
|
`a[href="/nested-navigation/${category.toLowerCase()}"]`
|
|
)
|
|
.click()
|
|
.waitForElementByCss(`#all-${category.toLowerCase()}`)
|
|
.text()
|
|
).toBe(`All ${category}`)
|
|
|
|
for (const subcategory of subCategories) {
|
|
expect(
|
|
await browser
|
|
.elementByCss(
|
|
`a[href="/nested-navigation/${category.toLowerCase()}/${subcategory.toLowerCase()}"]`
|
|
)
|
|
.click()
|
|
.waitForElementByCss(`#${subcategory.toLowerCase()}`)
|
|
.text()
|
|
).toBe(`${subcategory}`)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('SEO', () => {
|
|
it('should emit noindex meta tag for not found page when streaming', async () => {
|
|
const noIndexTag = '<meta name="robots" content="noindex"/>'
|
|
const defaultViewportTag =
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1"/>'
|
|
const html = await next.render('/not-found/suspense')
|
|
expect(html).toContain(noIndexTag)
|
|
// only contain once
|
|
expect(html.split(noIndexTag).length).toBe(2)
|
|
expect(html.split(defaultViewportTag).length).toBe(2)
|
|
})
|
|
|
|
it('should emit refresh meta tag for redirect page when streaming', async () => {
|
|
const html = await next.render('/redirect/suspense')
|
|
expect(html).toContain(
|
|
'<meta http-equiv="refresh" content="0;url=/redirect/result"/>'
|
|
)
|
|
})
|
|
|
|
it('should contain default meta tags in error page', async () => {
|
|
const html = await next.render('/not-found/servercomponent')
|
|
expect(html).toContain('<meta name="robots" content="noindex"/>')
|
|
expect(html).toContain(
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1"/>'
|
|
)
|
|
})
|
|
|
|
it('should not log 404 errors in ipc server', async () => {
|
|
await next.fetch('/this-path-does-not-exist')
|
|
expect(next.cliOutput).not.toInclude(
|
|
'PageNotFoundError: Cannot find module for page'
|
|
)
|
|
})
|
|
})
|
|
}
|
|
)
|