Repository: Automattic/wp-calypso
Pull request #103635: Hosting dashboard: load i18n data at boot
Author: login: jsnajdr
Labels: name: i18n
name: [Feature] Multi-site Dashboard
Created: 2025-05-22T13:51:25Z | Merged: 2025-05-27T07:25:57Z

Adds i18n to the Hosting Dashboard. Implemented from scratch, no dependencies on existing Calypso code. Loads the language JSON from a `widgets.wp.com` URL and sets the data on an `i18n` object from `@wordpress/i18n`.

This is the basic setup, main Calypso does more. Here are the next steps how to evolve this:
- bootstrap the i18n data on the server: send it in the initial HTML document so that the browser app doesn't need to wait for an extra network roundtrip before booting up.
- implement the `use-translation-chunk` feature where we don't read the language file for entire Calypso (the size is in the order of hundreds of kilobytes), but hook into webpack to load i18n data together with an async chunk's JS and CSS.
- set `lang` and `rtl` attributes in DOM when language changes (mimic the `setLocaleInDOM` function).
- switch CSS stylesheets from LTR to RTL versions and vice versa when language changes.

Fixes DOTCOM-10589.


Diff:
diff --git a/client/dashboard/app/i18n.tsx b/client/dashboard/app/i18n.tsx
new file mode 100644
index 000000000000..04733687902b
--- /dev/null
+++ b/client/dashboard/app/i18n.tsx
@@ -0,0 +1,70 @@
+import { defaultI18n } from '@wordpress/i18n';
+import { I18nProvider as WPI18nProvider } from '@wordpress/react-i18n';
+import { useEffect, useState, type PropsWithChildren } from 'react';
+import { useAuth } from './auth';
+
+async function fetchLocaleData( language: string, signal: AbortSignal ) {
+	if ( language === 'en' ) {
+		return [ language, undefined ];
+	}
+
+	try {
+		const response = await fetch(
+			`https://widgets.wp.com/languages/calypso/${ language }-v1.1.json`,
+			{ signal }
+		);
+
+		return [ language, await response.json() ];
+	} catch ( error ) {
+		// Only fall back to `en` when the error is not an abort
+		if ( error instanceof Error && error.name === 'AbortError' ) {
+			throw error;
+		}
+
+		// Fall back to `en` when fetching the language data fails. Without this
+		// the i18n provider would be stuck forever in a non-loaded state.
+		return [ 'en', undefined ];
+	}
+}
+
+// Determine the locale to use. The current implementation reads the logged-in user's
+// locale, but it can be made more flexible and support multiple sources. E.g., a locale
+// slug in the route path.
+function useLocaleSlug() {
+	const { user } = useAuth();
+	return user.locale_variant || user.language || 'en';
+}
+
+export function I18nProvider( { children }: PropsWithChildren ) {
+	const language = useLocaleSlug();
+	const [ loadedLocale, setLoadedLocale ] = useState< string | null >( null );
+
+	const i18n = defaultI18n;
+
+	useEffect( () => {
+		const abortController = new AbortController();
+
+		fetchLocaleData( language, abortController.signal )
+			.then( ( [ realLanguage, data ] ) => {
+				i18n.resetLocaleData( data );
+				// `realLanguage` can be different from `language` when loading language data fails
+				// and it falls back to `en`.
+				setLoadedLocale( realLanguage );
+			} )
+			.catch( () => {
+				// Ignore abort errors as they are expected during cleanup
+			} );
+
+		return () => {
+			abortController.abort();
+		};
+	}, [ i18n, language ] );
+
+	// Render the sub-tree only after the initial locale data are loaded. We don't want a
+	// flash of `en` content on the initial render that's updated a moment later.
+	if ( loadedLocale === null ) {
+		return null;
+	}
+
+	return <WPI18nProvider i18n={ i18n } children={ children } />;
+}
diff --git a/client/dashboard/app/layout.tsx b/client/dashboard/app/layout.tsx
index efa394664530..be6e07bbb174 100644
--- a/client/dashboard/app/layout.tsx
+++ b/client/dashboard/app/layout.tsx
@@ -3,6 +3,7 @@ import { RouterProvider } from '@tanstack/react-router';
 import { useMemo } from 'react';
 import { AuthProvider, useAuth } from './auth';
 import { AppProvider, type AppConfig } from './context';
+import { I18nProvider } from './i18n';
 import { queryClient } from './query-client';
 import { getRouter } from './router';
 
@@ -17,7 +18,9 @@ function Layout( { config }: { config: AppConfig } ) {
 		<AppProvider config={ config }>
 			<QueryClientProvider client={ queryClient }>
 				<AuthProvider>
-					<RouterProviderWithAuth config={ config } />
+					<I18nProvider>
+						<RouterProviderWithAuth config={ config } />
+					</I18nProvider>
 				</AuthProvider>
 			</QueryClientProvider>
 		</AppProvider>
diff --git a/client/dashboard/data/types.ts b/client/dashboard/data/types.ts
index f6b5aecb6fb5..080eeef1a84f 100644
--- a/client/dashboard/data/types.ts
+++ b/client/dashboard/data/types.ts
@@ -13,6 +13,8 @@ export interface User {
 	username: string;
 	display_name: string;
 	avatar_URL?: string;
+	language: string;
+	locale_variant: string;
 }
 
 export interface SiteDomain {


Reviews:
user: login: aduth
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T14:08:40Z
user: login: jsnajdr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T14:52:08Z
user: login: arthur791004
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T08:26:16Z
user: login: jsnajdr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T09:07:14Z
user: login: jsnajdr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T09:33:12Z
user: login: arthur791004
body: Let's continue to iterate this in the follow-up PR 🙂
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T09:30:57Z

Review comments:
diff_hunk: @@ -0,0 +1,55 @@
+import { defaultI18n } from '@wordpress/i18n';
+import { I18nProvider as WPI18nProvider } from '@wordpress/react-i18n';
+import { useEffect, useRef, useState, type PropsWithChildren } from 'react';
+import { useAuth } from './auth';
+
+async function fetchLocaleData( language: string ) {
+	if ( language === 'en' ) {
+		return [ language, undefined ];
+	}
+
+	try {
+		const response = await fetch(
+			`https://widgets.wp.com/languages/calypso/${ language }-v1.1.json`
+		);
+
+		return [ language, await response.json() ];
+	} catch {
+		// Fall back to `en` when fetching the language data fails. Without this
+		// the i18n provider would be stuck forever in a non-loaded state.
+		return [ 'en', undefined ];
+	}
+}
+
+export function I18nProvider( { children }: PropsWithChildren ) {
+	const [ loadedLocale, setLoadedLocale ] = useState< string | null >( null );
+	const loadingLocaleRef = useRef< string | null >( null );
+	const { user } = useAuth();
+
+	const i18n = defaultI18n;
+	const language = user.locale_variant || user.language || 'en';
+
+	useEffect( () => {
+		loadingLocaleRef.current = language;
+
+		fetchLocaleData( language ).then( ( [ realLanguage, data ] ) => {
path: client/dashboard/app/i18n.tsx
user: login: aduth
body: It's unlikely, but should this component be resilient to being unmounted, and handling what happens if it's unmounted while the fetch is still in-flight? (e.g. something with `AbortController` or maybe unsetting `loadingLocaleRef.current` in the `useEffect` return value function).
created_at: 2025-05-22T14:03:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,55 @@
+import { defaultI18n } from '@wordpress/i18n';
+import { I18nProvider as WPI18nProvider } from '@wordpress/react-i18n';
+import { useEffect, useRef, useState, type PropsWithChildren } from 'react';
+import { useAuth } from './auth';
+
+async function fetchLocaleData( language: string ) {
+	if ( language === 'en' ) {
+		return [ language, undefined ];
+	}
+
+	try {
+		const response = await fetch(
+			`https://widgets.wp.com/languages/calypso/${ language }-v1.1.json`
+		);
+
+		return [ language, await response.json() ];
+	} catch {
+		// Fall back to `en` when fetching the language data fails. Without this
+		// the i18n provider would be stuck forever in a non-loaded state.
+		return [ 'en', undefined ];
+	}
+}
+
+export function I18nProvider( { children }: PropsWithChildren ) {
+	const [ loadedLocale, setLoadedLocale ] = useState< string | null >( null );
+	const loadingLocaleRef = useRef< string | null >( null );
+	const { user } = useAuth();
+
+	const i18n = defaultI18n;
+	const language = user.locale_variant || user.language || 'en';
+
+	useEffect( () => {
+		loadingLocaleRef.current = language;
+
+		fetchLocaleData( language ).then( ( [ realLanguage, data ] ) => {
+			// Activate the data only if no other language switch has run in the meantime
+			if ( loadingLocaleRef.current !== language ) {
+				return;
+			}
+
+			i18n.resetLocaleData( data );
+			// `realLanguage` can be different from `language` when loading language data fails
+			// and it falls back to `en`.
+			setLoadedLocale( realLanguage );
+		} );
+	}, [ i18n, language ] );
+
+	// Render the sub-tree only after the initial locale data are loaded. We don't want a
+	// flash of `en` content on the initial render that's updated a moment later.
+	if ( loadedLocale === null ) {
+		return null;
+	}
path: client/dashboard/app/i18n.tsx
user: login: aduth
body: I think this makes sense as a first pass, but it feels kinda thrashy to show nothing until the network request resolves. I also wonder if it could create issues if we ever did server-rendering. Or maybe we should have children be a function so that we don't spend any effort creating the element tree until we need it. Worst-case: some kind of loader state.

I think ideally with your "bootstrap" next steps idea, this could all be synchronous though.
created_at: 2025-05-22T14:08:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,55 @@
+import { defaultI18n } from '@wordpress/i18n';
+import { I18nProvider as WPI18nProvider } from '@wordpress/react-i18n';
+import { useEffect, useRef, useState, type PropsWithChildren } from 'react';
+import { useAuth } from './auth';
+
+async function fetchLocaleData( language: string ) {
+	if ( language === 'en' ) {
+		return [ language, undefined ];
+	}
+
+	try {
+		const response = await fetch(
+			`https://widgets.wp.com/languages/calypso/${ language }-v1.1.json`
+		);
+
+		return [ language, await response.json() ];
+	} catch {
+		// Fall back to `en` when fetching the language data fails. Without this
+		// the i18n provider would be stuck forever in a non-loaded state.
+		return [ 'en', undefined ];
+	}
+}
+
+export function I18nProvider( { children }: PropsWithChildren ) {
+	const [ loadedLocale, setLoadedLocale ] = useState< string | null >( null );
+	const loadingLocaleRef = useRef< string | null >( null );
+	const { user } = useAuth();
+
+	const i18n = defaultI18n;
+	const language = user.locale_variant || user.language || 'en';
+
+	useEffect( () => {
+		loadingLocaleRef.current = language;
+
+		fetchLocaleData( language ).then( ( [ realLanguage, data ] ) => {
path: client/dashboard/app/i18n.tsx
user: login: jsnajdr
body: Good point, I rewrote the effect to use `AbortController`. Now it should handle all the relevant situations much better: abort when another locale switch is initiated while the previous one hasn't finished fetching yet; abort also when umounting and don't call `i18n` methods after the component was unmounted.
created_at: 2025-05-22T14:52:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,55 @@
+import { defaultI18n } from '@wordpress/i18n';
+import { I18nProvider as WPI18nProvider } from '@wordpress/react-i18n';
+import { useEffect, useRef, useState, type PropsWithChildren } from 'react';
+import { useAuth } from './auth';
+
+async function fetchLocaleData( language: string ) {
+	if ( language === 'en' ) {
+		return [ language, undefined ];
+	}
+
+	try {
+		const response = await fetch(
+			`https://widgets.wp.com/languages/calypso/${ language }-v1.1.json`
+		);
+
+		return [ language, await response.json() ];
+	} catch {
+		// Fall back to `en` when fetching the language data fails. Without this
+		// the i18n provider would be stuck forever in a non-loaded state.
+		return [ 'en', undefined ];
+	}
+}
+
+export function I18nProvider( { children }: PropsWithChildren ) {
+	const [ loadedLocale, setLoadedLocale ] = useState< string | null >( null );
+	const loadingLocaleRef = useRef< string | null >( null );
+	const { user } = useAuth();
+
+	const i18n = defaultI18n;
+	const language = user.locale_variant || user.language || 'en';
+
+	useEffect( () => {
+		loadingLocaleRef.current = language;
+
+		fetchLocaleData( language ).then( ( [ realLanguage, data ] ) => {
+			// Activate the data only if no other language switch has run in the meantime
+			if ( loadingLocaleRef.current !== language ) {
+				return;
+			}
+
+			i18n.resetLocaleData( data );
+			// `realLanguage` can be different from `language` when loading language data fails
+			// and it falls back to `en`.
+			setLoadedLocale( realLanguage );
+		} );
+	}, [ i18n, language ] );
+
+	// Render the sub-tree only after the initial locale data are loaded. We don't want a
+	// flash of `en` content on the initial render that's updated a moment later.
+	if ( loadedLocale === null ) {
+		return null;
+	}
path: client/dashboard/app/i18n.tsx
user: login: arthur791004
body: It would be great if we can simply load the locale data from the server side. I remember we have already had this mechanism [here](https://github.com/Automattic/wp-calypso/blob/1141bf702d5e32e88ccb6dbe44f7798fc9409cc5/client/document/index.jsx#L218) and [here](https://github.com/Automattic/wp-calypso/blob/1141bf702d5e32e88ccb6dbe44f7798fc9409cc5/client/document/index.jsx#L247-L251) and data is stored in somewhere like `window.i18nLanguageManifest`.
created_at: 2025-05-23T08:26:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,55 @@
+import { defaultI18n } from '@wordpress/i18n';
+import { I18nProvider as WPI18nProvider } from '@wordpress/react-i18n';
+import { useEffect, useRef, useState, type PropsWithChildren } from 'react';
+import { useAuth } from './auth';
+
+async function fetchLocaleData( language: string ) {
+	if ( language === 'en' ) {
+		return [ language, undefined ];
+	}
+
+	try {
+		const response = await fetch(
+			`https://widgets.wp.com/languages/calypso/${ language }-v1.1.json`
+		);
+
+		return [ language, await response.json() ];
+	} catch {
+		// Fall back to `en` when fetching the language data fails. Without this
+		// the i18n provider would be stuck forever in a non-loaded state.
+		return [ 'en', undefined ];
+	}
+}
+
+export function I18nProvider( { children }: PropsWithChildren ) {
+	const [ loadedLocale, setLoadedLocale ] = useState< string | null >( null );
+	const loadingLocaleRef = useRef< string | null >( null );
+	const { user } = useAuth();
+
+	const i18n = defaultI18n;
+	const language = user.locale_variant || user.language || 'en';
+
+	useEffect( () => {
+		loadingLocaleRef.current = language;
+
+		fetchLocaleData( language ).then( ( [ realLanguage, data ] ) => {
+			// Activate the data only if no other language switch has run in the meantime
+			if ( loadingLocaleRef.current !== language ) {
+				return;
+			}
+
+			i18n.resetLocaleData( data );
+			// `realLanguage` can be different from `language` when loading language data fails
+			// and it falls back to `en`.
+			setLoadedLocale( realLanguage );
+		} );
+	}, [ i18n, language ] );
+
+	// Render the sub-tree only after the initial locale data are loaded. We don't want a
+	// flash of `en` content on the initial render that's updated a moment later.
+	if ( loadedLocale === null ) {
+		return null;
+	}
path: client/dashboard/app/i18n.tsx
user: login: jsnajdr
body: I didn't want to do the initial render with `en` locale and only later switch to the real locale. Better to wait until we have all the data. It's a tradeoff of course.

A similar wait is done in the `AuthProvider` component. It won't render the subtree until the user object is loaded from the `/me` endpoint.

If we want to be ready for SSR, the first thing we should probably do is to rewrite both `AuthProvider` and `I18nProvider` to use Suspense. I guess the TanStack Router that we use here already suspends a lot! Then the components wouldn't render `null`, but would simply suspend and continue rendering only when the needed data are loaded.
created_at: 2025-05-23T09:07:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,55 @@
+import { defaultI18n } from '@wordpress/i18n';
+import { I18nProvider as WPI18nProvider } from '@wordpress/react-i18n';
+import { useEffect, useRef, useState, type PropsWithChildren } from 'react';
+import { useAuth } from './auth';
+
+async function fetchLocaleData( language: string ) {
+	if ( language === 'en' ) {
+		return [ language, undefined ];
+	}
+
+	try {
+		const response = await fetch(
+			`https://widgets.wp.com/languages/calypso/${ language }-v1.1.json`
+		);
+
+		return [ language, await response.json() ];
+	} catch {
+		// Fall back to `en` when fetching the language data fails. Without this
+		// the i18n provider would be stuck forever in a non-loaded state.
+		return [ 'en', undefined ];
+	}
+}
+
+export function I18nProvider( { children }: PropsWithChildren ) {
+	const [ loadedLocale, setLoadedLocale ] = useState< string | null >( null );
+	const loadingLocaleRef = useRef< string | null >( null );
+	const { user } = useAuth();
+
+	const i18n = defaultI18n;
+	const language = user.locale_variant || user.language || 'en';
+
+	useEffect( () => {
+		loadingLocaleRef.current = language;
+
+		fetchLocaleData( language ).then( ( [ realLanguage, data ] ) => {
+			// Activate the data only if no other language switch has run in the meantime
+			if ( loadingLocaleRef.current !== language ) {
+				return;
+			}
+
+			i18n.resetLocaleData( data );
+			// `realLanguage` can be different from `language` when loading language data fails
+			// and it falls back to `en`.
+			setLoadedLocale( realLanguage );
+		} );
+	}, [ i18n, language ] );
+
+	// Render the sub-tree only after the initial locale data are loaded. We don't want a
+	// flash of `en` content on the initial render that's updated a moment later.
+	if ( loadedLocale === null ) {
+		return null;
+	}
path: client/dashboard/app/i18n.tsx
user: login: jsnajdr
body: > It would be great if we can simply load the locale data from the server side.

Yes, that's the next thing I'd like to add support for, because it's useful for the Hosting dashboard.
created_at: 2025-05-23T09:33:12Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-22T13:51:37Z
body: <details>
<summary>Calypso Live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-145700">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-145700%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-145700">https://calypso.live?image=registry.a8c.com/calypso/app:build-145700</a>
</td>
</tr>
</table>
</details>
<details>
<summary>Jetpack Cloud live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-145700&env=jetpack">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-145700%26env%3Djetpack%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-145700&env=jetpack">https://calypso.live?image=registry.a8c.com/calypso/app:build-145700&env=jetpack</a>
</td>
</tr>
</table>
</details>
<details>
<summary>Automattic for Agencies live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-145700&env=a8c-for-agencies">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-145700%26env%3Da8c-for-agencies%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-145700&env=a8c-for-agencies">https://calypso.live?image=registry.a8c.com/calypso/app:build-145700&env=a8c-for-agencies</a>
</td>
</tr>
</table>
</details>
<!--calypso-live-watermark:apr@v1-->
author_association: NONE
user: login: arthur791004
created_at: 2025-05-23T08:22:02Z
body: The follow-up steps look good to me! One thing we have to consider is how to invalidate the query on dashboard v2 if the language changes?

Additionally, do we want to support following features to determine the locale?
* i18n_empathy_mode (no as this is a8c only feature)
* use_fallback_for_incomplete_languages
* locale slug from the server side
* locale from query string
author_association: CONTRIBUTOR
user: login: jsnajdr
created_at: 2025-05-23T09:31:44Z
body: > One thing we have to consider is how to invalidate the query on dashboard v2 if the language changes?

Not sure which queries you refer to, maybe all of them? Because we automatically add a `?_locale` query param to every `public-api` REST request and the server uses that to localize responses. In that case, we should invalidate all queries on locale change, because the locale is effectively a part of every query key.

In any case, I think we should not invest too much effort at this moment into supporting a "mid-flight" locale change. User's locale is usually stable long-term, and if it changes, you can always just reload the page to get a correctly localized version. Doing the switch perfectly means solving many little edge cases.

> Additionally, do we want to support following features to determine the locale?

Why not, but one thing I'd like to explicitly avoid is to add locale-related features just because they already exist in the main Calypso. Hosting dashboard is currently only for logged-in users, where the authoritative source of locale is the user object.


author_association: MEMBER
user: login: arthur791004
created_at: 2025-05-23T09:44:45Z
body: > Not sure which queries you refer to, maybe all of them?

Sorry for not being clear. The query I mean is the user query on dashboard v2 inside the `useAuth` hook. Currently, we have to go to /me to update the language and I'm not sure when will we switch this feature to v2. So, we have to invalidate the user query when the language changes. Otherwise, dashboard v2 would still use the old cache data.
author_association: CONTRIBUTOR
user: login: jsnajdr
created_at: 2025-05-23T10:21:22Z
body: > The query I mean is the user query on dashboard v2 inside the `useAuth` hook.

The user query in `AuthProvider` already has a stale time of 30 minutes. Every 30 minutes it will load the fresh data from the `/me` REST endpoint. And the `I18nProvider` will see the updated `.language` field, and the effect will fetch new translation data and reconfigure `@wordpress/i18n`. This should just work.

If 30 minutes wasn't enough, we'd have to observe `/me` in real time using some WebSocket connection, but WP.com doesn't support that.
author_association: MEMBER
user: login: matticbot
created_at: 2025-05-26T11:19:25Z
body: <!-- icfy-watermark: c52822 -->
Here is how your PR affects size of JS and CSS bundles shipped to the user's browser:

**App Entrypoints** (~278 bytes added 📈 [gzipped])
<details>

```
name                    parsed_size           gzip_size
entry-dashboard-dotcom       +754 B  (+0.0%)     +278 B  (+0.1%)
entry-dashboard-a4a          +754 B  (+0.0%)     +278 B  (+0.1%)
```

Common code that is always downloaded and parsed every time the app is loaded, no matter which route is used.
</details>

**Legend**
<details>
<summary>What is parsed and gzip size?</summary>

**Parsed Size:** Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory.
**Gzip Size:** Compressed size of the JS and CSS files. This much data needs to be downloaded over network.
</details>

Generated by performance advisor bot at [iscalypsofastyet.com](http://iscalypsofastyet.com).
author_association: CONTRIBUTOR
user: login: youknowriad
created_at: 2025-05-27T08:45:55Z
body: Thanks @jsnajdr 
author_association: CONTRIBUTOR