diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 864edffa955c940..c0bd24bb73abaf6 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -257,10 +257,12 @@ function Link(props: React.PropsWithChildren) { const pathname = (router && router.pathname) || '/' const { href, as } = React.useMemo(() => { - const resolvedHref = resolveHref(pathname, props.href) + const [resolvedHref, resolvedAs] = resolveHref(pathname, props.href, true) return { href: resolvedHref, - as: props.as ? resolveHref(pathname, props.as) : resolvedHref, + as: props.as + ? resolveHref(pathname, props.as) + : resolvedAs || resolvedHref, } }, [pathname, props.href, props.as]) diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts index a95267189fea139..49d63a6f465aabd 100644 --- a/packages/next/client/page-loader.ts +++ b/packages/next/client/page-loader.ts @@ -3,14 +3,16 @@ import type { ClientSsgManifest } from '../build' import type { ClientBuildManifest } from '../build/webpack/plugins/build-manifest-plugin' import mitt from '../next-server/lib/mitt' import type { MittEmitter } from '../next-server/lib/mitt' -import { addBasePath, markLoadingError } from '../next-server/lib/router/router' -import escapePathDelimiters from '../next-server/lib/router/utils/escape-path-delimiters' +import { + addBasePath, + markLoadingError, + interpolateAs, +} from '../next-server/lib/router/router' + import getAssetPathFromRoute from '../next-server/lib/router/utils/get-asset-path-from-route' import { isDynamicRoute } from '../next-server/lib/router/utils/is-dynamic' import { parseRelativeUrl } from '../next-server/lib/router/utils/parse-relative-url' import { searchParamsToUrlQuery } from '../next-server/lib/router/utils/querystring' -import { getRouteMatcher } from '../next-server/lib/router/utils/route-matcher' -import { getRouteRegex } from '../next-server/lib/router/utils/route-regex' export const looseToArray = (input: any): T[] => [].slice.call(input) @@ -216,51 +218,10 @@ export default class PageLoader { ) } - let isDynamic: boolean = isDynamicRoute(route), - interpolatedRoute: string | undefined - if (isDynamic) { - const dynamicRegex = getRouteRegex(route) - const dynamicGroups = dynamicRegex.groups - const dynamicMatches = - // Try to match the dynamic route against the asPath - getRouteMatcher(dynamicRegex)(asPathname) || - // Fall back to reading the values from the href - // TODO: should this take priority; also need to change in the router. - query - - interpolatedRoute = route - if ( - !Object.keys(dynamicGroups).every((param) => { - let value = dynamicMatches[param] || '' - const { repeat, optional } = dynamicGroups[param] - - // support single-level catch-all - // TODO: more robust handling for user-error (passing `/`) - let replaced = `[${repeat ? '...' : ''}${param}]` - if (optional) { - replaced = `${!value ? '/' : ''}[${replaced}]` - } - if (repeat && !Array.isArray(value)) value = [value] - - return ( - (optional || param in dynamicMatches) && - // Interpolate group into data URL if present - (interpolatedRoute = - interpolatedRoute!.replace( - replaced, - repeat - ? (value as string[]).map(escapePathDelimiters).join('/') - : escapePathDelimiters(value as string) - ) || '/') - ) - }) - ) { - interpolatedRoute = '' // did not satisfy all requirements - - // n.b. We ignore this error because we handle warning for this case in - // development in the `` component directly. - } - } + const isDynamic: boolean = isDynamicRoute(route) + const interpolatedRoute = isDynamic + ? interpolateAs(hrefPathname, asPathname, query) + : '' return isDynamic ? interpolatedRoute && getHrefForSlug(interpolatedRoute) diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index fc600e82424eac0..8843df68d9c9715 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -25,6 +25,7 @@ import { searchParamsToUrlQuery } from './utils/querystring' import resolveRewrites from './utils/resolve-rewrites' import { getRouteMatcher } from './utils/route-matcher' import { getRouteRegex } from './utils/route-regex' +import escapePathDelimiters from './utils/escape-path-delimiters' interface TransitionOptions { shallow?: boolean @@ -80,11 +81,66 @@ export function isLocalURL(url: string): boolean { type Url = UrlObject | string +export function interpolateAs( + route: string, + asPathname: string, + query: ParsedUrlQuery +) { + let interpolatedRoute = '' + + const dynamicRegex = getRouteRegex(route) + const dynamicGroups = dynamicRegex.groups + const dynamicMatches = + // Try to match the dynamic route against the asPath + (asPathname !== route ? getRouteMatcher(dynamicRegex)(asPathname) : '') || + // Fall back to reading the values from the href + // TODO: should this take priority; also need to change in the router. + query + + interpolatedRoute = route + if ( + !Object.keys(dynamicGroups).every((param) => { + let value = dynamicMatches[param] || '' + const { repeat, optional } = dynamicGroups[param] + + // support single-level catch-all + // TODO: more robust handling for user-error (passing `/`) + let replaced = `[${repeat ? '...' : ''}${param}]` + if (optional) { + replaced = `${!value ? '/' : ''}[${replaced}]` + } + if (repeat && !Array.isArray(value)) value = [value] + + return ( + (optional || param in dynamicMatches) && + // Interpolate group into data URL if present + (interpolatedRoute = + interpolatedRoute!.replace( + replaced, + repeat + ? (value as string[]).map(escapePathDelimiters).join('/') + : escapePathDelimiters(value as string) + ) || '/') + ) + }) + ) { + interpolatedRoute = '' // did not satisfy all requirements + + // n.b. We ignore this error because we handle warning for this case in + // development in the `` component directly. + } + return interpolatedRoute +} + /** * Resolves a given hyperlink with a certain router state (basePath not included). * Preserves absolute urls. */ -export function resolveHref(currentPath: string, href: Url): string { +export function resolveHref( + currentPath: string, + href: Url, + resolveAs?: boolean +): string { // we use a dummy base url for relative urls const base = new URL(currentPath, 'http://n') const urlAsString = @@ -92,12 +148,31 @@ export function resolveHref(currentPath: string, href: Url): string { try { const finalUrl = new URL(urlAsString, base) finalUrl.pathname = normalizePathTrailingSlash(finalUrl.pathname) + let interpolatedAs = '' + + if ( + isDynamicRoute(finalUrl.pathname) && + finalUrl.searchParams && + resolveAs + ) { + const query = searchParamsToUrlQuery(finalUrl.searchParams) + + interpolatedAs = interpolateAs( + finalUrl.pathname, + finalUrl.pathname, + query + ) + } + // 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 + const resolvedHref = + finalUrl.origin === base.origin + ? finalUrl.href.slice(finalUrl.origin.length) + : finalUrl.href + + return (resolveAs ? [resolvedHref, interpolatedAs] : resolvedHref) as string } catch (_) { - return urlAsString + return (resolveAs ? [urlAsString] : urlAsString) as string } } @@ -558,6 +633,8 @@ export default class Router implements BaseRouter { `Read more: https://err.sh/vercel/next.js/incompatible-href-as` ) } + } else if (route === asPathname) { + as = interpolateAs(route, asPathname, query) } else { // Merge params into `query`, overwriting any specified in search Object.assign(query, routeMatch) diff --git a/test/integration/dynamic-routing/pages/index.js b/test/integration/dynamic-routing/pages/index.js index 2fbc0578c76fe6a..bf9583e84609c91 100644 --- a/test/integration/dynamic-routing/pages/index.js +++ b/test/integration/dynamic-routing/pages/index.js @@ -10,7 +10,16 @@ const Page = () => {
- View post 1 + View post 1 (no as) + +
+ + View post 1 (interpolated)
@@ -22,7 +31,18 @@ const Page = () => {
- View comment 1 on post 1 + View comment 1 on post 1 (no as) + +
+ + + View comment 1 on post 1 (interpolated) +
@@ -40,42 +60,74 @@ const Page = () => { View test with hash +
Catch-all route (single) +
Catch-all route (multi) +
Catch-all route (encoded) +
Catch-all route :42 +
Catch-all route (single) +
+ + + Catch-all route (single interpolated) + + +
Catch-all route (multi) +
Catch-all route (multi) +
+ + + Catch-all route (multi interpolated) + + +
Nested Catch-all route (single) +
Nested Catch-all route (multi) +

{JSON.stringify(Object.keys(useRouter().query))}

) diff --git a/test/integration/dynamic-routing/test/index.test.js b/test/integration/dynamic-routing/test/index.test.js index 5537f59208516b2..a4ec4616e4c9140 100644 --- a/test/integration/dynamic-routing/test/index.test.js +++ b/test/integration/dynamic-routing/test/index.test.js @@ -3,6 +3,7 @@ import webdriver from 'next-webdriver' import { join, dirname } from 'path' import fs from 'fs-extra' +import url from 'url' import { renderViaHTTP, fetchViaHTTP, @@ -117,6 +118,30 @@ function runTests(dev) { } }) + it('should navigate to a dynamic page successfully interpolated', async () => { + let browser + try { + browser = await webdriver(appPort, '/') + await browser.eval('window.beforeNav = 1') + + const href = await browser + .elementByCss('#view-post-1-interpolated') + .getAttribute('href') + + expect(url.parse(href).pathname).toBe('/post-1') + + await browser.elementByCss('#view-post-1-interpolated').click() + await browser.waitForElementByCss('#asdf') + + expect(await browser.eval('window.beforeNav')).toBe(1) + + const text = await browser.elementByCss('#asdf').text() + expect(text).toMatch(/this is.*?post-1/i) + } finally { + if (browser) await browser.close() + } + }) + it('should allow calling Router.push on mount successfully', async () => { const browser = await webdriver(appPort, '/post-1/on-mount-redir') try { @@ -188,6 +213,30 @@ function runTests(dev) { } }) + it('should navigate to a nested dynamic page successfully interpolated', async () => { + let browser + try { + browser = await webdriver(appPort, '/') + await browser.eval('window.beforeNav = 1') + + const href = await browser + .elementByCss('#view-post-1-comment-1-interpolated') + .getAttribute('href') + + expect(url.parse(href).pathname).toBe('/post-1/comment-1') + + await browser.elementByCss('#view-post-1-comment-1-interpolated').click() + await browser.waitForElementByCss('#asdf') + + expect(await browser.eval('window.beforeNav')).toBe(1) + + const text = await browser.elementByCss('#asdf').text() + expect(text).toMatch(/i am.*comment-1.*on.*post-1/i) + } finally { + if (browser) await browser.close() + } + }) + it('should pass params in getInitialProps during SSR', async () => { const html = await renderViaHTTP(appPort, '/post-1/cmnt-1') expect(html).toMatch(/gip.*post-1/i) @@ -381,9 +430,36 @@ function runTests(dev) { let browser try { browser = await webdriver(appPort, '/') + await browser.eval('window.beforeNav = 1') await browser.elementByCss('#ssg-catch-all-single').click() await browser.waitForElementByCss('#all-ssg-content') + expect(await browser.eval('window.beforeNav')).toBe(1) + + const text = await browser.elementByCss('#all-ssg-content').text() + expect(text).toBe('{"rest":["hello"]}') + } finally { + if (browser) await browser.close() + } + }) + + it('[ssg: catch-all] should pass params in getStaticProps during client navigation (single interpolated)', async () => { + let browser + try { + browser = await webdriver(appPort, '/') + await browser.eval('window.beforeNav = 1') + + const href = await browser + .elementByCss('#ssg-catch-all-single-interpolated') + .getAttribute('href') + + expect(url.parse(href).pathname).toBe('/p1/p2/all-ssg/hello') + + await browser.elementByCss('#ssg-catch-all-single-interpolated').click() + await browser.waitForElementByCss('#all-ssg-content') + + expect(await browser.eval('window.beforeNav')).toBe(1) + const text = await browser.elementByCss('#all-ssg-content').text() expect(text).toBe('{"rest":["hello"]}') } finally { @@ -425,6 +501,30 @@ function runTests(dev) { } }) + it('[ssg: catch-all] should pass params in getStaticProps during client navigation (multi interpolated)', async () => { + let browser + try { + browser = await webdriver(appPort, '/') + await browser.eval('window.beforeNav = 1') + + const href = await browser + .elementByCss('#ssg-catch-all-multi-interpolated') + .getAttribute('href') + + expect(url.parse(href).pathname).toBe('/p1/p2/all-ssg/hello1/hello2') + + await browser.elementByCss('#ssg-catch-all-multi-interpolated').click() + await browser.waitForElementByCss('#all-ssg-content') + + expect(await browser.eval('window.beforeNav')).toBe(1) + + const text = await browser.elementByCss('#all-ssg-content').text() + expect(text).toBe('{"rest":["hello1","hello2"]}') + } finally { + if (browser) await browser.close() + } + }) + it('[nested ssg: catch-all] should pass params in getStaticProps during client navigation (single)', async () => { let browser try {