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