Skip to content

Commit

Permalink
feat: allow client side translation loading (#1726)
Browse files Browse the repository at this point in the history
* feat: allow client side translation loading

* use typeof window instead of process.browser

* update deps

* update react-i18next

* update i18next dep

* Update src/appWithTranslation.tsx

Co-authored-by: Isaac Hinman <isaac@isaachinman.com>

* use older next.js version also in this example

* update react-i18next

* update react-i18next

* move client-loading example out of here

* remove extra readme

* revert simple/yarn.lock

* Update src/appWithTranslation.client.test.tsx

Co-authored-by: Isaac Hinman <isaac@isaachinman.com>

* Update src/appWithTranslation.tsx

Co-authored-by: Isaac Hinman <isaac@isaachinman.com>

* bring back missing test for configOverride.resources

* next version seems to be major

* update react-i18next

Co-authored-by: Isaac Hinman <isaac@isaachinman.com>
  • Loading branch information
adrai and isaachinman authored Apr 3, 2022
1 parent 7bbefd9 commit 8132efd
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 39 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ export const getStaticProps = async ({ locale }) => ({
});
```

#### Client side loading of translations via HTTP

Since v11.0.0 next-i18next also provides support for client side loading of translations.

More information about that can be found [here](https://github.com/i18next/i18next-http-backend/tree/master/example/next).

#### Reloading Resources in Development

Because resources are loaded once when the server is started, any changes made to your translation JSON files in development will not be loaded until the server is restarted.
Expand Down
23 changes: 8 additions & 15 deletions examples/simple/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1874,12 +1874,12 @@ https-proxy-agent@5.0.0:
agent-base "6"
debug "4"

i18next-fs-backend@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-1.0.7.tgz#00ca4587e306f8948740408389dda73461a5d07f"
integrity sha512-aAZ3rvshe1Zbl6JSCWrWWqbZS5JpmVNG+84YqLcgdYcm9uAxzw4xWxnA/a3044Nm2PKXE62CT+pIZjk7OEYtTw==
i18next-fs-backend@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-1.1.4.tgz#d0e9b9ed2fa7a0f11002d82b9fa69c3c3d6482da"
integrity sha512-/MfAGMP0jHonV966uFf9PkWWuDjPYLIcsipnSO3NxpNtAgRUKLTwvm85fEmsF6hGeu0zbZiCQ3W74jwO6K9uXA==

i18next@^21.6.12:
i18next@^21.6.14:
version "21.6.14"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.6.14.tgz#2bc199fba7f4da44b5952d7df0a3814a6e5c3943"
integrity sha512-XL6WyD+xlwQwbieXRlXhKWoLb/rkch50/rA+vl6untHnJ+aYnkQ0YDZciTWE78PPhOpbi2gR0LTJCJpiAhA+uQ==
Expand Down Expand Up @@ -2483,15 +2483,8 @@ neo-async@^2.5.0, neo-async@^2.6.1, neo-async@^2.6.2:
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==

"next-i18next@link:../..":
version "10.5.0"
dependencies:
"@babel/runtime" "^7.13.17"
"@types/hoist-non-react-statics" "^3.3.1"
core-js "^3"
hoist-non-react-statics "^3.2.0"
i18next "^21.6.12"
i18next-fs-backend "^1.0.7"
react-i18next "^11.15.5"
version "0.0.0"
uid ""

next-tick@~1.0.0:
version "1.0.0"
Expand Down Expand Up @@ -3080,7 +3073,7 @@ rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"

react-i18next@^11.15.5:
react-i18next@^11.16.1:
version "11.16.2"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.16.2.tgz#650b18c12a624057ee2651ba4b4a989b526be554"
integrity sha512-1iuZduvARUelL5ux663FvIoDZExwFO+9QtRAAt4uvs1/aun4cUZt8XBrVg7iiDgNls9cOSORAhE7Ri5KA9RMvg==
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@
"@types/hoist-non-react-statics": "^3.3.1",
"core-js": "^3",
"hoist-non-react-statics": "^3.2.0",
"i18next": "^21.6.12",
"i18next-fs-backend": "^1.0.7",
"react-i18next": "^11.15.5"
"i18next": "^21.6.14",
"i18next-fs-backend": "^1.1.4",
"react-i18next": "^11.16.2"
},
"peerDependencies": {
"next": ">= 10.0.0",
Expand Down
131 changes: 122 additions & 9 deletions src/appWithTranslation.client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ jest.mock('./createClient', () => jest.fn())

const DummyApp = appWithTranslation(() => (
<div>Hello world</div>
))
), {
i18n: {
defaultLocale: 'en',
locales: ['en', 'de'],
},
})

const createProps = (locale = 'en', router: Partial<NextRouter> = {}) => ({
pageProps: {
Expand Down Expand Up @@ -148,6 +153,113 @@ describe('appWithTranslation', () => {
).toThrow('appWithTranslation was called without a next-i18next config')
})

it('throws an error if userConfig and configOverride are both missing an i18n property', () => {
const DummyAppConfigOverride = appWithTranslation(() => (
<div>Hello world</div>
), {} as any)
const customProps = {
...createProps(),
pageProps: {
_nextI18Next: {
initialLocale: 'en',
userConfig: {},
},
} as any,
} as any
expect(
() => render(
<DummyAppConfigOverride
{...customProps}
/>
)
).toThrow('appWithTranslation was called without config.i18n')
})

it('throws an error if userConfig and configOverride are both missing a defaultLocale property', () => {
const DummyAppConfigOverride = appWithTranslation(() => (
<div>Hello world</div>
), {i18n: {} as any})
const customProps = {
...createProps(),
pageProps: {
_nextI18Next: {
initialLocale: 'en',
userConfig: {i18n: {}},
},
} as any,
} as any
expect(
() => render(
<DummyAppConfigOverride
{...customProps}
/>
)
).toThrow('config.i18n does not include a defaultLocale property')
})

it('should use the initialLocale property if the router locale is undefined', () => {
const DummyAppConfigOverride = appWithTranslation(() => (
<div>Hello world</div>
))
const customProps = {
...createProps(),
pageProps: {
_nextI18Next: {
initialLocale: 'en',
userConfig: {i18n: {
defaultLocale: 'fr',
}},
},
} as any,
} as any

customProps.router = {
...customProps.router,
locale: undefined,
}

render(
<DummyAppConfigOverride
{...customProps}
/>
)

const [args] = (I18nextProvider as jest.Mock).mock.calls
expect(args[0].i18n.language).toEqual('en')
})

it('should use the userConfig defaltLocale property if the router locale is undefined and initialLocale is undefined', () => {
const DummyAppConfigOverride = appWithTranslation(() => (
<div>Hello world</div>
))
const customProps = {
...createProps(),

pageProps: {
_nextI18Next: {
initialLocale: undefined,
userConfig: {i18n: {
defaultLocale: 'fr',
}},
},
} as any,
} as any

customProps.router = {
...customProps.router,
locale: undefined,
}

render(
<DummyAppConfigOverride
{...customProps}
/>
)

const [args] = (I18nextProvider as jest.Mock).mock.calls
expect(args[0].i18n.language).toEqual('fr')
})

it('returns an I18nextProvider', () => {
renderComponent()
expect(I18nextProvider).toHaveBeenCalledTimes(1)
Expand All @@ -165,6 +277,12 @@ describe('appWithTranslation', () => {
expect(fs.readdirSync).toHaveBeenCalledTimes(0)
})

it('should use locale from router', () => {
renderComponent(createProps('de'))
const [args] = (I18nextProvider as jest.Mock).mock.calls
expect(args[0].i18n.language).toEqual('de')
})

it('does not re-call createClient on re-renders unless locale or props have changed', () => {
const { rerender } = renderComponent()
expect(createClient).toHaveBeenCalledTimes(1)
Expand All @@ -181,16 +299,11 @@ describe('appWithTranslation', () => {
/>
)
expect(createClient).toHaveBeenCalledTimes(2)
const deProps = createProps('de')
newProps.pageProps._nextI18Next.initialLocale = 'de'
newProps.router.locale = 'de'
rerender(
<DummyApp
{...deProps}
/>
)
expect(createClient).toHaveBeenCalledTimes(3)
rerender(
<DummyApp
{...deProps}
{...newProps}
/>
)
expect(createClient).toHaveBeenCalledTimes(3)
Expand Down
25 changes: 17 additions & 8 deletions src/appWithTranslation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,19 @@ export const appWithTranslation = <Props extends AppProps = AppProps>(
) => {
const AppWithTranslation = (props: Props) => {
const { _nextI18Next } = props.pageProps as SSRConfig
const locale: string | null = _nextI18Next?.initialLocale ?? null
let locale: string | null =
_nextI18Next?.initialLocale ?? props?.router?.locale

// Memoize the instance and only re-initialize when either:
// 1. The route changes (non-shallowly)
// 2. Router locale changes
// 3. UserConfig override changes
const i18n: I18NextClient | null = useMemo(() => {
if (!_nextI18Next) return null
if (!_nextI18Next && !configOverride) return null

let { userConfig } = _nextI18Next
const { initialI18nStore } = _nextI18Next
const resources =
configOverride?.resources ? configOverride.resources : initialI18nStore
let userConfig = configOverride ?? _nextI18Next?.userConfig

if (userConfig === null && configOverride === null) {
if (!userConfig && configOverride === null) {
throw new Error('appWithTranslation was called without a next-i18next config')
}

Expand All @@ -48,6 +47,16 @@ export const appWithTranslation = <Props extends AppProps = AppProps>(
throw new Error('appWithTranslation was called without config.i18n')
}

if (!userConfig?.i18n?.defaultLocale) {
throw new Error('config.i18n does not include a defaultLocale property')
}

const { initialI18nStore } = _nextI18Next || {}
const resources =
configOverride?.resources ? configOverride.resources : initialI18nStore

if (!locale) locale = userConfig.i18n.defaultLocale

const instance = createClient({
...createConfig({
...userConfig,
Expand All @@ -60,7 +69,7 @@ export const appWithTranslation = <Props extends AppProps = AppProps>(
globalI18n = instance

return instance
}, [_nextI18Next, locale])
}, [_nextI18Next, locale, configOverride])

return i18n !== null ? (
<I18nextProvider
Expand Down
15 changes: 11 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1031,7 +1031,7 @@
core-js-pure "^3.0.0"
regenerator-runtime "^0.13.4"

"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.17", "@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4":
"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.17", "@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4":
version "7.17.8"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.8.tgz#3e56e4aff81befa55ac3ac6a0967349fd1c5bca2"
integrity sha512-dQpEpK0O9o6lj6oPu0gRDbbnk+4LeHlNcBpspf6Olzt3GIX4P1lWF1gS+pHLDFlaJvbR6q7jCfQ08zA4QJBnmA==
Expand Down Expand Up @@ -4205,12 +4205,19 @@ husky@^3.0.0:
run-node "^1.0.0"
slash "^3.0.0"

i18next-fs-backend@>=1.1.4, i18next-fs-backend@^1.0.7:
i18next-fs-backend@>=1.1.4, i18next-fs-backend@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-1.1.4.tgz#d0e9b9ed2fa7a0f11002d82b9fa69c3c3d6482da"
integrity sha512-/MfAGMP0jHonV966uFf9PkWWuDjPYLIcsipnSO3NxpNtAgRUKLTwvm85fEmsF6hGeu0zbZiCQ3W74jwO6K9uXA==

i18next@^21.0.1, i18next@^21.6.12:
i18next@^21.0.1:
version "21.6.13"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.6.13.tgz#e881b05f156ac06997e9b63379d8b2674bb4a4f2"
integrity sha512-MVjNttw+5mIuu2/fwTpSU0EeI7iU/6pnDvGQboCzkILiv0/gD+FLZaF7qSHmUHO4ZkE6xJQ9SlBgGvMHxhC82Q==
dependencies:
"@babel/runtime" "^7.12.0"

i18next@^21.6.14:
version "21.6.14"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.6.14.tgz#2bc199fba7f4da44b5952d7df0a3814a6e5c3943"
integrity sha512-XL6WyD+xlwQwbieXRlXhKWoLb/rkch50/rA+vl6untHnJ+aYnkQ0YDZciTWE78PPhOpbi2gR0LTJCJpiAhA+uQ==
Expand Down Expand Up @@ -6241,7 +6248,7 @@ react-dom@^17.0.1:
object-assign "^4.1.1"
scheduler "^0.20.2"

react-i18next@^11.15.5:
react-i18next@^11.16.2:
version "11.16.2"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.16.2.tgz#650b18c12a624057ee2651ba4b4a989b526be554"
integrity sha512-1iuZduvARUelL5ux663FvIoDZExwFO+9QtRAAt4uvs1/aun4cUZt8XBrVg7iiDgNls9cOSORAhE7Ri5KA9RMvg==
Expand Down

0 comments on commit 8132efd

Please sign in to comment.