diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 06f17b36a052..ee22f0cbad55 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -1275,7 +1275,7 @@ export default async function build( hasReportWebVitals: namedExports?.includes('reportWebVitals') ?? false, rewritesCount: rewrites.length, headersCount: headers.length, - redirectsCount: redirects.length - 1, // reduce one for trailing slash + redirectsCount: redirects.length - 2, // subtract two for internal redirects }) ) diff --git a/packages/next/client/normalize-trailing-slash.ts b/packages/next/client/normalize-trailing-slash.ts index 6cbd6f72e0dd..5c258fef667f 100644 --- a/packages/next/client/normalize-trailing-slash.ts +++ b/packages/next/client/normalize-trailing-slash.ts @@ -20,3 +20,7 @@ export const normalizePathTrailingSlash = process.env.__NEXT_TRAILING_SLASH } } : removePathTrailingSlash + +export function normalizePathSlashes(path: string): string { + return normalizePathTrailingSlash(path.replace(/\/\/+/g, '/')) +} diff --git a/packages/next/lib/load-custom-routes.ts b/packages/next/lib/load-custom-routes.ts index 61b388d1cc87..e6654f11e5c8 100644 --- a/packages/next/lib/load-custom-routes.ts +++ b/packages/next/lib/load-custom-routes.ts @@ -510,6 +510,14 @@ export default async function loadCustomRoutes( } } + // multiple slashes redirect + redirects.unshift({ + source: '/:before*/{(/+)}:after(|.*/[^/.]+|[^/.]+)', + destination: '/:before*/:after', + permanent: true, + locale: config.i18n ? false : undefined, + } as Redirect) + return { headers, rewrites, diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 6238e1c139aa..847285079e19 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -4,7 +4,7 @@ import { ParsedUrlQuery } from 'querystring' import { ComponentType } from 'react' import { UrlObject } from 'url' import { - normalizePathTrailingSlash, + normalizePathSlashes, removePathTrailingSlash, } from '../../../client/normalize-trailing-slash' import { GoodPageCache, StyleSheetTuple } from '../../../client/page-loader' @@ -80,7 +80,7 @@ function buildCancellationError() { function addPathPrefix(path: string, prefix?: string) { return prefix && path.startsWith('/') ? path === '/' - ? normalizePathTrailingSlash(prefix) + ? normalizePathSlashes(prefix) : `${prefix}${pathNoQueryHash(path) === '/' ? path.substring(1) : path}` : path } @@ -270,8 +270,8 @@ export function resolveHref( return (resolveAs ? [urlAsString] : urlAsString) as string } try { - const finalUrl = new URL(urlAsString, base) - finalUrl.pathname = normalizePathTrailingSlash(finalUrl.pathname) + const finalUrl = new URL(urlAsString.replace(/^\/+/, '/'), base) + finalUrl.pathname = normalizePathSlashes(finalUrl.pathname) let interpolatedAs = '' if ( diff --git a/test/integration/build-output/test/index.test.js b/test/integration/build-output/test/index.test.js index cf8eca1c83ff..468cc1fc6bb0 100644 --- a/test/integration/build-output/test/index.test.js +++ b/test/integration/build-output/test/index.test.js @@ -104,7 +104,7 @@ describe('Build Output', () => { expect(parseFloat(err404FirstLoad)).toBeCloseTo(67, 1) expect(err404FirstLoad.endsWith('kB')).toBe(true) - expect(parseFloat(sharedByAll)).toBeCloseTo(63.5, 1) + expect(parseFloat(sharedByAll)).toBeCloseTo(63.6, 1) expect(sharedByAll.endsWith('kB')).toBe(true) if (_appSize.endsWith('kB')) { diff --git a/test/integration/custom-routes/test/index.test.js b/test/integration/custom-routes/test/index.test.js index eba6e200df98..4c223b434925 100644 --- a/test/integration/custom-routes/test/index.test.js +++ b/test/integration/custom-routes/test/index.test.js @@ -637,6 +637,14 @@ const runTests = (isDev = false) => { basePath: '', dataRoutes: [], redirects: [ + { + regex: normalizeRegEx( + '^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))?\\/(\\/+)(|.*\\/[^\\/.]+|[^\\/.]+)$' + ), + source: '/:before*/{(/+)}:after(|.*/[^/.]+|[^/.]+)', + destination: '/:before*/:after', + statusCode: 308, + }, { destination: '/:path+', regex: normalizeRegEx( diff --git a/test/integration/repeated-slash-redirect/next.config.js b/test/integration/repeated-slash-redirect/next.config.js new file mode 100644 index 000000000000..ed8e3d3f74af --- /dev/null +++ b/test/integration/repeated-slash-redirect/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + // target: 'serverless', +} diff --git a/test/integration/repeated-slash-redirect/pages/[[...slug]].js b/test/integration/repeated-slash-redirect/pages/[[...slug]].js new file mode 100644 index 000000000000..c9648d10e7a8 --- /dev/null +++ b/test/integration/repeated-slash-redirect/pages/[[...slug]].js @@ -0,0 +1,7 @@ +export async function getServerSideProps({ query: { slug } }) { + return { props: { slug: slug || [] } } +} + +export default function Page({ slug }) { + return
{slug.join('/')}
+} diff --git a/test/integration/repeated-slash-redirect/pages/linker.js b/test/integration/repeated-slash-redirect/pages/linker.js new file mode 100644 index 000000000000..abec3a4cc08b --- /dev/null +++ b/test/integration/repeated-slash-redirect/pages/linker.js @@ -0,0 +1,25 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export async function getServerSideProps({ query }) { + return { + props: { href: query.href || '/' }, + } +} + +export default function Linker({ href }) { + const router = useRouter() + const pushRoute = () => { + router.push(href) + } + return ( +
+ + link to {href} + + +
+ ) +} diff --git a/test/integration/repeated-slash-redirect/test/index.test.js b/test/integration/repeated-slash-redirect/test/index.test.js new file mode 100644 index 000000000000..31c0c4656d91 --- /dev/null +++ b/test/integration/repeated-slash-redirect/test/index.test.js @@ -0,0 +1,110 @@ +/* eslint-env jest */ + +import cheerio from 'cheerio' +import fs from 'fs-extra' +import { join } from 'path' +import { + launchApp, + killApp, + findPort, + nextBuild, + nextStart, + fetchViaHTTP, + renderViaHTTP, +} from 'next-test-utils' +import webdriver from 'next-webdriver' + +jest.setTimeout(1000 * 60 * 2) + +let appDir = join(__dirname, '..') +const nextConfigPath = join(appDir, 'next.config.js') +let nextConfigContent +let appPort +let app + +function getPathWithNormalizedQuery(url) { + const parsed = new URL(url, 'http://n') + if (parsed.search) { + parsed.search = `?${parsed.searchParams}` + } + return parsed.pathname + parsed.search +} + +const runTests = (isDev = false) => { + const cases = [ + ['//hello/world', '/hello/world'], + ['/hello//world', '/hello/world'], + ['/hello/world//', '/hello/world'], + ['/hello///world', '/hello/world'], + ['/hello///world////foo', '/hello/world/foo'], + ['/hello//world?foo=bar//baz', '/hello/world?foo=bar//baz'], + ] + + it.each(cases)('it should redirect %s to %s', async (from, to) => { + const res = await fetchViaHTTP(appPort, from) + expect(getPathWithNormalizedQuery(res.url)).toBe( + getPathWithNormalizedQuery(to) + ) + }) + + it.each(cases)('it should rewrite href %s to %s', async (from, to) => { + const content = await renderViaHTTP(appPort, `/linker?href=${from}`) + const $ = cheerio.load(content) + expect($('#link').attr('href')).toBe(to) + }) + + it.each(cases)('it should navigate a href %s to %s', async (from, to) => { + const browser = await webdriver(appPort, `/linker?href=${from}`) + await browser.elementByCss('#link').click() + await browser.waitForElementByCss('#page') + const href = await browser.eval('window.location.href') + expect(getPathWithNormalizedQuery(href)).toBe( + getPathWithNormalizedQuery(to) + ) + }) +} + +describe('Repeated trailing slashes', () => { + describe('dev mode', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(() => killApp(app)) + runTests(true) + }) + + describe('server mode', () => { + beforeAll(async () => { + await nextBuild(appDir, [], { + stdout: true, + }) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(() => killApp(app)) + runTests() + }) + + describe('serverless mode', () => { + beforeAll(async () => { + nextConfigContent = await fs.readFile(nextConfigPath, 'utf8') + await fs.writeFile( + nextConfigPath, + nextConfigContent.replace(/\/\/ target/, 'target'), + 'utf8' + ) + await nextBuild(appDir, [], { + stdout: true, + }) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await fs.writeFile(nextConfigPath, nextConfigContent, 'utf8') + await killApp(app) + }) + + runTests() + }) +})