diff --git a/packages/next/src/client/route-params.ts b/packages/next/src/client/route-params.ts index 18218be28d79..90a6d75a93a5 100644 --- a/packages/next/src/client/route-params.ts +++ b/packages/next/src/client/route-params.ts @@ -11,6 +11,7 @@ import { NEXT_RSC_UNION_QUERY, } from './components/app-router-headers' import { hasBasePath } from './has-base-path' +import { normalizePathTrailingSlash } from './normalize-trailing-slash' import { removeBasePath } from './remove-base-path' import type { NormalizedPathname, @@ -225,9 +226,15 @@ export function urlToUrlWithoutFlightMarker(url: URL): URL { urlWithoutFlightParameters.pathname.endsWith('.txt') ) { const { pathname } = urlWithoutFlightParameters - const length = pathname.endsWith('/index.txt') ? 10 : 4 - // Slice off `/index.txt` or `.txt` from the end of the pathname - urlWithoutFlightParameters.pathname = pathname.slice(0, -length) + // Undo the marker appended in `fetchServerResponse`, which is keyed on + // whether the requested pathname ended with a slash: `index.txt` for + // `/foo/`, `.txt` for `/foo`. Slicing off only `index.txt` keeps that + // slash, then `normalizePathTrailingSlash` applies the configured + // policy so we don't hand-roll a second one here. + const length = pathname.endsWith('/index.txt') ? 9 : 4 + urlWithoutFlightParameters.pathname = normalizePathTrailingSlash( + pathname.slice(0, -length) + ) } } return urlWithoutFlightParameters diff --git a/test/cache-components-tests-manifest.json b/test/cache-components-tests-manifest.json index ad73de010f33..b4a73639e014 100644 --- a/test/cache-components-tests-manifest.json +++ b/test/cache-components-tests-manifest.json @@ -264,6 +264,7 @@ "test/e2e/app-dir/server-source-maps/server-source-maps.test.ts", "test/e2e/app-dir/set-cookies/set-cookies.test.ts", "test/e2e/app-dir/shallow-routing/shallow-routing.test.ts", + "test/e2e/app-dir/static-export-skew-trailing-slash/static-export-skew-trailing-slash.test.ts", "test/e2e/app-dir/static-generation-status/index.test.ts", "test/e2e/app-dir/static-shell-debugging/static-shell-debugging.test.ts", "test/e2e/app-dir/taint/process-taint.test.ts", diff --git a/test/e2e/app-dir/static-export-skew-trailing-slash/app/layout.tsx b/test/e2e/app-dir/static-export-skew-trailing-slash/app/layout.tsx new file mode 100644 index 000000000000..888614deda3b --- /dev/null +++ b/test/e2e/app-dir/static-export-skew-trailing-slash/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/static-export-skew-trailing-slash/app/page.tsx b/test/e2e/app-dir/static-export-skew-trailing-slash/app/page.tsx new file mode 100644 index 000000000000..9b7c8b6ada6c --- /dev/null +++ b/test/e2e/app-dir/static-export-skew-trailing-slash/app/page.tsx @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+

Home page

+ + Target page + +
+ ) +} diff --git a/test/e2e/app-dir/static-export-skew-trailing-slash/app/target/page.tsx b/test/e2e/app-dir/static-export-skew-trailing-slash/app/target/page.tsx new file mode 100644 index 000000000000..20809ad4cdf6 --- /dev/null +++ b/test/e2e/app-dir/static-export-skew-trailing-slash/app/target/page.tsx @@ -0,0 +1,3 @@ +export default function TargetPage() { + return

Target page

+} diff --git a/test/e2e/app-dir/static-export-skew-trailing-slash/next.config.js b/test/e2e/app-dir/static-export-skew-trailing-slash/next.config.js new file mode 100644 index 000000000000..134621074c99 --- /dev/null +++ b/test/e2e/app-dir/static-export-skew-trailing-slash/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + output: 'export', + trailingSlash: true, + generateBuildId: async () => 'current-build-id', +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/static-export-skew-trailing-slash/server.mjs b/test/e2e/app-dir/static-export-skew-trailing-slash/server.mjs new file mode 100644 index 000000000000..0be27b35c61c --- /dev/null +++ b/test/e2e/app-dir/static-export-skew-trailing-slash/server.mjs @@ -0,0 +1,19 @@ +import { createReadStream } from 'node:fs' +import { createServer } from 'node:http' +import { join } from 'node:path' +import handler from 'serve-handler' + +export function createExportServer(outDir, requests) { + return createServer((request, response) => { + const { pathname } = new URL(request.url, 'http://localhost') + requests.push(pathname) + + if (pathname === '/target' || pathname === '/target/') { + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + createReadStream(join(outDir, 'target/index.html')).pipe(response) + return + } + + return handler(request, response, { public: outDir }) + }) +} diff --git a/test/e2e/app-dir/static-export-skew-trailing-slash/static-export-skew-trailing-slash.test.ts b/test/e2e/app-dir/static-export-skew-trailing-slash/static-export-skew-trailing-slash.test.ts new file mode 100644 index 000000000000..558e3fc46aed --- /dev/null +++ b/test/e2e/app-dir/static-export-skew-trailing-slash/static-export-skew-trailing-slash.test.ts @@ -0,0 +1,64 @@ +import type { Server } from 'node:http' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { findPort, retry } from 'next-test-utils' +import { isNextStart, nextTestSetup } from 'e2e-utils' +import { createExportServer } from './server.mjs' + +describe('static-export-skew-trailing-slash', () => { + if (!isNextStart) { + test('build test should not run during dev test run', () => {}) + return + } + + const { next } = nextTestSetup({ + files: __dirname, + skipStart: true, + disableAutoSkewProtection: true, + }) + + let port: number + let server: Server + const requests: string[] = [] + + beforeAll(async () => { + await next.build() + + const targetFlightPath = join(next.testDir, 'out/target/index.txt') + const targetFlight = readFileSync(targetFlightPath, 'utf8') + const currentBuildId = '"b":"current-build-id"' + if (!targetFlight.includes(currentBuildId)) { + throw new Error('Could not find the current build ID in target RSC data') + } + writeFileSync( + targetFlightPath, + targetFlight.replace(currentBuildId, '"b":"foreign-build-id"') + ) + + port = await findPort() + server = createExportServer(join(next.testDir, 'out'), requests) + server.listen(port) + }) + + afterAll(() => { + server?.close() + }) + + it('preserves the trailing slash during an MPA fallback', async () => { + const browser = await next.browser('/', { baseUrl: port }) + + await browser.elementById('target-link').click() + await browser.waitForElementByCss('#target-page') + + await retry(async () => { + expect(new URL(await browser.url()).pathname).toBe('/target/') + }) + + expect(requests).toContain('/target/index.txt') + expect( + requests.filter( + (pathname) => pathname === '/target' || pathname === '/target/' + ) + ).toEqual(['/target/']) + }) +})