From 3491a0a08ecc7d16278ae89c00ca402748c12ee9 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 6 Mar 2026 03:02:06 +0000 Subject: [PATCH] fix i18n runtime scoping for SSR requests Create a dedicated i18next instance per SSR request and remove server context-storage coupling from translation runtime, while keeping a separate client singleton path. Co-Authored-By: Claude Opus 4.6 --- src/i18n/index.ts | 4 ++-- src/lib/translation/client.ts | 15 ++++++++++++++ src/lib/translation/index.ts | 39 +++++++---------------------------- src/main.ts | 16 ++++++++++---- src/server/routes/ssr.ts | 11 +++++++--- 5 files changed, 45 insertions(+), 40 deletions(-) create mode 100644 src/lib/translation/client.ts diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 2f316f3..f752a67 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,6 +1,6 @@ import type { i18n as I18nInstance } from 'i18next'; -import { getActiveI18nInstance } from '@/lib/translation'; +import { getClientI18nInstance } from '@/lib/translation/client'; import { defaultLocale, supportedLocales, type SupportedLocale } from './constants'; @@ -15,5 +15,5 @@ export const normalizeLocale = (locale?: string): SupportedLocale => { }; export const getActiveI18n = (): I18nInstance | undefined => { - return getActiveI18nInstance(); + return import.meta.env.SSR ? undefined : getClientI18nInstance(); }; diff --git a/src/lib/translation/client.ts b/src/lib/translation/client.ts new file mode 100644 index 0000000..10bccb8 --- /dev/null +++ b/src/lib/translation/client.ts @@ -0,0 +1,15 @@ +import type { i18n as I18nInstance } from 'i18next'; + +import { createI18nInstance, initI18nInstance } from '@/lib/translation'; + +let clientI18n: I18nInstance | undefined; + +export const createI18nForClient = async (language?: string) => { + if (!clientI18n) { + clientI18n = createI18nInstance(false); + } + + return initI18nInstance(clientI18n, language, false); +}; + +export const getClientI18nInstance = () => clientI18n; diff --git a/src/lib/translation/index.ts b/src/lib/translation/index.ts index 694d62e..bb26c00 100644 --- a/src/lib/translation/index.ts +++ b/src/lib/translation/index.ts @@ -1,14 +1,11 @@ import i18next, { type i18n as I18nInstance } from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import I18NextHttpBackend from 'i18next-http-backend'; -import { tryGetContext } from 'hono/context-storage'; import { defaultLocale, localeCookieKey, supportedLocales, type SupportedLocale } from '@/i18n/constants'; const runtimeNamespace = 'translation'; -let clientI18n: I18nInstance | undefined; - const normalizeLanguage = (language?: string): SupportedLocale => { if (!language) return defaultLocale; const normalized = language.toLowerCase().split('-')[0] as SupportedLocale; @@ -23,18 +20,22 @@ const getLoadPath = () => { return '/locales/{{lng}}/{{lng}}.json'; }; -const createInstance = () => { +export const createI18nInstance = (forServer: boolean) => { const instance = i18next.createInstance(); instance.use(I18NextHttpBackend); - if (!import.meta.env.SSR) { + if (!forServer) { instance.use(LanguageDetector); } return instance; }; -const initInstance = async (instance: I18nInstance, language?: string) => { +export const initI18nInstance = async ( + instance: I18nInstance, + language?: string, + forServer: boolean = import.meta.env.SSR, +) => { const lng = normalizeLanguage(language); if (!instance.isInitialized) { @@ -52,7 +53,7 @@ const initInstance = async (instance: I18nInstance, language?: string) => { backend: { loadPath: getLoadPath(), }, - ...(import.meta.env.SSR + ...(forServer ? {} : { detection: { @@ -72,27 +73,3 @@ const initInstance = async (instance: I18nInstance, language?: string) => { return instance; }; - -export const createI18nForRuntime = async (language?: string) => { - if (import.meta.env.SSR) { - const serverI18n = await initInstance(createInstance(), language); - const context = tryGetContext(); - context?.set?.('i18n', serverI18n); - return serverI18n; - } - - if (!clientI18n) { - clientI18n = createInstance(); - } - - return initInstance(clientI18n, language); -}; - -export const getActiveI18nInstance = () => { - if (!import.meta.env.SSR) { - return clientI18n; - } - - const context = tryGetContext(); - return context?.get?.('i18n') as I18nInstance | undefined; -}; diff --git a/src/main.ts b/src/main.ts index 302d4ba..3e71882 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,8 +5,11 @@ import { createPinia } from 'pinia'; import { createSSRApp } from 'vue'; import { RouterView } from 'vue-router'; +import type { i18n as I18nInstance } from 'i18next'; import I18NextVue from 'i18next-vue'; -import { createI18nForRuntime } from '@/lib/translation'; + +import { createI18nInstance, initI18nInstance } from '@/lib/translation'; +import { createI18nForClient } from '@/lib/translation/client'; import { withErrorBoundary } from './lib/hoc/withErrorBoundary'; import createAppRouter from './routes'; @@ -18,7 +21,7 @@ const getSerializedAppData = () => { return JSON.parse(document.getElementById('__APP_DATA__')?.innerText || '{}') as Record; }; -export async function createApp(lng: string = 'en') { +export async function createApp(lng: string = 'en', i18next?: I18nInstance) { const pinia = createPinia(); const app = createSSRApp(withErrorBoundary(RouterView)); @@ -32,8 +35,13 @@ export async function createApp(lng: string = 'en') { } }); app.use(pinia); - const i18next = await createI18nForRuntime(lng); - app.use(I18NextVue, { i18next }); + const runtimeI18n = import.meta.env.SSR + ? (i18next ?? createI18nInstance(true)) + : await createI18nForClient(lng); + if (import.meta.env.SSR) { + await initI18nInstance(runtimeI18n, lng, true); + } + app.use(I18NextVue, { i18next: runtimeI18n }); app.use(PiniaColada, { pinia, plugins: [ diff --git a/src/server/routes/ssr.ts b/src/server/routes/ssr.ts index 0cd6213..b6b3d2d 100644 --- a/src/server/routes/ssr.ts +++ b/src/server/routes/ssr.ts @@ -3,6 +3,8 @@ import { renderSSRHead } from '@unhead/vue/server'; import { streamText } from 'hono/streaming'; import { renderToWebStream } from 'vue/server-renderer'; +import { localeCookieKey } from '@/i18n'; +import { createI18nInstance, initI18nInstance } from '@/lib/translation'; import { buildBootstrapScript } from '@/lib/manifest'; import { createApp } from '@/main'; import { htmlEscape } from '@/server/utils/htmlEscape'; @@ -25,7 +27,10 @@ export function registerSSRRoutes(app: Hono) { const nonce = crypto.randomUUID(); const url = new URL(c.req.url); const lang = c.get("language") - const { app: vueApp, router, head, pinia, bodyClass, queryCache } = await createApp(lang); + const localeFromCookie = parseCookie(c.req.header('cookie'), localeCookieKey); + const i18next = createI18nInstance(true); + await initI18nInstance(i18next, localeFromCookie ?? lang, true); + const { app: vueApp, router, head, pinia, bodyClass, queryCache } = await createApp(localeFromCookie ?? lang, i18next); vueApp.provide("honoContext", c); @@ -44,7 +49,7 @@ export function registerSSRRoutes(app: Hono) { const appStream = renderToWebStream(vueApp, ctx); // HTML Head - await stream.write(``); + await stream.write(``); await stream.write(""); // SSR Head tags @@ -74,7 +79,7 @@ export function registerSSRRoutes(app: Hono) { Object.assign(ctx, { $p: pinia.state.value, $colada: serializeQueryCache(queryCache), - $locale: lang, + $locale: i18next.resolvedLanguage ?? lang, }); // App data script