Skip to content

Commit e554a1f

Browse files
authored
Add locale prop for transitioning locales client side (#17898)
This adds the `locale` prop for `next/link` to allow transitioning between locales client-side and also allows passing the locale to `router.push/replace` via the transition options similar to `shallow` e.g. `router.push('/another', '/another, { locale: 'nl' })` x-ref: #17370
1 parent 245499a commit e554a1f

File tree

6 files changed

+248
-18
lines changed

6 files changed

+248
-18
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ const nextServerlessLoader: loader.Loader = function () {
558558
isDataReq: _nextData,
559559
locale: detectedLocale,
560560
locales,
561-
defaultLocale,
561+
defaultLocale: i18n.defaultLocale,
562562
},
563563
options,
564564
)

packages/next/client/link.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type LinkProps = {
2626
shallow?: boolean
2727
passHref?: boolean
2828
prefetch?: boolean
29+
locale?: string
2930
}
3031
type LinkPropsRequired = RequiredKeys<LinkProps>
3132
type LinkPropsOptional = OptionalKeys<LinkProps>
@@ -125,7 +126,8 @@ function linkClicked(
125126
as: string,
126127
replace?: boolean,
127128
shallow?: boolean,
128-
scroll?: boolean
129+
scroll?: boolean,
130+
locale?: string
129131
): void {
130132
const { nodeName } = e.currentTarget
131133

@@ -142,7 +144,7 @@ function linkClicked(
142144
}
143145

144146
// replace state instead of push if prop is present
145-
router[replace ? 'replace' : 'push'](href, as, { shallow }).then(
147+
router[replace ? 'replace' : 'push'](href, as, { shallow, locale }).then(
146148
(success: boolean) => {
147149
if (!success) return
148150
if (scroll) {
@@ -202,21 +204,28 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
202204
shallow: true,
203205
passHref: true,
204206
prefetch: true,
207+
locale: true,
205208
} as const
206209
const optionalProps: LinkPropsOptional[] = Object.keys(
207210
optionalPropsGuard
208211
) as LinkPropsOptional[]
209212
optionalProps.forEach((key: LinkPropsOptional) => {
213+
const valType = typeof props[key]
214+
210215
if (key === 'as') {
211-
if (
212-
props[key] &&
213-
typeof props[key] !== 'string' &&
214-
typeof props[key] !== 'object'
215-
) {
216+
if (props[key] && valType !== 'string' && valType !== 'object') {
216217
throw createPropError({
217218
key,
218219
expected: '`string` or `object`',
219-
actual: typeof props[key],
220+
actual: valType,
221+
})
222+
}
223+
} else if (key === 'locale') {
224+
if (props[key] && valType !== 'string') {
225+
throw createPropError({
226+
key,
227+
expected: '`string`',
228+
actual: valType,
220229
})
221230
}
222231
} else if (
@@ -226,11 +235,11 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
226235
key === 'passHref' ||
227236
key === 'prefetch'
228237
) {
229-
if (props[key] != null && typeof props[key] !== 'boolean') {
238+
if (props[key] != null && valType !== 'boolean') {
230239
throw createPropError({
231240
key,
232241
expected: '`boolean`',
233-
actual: typeof props[key],
242+
actual: valType,
234243
})
235244
}
236245
} else {
@@ -285,7 +294,7 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
285294
}
286295
}, [p, childElm, href, as, router])
287296

288-
let { children, replace, shallow, scroll } = props
297+
let { children, replace, shallow, scroll, locale } = props
289298
// Deprecated. Warning shown by propType check. If the children provided is a string (<Link>example</Link>) we wrap it in an <a> tag
290299
if (typeof children === 'string') {
291300
children = <a>{children}</a>
@@ -314,7 +323,7 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
314323
child.props.onClick(e)
315324
}
316325
if (!e.defaultPrevented) {
317-
linkClicked(e, router, href, as, replace, shallow, scroll)
326+
linkClicked(e, router, href, as, replace, shallow, scroll, locale)
318327
}
319328
},
320329
}
@@ -333,7 +342,11 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
333342
// defined, we specify the current 'href', so that repetition is not needed by the user
334343
if (props.passHref || (child.type === 'a' && !('href' in child.props))) {
335344
childProps.href = addBasePath(
336-
addLocale(as, router && router.locale, router && router.defaultLocale)
345+
addLocale(
346+
as,
347+
locale || (router && router.locale),
348+
router && router.defaultLocale
349+
)
337350
)
338351
}
339352

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import escapePathDelimiters from './utils/escape-path-delimiters'
2929

3030
interface TransitionOptions {
3131
shallow?: boolean
32+
locale?: string
3233
}
3334

3435
interface NextHistoryState {
@@ -592,6 +593,7 @@ export default class Router implements BaseRouter {
592593
window.location.href = url
593594
return false
594595
}
596+
this.locale = options.locale || this.locale
595597

596598
if (!(options as any)._h) {
597599
this.isSsr = false

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export default class Server {
197197
? requireFontManifest(this.distDir, this._isLikeServerless)
198198
: null,
199199
optimizeImages: this.nextConfig.experimental.optimizeImages,
200+
defaultLocale: this.nextConfig.experimental.i18n?.defaultLocale,
200201
}
201202

202203
// Only the `publicRuntimeConfig` key is exposed to the client side
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import Link from 'next/link'
2+
import { useRouter } from 'next/router'
3+
4+
export default function Page(props) {
5+
const router = useRouter()
6+
const { nextLocale } = router.query
7+
8+
return (
9+
<>
10+
<p id="links">links page</p>
11+
<p id="props">{JSON.stringify(props)}</p>
12+
<p id="router-locale">{router.locale}</p>
13+
<p id="router-locales">{JSON.stringify(router.locales)}</p>
14+
<p id="router-query">{JSON.stringify(router.query)}</p>
15+
<p id="router-pathname">{router.pathname}</p>
16+
<p id="router-as-path">{router.asPath}</p>
17+
<Link href="/another" locale={nextLocale}>
18+
<a id="to-another">to /another</a>
19+
</Link>
20+
<br />
21+
<Link href="/gsp" locale={nextLocale}>
22+
<a id="to-gsp">to /gsp</a>
23+
</Link>
24+
<br />
25+
<Link href="/gsp/fallback/first" locale={nextLocale}>
26+
<a id="to-fallback-first">to /gsp/fallback/first</a>
27+
</Link>
28+
<br />
29+
<Link href="/gsp/fallback/hello" locale={nextLocale}>
30+
<a id="to-fallback-hello">to /gsp/fallback/hello</a>
31+
</Link>
32+
<br />
33+
<Link href="/gsp/no-fallback/first" locale={nextLocale}>
34+
<a id="to-no-fallback-first">to /gsp/no-fallback/first</a>
35+
</Link>
36+
<br />
37+
<Link href="/gssp" locale={nextLocale}>
38+
<a id="to-gssp">to /gssp</a>
39+
</Link>
40+
<br />
41+
<Link href="/gssp/first" locale={nextLocale}>
42+
<a id="to-gssp-slug">to /gssp/first</a>
43+
</Link>
44+
<br />
45+
</>
46+
)
47+
}
48+
49+
// make SSR page so we have query values immediately
50+
export const getServerSideProps = () => {
51+
return {
52+
props: {},
53+
}
54+
}

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

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,170 @@ let appPort
2626

2727
const locales = ['en-US', 'nl-NL', 'nl-BE', 'nl', 'fr-BE', 'fr', 'en']
2828

29+
async function addDefaultLocaleCookie(browser) {
30+
// make sure default locale is used in case browser isn't set to
31+
// favor en-US by default, (we use all caps to ensure it's case-insensitive)
32+
await browser.manage().addCookie({ name: 'NEXT_LOCALE', value: 'EN-US' })
33+
await browser.get(browser.initUrl)
34+
}
35+
2936
function runTests(isDev) {
37+
it('should navigate with locale prop correctly', async () => {
38+
const browser = await webdriver(appPort, '/links?nextLocale=fr')
39+
await addDefaultLocaleCookie(browser)
40+
41+
expect(await browser.elementByCss('#router-pathname').text()).toBe('/links')
42+
expect(await browser.elementByCss('#router-as-path').text()).toBe(
43+
'/links?nextLocale=fr'
44+
)
45+
expect(await browser.elementByCss('#router-locale').text()).toBe('en-US')
46+
expect(
47+
JSON.parse(await browser.elementByCss('#router-locales').text())
48+
).toEqual(locales)
49+
expect(
50+
JSON.parse(await browser.elementByCss('#router-query').text())
51+
).toEqual({ nextLocale: 'fr' })
52+
53+
await browser.elementByCss('#to-another').click()
54+
await browser.waitForElementByCss('#another')
55+
56+
expect(await browser.elementByCss('#router-pathname').text()).toBe(
57+
'/another'
58+
)
59+
expect(await browser.elementByCss('#router-as-path').text()).toBe(
60+
'/another'
61+
)
62+
expect(await browser.elementByCss('#router-locale').text()).toBe('fr')
63+
expect(
64+
JSON.parse(await browser.elementByCss('#router-locales').text())
65+
).toEqual(locales)
66+
expect(
67+
JSON.parse(await browser.elementByCss('#router-query').text())
68+
).toEqual({})
69+
70+
let parsedUrl = url.parse(await browser.eval('window.location.href'), true)
71+
expect(parsedUrl.pathname).toBe('/fr/another')
72+
expect(parsedUrl.query).toEqual({})
73+
74+
await browser.eval('window.history.back()')
75+
await browser.waitForElementByCss('#links')
76+
77+
expect(await browser.elementByCss('#router-pathname').text()).toBe('/links')
78+
expect(await browser.elementByCss('#router-as-path').text()).toBe(
79+
'/links?nextLocale=fr'
80+
)
81+
expect(await browser.elementByCss('#router-locale').text()).toBe('fr')
82+
expect(
83+
JSON.parse(await browser.elementByCss('#router-locales').text())
84+
).toEqual(locales)
85+
expect(
86+
JSON.parse(await browser.elementByCss('#router-query').text())
87+
).toEqual({ nextLocale: 'fr' })
88+
89+
parsedUrl = url.parse(await browser.eval('window.location.href'), true)
90+
expect(parsedUrl.pathname).toBe('/fr/links')
91+
expect(parsedUrl.query).toEqual({ nextLocale: 'fr' })
92+
93+
await browser.eval('window.history.forward()')
94+
await browser.waitForElementByCss('#another')
95+
96+
expect(await browser.elementByCss('#router-pathname').text()).toBe(
97+
'/another'
98+
)
99+
expect(await browser.elementByCss('#router-as-path').text()).toBe(
100+
'/another'
101+
)
102+
expect(await browser.elementByCss('#router-locale').text()).toBe('fr')
103+
expect(
104+
JSON.parse(await browser.elementByCss('#router-locales').text())
105+
).toEqual(locales)
106+
expect(
107+
JSON.parse(await browser.elementByCss('#router-query').text())
108+
).toEqual({})
109+
110+
parsedUrl = url.parse(await browser.eval('window.location.href'), true)
111+
expect(parsedUrl.pathname).toBe('/fr/another')
112+
expect(parsedUrl.query).toEqual({})
113+
})
114+
115+
it('should navigate with locale prop correctly GSP', async () => {
116+
const browser = await webdriver(appPort, '/links?nextLocale=nl')
117+
await addDefaultLocaleCookie(browser)
118+
119+
expect(await browser.elementByCss('#router-pathname').text()).toBe('/links')
120+
expect(await browser.elementByCss('#router-as-path').text()).toBe(
121+
'/links?nextLocale=nl'
122+
)
123+
expect(await browser.elementByCss('#router-locale').text()).toBe('en-US')
124+
expect(
125+
JSON.parse(await browser.elementByCss('#router-locales').text())
126+
).toEqual(locales)
127+
expect(
128+
JSON.parse(await browser.elementByCss('#router-query').text())
129+
).toEqual({ nextLocale: 'nl' })
130+
131+
await browser.elementByCss('#to-fallback-first').click()
132+
await browser.waitForElementByCss('#gsp')
133+
134+
expect(await browser.elementByCss('#router-pathname').text()).toBe(
135+
'/gsp/fallback/[slug]'
136+
)
137+
expect(await browser.elementByCss('#router-as-path').text()).toBe(
138+
'/gsp/fallback/first'
139+
)
140+
expect(await browser.elementByCss('#router-locale').text()).toBe('nl')
141+
expect(
142+
JSON.parse(await browser.elementByCss('#router-locales').text())
143+
).toEqual(locales)
144+
expect(
145+
JSON.parse(await browser.elementByCss('#router-query').text())
146+
).toEqual({ slug: 'first' })
147+
148+
let parsedUrl = url.parse(await browser.eval('window.location.href'), true)
149+
expect(parsedUrl.pathname).toBe('/nl/gsp/fallback/first')
150+
expect(parsedUrl.query).toEqual({})
151+
152+
await browser.eval('window.history.back()')
153+
await browser.waitForElementByCss('#links')
154+
155+
expect(await browser.elementByCss('#router-pathname').text()).toBe('/links')
156+
expect(await browser.elementByCss('#router-as-path').text()).toBe(
157+
'/links?nextLocale=nl'
158+
)
159+
expect(await browser.elementByCss('#router-locale').text()).toBe('nl')
160+
expect(
161+
JSON.parse(await browser.elementByCss('#router-locales').text())
162+
).toEqual(locales)
163+
expect(
164+
JSON.parse(await browser.elementByCss('#router-query').text())
165+
).toEqual({ nextLocale: 'nl' })
166+
167+
parsedUrl = url.parse(await browser.eval('window.location.href'), true)
168+
expect(parsedUrl.pathname).toBe('/nl/links')
169+
expect(parsedUrl.query).toEqual({ nextLocale: 'nl' })
170+
171+
await browser.eval('window.history.forward()')
172+
await browser.waitForElementByCss('#gsp')
173+
174+
expect(await browser.elementByCss('#router-pathname').text()).toBe(
175+
'/gsp/fallback/[slug]'
176+
)
177+
expect(await browser.elementByCss('#router-as-path').text()).toBe(
178+
'/gsp/fallback/first'
179+
)
180+
expect(await browser.elementByCss('#router-locale').text()).toBe('nl')
181+
expect(
182+
JSON.parse(await browser.elementByCss('#router-locales').text())
183+
).toEqual(locales)
184+
expect(
185+
JSON.parse(await browser.elementByCss('#router-query').text())
186+
).toEqual({ slug: 'first' })
187+
188+
parsedUrl = url.parse(await browser.eval('window.location.href'), true)
189+
expect(parsedUrl.pathname).toBe('/nl/gsp/fallback/first')
190+
expect(parsedUrl.query).toEqual({})
191+
})
192+
30193
it('should update asPath on the client correctly', async () => {
31194
for (const check of ['en', 'En']) {
32195
const browser = await webdriver(appPort, `/${check}`)
@@ -509,10 +672,7 @@ function runTests(isDev) {
509672

510673
it('should navigate client side for default locale with no prefix', async () => {
511674
const browser = await webdriver(appPort, '/')
512-
// make sure default locale is used in case browser isn't set to
513-
// favor en-US by default, (we use all caps to ensure it's case-insensitive)
514-
await browser.manage().addCookie({ name: 'NEXT_LOCALE', value: 'EN-US' })
515-
await browser.get(browser.initUrl)
675+
await addDefaultLocaleCookie(browser)
516676

517677
const checkIndexValues = async () => {
518678
expect(await browser.elementByCss('#router-locale').text()).toBe('en-US')

0 commit comments

Comments
 (0)