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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 03:02:06 +00:00
parent 6d04f1cbdc
commit 3491a0a08e
5 changed files with 45 additions and 40 deletions

View File

@@ -1,6 +1,6 @@
import type { i18n as I18nInstance } from 'i18next'; 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'; import { defaultLocale, supportedLocales, type SupportedLocale } from './constants';
@@ -15,5 +15,5 @@ export const normalizeLocale = (locale?: string): SupportedLocale => {
}; };
export const getActiveI18n = (): I18nInstance | undefined => { export const getActiveI18n = (): I18nInstance | undefined => {
return getActiveI18nInstance(); return import.meta.env.SSR ? undefined : getClientI18nInstance();
}; };

View File

@@ -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;

View File

@@ -1,14 +1,11 @@
import i18next, { type i18n as I18nInstance } from 'i18next'; import i18next, { type i18n as I18nInstance } from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
import I18NextHttpBackend from 'i18next-http-backend'; import I18NextHttpBackend from 'i18next-http-backend';
import { tryGetContext } from 'hono/context-storage';
import { defaultLocale, localeCookieKey, supportedLocales, type SupportedLocale } from '@/i18n/constants'; import { defaultLocale, localeCookieKey, supportedLocales, type SupportedLocale } from '@/i18n/constants';
const runtimeNamespace = 'translation'; const runtimeNamespace = 'translation';
let clientI18n: I18nInstance | undefined;
const normalizeLanguage = (language?: string): SupportedLocale => { const normalizeLanguage = (language?: string): SupportedLocale => {
if (!language) return defaultLocale; if (!language) return defaultLocale;
const normalized = language.toLowerCase().split('-')[0] as SupportedLocale; const normalized = language.toLowerCase().split('-')[0] as SupportedLocale;
@@ -23,18 +20,22 @@ const getLoadPath = () => {
return '/locales/{{lng}}/{{lng}}.json'; return '/locales/{{lng}}/{{lng}}.json';
}; };
const createInstance = () => { export const createI18nInstance = (forServer: boolean) => {
const instance = i18next.createInstance(); const instance = i18next.createInstance();
instance.use(I18NextHttpBackend); instance.use(I18NextHttpBackend);
if (!import.meta.env.SSR) { if (!forServer) {
instance.use(LanguageDetector); instance.use(LanguageDetector);
} }
return instance; 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); const lng = normalizeLanguage(language);
if (!instance.isInitialized) { if (!instance.isInitialized) {
@@ -52,7 +53,7 @@ const initInstance = async (instance: I18nInstance, language?: string) => {
backend: { backend: {
loadPath: getLoadPath(), loadPath: getLoadPath(),
}, },
...(import.meta.env.SSR ...(forServer
? {} ? {}
: { : {
detection: { detection: {
@@ -72,27 +73,3 @@ const initInstance = async (instance: I18nInstance, language?: string) => {
return instance; return instance;
}; };
export const createI18nForRuntime = async (language?: string) => {
if (import.meta.env.SSR) {
const serverI18n = await initInstance(createInstance(), language);
const context = tryGetContext<any>();
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<any>();
return context?.get?.('i18n') as I18nInstance | undefined;
};

View File

@@ -5,8 +5,11 @@ import { createPinia } from 'pinia';
import { createSSRApp } from 'vue'; import { createSSRApp } from 'vue';
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import type { i18n as I18nInstance } from 'i18next';
import I18NextVue from 'i18next-vue'; 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 { withErrorBoundary } from './lib/hoc/withErrorBoundary';
import createAppRouter from './routes'; import createAppRouter from './routes';
@@ -18,7 +21,7 @@ const getSerializedAppData = () => {
return JSON.parse(document.getElementById('__APP_DATA__')?.innerText || '{}') as Record<string, any>; return JSON.parse(document.getElementById('__APP_DATA__')?.innerText || '{}') as Record<string, any>;
}; };
export async function createApp(lng: string = 'en') { export async function createApp(lng: string = 'en', i18next?: I18nInstance) {
const pinia = createPinia(); const pinia = createPinia();
const app = createSSRApp(withErrorBoundary(RouterView)); const app = createSSRApp(withErrorBoundary(RouterView));
@@ -32,8 +35,13 @@ export async function createApp(lng: string = 'en') {
} }
}); });
app.use(pinia); app.use(pinia);
const i18next = await createI18nForRuntime(lng); const runtimeI18n = import.meta.env.SSR
app.use(I18NextVue, { i18next }); ? (i18next ?? createI18nInstance(true))
: await createI18nForClient(lng);
if (import.meta.env.SSR) {
await initI18nInstance(runtimeI18n, lng, true);
}
app.use(I18NextVue, { i18next: runtimeI18n });
app.use(PiniaColada, { app.use(PiniaColada, {
pinia, pinia,
plugins: [ plugins: [

View File

@@ -3,6 +3,8 @@ import { renderSSRHead } from '@unhead/vue/server';
import { streamText } from 'hono/streaming'; import { streamText } from 'hono/streaming';
import { renderToWebStream } from 'vue/server-renderer'; import { renderToWebStream } from 'vue/server-renderer';
import { localeCookieKey } from '@/i18n';
import { createI18nInstance, initI18nInstance } from '@/lib/translation';
import { buildBootstrapScript } from '@/lib/manifest'; import { buildBootstrapScript } from '@/lib/manifest';
import { createApp } from '@/main'; import { createApp } from '@/main';
import { htmlEscape } from '@/server/utils/htmlEscape'; import { htmlEscape } from '@/server/utils/htmlEscape';
@@ -25,7 +27,10 @@ export function registerSSRRoutes(app: Hono) {
const nonce = crypto.randomUUID(); const nonce = crypto.randomUUID();
const url = new URL(c.req.url); const url = new URL(c.req.url);
const lang = c.get("language") 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); vueApp.provide("honoContext", c);
@@ -44,7 +49,7 @@ export function registerSSRRoutes(app: Hono) {
const appStream = renderToWebStream(vueApp, ctx); const appStream = renderToWebStream(vueApp, ctx);
// HTML Head // HTML Head
await stream.write(`<!DOCTYPE html><html lang='${lang}'><head>`); await stream.write(`<!DOCTYPE html><html lang='${i18next.resolvedLanguage ?? lang}'><head>`);
await stream.write("<base href='" + url.origin + "'/>"); await stream.write("<base href='" + url.origin + "'/>");
// SSR Head tags // SSR Head tags
@@ -74,7 +79,7 @@ export function registerSSRRoutes(app: Hono) {
Object.assign(ctx, { Object.assign(ctx, {
$p: pinia.state.value, $p: pinia.state.value,
$colada: serializeQueryCache(queryCache), $colada: serializeQueryCache(queryCache),
$locale: lang, $locale: i18next.resolvedLanguage ?? lang,
}); });
// App data script // App data script