Skip to content

Commit bbc1a21

Browse files
authored
Update to have default locale matched on root (#17669)
Follow-up PR to #17370 when the path is not prefixed with a locale and the default locale is the detected locale it doesn't redirect to locale prefixed variant. If the default locale path is visited and the default locale is visited this also redirects to the root removing the un-necessary locale in the URL. This also exposes the `defaultLocale` on the router since the RFC mentions `Setting a defaultLocale is required in every i18n library so it'd be useful for Next.js to provide it to the application.` although doesn't explicitly spec where we want to expose it. If we want to expose it differently this can be updated.
1 parent a79bcfb commit bbc1a21

File tree

15 files changed

+331
-51
lines changed

15 files changed

+331
-51
lines changed

packages/next/build/webpack/loaders/next-serverless-loader.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,24 +229,34 @@ const nextServerlessLoader: loader.Loader = function () {
229229
detectedLocale = accept.language(
230230
req.headers['accept-language'],
231231
i18n.locales
232-
) || i18n.defaultLocale
232+
)
233233
}
234234
235+
const denormalizedPagePath = denormalizePagePath(parsedUrl.pathname || '/')
236+
const detectedDefaultLocale = detectedLocale === i18n.defaultLocale
237+
const shouldStripDefaultLocale =
238+
detectedDefaultLocale &&
239+
denormalizedPagePath === \`/\${i18n.defaultLocale}\`
240+
const shouldAddLocalePrefix =
241+
!detectedDefaultLocale && denormalizedPagePath === '/'
242+
detectedLocale = detectedLocale || i18n.defaultLocale
243+
235244
if (
236245
!nextStartMode &&
237246
i18n.localeDetection !== false &&
238-
denormalizePagePath(parsedUrl.pathname || '/') === '/'
247+
(shouldAddLocalePrefix || shouldStripDefaultLocale)
239248
) {
240249
res.setHeader(
241250
'Location',
242251
formatUrl({
243252
// make sure to include any query values when redirecting
244253
...parsedUrl,
245-
pathname: \`/\${detectedLocale}\`,
254+
pathname: shouldStripDefaultLocale ? '/' : \`/\${detectedLocale}\`,
246255
})
247256
)
248257
res.statusCode = 307
249258
res.end()
259+
return
250260
}
251261
252262
// TODO: domain based locales (domain to locale mapping needs to be provided in next.config.js)
@@ -458,6 +468,7 @@ const nextServerlessLoader: loader.Loader = function () {
458468
isDataReq: _nextData,
459469
locale: detectedLocale,
460470
locales: i18n.locales,
471+
defaultLocale: i18n.defaultLocale,
461472
},
462473
options,
463474
)

packages/next/client/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const {
6565
isFallback,
6666
head: initialHeadData,
6767
locales,
68+
defaultLocale,
6869
} = data
6970

7071
let { locale } = data
@@ -317,6 +318,7 @@ export default async (opts: { webpackHMR?: any } = {}) => {
317318
render({ App, Component, styleSheets, props, err }),
318319
locale,
319320
locales,
321+
defaultLocale,
320322
})
321323

322324
// call init-client middleware

packages/next/client/link.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,9 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
332332
// If child is an <a> tag and doesn't have a href attribute, or if the 'passHref' property is
333333
// defined, we specify the current 'href', so that repetition is not needed by the user
334334
if (props.passHref || (child.type === 'a' && !('href' in child.props))) {
335-
childProps.href = addBasePath(addLocale(as, router && router.locale))
335+
childProps.href = addBasePath(
336+
addLocale(as, router && router.locale, router && router.defaultLocale)
337+
)
336338
}
337339

338340
return React.cloneElement(child, childProps)

packages/next/client/page-loader.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,23 @@ export default class PageLoader {
203203
* @param {string} href the route href (file-system path)
204204
* @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes
205205
*/
206-
getDataHref(href: string, asPath: string, ssg: boolean, locale?: string) {
206+
getDataHref(
207+
href: string,
208+
asPath: string,
209+
ssg: boolean,
210+
locale?: string,
211+
defaultLocale?: string
212+
) {
207213
const { pathname: hrefPathname, query, search } = parseRelativeUrl(href)
208214
const { pathname: asPathname } = parseRelativeUrl(asPath)
209215
const route = normalizeRoute(hrefPathname)
210216

211217
const getHrefForSlug = (path: string) => {
212-
const dataRoute = addLocale(getAssetPathFromRoute(path, '.json'), locale)
218+
const dataRoute = addLocale(
219+
getAssetPathFromRoute(path, '.json'),
220+
locale,
221+
defaultLocale
222+
)
213223
return addBasePath(
214224
`/_next/data/${this.buildId}${dataRoute}${ssg ? '' : search}`
215225
)
@@ -229,15 +239,26 @@ export default class PageLoader {
229239
* @param {string} href the route href (file-system path)
230240
* @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes
231241
*/
232-
prefetchData(href: string, asPath: string) {
242+
prefetchData(
243+
href: string,
244+
asPath: string,
245+
locale?: string,
246+
defaultLocale?: string
247+
) {
233248
const { pathname: hrefPathname } = parseRelativeUrl(href)
234249
const route = normalizeRoute(hrefPathname)
235250
return this.promisedSsgManifest!.then(
236251
(s: ClientSsgManifest, _dataHref?: string) =>
237252
// Check if the route requires a data file
238253
s.has(route) &&
239254
// Try to generate data href, noop when falsy
240-
(_dataHref = this.getDataHref(href, asPath, true)) &&
255+
(_dataHref = this.getDataHref(
256+
href,
257+
asPath,
258+
true,
259+
locale,
260+
defaultLocale
261+
)) &&
241262
// noop when data has already been prefetched (dedupe)
242263
!document.querySelector(
243264
`link[rel="${relPrefetch}"][href^="${_dataHref}"]`

packages/next/client/router.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const urlPropertyFields = [
3939
'basePath',
4040
'locale',
4141
'locales',
42+
'defaultLocale',
4243
]
4344
const routerEvents = [
4445
'routeChangeStart',

packages/next/export/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ export default async function exportApp(
283283
}
284284
}
285285

286+
const { i18n } = nextConfig.experimental
287+
286288
// Start the rendering process
287289
const renderOpts = {
288290
dir,
@@ -298,8 +300,9 @@ export default async function exportApp(
298300
ampValidatorPath: nextConfig.experimental.amp?.validator || undefined,
299301
ampSkipValidation: nextConfig.experimental.amp?.skipValidation || false,
300302
ampOptimizerConfig: nextConfig.experimental.amp?.optimizer || undefined,
301-
locales: nextConfig.experimental.i18n?.locales,
302-
locale: nextConfig.experimental.i18n?.defaultLocale,
303+
locales: i18n?.locales,
304+
locale: i18n.defaultLocale,
305+
defaultLocale: i18n.defaultLocale,
303306
}
304307

305308
const { serverRuntimeConfig, publicRuntimeConfig } = nextConfig

packages/next/next-server/lib/i18n/normalize-locale-path.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ export function normalizeLocalePath(
66
pathname: string
77
} {
88
let detectedLocale: string | undefined
9+
// first item will be empty string from splitting at first char
10+
const pathnameParts = pathname.split('/')
11+
912
;(locales || []).some((locale) => {
10-
if (pathname.startsWith(`/${locale}`)) {
13+
if (pathnameParts[1] === locale) {
1114
detectedLocale = locale
12-
pathname = pathname.replace(new RegExp(`^/${locale}`), '') || '/'
15+
pathnameParts.splice(1, 1)
16+
pathname = pathnameParts.join('/') || '/'
1317
return true
1418
}
1519
return false

packages/next/next-server/lib/router/router.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,13 @@ function addPathPrefix(path: string, prefix?: string) {
5555
: path
5656
}
5757

58-
export function addLocale(path: string, locale?: string) {
58+
export function addLocale(
59+
path: string,
60+
locale?: string,
61+
defaultLocale?: string
62+
) {
5963
if (process.env.__NEXT_i18n_SUPPORT) {
60-
return locale && !path.startsWith('/' + locale)
64+
return locale && locale !== defaultLocale && !path.startsWith('/' + locale)
6165
? addPathPrefix(path, '/' + locale)
6266
: path
6367
}
@@ -246,6 +250,7 @@ export type BaseRouter = {
246250
basePath: string
247251
locale?: string
248252
locales?: string[]
253+
defaultLocale?: string
249254
}
250255

251256
export type NextRouter = BaseRouter &
@@ -356,6 +361,7 @@ export default class Router implements BaseRouter {
356361
_shallow?: boolean
357362
locale?: string
358363
locales?: string[]
364+
defaultLocale?: string
359365

360366
static events: MittEmitter = mitt()
361367

@@ -375,6 +381,7 @@ export default class Router implements BaseRouter {
375381
isFallback,
376382
locale,
377383
locales,
384+
defaultLocale,
378385
}: {
379386
subscription: Subscription
380387
initialProps: any
@@ -387,6 +394,7 @@ export default class Router implements BaseRouter {
387394
isFallback: boolean
388395
locale?: string
389396
locales?: string[]
397+
defaultLocale?: string
390398
}
391399
) {
392400
// represents the current component key
@@ -440,6 +448,7 @@ export default class Router implements BaseRouter {
440448
if (process.env.__NEXT_i18n_SUPPORT) {
441449
this.locale = locale
442450
this.locales = locales
451+
this.defaultLocale = defaultLocale
443452
}
444453

445454
if (typeof window !== 'undefined') {
@@ -596,7 +605,7 @@ export default class Router implements BaseRouter {
596605
this.abortComponentLoad(this._inFlightRoute)
597606
}
598607

599-
as = addLocale(as, this.locale)
608+
as = addLocale(as, this.locale, this.defaultLocale)
600609
const cleanedAs = delLocale(
601610
hasBasePath(as) ? delBasePath(as) : as,
602611
this.locale
@@ -790,7 +799,12 @@ export default class Router implements BaseRouter {
790799
}
791800

792801
Router.events.emit('beforeHistoryChange', as)
793-
this.changeState(method, url, addLocale(as, this.locale), options)
802+
this.changeState(
803+
method,
804+
url,
805+
addLocale(as, this.locale, this.defaultLocale),
806+
options
807+
)
794808

795809
if (process.env.NODE_ENV !== 'production') {
796810
const appComp: any = this.components['/_app'].Component
@@ -960,7 +974,8 @@ export default class Router implements BaseRouter {
960974
formatWithValidation({ pathname, query }),
961975
delBasePath(as),
962976
__N_SSG,
963-
this.locale
977+
this.locale,
978+
this.defaultLocale
964979
)
965980
}
966981

@@ -1117,7 +1132,12 @@ export default class Router implements BaseRouter {
11171132

11181133
const route = removePathTrailingSlash(pathname)
11191134
await Promise.all([
1120-
this.pageLoader.prefetchData(url, asPath),
1135+
this.pageLoader.prefetchData(
1136+
url,
1137+
asPath,
1138+
this.locale,
1139+
this.defaultLocale
1140+
),
11211141
this.pageLoader[options.priority ? 'loadPage' : 'prefetch'](route),
11221142
])
11231143
}

packages/next/next-server/lib/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export type NEXT_DATA = {
103103
head: HeadEntry[]
104104
locale?: string
105105
locales?: string[]
106+
defaultLocale?: string
106107
}
107108

108109
/**

packages/next/next-server/server/config.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,12 +227,35 @@ function assignDefaults(userConfig: { [key: string]: any }) {
227227
throw new Error(`Specified i18n.defaultLocale should be a string`)
228228
}
229229

230+
if (!Array.isArray(i18n.locales)) {
231+
throw new Error(
232+
`Specified i18n.locales must be an array of locale strings e.g. ["en-US", "nl-NL"] received ${typeof i18n.locales}`
233+
)
234+
}
235+
236+
const invalidLocales = i18n.locales.filter(
237+
(locale: any) => typeof locale !== 'string'
238+
)
239+
240+
if (invalidLocales.length > 0) {
241+
throw new Error(
242+
`Specified i18n.locales contains invalid values, locales must be valid locale tags provided as strings e.g. "en-US".\n` +
243+
`See here for list of valid language sub-tags: http://www.iana.org/assignments/language-subtag-registry/language-subtag-registry`
244+
)
245+
}
246+
230247
if (!i18n.locales.includes(i18n.defaultLocale)) {
231248
throw new Error(
232249
`Specified i18n.defaultLocale should be included in i18n.locales`
233250
)
234251
}
235252

253+
// make sure default Locale is at the front
254+
i18n.locales = [
255+
i18n.defaultLocale,
256+
...i18n.locales.filter((locale: string) => locale !== i18n.defaultLocale),
257+
]
258+
236259
const localeDetectionType = typeof i18n.locales.localeDetection
237260

238261
if (

0 commit comments

Comments
 (0)