Files
Tim NeutkensandGitHub c7a5a0954d Revert "Revert "refactor: write pages router export files directly to server/pages/"" (#93409)
Re-apply #92735

Before re-landing we need to investigate why the pages router middleware
i18n test fails when deployed.

Update: Investigation/fix:
https://github.com/vercel/next.js/pull/92735#issuecomment-4242978559

Reverts vercel/next.js#92733

---

Cursor plan based on the investigation Claude did:

# Fix i18n export orphan HTML files in server/pages/

## Problem

When i18n is configured, the export path map in `build/index.ts` creates
locale-prefixed entries for every page (e.g., `/en/home/a`,
`/fr/home/a`), but only deletes the non-locale entry from `defaultMap`
for SSG pages. For non-SSG static pages, the non-locale entry (e.g.,
`/home/a`) remains, causing the export worker to render and write orphan
HTML files directly to `server/pages/`.

These orphan files interfere with routing on deployment platforms (e.g.,
a `[teamId]/[slug].html` orphan matches rewritten paths that should
404).

## Root Cause

In [packages/next/src/build/index.ts](packages/next/src/build/index.ts)
at line 2988:

```2988:2991:packages/next/src/build/index.ts
                  if (isSsg) {
                    // remove non-locale prefixed variant from defaultMap
                    delete defaultMap[page]
                  }
```

The `if (isSsg)` guard means non-SSG pages keep their non-locale entry,
which gets rendered and written to `server/pages/` as an orphan file.

## Fix

**Single-line change** in
[packages/next/src/build/index.ts](packages/next/src/build/index.ts):
Remove the `if (isSsg)` guard so `delete defaultMap[page]` always runs
when i18n is enabled.

The locale-prefixed entries are already created in the loop above (line
2981), and they capture the original `page` value via
`defaultMap[page]?.page || page`. The non-locale entry is not needed
after that -- in the old staging-directory flow (`moveExportedPage`), it
was rendered but never moved to the final output directory.

**Safety verification:**

- `updatePagesManifestForExportedPage` (line 3754) is called based on
`combinedPages`, not the export map -- it still correctly replaces
non-locale manifest entries with locale-prefixed ones
- `pageDuration` tracking (line 3745) may return `undefined` for the
non-locale path, but this is typed `number | undefined` and used with
`|| 0` fallback at line 375 of `build/utils.ts` -- purely cosmetic
- SSG pages already have this exact behavior (delete from defaultMap),
so this change makes non-SSG consistent with SSG
- The `ssgPageDurations` invariant check (line 3737-3738) only runs for
SSG pages, which are unaffected

## Test

Add assertions to the existing i18n integration test at
[test/integration/i18n-support/test/index.test.ts](test/integration/i18n-support/test/index.test.ts)
to verify that non-locale-prefixed HTML orphans do not exist in
`server/pages/` after build. The test already has production mode
assertions checking locale-prefixed file existence and manifest
correctness (lines 81-99).
2026-05-26 16:50:38 +02:00

603 lines
18 KiB
TypeScript

import http from 'http'
import fs from 'fs-extra'
import { join } from 'path'
import cheerio from 'cheerio'
import { runTests, locales, nonDomainLocales } from './shared'
import { findPort, fetchViaHTTP, retry } from 'next-test-utils'
import { nextTestSetup, isNextDev, type NextInstance } from 'e2e-utils'
import assert from 'assert'
type BrowserOptions = Parameters<NextInstance['browser']>[1]
describe('i18n Support', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
skipStart: true,
skipDeployment: true,
})
if (skipped) return
const ctx: Record<string, any> = {
basePath: '',
isDev: isNextDev,
browser: (pathname: string, options?: BrowserOptions) =>
next.browser(pathname, options),
}
let externalServer: http.Server
let externalPort: number
let origConfigContent: string
beforeAll(async () => {
externalPort = await findPort()
externalServer = http.createServer((req, res) => {
res.statusCode = 200
res.end(JSON.stringify({ url: req.url, external: true }))
})
await new Promise<void>((resolve, reject) => {
externalServer.listen(externalPort, (err?: Error) =>
err ? reject(err) : resolve()
)
})
await next.patchFile('next.config.js', (content) =>
content.replace(/__EXTERNAL_PORT__/g, String(externalPort))
)
origConfigContent = await next.readFile('next.config.js')
if (!isNextDev) {
await next.build()
}
await next.start()
ctx.appDir = next.testDir
ctx.appPort = new URL(next.url).port
if (!isNextDev) {
ctx.buildId = (await next.readFile('.next/BUILD_ID')).trim()
ctx.buildPagesDir = join(next.testDir, '.next/server/pages')
} else {
ctx.buildId = 'development'
}
})
afterAll(() => {
externalServer?.close()
})
runTests(ctx)
if (!isNextDev) {
it('should have pre-rendered /500 correctly', async () => {
for (const locale of locales) {
const content = await fs.readFile(
join(next.testDir, '.next/server/pages/', locale, '500.html'),
'utf8'
)
expect(content).toContain('500')
expect(content).toMatch(/Internal Server Error/i)
}
})
it('should have locale-prefixed pages-manifest entries for default 500', async () => {
const manifest = await fs.readJSON(
join(next.testDir, '.next/server/pages-manifest.json')
)
for (const locale of locales) {
expect(manifest[`/${locale}/500`]).toBe(`pages/${locale}/500.html`)
}
})
it('should not have non-locale-prefixed HTML files for auto-static pages', async () => {
const pagesDir = join(next.testDir, '.next/server/pages')
// auto-export is a non-SSG auto-static page; only locale-prefixed
// variants should exist in server/pages/ (e.g. en-US/auto-export.html).
// A bare auto-export.html would be an orphan that can interfere with routing.
expect(await fs.pathExists(join(pagesDir, 'auto-export.html'))).toBe(
false
)
for (const locale of locales) {
expect(
await fs.pathExists(join(pagesDir, locale, 'auto-export.html'))
).toBe(true)
}
})
}
describe('with localeDetection disabled', () => {
if (!isNextDev) {
beforeAll(async () => {
await next.stop()
await next.patchFile('next.config.js', (content) =>
content.replace('// localeDetection', 'localeDetection')
)
await next.build()
await next.start()
ctx.appPort = new URL(next.url).port
})
afterAll(async () => {
await next.stop()
await next.patchFile('next.config.js', origConfigContent)
await next.build()
await next.start()
ctx.appPort = new URL(next.url).port
})
it('should have localeDetection in routes-manifest', async () => {
const routesManifest = JSON.parse(
await next.readFile('.next/routes-manifest.json')
)
expect(routesManifest.i18n).toEqual({
localeDetection: false,
locales: [
'en-US',
'nl-NL',
'nl-BE',
'nl',
'fr-BE',
'fr',
'en',
'go',
'go-BE',
'do',
'do-BE',
],
defaultLocale: 'en-US',
domains: [
{
http: true,
domain: 'example.do',
defaultLocale: 'do',
locales: ['do-BE'],
},
{
domain: 'example.com',
defaultLocale: 'go',
locales: ['go-BE'],
},
],
})
})
it('should not detect locale from accept-language', async () => {
const res = await fetchViaHTTP(
ctx.appPort,
'/',
{},
{
redirect: 'manual',
headers: {
'accept-language': 'fr',
},
}
)
expect(res.status).toBe(200)
const $ = cheerio.load(await res.text())
expect($('html').attr('lang')).toBe('en-US')
expect($('#router-locale').text()).toBe('en-US')
expect(JSON.parse($('#router-locales').text())).toEqual(locales)
expect($('#router-pathname').text()).toBe('/')
expect($('#router-as-path').text()).toBe('/')
})
it('should ignore the invalid accept-language header', async () => {
await next.patchFile('next.config.js', (content) =>
content.replace('localeDetection: false', 'localeDetection: true')
)
await next.stop()
await next.build()
await next.start()
ctx.appPort = new URL(next.url).port
const res = await fetchViaHTTP(
ctx.appPort,
'/',
{},
{
headers: {
'accept-language': 'ldfir;',
},
}
)
expect(res.status).toBe(200)
const $ = cheerio.load(await res.text())
expect($('html').attr('lang')).toBe('en-US')
expect($('#router-locale').text()).toBe('en-US')
expect(JSON.parse($('#router-locales').text())).toEqual(locales)
expect($('#router-pathname').text()).toBe('/')
expect($('#router-as-path').text()).toBe('/')
})
it('should set locale from detected path', async () => {
for (const locale of nonDomainLocales) {
const res = await fetchViaHTTP(
ctx.appPort,
`/${locale}`,
{},
{
redirect: 'manual',
headers: {
'accept-language': 'en-US,en;q=0.9',
},
}
)
expect(res.status).toBe(200)
const $ = cheerio.load(await res.text())
expect($('html').attr('lang')).toBe(locale)
expect($('#router-locale').text()).toBe(locale)
expect(JSON.parse($('#router-locales').text())).toEqual(locales)
expect($('#router-pathname').text()).toBe('/')
expect($('#router-as-path').text()).toBe('/')
}
})
}
})
describe('with trailingSlash: true', () => {
const runSlashTests = (curCtx) => {
if (!curCtx.isDev) {
it('should preload all locales data correctly', async () => {
const browser = await curCtx.browser(`${curCtx.basePath}/mixed`)
await browser.eval(`(function() {
document.querySelector('#to-gsp-en-us').scrollIntoView()
document.querySelector('#to-gsp-nl-nl').scrollIntoView()
document.querySelector('#to-gsp-fr').scrollIntoView()
})()`)
await retry(async () => {
const hrefs = await browser.eval(
`Object.keys(window.next.router.sdc)`
)
hrefs.sort()
const baseURL = await browser.url()
assert.deepEqual(
hrefs.map((href) =>
new URL(href, baseURL).pathname
.replace(ctx.basePath, '')
.replace(/^\/_next\/data\/[^/]+/, '')
),
['/en-US/gsp.json', '/fr.json', '/fr/gsp.json', '/nl-NL/gsp.json']
)
})
})
it('should have correct locale domain hrefs', async () => {
const res = await fetchViaHTTP(
curCtx.appPort,
'/do-BE/frank/',
undefined,
{
redirect: 'manual',
}
)
expect(res.status).toBe(200)
const html = await res.text()
const $ = cheerio.load(html)
expect($('#to-fallback-hello')[0].attribs.href).toBe(
'http://example.do/do-BE/gsp/fallback/hello/'
)
expect($('#to-no-fallback-first')[0].attribs.href).toBe(
'http://example.do/do-BE/gsp/no-fallback/first/'
)
})
}
it('should redirect correctly', async () => {
for (const locale of nonDomainLocales) {
const res = await fetchViaHTTP(curCtx.appPort, '/', undefined, {
redirect: 'manual',
headers: {
'accept-language': locale,
},
})
if (locale === 'en-US') {
expect(res.status).toBe(200)
} else {
expect(res.status).toBe(307)
const parsed = new URL(res.headers.get('location'), res.url)
expect(parsed.pathname).toBe(`/${locale}/`)
expect(Object.fromEntries(parsed.searchParams.entries())).toEqual(
{}
)
}
}
})
it('should serve pages correctly with locale prefix', async () => {
for (const locale of nonDomainLocales) {
for (const [pathname, asPath] of [
['/', '/'],
['/links', '/links/'],
['/auto-export', '/auto-export/'],
['/gsp', '/gsp/'],
['/gsp/fallback/[slug]', '/gsp/fallback/always/'],
['/gssp', '/gssp/'],
['/gssp/[slug]', '/gssp/first/'],
]) {
const res = await fetchViaHTTP(
curCtx.appPort,
`${locale === 'en-US' ? '' : `/${locale}`}${asPath}`,
undefined,
{
redirect: 'manual',
}
)
expect(res.status).toBe(200)
const $ = cheerio.load(await res.text())
expect($('#router-pathname').text()).toBe(pathname)
expect($('#router-as-path').text()).toBe(asPath)
expect($('#router-locale').text()).toBe(locale)
expect(JSON.parse($('#router-locales').text())).toEqual(locales)
expect($('#router-default-locale').text()).toBe('en-US')
}
}
})
it('should return 404 error for repeating locales', async () => {
const defaultLocale = 'en-US'
for (const locale of nonDomainLocales) {
for (const asPath of [
'/gsp/fallback/always/',
'/post/comment/',
'/gssp/first/',
]) {
const res = await fetchViaHTTP(
curCtx.appPort,
`/${locale}/${defaultLocale}${asPath}`,
undefined,
{
redirect: 'manual',
}
)
expect(res.status).toBe(404)
const $ = cheerio.load(await res.text())
const props = JSON.parse($('#props').text())
expect($('#not-found').text().length > 0).toBe(true)
expect(props).toEqual({
is404: true,
locale,
locales,
defaultLocale,
})
}
}
})
it('should navigate between pages correctly', async () => {
for (const locale of nonDomainLocales) {
const localePath = `/${locale !== 'en-US' ? `${locale}/` : ''}`
const browser = await curCtx.browser(localePath)
await browser.eval('window.beforeNav = 1')
await browser.elementByCss('#to-gsp').click()
await browser.waitForElementByCss('#gsp')
expect(await browser.elementByCss('#router-pathname').text()).toBe(
'/gsp'
)
expect(await browser.elementByCss('#router-as-path').text()).toBe(
'/gsp/'
)
expect(await browser.elementByCss('#router-locale').text()).toBe(
locale
)
expect(await browser.eval('window.beforeNav')).toBe(1)
expect(await browser.eval('window.location.pathname')).toBe(
`${localePath}gsp/`
)
await browser.back().waitForElementByCss('#index')
expect(await browser.elementByCss('#router-pathname').text()).toBe(
'/'
)
expect(await browser.elementByCss('#router-as-path').text()).toBe('/')
expect(await browser.elementByCss('#router-locale').text()).toBe(
locale
)
expect(await browser.eval('window.beforeNav')).toBe(1)
expect(await browser.eval('window.location.pathname')).toBe(
`${localePath}`
)
await browser.elementByCss('#to-gssp-slug').click()
await browser.waitForElementByCss('#gssp')
expect(await browser.elementByCss('#router-pathname').text()).toBe(
'/gssp/[slug]'
)
expect(await browser.elementByCss('#router-as-path').text()).toBe(
'/gssp/first/'
)
expect(await browser.elementByCss('#router-locale').text()).toBe(
locale
)
expect(await browser.eval('window.beforeNav')).toBe(1)
expect(await browser.eval('window.location.pathname')).toBe(
`${localePath}gssp/first/`
)
await browser.back().waitForElementByCss('#index')
await browser.elementByCss('#to-api-post').click()
await browser.waitForCondition(
'window.location.pathname === "/api/post/asdf/"'
)
const body = await browser.elementByCss('body').text()
const json = JSON.parse(body)
expect(json.post).toBe(true)
}
})
}
beforeAll(async () => {
await next.stop()
await next.patchFile('next.config.js', (content) =>
content.replace('// trailingSlash', 'trailingSlash')
)
if (!isNextDev) {
await next.build()
}
await next.start()
ctx.appPort = new URL(next.url).port
})
afterAll(async () => {
await next.stop()
await next.patchFile('next.config.js', origConfigContent)
})
runSlashTests(ctx)
})
describe('with trailingSlash: false', () => {
const runSlashTests = (curCtx) => {
it('should redirect correctly', async () => {
for (const locale of nonDomainLocales) {
const res = await fetchViaHTTP(curCtx.appPort, '/', undefined, {
redirect: 'manual',
headers: {
'accept-language': locale,
},
})
if (locale === 'en-US') {
expect(res.status).toBe(200)
} else {
expect(res.status).toBe(307)
const parsed = new URL(res.headers.get('location'), res.url)
expect(parsed.pathname).toBe(`/${locale}`)
expect(Object.fromEntries(parsed.searchParams.entries())).toEqual(
{}
)
}
}
})
}
beforeAll(async () => {
await next.stop()
await next.patchFile('next.config.js', (content) =>
content.replace('// trailingSlash: true', 'trailingSlash: false')
)
if (!isNextDev) {
await next.build()
}
await next.start()
ctx.appPort = new URL(next.url).port
})
afterAll(async () => {
await next.stop()
await next.patchFile('next.config.js', origConfigContent)
})
runSlashTests(ctx)
})
if (!isNextDev) {
describe('error configs', () => {
it('should show proper error for duplicate defaultLocales', async () => {
await next.stop()
const origContent = await next.readFile('next.config.js')
await next.patchFile(
'next.config.js',
`
module.exports = {
i18n: {
locales: ['en', 'fr', 'nl'],
defaultLocale: 'en',
domains: [
{
domain: 'example.com',
defaultLocale: 'en'
},
{
domain: 'fr.example.com',
defaultLocale: 'fr',
},
{
domain: 'french.example.com',
defaultLocale: 'fr',
}
]
}
}
`
)
const { exitCode } = await next.build()
expect(exitCode).toBe(1)
expect(next.cliOutput).toContain(
'Both fr.example.com and french.example.com configured the defaultLocale fr but only one can'
)
await next.patchFile('next.config.js', origContent)
})
it('should show proper error for duplicate locales', async () => {
const origContent = await next.readFile('next.config.js')
await next.patchFile(
'next.config.js',
`
module.exports = {
i18n: {
locales: ['en', 'fr', 'nl', 'eN', 'fr'],
defaultLocale: 'en',
}
}
`
)
const { exitCode } = await next.build()
expect(exitCode).toBe(1)
expect(next.cliOutput).toContain(
'Specified i18n.locales contains the following duplicate locales:'
)
expect(next.cliOutput).toContain(`eN, fr`)
await next.patchFile('next.config.js', origContent)
})
it('should show proper error for invalid locale domain', async () => {
const origContent = await next.readFile('next.config.js')
await next.patchFile(
'next.config.js',
`
module.exports = {
i18n: {
locales: ['en', 'fr', 'nl', 'eN', 'fr'],
domains: [
{
domain: 'hello:3000',
defaultLocale: 'en',
}
],
defaultLocale: 'en',
}
}
`
)
const { exitCode } = await next.build()
expect(exitCode).toBe(1)
expect(next.cliOutput).toContain(
`i18n domain: "hello:3000" is invalid it should be a valid domain without protocol (https://) or port (:3000) e.g. example.vercel.sh`
)
await next.patchFile('next.config.js', origContent)
})
})
}
})