diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 0dfb42a19936c..0aebbf5451c59 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -211,19 +211,15 @@ function Link(props: React.PropsWithChildren) { } } const p = props.prefetch !== false - const router = useRouter() - const pathname = (router && router.asPath) || '/' const { href, as } = React.useMemo(() => { - const [resolvedHref, resolvedAs] = resolveHref(pathname, props.href, true) + const [resolvedHref, resolvedAs] = resolveHref(router, props.href, true) return { href: resolvedHref, - as: props.as - ? resolveHref(pathname, props.as) - : resolvedAs || resolvedHref, + as: props.as ? resolveHref(router, props.as) : resolvedAs || resolvedHref, } - }, [pathname, props.href, props.as]) + }, [router, props.href, props.as]) let { children, replace, shallow, scroll, locale } = props diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 9e495493ebf8c..6da7a70a65ba6 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -173,7 +173,8 @@ 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('#')) return true + if (url.startsWith('/') || url.startsWith('#') || url.startsWith('?')) + return true try { // absolute urls can be local if they are on the same origin const locationOrigin = getLocationOrigin() @@ -266,21 +267,24 @@ function omitParmsFromQuery(query: ParsedUrlQuery, params: string[]) { * Preserves absolute urls. */ export function resolveHref( - currentPath: string, + router: NextRouter, href: Url, resolveAs?: boolean ): string { // we use a dummy base url for relative urls let base: URL + const urlAsString = + typeof href === 'string' ? href : formatWithValidation(href) try { - base = new URL(currentPath, 'http://n') + base = new URL( + urlAsString.startsWith('#') ? router.asPath : router.pathname, + 'http://n' + ) } catch (_) { // fallback to / for invalid asPath values e.g. // base = new URL('/', 'http://n') } - const urlAsString = - typeof href === 'string' ? href : formatWithValidation(href) // Return because it cannot be routed by the Next.js router if (!isLocalURL(urlAsString)) { return (resolveAs ? [urlAsString] : urlAsString) as string @@ -335,7 +339,7 @@ function stripOrigin(url: string) { function prepareUrlAs(router: NextRouter, url: Url, as?: Url) { // If url and as provided as an object representation, // we'll format them into the string version here. - let [resolvedHref, resolvedAs] = resolveHref(router.asPath, url, true) + let [resolvedHref, resolvedAs] = resolveHref(router, url, true) const origin = getLocationOrigin() const hrefHadOrigin = resolvedHref.startsWith(origin) const asHadOrigin = resolvedAs && resolvedAs.startsWith(origin) @@ -345,7 +349,7 @@ function prepareUrlAs(router: NextRouter, url: Url, as?: Url) { const preparedUrl = hrefHadOrigin ? resolvedHref : addBasePath(resolvedHref) const preparedAs = as - ? stripOrigin(resolveHref(router.asPath, as)) + ? stripOrigin(resolveHref(router, as)) : resolvedAs || resolvedHref return { diff --git a/test/integration/dynamic-routing/pages/[name]/index.js b/test/integration/dynamic-routing/pages/[name]/index.js index 919f0560025f5..8f801b21d2bcc 100644 --- a/test/integration/dynamic-routing/pages/[name]/index.js +++ b/test/integration/dynamic-routing/pages/[name]/index.js @@ -14,6 +14,53 @@ const Page = () => { Dynamic route only hash object
+ + Dynamic route only query + +
+ + + Dynamic route only query extra + + +
+ + Dynamic route only query object + +
+ + + Dynamic route only query object extra + + +
+ + Dynamic route query and hash + +
+ + + Dynamic route query extra and hash + + +
+ + + Dynamic route query and hash object + + +
+ + + Dynamic route query and hash object extra + + +

This is {query.name}

{JSON.stringify(query)}

diff --git a/test/integration/dynamic-routing/test/index.test.js b/test/integration/dynamic-routing/test/index.test.js index b9b6e3511f64a..2b0054ce3784e 100644 --- a/test/integration/dynamic-routing/test/index.test.js +++ b/test/integration/dynamic-routing/test/index.test.js @@ -30,23 +30,117 @@ const appDir = join(__dirname, '../') const buildIdPath = join(appDir, '.next/BUILD_ID') function runTests(dev) { + it('should handle only query on dynamic route', async () => { + const browser = await webdriver(appPort, '/post-1') + + for (const expectedValues of [ + { + id: 'dynamic-route-only-query', + pathname: '/post-2', + query: {}, + hash: '', + navQuery: { name: 'post-2' }, + }, + { + id: 'dynamic-route-only-query-extra', + pathname: '/post-3', + query: { another: 'value' }, + hash: '', + navQuery: { name: 'post-3', another: 'value' }, + }, + { + id: 'dynamic-route-only-query-obj', + pathname: '/post-4', + query: {}, + hash: '', + navQuery: { name: 'post-4' }, + }, + { + id: 'dynamic-route-only-query-obj-extra', + pathname: '/post-5', + query: { another: 'value' }, + hash: '', + navQuery: { name: 'post-5', another: 'value' }, + }, + { + id: 'dynamic-route-query-hash', + pathname: '/post-2', + query: {}, + hash: '#hash-too', + navQuery: { name: 'post-2' }, + }, + { + id: 'dynamic-route-query-extra-hash', + pathname: '/post-3', + query: { another: 'value' }, + hash: '#hash-again', + navQuery: { name: 'post-3', another: 'value' }, + }, + { + id: 'dynamic-route-query-hash-obj', + pathname: '/post-4', + query: {}, + hash: '#hash-too', + navQuery: { name: 'post-4' }, + }, + { + id: 'dynamic-route-query-obj-extra-hash', + pathname: '/post-5', + query: { another: 'value' }, + hash: '#hash-again', + navQuery: { name: 'post-5', another: 'value' }, + }, + ]) { + const { id, pathname, query, hash, navQuery } = expectedValues + + const parsedHref = url.parse( + await browser.elementByCss(`#${id}`).getAttribute('href'), + true + ) + expect(parsedHref.pathname).toBe(pathname) + expect(parsedHref.query || {}).toEqual(query) + expect(parsedHref.hash || '').toBe(hash) + + await browser.eval('window.beforeNav = 1') + await browser.elementByCss(`#${id}`).click() + await check(() => browser.eval('window.location.pathname'), pathname) + + expect(JSON.parse(await browser.elementByCss('#query').text())).toEqual( + navQuery + ) + expect(await browser.eval('window.location.pathname')).toBe(pathname) + expect(await browser.eval('window.location.hash')).toBe(hash) + expect( + Object.fromEntries( + new URLSearchParams(await browser.eval('window.location.search')) + ) + ).toEqual(query) + expect(await browser.eval('window.beforeNav')).toBe(1) + } + }) + it('should handle only hash on dynamic route', async () => { const browser = await webdriver(appPort, '/post-1') const parsedHref = url.parse( await browser .elementByCss('#dynamic-route-only-hash') - .getAttribute('href') + .getAttribute('href'), + true ) expect(parsedHref.pathname).toBe('/post-1') expect(parsedHref.hash).toBe('#only-hash') + expect(parsedHref.query || {}).toEqual({}) const parsedHref2 = url.parse( await browser .elementByCss('#dynamic-route-only-hash-obj') - .getAttribute('href') + .getAttribute('href'), + true ) expect(parsedHref2.pathname).toBe('/post-1') expect(parsedHref2.hash).toBe('#only-hash-obj') + expect(parsedHref2.query || {}).toEqual({}) + expect(await browser.eval('window.location.hash')).toBe('') await browser.elementByCss('#dynamic-route-only-hash').click()