Skip to content

Commit

Permalink
Add locale prop for transitioning locales client side (#17898)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ijjk committed Oct 15, 2020
1 parent 245499a commit e554a1f
Show file tree
Hide file tree
Showing 6 changed files with 248 additions and 18 deletions.
Expand Up @@ -558,7 +558,7 @@ const nextServerlessLoader: loader.Loader = function () {
isDataReq: _nextData,
locale: detectedLocale,
locales,
defaultLocale,
defaultLocale: i18n.defaultLocale,
},
options,
)
Expand Down
39 changes: 26 additions & 13 deletions packages/next/client/link.tsx
Expand Up @@ -26,6 +26,7 @@ export type LinkProps = {
shallow?: boolean
passHref?: boolean
prefetch?: boolean
locale?: string
}
type LinkPropsRequired = RequiredKeys<LinkProps>
type LinkPropsOptional = OptionalKeys<LinkProps>
Expand Down Expand Up @@ -125,7 +126,8 @@ function linkClicked(
as: string,
replace?: boolean,
shallow?: boolean,
scroll?: boolean
scroll?: boolean,
locale?: string
): void {
const { nodeName } = e.currentTarget

Expand All @@ -142,7 +144,7 @@ function linkClicked(
}

// replace state instead of push if prop is present
router[replace ? 'replace' : 'push'](href, as, { shallow }).then(
router[replace ? 'replace' : 'push'](href, as, { shallow, locale }).then(
(success: boolean) => {
if (!success) return
if (scroll) {
Expand Down Expand Up @@ -202,21 +204,28 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
shallow: true,
passHref: true,
prefetch: true,
locale: true,
} as const
const optionalProps: LinkPropsOptional[] = Object.keys(
optionalPropsGuard
) as LinkPropsOptional[]
optionalProps.forEach((key: LinkPropsOptional) => {
const valType = typeof props[key]

if (key === 'as') {
if (
props[key] &&
typeof props[key] !== 'string' &&
typeof props[key] !== 'object'
) {
if (props[key] && valType !== 'string' && valType !== 'object') {
throw createPropError({
key,
expected: '`string` or `object`',
actual: typeof props[key],
actual: valType,
})
}
} else if (key === 'locale') {
if (props[key] && valType !== 'string') {
throw createPropError({
key,
expected: '`string`',
actual: valType,
})
}
} else if (
Expand All @@ -226,11 +235,11 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
key === 'passHref' ||
key === 'prefetch'
) {
if (props[key] != null && typeof props[key] !== 'boolean') {
if (props[key] != null && valType !== 'boolean') {
throw createPropError({
key,
expected: '`boolean`',
actual: typeof props[key],
actual: valType,
})
}
} else {
Expand Down Expand Up @@ -285,7 +294,7 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
}
}, [p, childElm, href, as, router])

let { children, replace, shallow, scroll } = props
let { children, replace, shallow, scroll, locale } = props
// Deprecated. Warning shown by propType check. If the children provided is a string (<Link>example</Link>) we wrap it in an <a> tag
if (typeof children === 'string') {
children = <a>{children}</a>
Expand Down Expand Up @@ -314,7 +323,7 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
child.props.onClick(e)
}
if (!e.defaultPrevented) {
linkClicked(e, router, href, as, replace, shallow, scroll)
linkClicked(e, router, href, as, replace, shallow, scroll, locale)
}
},
}
Expand All @@ -333,7 +342,11 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
// defined, we specify the current 'href', so that repetition is not needed by the user
if (props.passHref || (child.type === 'a' && !('href' in child.props))) {
childProps.href = addBasePath(
addLocale(as, router && router.locale, router && router.defaultLocale)
addLocale(
as,
locale || (router && router.locale),
router && router.defaultLocale
)
)
}

Expand Down
2 changes: 2 additions & 0 deletions packages/next/next-server/lib/router/router.ts
Expand Up @@ -29,6 +29,7 @@ import escapePathDelimiters from './utils/escape-path-delimiters'

interface TransitionOptions {
shallow?: boolean
locale?: string
}

interface NextHistoryState {
Expand Down Expand Up @@ -592,6 +593,7 @@ export default class Router implements BaseRouter {
window.location.href = url
return false
}
this.locale = options.locale || this.locale

if (!(options as any)._h) {
this.isSsr = false
Expand Down
1 change: 1 addition & 0 deletions packages/next/next-server/server/next-server.ts
Expand Up @@ -197,6 +197,7 @@ export default class Server {
? requireFontManifest(this.distDir, this._isLikeServerless)
: null,
optimizeImages: this.nextConfig.experimental.optimizeImages,
defaultLocale: this.nextConfig.experimental.i18n?.defaultLocale,
}

// Only the `publicRuntimeConfig` key is exposed to the client side
Expand Down
54 changes: 54 additions & 0 deletions test/integration/i18n-support/pages/links.js
@@ -0,0 +1,54 @@
import Link from 'next/link'
import { useRouter } from 'next/router'

export default function Page(props) {
const router = useRouter()
const { nextLocale } = router.query

return (
<>
<p id="links">links page</p>
<p id="props">{JSON.stringify(props)}</p>
<p id="router-locale">{router.locale}</p>
<p id="router-locales">{JSON.stringify(router.locales)}</p>
<p id="router-query">{JSON.stringify(router.query)}</p>
<p id="router-pathname">{router.pathname}</p>
<p id="router-as-path">{router.asPath}</p>
<Link href="/another" locale={nextLocale}>
<a id="to-another">to /another</a>
</Link>
<br />
<Link href="/gsp" locale={nextLocale}>
<a id="to-gsp">to /gsp</a>
</Link>
<br />
<Link href="/gsp/fallback/first" locale={nextLocale}>
<a id="to-fallback-first">to /gsp/fallback/first</a>
</Link>
<br />
<Link href="/gsp/fallback/hello" locale={nextLocale}>
<a id="to-fallback-hello">to /gsp/fallback/hello</a>
</Link>
<br />
<Link href="/gsp/no-fallback/first" locale={nextLocale}>
<a id="to-no-fallback-first">to /gsp/no-fallback/first</a>
</Link>
<br />
<Link href="/gssp" locale={nextLocale}>
<a id="to-gssp">to /gssp</a>
</Link>
<br />
<Link href="/gssp/first" locale={nextLocale}>
<a id="to-gssp-slug">to /gssp/first</a>
</Link>
<br />
</>
)
}

// make SSR page so we have query values immediately
export const getServerSideProps = () => {
return {
props: {},
}
}
168 changes: 164 additions & 4 deletions test/integration/i18n-support/test/index.test.js
Expand Up @@ -26,7 +26,170 @@ let appPort

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

async function addDefaultLocaleCookie(browser) {
// make sure default locale is used in case browser isn't set to
// favor en-US by default, (we use all caps to ensure it's case-insensitive)
await browser.manage().addCookie({ name: 'NEXT_LOCALE', value: 'EN-US' })
await browser.get(browser.initUrl)
}

function runTests(isDev) {
it('should navigate with locale prop correctly', async () => {
const browser = await webdriver(appPort, '/links?nextLocale=fr')
await addDefaultLocaleCookie(browser)

expect(await browser.elementByCss('#router-pathname').text()).toBe('/links')
expect(await browser.elementByCss('#router-as-path').text()).toBe(
'/links?nextLocale=fr'
)
expect(await browser.elementByCss('#router-locale').text()).toBe('en-US')
expect(
JSON.parse(await browser.elementByCss('#router-locales').text())
).toEqual(locales)
expect(
JSON.parse(await browser.elementByCss('#router-query').text())
).toEqual({ nextLocale: 'fr' })

await browser.elementByCss('#to-another').click()
await browser.waitForElementByCss('#another')

expect(await browser.elementByCss('#router-pathname').text()).toBe(
'/another'
)
expect(await browser.elementByCss('#router-as-path').text()).toBe(
'/another'
)
expect(await browser.elementByCss('#router-locale').text()).toBe('fr')
expect(
JSON.parse(await browser.elementByCss('#router-locales').text())
).toEqual(locales)
expect(
JSON.parse(await browser.elementByCss('#router-query').text())
).toEqual({})

let parsedUrl = url.parse(await browser.eval('window.location.href'), true)
expect(parsedUrl.pathname).toBe('/fr/another')
expect(parsedUrl.query).toEqual({})

await browser.eval('window.history.back()')
await browser.waitForElementByCss('#links')

expect(await browser.elementByCss('#router-pathname').text()).toBe('/links')
expect(await browser.elementByCss('#router-as-path').text()).toBe(
'/links?nextLocale=fr'
)
expect(await browser.elementByCss('#router-locale').text()).toBe('fr')
expect(
JSON.parse(await browser.elementByCss('#router-locales').text())
).toEqual(locales)
expect(
JSON.parse(await browser.elementByCss('#router-query').text())
).toEqual({ nextLocale: 'fr' })

parsedUrl = url.parse(await browser.eval('window.location.href'), true)
expect(parsedUrl.pathname).toBe('/fr/links')
expect(parsedUrl.query).toEqual({ nextLocale: 'fr' })

await browser.eval('window.history.forward()')
await browser.waitForElementByCss('#another')

expect(await browser.elementByCss('#router-pathname').text()).toBe(
'/another'
)
expect(await browser.elementByCss('#router-as-path').text()).toBe(
'/another'
)
expect(await browser.elementByCss('#router-locale').text()).toBe('fr')
expect(
JSON.parse(await browser.elementByCss('#router-locales').text())
).toEqual(locales)
expect(
JSON.parse(await browser.elementByCss('#router-query').text())
).toEqual({})

parsedUrl = url.parse(await browser.eval('window.location.href'), true)
expect(parsedUrl.pathname).toBe('/fr/another')
expect(parsedUrl.query).toEqual({})
})

it('should navigate with locale prop correctly GSP', async () => {
const browser = await webdriver(appPort, '/links?nextLocale=nl')
await addDefaultLocaleCookie(browser)

expect(await browser.elementByCss('#router-pathname').text()).toBe('/links')
expect(await browser.elementByCss('#router-as-path').text()).toBe(
'/links?nextLocale=nl'
)
expect(await browser.elementByCss('#router-locale').text()).toBe('en-US')
expect(
JSON.parse(await browser.elementByCss('#router-locales').text())
).toEqual(locales)
expect(
JSON.parse(await browser.elementByCss('#router-query').text())
).toEqual({ nextLocale: 'nl' })

await browser.elementByCss('#to-fallback-first').click()
await browser.waitForElementByCss('#gsp')

expect(await browser.elementByCss('#router-pathname').text()).toBe(
'/gsp/fallback/[slug]'
)
expect(await browser.elementByCss('#router-as-path').text()).toBe(
'/gsp/fallback/first'
)
expect(await browser.elementByCss('#router-locale').text()).toBe('nl')
expect(
JSON.parse(await browser.elementByCss('#router-locales').text())
).toEqual(locales)
expect(
JSON.parse(await browser.elementByCss('#router-query').text())
).toEqual({ slug: 'first' })

let parsedUrl = url.parse(await browser.eval('window.location.href'), true)
expect(parsedUrl.pathname).toBe('/nl/gsp/fallback/first')
expect(parsedUrl.query).toEqual({})

await browser.eval('window.history.back()')
await browser.waitForElementByCss('#links')

expect(await browser.elementByCss('#router-pathname').text()).toBe('/links')
expect(await browser.elementByCss('#router-as-path').text()).toBe(
'/links?nextLocale=nl'
)
expect(await browser.elementByCss('#router-locale').text()).toBe('nl')
expect(
JSON.parse(await browser.elementByCss('#router-locales').text())
).toEqual(locales)
expect(
JSON.parse(await browser.elementByCss('#router-query').text())
).toEqual({ nextLocale: 'nl' })

parsedUrl = url.parse(await browser.eval('window.location.href'), true)
expect(parsedUrl.pathname).toBe('/nl/links')
expect(parsedUrl.query).toEqual({ nextLocale: 'nl' })

await browser.eval('window.history.forward()')
await browser.waitForElementByCss('#gsp')

expect(await browser.elementByCss('#router-pathname').text()).toBe(
'/gsp/fallback/[slug]'
)
expect(await browser.elementByCss('#router-as-path').text()).toBe(
'/gsp/fallback/first'
)
expect(await browser.elementByCss('#router-locale').text()).toBe('nl')
expect(
JSON.parse(await browser.elementByCss('#router-locales').text())
).toEqual(locales)
expect(
JSON.parse(await browser.elementByCss('#router-query').text())
).toEqual({ slug: 'first' })

parsedUrl = url.parse(await browser.eval('window.location.href'), true)
expect(parsedUrl.pathname).toBe('/nl/gsp/fallback/first')
expect(parsedUrl.query).toEqual({})
})

it('should update asPath on the client correctly', async () => {
for (const check of ['en', 'En']) {
const browser = await webdriver(appPort, `/${check}`)
Expand Down Expand Up @@ -509,10 +672,7 @@ function runTests(isDev) {

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

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

0 comments on commit e554a1f

Please sign in to comment.