Преглед на файлове

Neo/fix ssr i18n race (#5810)

* Rename Notifications menu Title to Activity

* Neo: render each SSR request with its own i18n instance

The server's i18n singleton is shared by all requests. The page renders
inside Suspense after its data arrives, so a concurrent request could
switch the singleton's language before useTranslation ran, and the
server HTML came out in another language than the client's (hydration
mismatch: "Welcome to" vs "Bienvenido a").

On the server, Providers now creates a per-request instance and passes
it down with I18nextProvider. The client keeps the global singleton.
RomanL преди 1 седмица
родител
ревизия
91ad67f80e
променени са 2 файла, в които са добавени 40 реда и са изтрити 20 реда
  1. 39 19
      neo/apps/next/app/providers.js
  2. 1 1
      neo/packages/app/settings/menus.js

+ 39 - 19
neo/apps/next/app/providers.js

@@ -6,9 +6,9 @@ import { QueryClientProvider } from '@tanstack/react-query'
 import { queryClient } from 'app/lib/platform/query-client'
 import { Provider as JotaiProvider } from 'jotai'
 import i18n from 'i18next'
-import { initReactI18next } from 'react-i18next'
+import { I18nextProvider, initReactI18next } from 'react-i18next'
 import * as WebBrowser from 'expo-web-browser'
-import { useEffect } from 'react'
+import { useEffect, useState } from 'react'
 import Subscriber from 'app/ui/molecules/system/subscriber'
 import { useLayoutSettings } from 'app/context/layout-settings'
 import { addI18nResources, changeI18nLanguage } from 'app/lib/i18n-resources'
@@ -20,21 +20,23 @@ if (typeof window !== 'undefined') {
     WebBrowser.maybeCompleteAuthSession()
 }
 
+const i18nOptions = (lang, resources) => ({
+    compatibilityJSON: 'v3',
+    resources,
+    lng: lang,
+    fallbackLng: 'en',
+    interpolation: {
+        escapeValue: false,
+    },
+})
+
 /**
  * `initialResources` holds only this page's language + `en` (see layout.js);
  * other languages are loaded by changeI18nLanguage when needed.
  */
 function ensureI18n(lang, initialResources) {
     if (!i18n.isInitialized) {
-        i18n.use(initReactI18next).init({
-            compatibilityJSON: 'v3',
-            resources: initialResources,
-            lng: lang,
-            fallbackLng: 'en',
-            interpolation: {
-                escapeValue: false,
-            },
-        })
+        i18n.use(initReactI18next).init(i18nOptions(lang, initialResources))
         return
     }
     // The server's i18n singleton serves every request: add languages it has not seen yet.
@@ -46,6 +48,9 @@ function ensureI18n(lang, initialResources) {
      * mismatch (e.g. server "Contact" vs client "Контакты"). The bundle is
      * in memory, so changeLanguage resolves synchronously. Server-only: on the
      * client the user's stored preference (applied post-hydration) must win.
+     * Concurrent requests still overwrite each other here, so components get
+     * a per-request instance (see Providers); this keeps direct `i18n.t`
+     * callers close.
      */
     if (typeof window === 'undefined' && lang && i18n.language !== lang) {
         i18n.changeLanguage(lang)
@@ -56,6 +61,19 @@ export function Providers({ children, initialLang = 'en', initialResources }) {
     // Must match server-rendered copy before the first child paints (menu aria-labels, etc.).
     ensureI18n(initialLang, initialResources)
 
+    /**
+     * On the server, `useTranslation` must not read the shared singleton: the
+     * page renders inside Suspense once its data arrives, and by then another
+     * request may have switched the singleton's language (server "Welcome to"
+     * vs client "Bienvenido a"). Resources are inline, so init is synchronous.
+     */
+    const [i18nInstance] = useState(() => {
+        if (typeof window !== 'undefined') return i18n
+        const instance = i18n.createInstance()
+        instance.init(i18nOptions(initialLang, initialResources))
+        return instance
+    })
+
     const { langCode, hydrated } = useLayoutSettings()
 
     // Until the stored settings load, `langCode` is the default language, not
@@ -74,13 +92,15 @@ export function Providers({ children, initialLang = 'en', initialResources }) {
     }, [hydrated, langCode, initialLang])
 
     return (
-        <JotaiProvider>
-            <QueryClientProvider client={queryClient}>
-                <Analytics />
-                <SpeedInsights />
-                {children}
-                <Subscriber />
-            </QueryClientProvider>
-        </JotaiProvider>
+        <I18nextProvider i18n={i18nInstance}>
+            <JotaiProvider>
+                <QueryClientProvider client={queryClient}>
+                    <Analytics />
+                    <SpeedInsights />
+                    {children}
+                    <Subscriber />
+                </QueryClientProvider>
+            </JotaiProvider>
+        </I18nextProvider>
     )
 }

+ 1 - 1
neo/packages/app/settings/menus.js

@@ -318,7 +318,7 @@ export const settingsMenus = {
             },
             {
                 key: '/tab3',
-                title: 'Notifications',
+                title: 'Activity',
                 url: '/notifications-view',
                 icon: 'Bell',
                 badge: 'notifications',