From 1203b9082bce72efff826b21a2524341bee3891d Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Mon, 28 Dec 2020 14:08:58 -0600 Subject: [PATCH] Ensure path encoding is handled consistently for prerendered pages (#19135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This ensures we handle encoding/decoding for SSG prerendered/fallback pages correctly. Since we only encode path delimiters when outputting to the disk we need to match this encoding when building the `ssgCacheKey` to look-up the prerendered pages. This also fixes non-ascii prerendered paths (e.g. 商業日語) not matching correctly. This does not resolve 👉 https://github.com/vercel/next.js/issues/10084 and further investigation will be needed before addressing non-ascii paths for non-SSG pages. The encoding output was tested against https://tst-encoding-l7amu5b9c.vercel.app/ to ensure the values will match correctly on Vercel. Closes: https://github.com/vercel/next.js/issues/17582 Closes: https://github.com/vercel/next.js/issues/17642 x-ref: https://github.com/vercel/next.js/pull/14717 --- packages/next/build/index.ts | 19 +- packages/next/build/utils.ts | 57 ++- packages/next/export/worker.ts | 3 +- .../next/next-server/lib/router/router.ts | 26 +- .../router/utils/escape-path-delimiters.ts | 10 +- .../next/next-server/server/next-server.ts | 34 +- .../next.config.js | 6 + .../pages/fallback-blocking/[slug].js | 39 ++ .../pages/fallback-false/[slug].js | 39 ++ .../pages/fallback-true/[slug].js | 39 ++ .../prerender-fallback-encoding/paths.js | 22 ++ .../test/index.test.js | 355 ++++++++++++++++++ 12 files changed, 624 insertions(+), 25 deletions(-) create mode 100644 test/integration/prerender-fallback-encoding/next.config.js create mode 100644 test/integration/prerender-fallback-encoding/pages/fallback-blocking/[slug].js create mode 100644 test/integration/prerender-fallback-encoding/pages/fallback-false/[slug].js create mode 100644 test/integration/prerender-fallback-encoding/pages/fallback-true/[slug].js create mode 100644 test/integration/prerender-fallback-encoding/paths.js create mode 100644 test/integration/prerender-fallback-encoding/test/index.test.js diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index ee759e1ede..28025cc592 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -523,6 +523,7 @@ export default async function build( const hybridAmpPages = new Set() const serverPropsPages = new Set() const additionalSsgPaths = new Map>() + const additionalSsgPathsEncoded = new Map>() const pageInfos = new Map() const pagesManifest = JSON.parse( await promises.readFile(manifestPath, 'utf8') @@ -640,8 +641,15 @@ export default async function build( ssgPages.add(page) isSsg = true - if (workerResult.prerenderRoutes) { + if ( + workerResult.prerenderRoutes && + workerResult.encodedPrerenderRoutes + ) { additionalSsgPaths.set(page, workerResult.prerenderRoutes) + additionalSsgPathsEncoded.set( + page, + workerResult.encodedPrerenderRoutes + ) ssgPageRoutes = workerResult.prerenderRoutes } @@ -841,8 +849,13 @@ export default async function build( // Append the "well-known" routes we should prerender for, e.g. blog // post slugs. additionalSsgPaths.forEach((routes, page) => { - routes.forEach((route) => { - defaultMap[route] = { page } + const encodedRoutes = additionalSsgPathsEncoded.get(page) + + routes.forEach((route, routeIdx) => { + defaultMap[route] = { + page, + query: { __nextSsgPath: encodedRoutes?.[routeIdx] }, + } }) }) diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index d0e17714c6..dff8c03d18 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -515,9 +515,13 @@ export async function buildStaticPaths( locales?: string[], defaultLocale?: string ): Promise< - Omit>, 'paths'> & { paths: string[] } + Omit>, 'paths'> & { + paths: string[] + encodedPaths: string[] + } > { const prerenderPaths = new Set() + const encodedPrerenderPaths = new Set() const _routeRegex = getRouteRegex(page) const _routeMatcher = getRouteMatcher(_routeRegex) @@ -595,7 +599,18 @@ export async function buildStaticPaths( ) } - prerenderPaths?.add(entry) + // If leveraging the string paths variant the entry should already be + // encoded so we decode the segments ensuring we only escape path + // delimiters + prerenderPaths.add( + entry + .split('/') + .map((segment) => + escapePathDelimiters(decodeURIComponent(segment), true) + ) + .join('/') + ) + encodedPrerenderPaths.add(entry) } // For the object-provided path, we must make sure it specifies all // required keys. @@ -617,6 +632,8 @@ export async function buildStaticPaths( const { params = {} } = entry let builtPage = page + let encodedBuiltPage = page + _validParamKeys.forEach((validParamKey) => { const { repeat, optional } = _routeRegex.groups[validParamKey] let paramValue = params[validParamKey] @@ -647,8 +664,19 @@ export async function buildStaticPaths( .replace( replaced, repeat - ? (paramValue as string[]).map(escapePathDelimiters).join('/') - : escapePathDelimiters(paramValue as string) + ? (paramValue as string[]) + .map((segment) => escapePathDelimiters(segment, true)) + .join('/') + : escapePathDelimiters(paramValue as string, true) + ) + .replace(/(?!^)\/$/, '') + + encodedBuiltPage = encodedBuiltPage + .replace( + replaced, + repeat + ? (paramValue as string[]).map(encodeURIComponent).join('/') + : encodeURIComponent(paramValue as string) ) .replace(/(?!^)\/$/, '') }) @@ -660,15 +688,24 @@ export async function buildStaticPaths( } const curLocale = entry.locale || defaultLocale || '' - prerenderPaths?.add( + prerenderPaths.add( `${curLocale ? `/${curLocale}` : ''}${ curLocale && builtPage === '/' ? '' : builtPage }` ) + encodedPrerenderPaths.add( + `${curLocale ? `/${curLocale}` : ''}${ + curLocale && encodedBuiltPage === '/' ? '' : encodedBuiltPage + }` + ) } }) - return { paths: [...prerenderPaths], fallback: staticPathsResult.fallback } + return { + paths: [...prerenderPaths], + fallback: staticPathsResult.fallback, + encodedPaths: [...encodedPrerenderPaths], + } } export async function isPageStatic( @@ -683,8 +720,9 @@ export async function isPageStatic( isHybridAmp?: boolean hasServerProps?: boolean hasStaticProps?: boolean - prerenderRoutes?: string[] | undefined - prerenderFallback?: boolean | 'blocking' | undefined + prerenderRoutes?: string[] + encodedPrerenderRoutes?: string[] + prerenderFallback?: boolean | 'blocking' isNextImageImported?: boolean }> { try { @@ -760,11 +798,13 @@ export async function isPageStatic( } let prerenderRoutes: Array | undefined + let encodedPrerenderRoutes: Array | undefined let prerenderFallback: boolean | 'blocking' | undefined if (hasStaticProps && hasStaticPaths) { ;({ paths: prerenderRoutes, fallback: prerenderFallback, + encodedPaths: encodedPrerenderRoutes, } = await buildStaticPaths( page, mod.getStaticPaths, @@ -781,6 +821,7 @@ export async function isPageStatic( isAmpOnly: config.amp === true, prerenderRoutes, prerenderFallback, + encodedPrerenderRoutes, hasStaticProps, hasServerProps, isNextImageImported, diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index b84e1b877b..7704512c46 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -110,9 +110,10 @@ export default async function exportPage({ let query = { ...originalQuery } let params: { [key: string]: string | string[] } | undefined - let updatedPath = path + let updatedPath = (query.__nextSsgPath as string) || path let locale = query.__nextLocale || renderOpts.locale delete query.__nextLocale + delete query.__nextSsgPath if (renderOpts.locale) { const localePathResult = normalizeLocalePath(path, renderOpts.locales) diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 74622ecb6c..493b73df67 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -25,7 +25,6 @@ import { NextPageContext, ST, } from '../utils' -import escapePathDelimiters from './utils/escape-path-delimiters' import { isDynamicRoute } from './utils/is-dynamic' import { parseRelativeUrl } from './utils/parse-relative-url' import { searchParamsToUrlQuery } from './utils/querystring' @@ -161,8 +160,16 @@ export function interpolateAs( interpolatedRoute!.replace( replaced, repeat - ? (value as string[]).map(escapePathDelimiters).join('/') - : escapePathDelimiters(value as string) + ? (value as string[]) + .map( + // these values should be fully encoded instead of just + // path delimiter escaped since they are being inserted + // into the URL and we expect URL encoded segments + // when parsing dynamic route params + (segment) => encodeURIComponent(segment) + ) + .join('/') + : encodeURIComponent(value as string) ) || '/') ) }) @@ -247,12 +254,15 @@ export function resolveHref( } } -function prepareUrlAs(router: NextRouter, url: Url, as: Url) { +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. + const [resolvedHref, resolvedAs] = resolveHref(router.pathname, url, true) return { - url: addBasePath(resolveHref(router.pathname, url)), - as: as ? addBasePath(resolveHref(router.pathname, as)) : as, + url: addBasePath(resolvedHref), + as: addBasePath( + as ? resolveHref(router.pathname, as) : resolvedAs || resolvedHref + ), } } @@ -592,7 +602,7 @@ export default class Router implements BaseRouter { * @param as masks `url` for the browser * @param options object you can define `shallow` and other options */ - push(url: Url, as: Url = url, options: TransitionOptions = {}) { + push(url: Url, as?: Url, options: TransitionOptions = {}) { ;({ url, as } = prepareUrlAs(this, url, as)) return this.change('pushState', url, as, options) } @@ -603,7 +613,7 @@ export default class Router implements BaseRouter { * @param as masks `url` for the browser * @param options object you can define `shallow` and other options */ - replace(url: Url, as: Url = url, options: TransitionOptions = {}) { + replace(url: Url, as?: Url, options: TransitionOptions = {}) { ;({ url, as } = prepareUrlAs(this, url, as)) return this.change('replaceState', url, as, options) } diff --git a/packages/next/next-server/lib/router/utils/escape-path-delimiters.ts b/packages/next/next-server/lib/router/utils/escape-path-delimiters.ts index 4bdb0fb796..f3e1c3679c 100644 --- a/packages/next/next-server/lib/router/utils/escape-path-delimiters.ts +++ b/packages/next/next-server/lib/router/utils/escape-path-delimiters.ts @@ -1,4 +1,10 @@ // escape delimiters used by path-to-regexp -export default function escapePathDelimiters(segment: string): string { - return segment.replace(/[/#?]/g, (char: string) => encodeURIComponent(char)) +export default function escapePathDelimiters( + segment: string, + escapeEncoded?: boolean +): string { + return segment.replace( + new RegExp(`([/#?]${escapeEncoded ? '|%(2f|23|3f)' : ''})`, 'gi'), + (char: string) => encodeURIComponent(char) + ) } diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 8eb9b99057..dc00454bf0 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -85,6 +85,7 @@ import * as Log from '../../build/output/log' import { imageOptimizer } from './image-optimizer' import { detectDomainLocale } from '../lib/i18n/detect-domain-locale' import cookie from 'next/dist/compiled/cookie' +import escapePathDelimiters from '../lib/router/utils/escape-path-delimiters' import { getUtils } from '../../build/webpack/loaders/next-serverless-loader/utils' const getCustomRouteMatcher = pathMatch(true) @@ -1429,6 +1430,33 @@ export default class Server { }` } + if (ssgCacheKey) { + // we only encode path delimiters for path segments from + // getStaticPaths so we need to attempt decoding the URL + // to match against and only escape the path delimiters + // this allows non-ascii values to be handled e.g. Japanese characters + + // TODO: investigate adding this handling for non-SSG pages so + // non-ascii names work there also + ssgCacheKey = ssgCacheKey + .split('/') + .map((seg) => { + try { + seg = escapePathDelimiters(decodeURIComponent(seg), true) + } catch (_) { + // An improperly encoded URL was provided, this is considered + // a bad request (400) + const err: Error & { code?: string } = new Error( + 'failed to decode param' + ) + err.code = 'DECODE_FAILED' + throw err + } + return seg + }) + .join('/') + } + // Complete the response with cached data if its present const cachedData = ssgCacheKey ? await this.incrementalCache.get(ssgCacheKey) @@ -1608,10 +1636,10 @@ export default class Server { // `getStaticPaths` (isProduction || !staticPaths || - // static paths always includes locale so make sure it's prefixed - // with it !staticPaths.includes( - `${locale ? '/' + locale : ''}${resolvedUrlPathname}` + // we use ssgCacheKey here as it is normalized to match the + // encoding from getStaticPaths along with including the locale + query.amp ? ssgCacheKey.replace(/\.amp$/, '') : ssgCacheKey )) ) { if ( diff --git a/test/integration/prerender-fallback-encoding/next.config.js b/test/integration/prerender-fallback-encoding/next.config.js new file mode 100644 index 0000000000..cc17cf48c5 --- /dev/null +++ b/test/integration/prerender-fallback-encoding/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + onDemandEntries: { + // Make sure entries are not getting disposed. + maxInactiveAge: 1000 * 60 * 60, + }, +} diff --git a/test/integration/prerender-fallback-encoding/pages/fallback-blocking/[slug].js b/test/integration/prerender-fallback-encoding/pages/fallback-blocking/[slug].js new file mode 100644 index 0000000000..8ca7aeb875 --- /dev/null +++ b/test/integration/prerender-fallback-encoding/pages/fallback-blocking/[slug].js @@ -0,0 +1,39 @@ +import getPaths from '../../paths' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + if (router.isFallback) { + return 'Loading...' + } + + return ( + <> +

{JSON.stringify(props)}

+

+ {JSON.stringify({ + query: router.query, + asPath: router.asPath, + pathname: router.pathname, + })} +

+ + ) +} + +export const getStaticProps = ({ params }) => { + return { + props: { + random: Math.random(), + params, + }, + } +} + +export const getStaticPaths = () => { + return { + paths: getPaths('/fallback-blocking'), + fallback: 'blocking', + } +} diff --git a/test/integration/prerender-fallback-encoding/pages/fallback-false/[slug].js b/test/integration/prerender-fallback-encoding/pages/fallback-false/[slug].js new file mode 100644 index 0000000000..d9d7dbafbd --- /dev/null +++ b/test/integration/prerender-fallback-encoding/pages/fallback-false/[slug].js @@ -0,0 +1,39 @@ +import { useRouter } from 'next/router' +import getPaths from '../../paths' + +export default function Page(props) { + const router = useRouter() + + if (router.isFallback) { + return 'Loading...' + } + + return ( + <> +

{JSON.stringify(props)}

+

+ {JSON.stringify({ + query: router.query, + asPath: router.asPath, + pathname: router.pathname, + })} +

+ + ) +} + +export const getStaticProps = ({ params }) => { + return { + props: { + random: Math.random(), + params, + }, + } +} + +export const getStaticPaths = () => { + return { + paths: getPaths('/fallback-false'), + fallback: false, + } +} diff --git a/test/integration/prerender-fallback-encoding/pages/fallback-true/[slug].js b/test/integration/prerender-fallback-encoding/pages/fallback-true/[slug].js new file mode 100644 index 0000000000..7657bdf4df --- /dev/null +++ b/test/integration/prerender-fallback-encoding/pages/fallback-true/[slug].js @@ -0,0 +1,39 @@ +import getPaths from '../../paths' +import { useRouter } from 'next/router' + +export default function Page(props) { + const router = useRouter() + + if (router.isFallback) { + return 'Loading...' + } + + return ( + <> +

{JSON.stringify(props)}

+

+ {JSON.stringify({ + query: router.query, + asPath: router.asPath, + pathname: router.pathname, + })} +

+ + ) +} + +export const getStaticProps = ({ params }) => { + return { + props: { + random: Math.random(), + params, + }, + } +} + +export const getStaticPaths = () => { + return { + paths: getPaths('/fallback-true'), + fallback: true, + } +} diff --git a/test/integration/prerender-fallback-encoding/paths.js b/test/integration/prerender-fallback-encoding/paths.js new file mode 100644 index 0000000000..3f2cebf475 --- /dev/null +++ b/test/integration/prerender-fallback-encoding/paths.js @@ -0,0 +1,22 @@ +export default function getPaths(pathPrefix) { + return [ + // this will get turned into %2Fmy-post%2F + { params: { slug: '/my-post/' } }, + // this will get turned into %252Fmy-post%252F + { params: { slug: '%2Fmy-post%2F' } }, + // this will be passed through + { params: { slug: '+my-post+' } }, + // this will get turned into %3Fmy-post%3F + { params: { slug: '?my-post?' } }, + // ampersand signs + { params: { slug: '&my-post&' } }, + // non-ascii characters + { params: { slug: '商業日語' } }, + { params: { slug: ' my-post ' } }, + { params: { slug: encodeURIComponent('商業日語') } }, + `${pathPrefix}/%2Fsecond-post%2F`, + `${pathPrefix}/%2Bsecond-post%2B`, + `${pathPrefix}/%26second-post%26`, + `${pathPrefix}/mixed-${encodeURIComponent('商業日語')}`, + ] +} diff --git a/test/integration/prerender-fallback-encoding/test/index.test.js b/test/integration/prerender-fallback-encoding/test/index.test.js new file mode 100644 index 0000000000..c58c18820f --- /dev/null +++ b/test/integration/prerender-fallback-encoding/test/index.test.js @@ -0,0 +1,355 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { join } from 'path' +import cheerio from 'cheerio' +import webdriver from 'next-webdriver' +import { + killApp, + findPort, + nextBuild, + launchApp, + nextStart, + fetchViaHTTP, + check, +} from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 2) + +const appDir = join(__dirname, '..') +let app +let appPort +let buildId + +// paths on the filesystem +const prerenderedPaths = [ + '%2Fmy-post%2F', + '%252Fmy-post%252F', + '+my-post+', + '%3Fmy-post%3F', + '&my-post&', + '商業日語', + encodeURIComponent('商業日語'), + ' my-post ', + '%2Fsecond-post%2F', + '+second-post+', + '&second-post&', + 'mixed-商業日語', +] + +// paths that should be requested in the URL +const urlPaths = [ + '%2Fmy-post%2F', + '%252Fmy-post%252F', + '%2Bmy-post%2B', + '%3Fmy-post%3F', + '%26my-post%26', + encodeURIComponent('商業日語'), + encodeURIComponent(encodeURIComponent('商業日語')), + '%20my-post%20', + '%2Fsecond-post%2F', + '%2Bsecond-post%2B', + '%26second-post%26', + `mixed-${encodeURIComponent('商業日語')}`, +] + +const modePaths = ['fallback-blocking', 'fallback-false', 'fallback-true'] +const pagesDir = join(appDir, '.next/server/pages') + +function runTests(isDev) { + if (!isDev) { + it('should output paths correctly', async () => { + for (const path of prerenderedPaths) { + for (const mode of modePaths) { + console.log('checking output', { path, mode }) + expect(await fs.exists(join(pagesDir, mode, path + '.html'))).toBe( + true + ) + expect(await fs.exists(join(pagesDir, mode, path + '.json'))).toBe( + true + ) + } + } + }) + + it('should handle non-prerendered paths correctly', async () => { + const prerenderedPaths = [ + '%2Fanother-post%2F', + '+another-post+', + '%3Fanother-post%3F', + '&another-post&', + '商業日語商業日語', + ] + + const urlPaths = [ + '%2Fanother-post%2F', + '%2Banother-post%2B', + '%3Fanother-post%3F', + '%26another-post%26', + encodeURIComponent('商業日語商業日語'), + ] + + for (const mode of modePaths) { + for (let i = 0; i < urlPaths.length; i++) { + const testSlug = urlPaths[i] + const path = prerenderedPaths[i] + + const res = await fetchViaHTTP( + appPort, + `/_next/data/${buildId}/${mode}/${testSlug}.json` + ) + + if (mode === 'fallback-false') { + expect(res.status).toBe(404) + } else { + expect(res.status).toBe(200) + + const { pageProps: props } = await res.json() + + expect(props.params).toEqual({ + slug: decodeURIComponent(testSlug), + }) + + if (!isDev) { + // we don't block on writing incremental data to the + // disk so use check + await check( + () => + fs + .exists(join(pagesDir, mode, path + '.html')) + .then((res) => (res ? 'yes' : 'no')), + 'yes' + ) + await check( + () => + fs + .exists(join(pagesDir, mode, path + '.json')) + .then((res) => (res ? 'yes' : 'no')), + 'yes' + ) + } + + const browser = await webdriver(appPort, `/${mode}/${testSlug}`) + + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: decodeURIComponent(testSlug), + }) + + const browserRouter = JSON.parse( + await browser.elementByCss('#router').text() + ) + + expect(browserRouter.pathname).toBe(`/${mode}/[slug]`) + expect(browserRouter.asPath).toBe(`/${mode}/${testSlug}`) + expect(browserRouter.query).toEqual({ + slug: decodeURIComponent(testSlug), + }) + } + } + } + }) + } + + it('should respond with the prerendered pages correctly', async () => { + for (let i = 0; i < urlPaths.length; i++) { + const testSlug = urlPaths[i] + + for (const mode of modePaths) { + const res = await fetchViaHTTP( + appPort, + `/${mode}/${testSlug}`, + undefined, + { + redirect: 'manual', + } + ) + + console.log('checking', { mode, testSlug }) + + expect(res.status).toBe(200) + + const $ = cheerio.load(await res.text()) + + expect(JSON.parse($('#props').text()).params).toEqual({ + slug: decodeURIComponent(testSlug), + }) + const router = JSON.parse($('#router').text()) + + expect(router.pathname).toBe(`/${mode}/[slug]`) + expect(router.asPath).toBe(`/${mode}/${testSlug}`) + expect(router.query).toEqual({ + slug: decodeURIComponent(testSlug), + }) + } + } + }) + + it('should respond with the prerendered data correctly', async () => { + for (const path of urlPaths) { + for (const mode of modePaths) { + const res = await fetchViaHTTP( + appPort, + `/_next/data/${buildId}/${mode}/${path}.json`, + undefined, + { + redirect: 'manual', + } + ) + expect(res.status).toBe(200) + + const { pageProps: props } = await res.json() + + expect(props.params).toEqual({ + slug: decodeURIComponent(path), + }) + } + } + }) + + it('should render correctly in the browser for prerender paths', async () => { + for (let i = 0; i < urlPaths.length; i++) { + const testSlug = urlPaths[i] + + for (const mode of modePaths) { + const browser = await webdriver(appPort, `/${mode}/${testSlug}`) + + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: decodeURIComponent(testSlug), + }) + + const browserRouter = JSON.parse( + await browser.elementByCss('#router').text() + ) + + expect(browserRouter.pathname).toBe(`/${mode}/[slug]`) + expect(browserRouter.asPath).toBe(`/${mode}/${testSlug}`) + expect(browserRouter.query).toEqual({ + slug: decodeURIComponent(testSlug), + }) + } + } + }) + + it('should navigate client-side correctly with interpolating', async () => { + for (const mode of modePaths) { + const testSlug = urlPaths[0] + const browser = await webdriver(appPort, `/${mode}/${testSlug}`) + + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: decodeURIComponent(testSlug), + }) + + const browserRouter = JSON.parse( + await browser.elementByCss('#router').text() + ) + + expect(browserRouter.pathname).toBe(`/${mode}/[slug]`) + expect(browserRouter.asPath).toBe(`/${mode}/${testSlug}`) + expect(browserRouter.query).toEqual({ + slug: decodeURIComponent(testSlug), + }) + + await browser.eval('window.beforeNav = 1') + + for (const nextSlug of urlPaths) { + if (nextSlug === testSlug) continue + + await browser.eval(`(function() { + window.next.router.push({ + pathname: '/${mode}/[slug]', + query: { slug: '${decodeURIComponent(nextSlug)}' } + }) + })()`) + + await check(async () => { + const browserRouter = JSON.parse( + await browser.elementByCss('#router').text() + ) + return browserRouter.asPath === `/${mode}/${nextSlug}` + ? 'success' + : 'fail' + }, 'success') + + expect(await browser.eval('window.beforeNav')).toBe(1) + } + } + }) + + it('should navigate client-side correctly with string href', async () => { + for (const mode of modePaths) { + const testSlug = urlPaths[0] + const browser = await webdriver(appPort, `/${mode}/${testSlug}`) + + expect( + JSON.parse(await browser.elementByCss('#props').text()).params + ).toEqual({ + slug: decodeURIComponent(testSlug), + }) + + const browserRouter = JSON.parse( + await browser.elementByCss('#router').text() + ) + + expect(browserRouter.pathname).toBe(`/${mode}/[slug]`) + expect(browserRouter.asPath).toBe(`/${mode}/${testSlug}`) + expect(browserRouter.query).toEqual({ + slug: decodeURIComponent(testSlug), + }) + + await browser.eval('window.beforeNav = 1') + + for (const nextSlug of urlPaths) { + if (nextSlug === testSlug) continue + + await browser.eval(`(function() { + window.next.router.push('/${mode}/${nextSlug}') + })()`) + + await check(async () => { + const browserRouter = JSON.parse( + await browser.elementByCss('#router').text() + ) + return browserRouter.asPath === `/${mode}/${nextSlug}` + ? 'success' + : 'fail' + }, 'success') + + expect(await browser.eval('window.beforeNav')).toBe(1) + } + } + }) +} + +describe('Fallback path encoding', () => { + describe('dev mode', () => { + beforeAll(async () => { + await fs.remove(join(appDir, '.next')) + appPort = await findPort() + app = await launchApp(appDir, appPort) + buildId = 'development' + }) + afterAll(() => killApp(app)) + + runTests(true) + }) + + describe('production mode', () => { + beforeAll(async () => { + await fs.remove(join(appDir, '.next')) + appPort = await findPort() + await nextBuild(appDir) + + app = await nextStart(appDir, appPort) + buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8') + }) + afterAll(() => killApp(app)) + + runTests() + }) +}) -- GitLab