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/'])
+ })
+})