Skip to content

Commit 9afcdfc

Browse files
authored
Ensure i18n index prefetch is correct with trailingSlash (#22746)
This fixes index data route loading for i18n with `trailingSlash: true` enabled and also fixes prerendering `asPath` values not containing a trailingSlash when enabled. Fixes: #17813 Fixes: #22747
1 parent c1b2b3f commit 9afcdfc

File tree

8 files changed

+190
-103
lines changed

8 files changed

+190
-103
lines changed

packages/next/build/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,6 @@ export default async function build(
10221022
}
10231023
return defaultMap
10241024
},
1025-
trailingSlash: false,
10261025
}
10271026

10281027
await exportApp(dir, exportOptions, exportConfig)

packages/next/client/page-loader.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import getAssetPathFromRoute from '../next-server/lib/router/utils/get-asset-path-from-route'
99
import { isDynamicRoute } from '../next-server/lib/router/utils/is-dynamic'
1010
import { parseRelativeUrl } from '../next-server/lib/router/utils/parse-relative-url'
11+
import { removePathTrailingSlash } from './normalize-trailing-slash'
1112
import createRouteLoader, {
1213
getClientBuildManifest,
1314
RouteLoader,
@@ -96,7 +97,10 @@ export default class PageLoader {
9697
const route = normalizeRoute(hrefPathname)
9798

9899
const getHrefForSlug = (path: string) => {
99-
const dataRoute = getAssetPathFromRoute(addLocale(path, locale), '.json')
100+
const dataRoute = getAssetPathFromRoute(
101+
removePathTrailingSlash(addLocale(path, locale)),
102+
'.json'
103+
)
100104
return addBasePath(
101105
`/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}`
102106
)

packages/next/export/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export default async function exportApp(
170170
)
171171
}
172172

173-
const subFolders = nextConfig.trailingSlash
173+
const subFolders = nextConfig.trailingSlash && !options.buildExport
174174
const isLikeServerless = nextConfig.target !== 'server'
175175

176176
if (!options.silent && !options.buildExport) {
@@ -367,6 +367,7 @@ export default async function exportApp(
367367
locale: i18n?.defaultLocale,
368368
defaultLocale: i18n?.defaultLocale,
369369
domainLocales: i18n?.domains,
370+
trailingSlash: nextConfig.trailingSlash,
370371
}
371372

372373
const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig

packages/next/export/worker.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ interface RenderOpts {
7676
locales?: string[]
7777
locale?: string
7878
defaultLocale?: string
79+
trailingSlash?: boolean
7980
}
8081

8182
type ComponentModule = ComponentType<{}> & {
@@ -184,6 +185,10 @@ export default async function exportPage({
184185
res.statusCode = 500
185186
}
186187

188+
if (renderOpts.trailingSlash && !req.url?.endsWith('/')) {
189+
req.url += '/'
190+
}
191+
187192
envConfig.setConfig({
188193
serverRuntimeConfig,
189194
publicRuntimeConfig: renderOpts.runtimeConfig,

test/integration/i18n-support-base-path/pages/mixed.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export default function Page(props) {
2828
<Link href="/gsp" locale="fr">
2929
<a id="to-gsp-fr">to /gsp</a>
3030
</Link>
31+
<br />
32+
33+
<Link href="/" locale="fr">
34+
<a id="to-index-fr">to /</a>
35+
</Link>
3136
</>
3237
)
3338
}

test/integration/i18n-support/pages/mixed.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export default function Page(props) {
2828
<Link href="/gsp" locale="fr">
2929
<a id="to-gsp-fr">to /gsp</a>
3030
</Link>
31+
<br />
32+
33+
<Link href="/" locale="fr">
34+
<a id="to-index-fr">to /</a>
35+
</Link>
3136
</>
3237
)
3338
}

test/integration/i18n-support/test/index.test.js

Lines changed: 167 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import {
1414
fetchViaHTTP,
1515
File,
1616
launchApp,
17+
check,
1718
} from 'next-test-utils'
19+
import assert from 'assert'
1820

1921
const appDir = join(__dirname, '../')
2022
const nextConfig = new File(join(appDir, 'next.config.js'))
@@ -291,121 +293,187 @@ describe('i18n Support', () => {
291293
})
292294

293295
describe('with trailingSlash: true', () => {
294-
const curCtx = {
295-
...ctx,
296-
isDev: true,
297-
}
298-
beforeAll(async () => {
299-
await fs.remove(join(appDir, '.next'))
300-
nextConfig.replace('// trailingSlash', 'trailingSlash')
301-
302-
curCtx.appPort = await findPort()
303-
curCtx.app = await launchApp(appDir, curCtx.appPort)
304-
})
305-
afterAll(async () => {
306-
nextConfig.restore()
307-
await killApp(curCtx.app)
308-
})
296+
const runSlashTests = (curCtx) => {
297+
if (!curCtx.isDev) {
298+
it('should preload all locales data correctly', async () => {
299+
const browser = await webdriver(
300+
curCtx.appPort,
301+
`${curCtx.basePath}/mixed`
302+
)
309303

310-
it('should redirect correctly', async () => {
311-
for (const locale of nonDomainLocales) {
312-
const res = await fetchViaHTTP(curCtx.appPort, '/', undefined, {
313-
redirect: 'manual',
314-
headers: {
315-
'accept-language': locale,
316-
},
304+
await browser.eval(`(function() {
305+
document.querySelector('#to-gsp-en-us').scrollIntoView()
306+
document.querySelector('#to-gsp-nl-nl').scrollIntoView()
307+
document.querySelector('#to-gsp-fr').scrollIntoView()
308+
})()`)
309+
310+
await check(async () => {
311+
const hrefs = await browser.eval(
312+
`Object.keys(window.next.router.sdc)`
313+
)
314+
hrefs.sort()
315+
316+
assert.deepEqual(
317+
hrefs.map((href) =>
318+
new URL(href).pathname
319+
.replace(ctx.basePath, '')
320+
.replace(/^\/_next\/data\/[^/]+/, '')
321+
),
322+
['/en-US/gsp.json', '/fr.json', '/fr/gsp.json', '/nl-NL/gsp.json']
323+
)
324+
return 'yes'
325+
}, 'yes')
317326
})
327+
}
318328

319-
if (locale === 'en-US') {
320-
expect(res.status).toBe(200)
321-
} else {
322-
expect(res.status).toBe(307)
329+
it('should redirect correctly', async () => {
330+
for (const locale of nonDomainLocales) {
331+
const res = await fetchViaHTTP(curCtx.appPort, '/', undefined, {
332+
redirect: 'manual',
333+
headers: {
334+
'accept-language': locale,
335+
},
336+
})
323337

324-
const parsed = url.parse(res.headers.get('location'), true)
325-
expect(parsed.pathname).toBe(`/${locale}`)
326-
expect(parsed.query).toEqual({})
338+
if (locale === 'en-US') {
339+
expect(res.status).toBe(200)
340+
} else {
341+
expect(res.status).toBe(307)
342+
343+
const parsed = url.parse(res.headers.get('location'), true)
344+
expect(parsed.pathname).toBe(`/${locale}`)
345+
expect(parsed.query).toEqual({})
346+
}
327347
}
328-
}
329-
})
348+
})
330349

331-
it('should serve pages correctly with locale prefix', async () => {
332-
for (const locale of nonDomainLocales) {
333-
for (const [pathname, asPath] of [
334-
['/', '/'],
335-
['/links', '/links/'],
336-
['/auto-export', '/auto-export/'],
337-
['/gsp', '/gsp/'],
338-
['/gsp/fallback/[slug]', '/gsp/fallback/always/'],
339-
['/gssp', '/gssp/'],
340-
['/gssp/[slug]', '/gssp/first/'],
341-
]) {
342-
const res = await fetchViaHTTP(
343-
curCtx.appPort,
344-
`${locale === 'en-US' ? '' : `/${locale}`}${asPath}`,
345-
undefined,
346-
{
347-
redirect: 'manual',
348-
}
349-
)
350-
expect(res.status).toBe(200)
350+
it('should serve pages correctly with locale prefix', async () => {
351+
for (const locale of nonDomainLocales) {
352+
for (const [pathname, asPath] of [
353+
['/', '/'],
354+
['/links', '/links/'],
355+
['/auto-export', '/auto-export/'],
356+
['/gsp', '/gsp/'],
357+
['/gsp/fallback/[slug]', '/gsp/fallback/always/'],
358+
['/gssp', '/gssp/'],
359+
['/gssp/[slug]', '/gssp/first/'],
360+
]) {
361+
const res = await fetchViaHTTP(
362+
curCtx.appPort,
363+
`${locale === 'en-US' ? '' : `/${locale}`}${asPath}`,
364+
undefined,
365+
{
366+
redirect: 'manual',
367+
}
368+
)
369+
expect(res.status).toBe(200)
370+
371+
const $ = cheerio.load(await res.text())
372+
373+
expect($('#router-pathname').text()).toBe(pathname)
374+
expect($('#router-as-path').text()).toBe(asPath)
375+
expect($('#router-locale').text()).toBe(locale)
376+
expect(JSON.parse($('#router-locales').text())).toEqual(locales)
377+
expect($('#router-default-locale').text()).toBe('en-US')
378+
}
379+
}
380+
})
351381

352-
const $ = cheerio.load(await res.text())
382+
it('should navigate between pages correctly', async () => {
383+
for (const locale of nonDomainLocales) {
384+
const localePath = `/${locale !== 'en-US' ? `${locale}/` : ''}`
385+
const browser = await webdriver(curCtx.appPort, localePath)
353386

354-
expect($('#router-pathname').text()).toBe(pathname)
355-
expect($('#router-as-path').text()).toBe(asPath)
356-
expect($('#router-locale').text()).toBe(locale)
357-
expect(JSON.parse($('#router-locales').text())).toEqual(locales)
358-
expect($('#router-default-locale').text()).toBe('en-US')
359-
}
360-
}
361-
})
387+
await browser.eval('window.beforeNav = 1')
388+
await browser.elementByCss('#to-gsp').click()
389+
await browser.waitForElementByCss('#gsp')
362390

363-
it('should navigate between pages correctly', async () => {
364-
for (const locale of nonDomainLocales) {
365-
const localePath = `/${locale !== 'en-US' ? `${locale}/` : ''}`
366-
const browser = await webdriver(curCtx.appPort, localePath)
391+
expect(await browser.elementByCss('#router-pathname').text()).toBe(
392+
'/gsp'
393+
)
394+
expect(await browser.elementByCss('#router-as-path').text()).toBe(
395+
'/gsp/'
396+
)
397+
expect(await browser.elementByCss('#router-locale').text()).toBe(
398+
locale
399+
)
400+
expect(await browser.eval('window.beforeNav')).toBe(1)
401+
expect(await browser.eval('window.location.pathname')).toBe(
402+
`${localePath}gsp/`
403+
)
367404

368-
await browser.eval('window.beforeNav = 1')
369-
await browser.elementByCss('#to-gsp').click()
370-
await browser.waitForElementByCss('#gsp')
405+
await browser.back().waitForElementByCss('#index')
371406

372-
expect(await browser.elementByCss('#router-pathname').text()).toBe(
373-
'/gsp'
374-
)
375-
expect(await browser.elementByCss('#router-as-path').text()).toBe(
376-
'/gsp/'
377-
)
378-
expect(await browser.elementByCss('#router-locale').text()).toBe(locale)
379-
expect(await browser.eval('window.beforeNav')).toBe(1)
380-
expect(await browser.eval('window.location.pathname')).toBe(
381-
`${localePath}gsp/`
382-
)
407+
expect(await browser.elementByCss('#router-pathname').text()).toBe(
408+
'/'
409+
)
410+
expect(await browser.elementByCss('#router-as-path').text()).toBe('/')
411+
expect(await browser.elementByCss('#router-locale').text()).toBe(
412+
locale
413+
)
414+
expect(await browser.eval('window.beforeNav')).toBe(1)
415+
expect(await browser.eval('window.location.pathname')).toBe(
416+
`${localePath}`
417+
)
383418

384-
await browser.back().waitForElementByCss('#index')
419+
await browser.elementByCss('#to-gssp-slug').click()
420+
await browser.waitForElementByCss('#gssp')
385421

386-
expect(await browser.elementByCss('#router-pathname').text()).toBe('/')
387-
expect(await browser.elementByCss('#router-as-path').text()).toBe('/')
388-
expect(await browser.elementByCss('#router-locale').text()).toBe(locale)
389-
expect(await browser.eval('window.beforeNav')).toBe(1)
390-
expect(await browser.eval('window.location.pathname')).toBe(
391-
`${localePath}`
392-
)
422+
expect(await browser.elementByCss('#router-pathname').text()).toBe(
423+
'/gssp/[slug]'
424+
)
425+
expect(await browser.elementByCss('#router-as-path').text()).toBe(
426+
'/gssp/first/'
427+
)
428+
expect(await browser.elementByCss('#router-locale').text()).toBe(
429+
locale
430+
)
431+
expect(await browser.eval('window.beforeNav')).toBe(1)
432+
expect(await browser.eval('window.location.pathname')).toBe(
433+
`${localePath}gssp/first/`
434+
)
435+
}
436+
})
437+
}
393438

394-
await browser.elementByCss('#to-gssp-slug').click()
395-
await browser.waitForElementByCss('#gssp')
439+
describe('dev mode', () => {
440+
const curCtx = {
441+
...ctx,
442+
isDev: true,
443+
}
444+
beforeAll(async () => {
445+
await fs.remove(join(appDir, '.next'))
446+
nextConfig.replace('// trailingSlash', 'trailingSlash')
396447

397-
expect(await browser.elementByCss('#router-pathname').text()).toBe(
398-
'/gssp/[slug]'
399-
)
400-
expect(await browser.elementByCss('#router-as-path').text()).toBe(
401-
'/gssp/first/'
402-
)
403-
expect(await browser.elementByCss('#router-locale').text()).toBe(locale)
404-
expect(await browser.eval('window.beforeNav')).toBe(1)
405-
expect(await browser.eval('window.location.pathname')).toBe(
406-
`${localePath}gssp/first/`
407-
)
448+
curCtx.appPort = await findPort()
449+
curCtx.app = await launchApp(appDir, curCtx.appPort)
450+
})
451+
afterAll(async () => {
452+
nextConfig.restore()
453+
await killApp(curCtx.app)
454+
})
455+
456+
runSlashTests(curCtx)
457+
})
458+
459+
describe('production mode', () => {
460+
const curCtx = {
461+
...ctx,
408462
}
463+
beforeAll(async () => {
464+
await fs.remove(join(appDir, '.next'))
465+
nextConfig.replace('// trailingSlash', 'trailingSlash')
466+
467+
await nextBuild(appDir)
468+
curCtx.appPort = await findPort()
469+
curCtx.app = await nextStart(appDir, curCtx.appPort)
470+
})
471+
afterAll(async () => {
472+
nextConfig.restore()
473+
await killApp(curCtx.app)
474+
})
475+
476+
runSlashTests(curCtx)
409477
})
410478
})
411479
})

test/integration/i18n-support/test/shared.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ export function runTests(ctx) {
339339
.replace(ctx.basePath, '')
340340
.replace(/^\/_next\/data\/[^/]+/, '')
341341
),
342-
['/en-US/gsp.json', '/fr/gsp.json', '/nl-NL/gsp.json']
342+
['/en-US/gsp.json', '/fr.json', '/fr/gsp.json', '/nl-NL/gsp.json']
343343
)
344344
return 'yes'
345345
}, 'yes')

0 commit comments

Comments
 (0)