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:
@@ -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();
|
||||
};
|
||||
|
||||
15
src/lib/translation/client.ts
Normal file
15
src/lib/translation/client.ts
Normal 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;
|
||||
@@ -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<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;
|
||||
};
|
||||
|
||||
16
src/main.ts
16
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<string, any>;
|
||||
};
|
||||
|
||||
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: [
|
||||
|
||||
@@ -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(`<!DOCTYPE html><html lang='${lang}'><head>`);
|
||||
await stream.write(`<!DOCTYPE html><html lang='${i18next.resolvedLanguage ?? lang}'><head>`);
|
||||
await stream.write("<base href='" + url.origin + "'/>");
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user