From e554a1fbb92b2457bc3a35162028b2c0537391fb Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 15 Oct 2020 03:58:26 -0500 Subject: [PATCH] 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: https://github.com/vercel/next.js/pull/17370 --- .../webpack/loaders/next-serverless-loader.ts | 2 +- packages/next/client/link.tsx | 39 ++-- .../next/next-server/lib/router/router.ts | 2 + .../next/next-server/server/next-server.ts | 1 + test/integration/i18n-support/pages/links.js | 54 ++++++ .../i18n-support/test/index.test.js | 168 +++++++++++++++++- 6 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 test/integration/i18n-support/pages/links.js diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 41d173d9840b4ed..f38f687558a600d 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -558,7 +558,7 @@ const nextServerlessLoader: loader.Loader = function () { isDataReq: _nextData, locale: detectedLocale, locales, - defaultLocale, + defaultLocale: i18n.defaultLocale, }, options, ) diff --git a/packages/next/client/link.tsx b/packages/next/client/link.tsx index 56da5f68c4c3c38..41bdf3ef542842b 100644 --- a/packages/next/client/link.tsx +++ b/packages/next/client/link.tsx @@ -26,6 +26,7 @@ export type LinkProps = { shallow?: boolean passHref?: boolean prefetch?: boolean + locale?: string } type LinkPropsRequired = RequiredKeys type LinkPropsOptional = OptionalKeys @@ -125,7 +126,8 @@ function linkClicked( as: string, replace?: boolean, shallow?: boolean, - scroll?: boolean + scroll?: boolean, + locale?: string ): void { const { nodeName } = e.currentTarget @@ -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) { @@ -202,21 +204,28 @@ function Link(props: React.PropsWithChildren) { 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 ( @@ -226,11 +235,11 @@ function Link(props: React.PropsWithChildren) { 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 { @@ -285,7 +294,7 @@ function Link(props: React.PropsWithChildren) { } }, [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 (example) we wrap it in an tag if (typeof children === 'string') { children = {children} @@ -314,7 +323,7 @@ function Link(props: React.PropsWithChildren) { child.props.onClick(e) } if (!e.defaultPrevented) { - linkClicked(e, router, href, as, replace, shallow, scroll) + linkClicked(e, router, href, as, replace, shallow, scroll, locale) } }, } @@ -333,7 +342,11 @@ function Link(props: React.PropsWithChildren) { // 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 + ) ) } diff --git a/packages/next/next-server/lib/router/router.ts b/packages/next/next-server/lib/router/router.ts index 4a0bf3fd6a413ae..240b206dcc1c231 100644 --- a/packages/next/next-server/lib/router/router.ts +++ b/packages/next/next-server/lib/router/router.ts @@ -29,6 +29,7 @@ import escapePathDelimiters from './utils/escape-path-delimiters' interface TransitionOptions { shallow?: boolean + locale?: string } interface NextHistoryState { @@ -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 diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index fb636ac9b9de406..4c2d6244ba24bba 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -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 diff --git a/test/integration/i18n-support/pages/links.js b/test/integration/i18n-support/pages/links.js new file mode 100644 index 000000000000000..9e358b790020634 --- /dev/null +++ b/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 ( + <> + +

{JSON.stringify(props)}

+

{router.locale}

+

{JSON.stringify(router.locales)}

+

{JSON.stringify(router.query)}

+

{router.pathname}

+

{router.asPath}

+ + to /another + +
+ + to /gsp + +
+ + to /gsp/fallback/first + +
+ + to /gsp/fallback/hello + +
+ + to /gsp/no-fallback/first + +
+ + to /gssp + +
+ + to /gssp/first + +
+ + ) +} + +// make SSR page so we have query values immediately +export const getServerSideProps = () => { + return { + props: {}, + } +} diff --git a/test/integration/i18n-support/test/index.test.js b/test/integration/i18n-support/test/index.test.js index c34cdc488221130..90f8f36835e2a81 100644 --- a/test/integration/i18n-support/test/index.test.js +++ b/test/integration/i18n-support/test/index.test.js @@ -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}`) @@ -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')