From 79f000fcc53fa08eeddb9ad37cae12b6ec1d9fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 29 Mar 2026 11:38:36 +0200 Subject: [PATCH] Upgrade i18next v26 + react-i18next v17 with SSR-safe init Split initI18n() into separate server/client initialization paths inspired by remix-i18next's architecture: - initI18nServer(lng): no LanguageDetector, per-request language from Accept-Language header - initI18nClient(): LanguageDetector reads first to match server, then localStorage and navigator - detectLanguage(request): parses Accept-Language for best match Fixes rehydration errors caused by v26 removing initImmediate option (replaced with initAsync) and LanguageDetector running on the server where browser APIs don't exist. Also makes dynamic instead of hardcoded "en". Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/entry.client.tsx | 3 ++ apps/journal/app/entry.server.tsx | 3 ++ apps/journal/app/root.tsx | 6 +-- apps/planner/app/entry.client.tsx | 3 ++ apps/planner/app/entry.server.tsx | 79 +++++++++++++++++++++++++++++++ apps/planner/app/root.tsx | 6 +-- package.json | 4 +- packages/i18n/package.json | 4 +- packages/i18n/src/index.ts | 62 ++++++++++++++++++++---- pnpm-lock.yaml | 36 +++++++------- 10 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 apps/planner/app/entry.server.tsx diff --git a/apps/journal/app/entry.client.tsx b/apps/journal/app/entry.client.tsx index f77e5e9..8e3f019 100644 --- a/apps/journal/app/entry.client.tsx +++ b/apps/journal/app/entry.client.tsx @@ -4,6 +4,7 @@ import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; +import { initI18nClient } from "@trails-cool/i18n"; const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? (import.meta.env.PROD ? "production" : "development"); @@ -24,6 +25,8 @@ Sentry.init({ enabled: import.meta.env.PROD && sentryEnvironment !== "ci", }); +initI18nClient(); + startTransition(() => { hydrateRoot( document, diff --git a/apps/journal/app/entry.server.tsx b/apps/journal/app/entry.server.tsx index 01dbf3d..0bdfe8f 100644 --- a/apps/journal/app/entry.server.tsx +++ b/apps/journal/app/entry.server.tsx @@ -6,6 +6,7 @@ import { ServerRouter } from "react-router"; import { isbot } from "isbot"; import type { RenderToPipeableStreamOptions } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server"; +import { initI18nServer, detectLanguage } from "@trails-cool/i18n"; const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development"); @@ -32,6 +33,8 @@ export default function handleRequest( routerContext: EntryContext, _loadContext: AppLoadContext, ) { + initI18nServer(detectLanguage(request)); + if (request.method.toUpperCase() === "HEAD") { return new Response(null, { status: responseStatusCode, diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index 159af7c..c041ae7 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -4,17 +4,15 @@ import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; import { useTranslation } from "react-i18next"; -import { initI18n } from "@trails-cool/i18n"; import { getSessionUser } from "~/lib/auth.server"; import stylesheet from "@trails-cool/ui/styles.css?url"; -initI18n(); - export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; export function Layout({ children }: { children: React.ReactNode }) { + const { i18n } = useTranslation(); return ( - + diff --git a/apps/planner/app/entry.client.tsx b/apps/planner/app/entry.client.tsx index a9813eb..f4230ed 100644 --- a/apps/planner/app/entry.client.tsx +++ b/apps/planner/app/entry.client.tsx @@ -4,6 +4,7 @@ import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; +import { initI18nClient } from "@trails-cool/i18n"; const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? (import.meta.env.PROD ? "production" : "development"); @@ -24,6 +25,8 @@ Sentry.init({ enabled: import.meta.env.PROD && sentryEnvironment !== "ci", }); +initI18nClient(); + startTransition(() => { hydrateRoot( document, diff --git a/apps/planner/app/entry.server.tsx b/apps/planner/app/entry.server.tsx new file mode 100644 index 0000000..9220df5 --- /dev/null +++ b/apps/planner/app/entry.server.tsx @@ -0,0 +1,79 @@ +import { PassThrough } from "node:stream"; +import type { AppLoadContext, EntryContext } from "react-router"; +import { createReadableStreamFromReadable } from "@react-router/node"; +import { ServerRouter } from "react-router"; +import { isbot } from "isbot"; +import type { RenderToPipeableStreamOptions } from "react-dom/server"; +import { renderToPipeableStream } from "react-dom/server"; +import { initI18nServer, detectLanguage } from "@trails-cool/i18n"; + +export const streamTimeout = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + _loadContext: AppLoadContext, +) { + initI18nServer(detectLanguage(request)); + + if (request.method.toUpperCase() === "HEAD") { + return new Response(null, { + status: responseStatusCode, + headers: responseHeaders, + }); + } + + return new Promise((resolve, reject) => { + let shellRendered = false; + const userAgent = request.headers.get("user-agent"); + + const readyOption: keyof RenderToPipeableStreamOptions = + (userAgent && isbot(userAgent)) || routerContext.isSpaMode + ? "onAllReady" + : "onShellReady"; + + let timeoutId: ReturnType | undefined = setTimeout( + () => abort(), + streamTimeout + 1000, + ); + + const { pipe, abort } = renderToPipeableStream( + , + { + [readyOption]() { + shellRendered = true; + const body = new PassThrough({ + final(callback) { + clearTimeout(timeoutId); + timeoutId = undefined; + callback(); + }, + }); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + pipe(body); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + if (shellRendered) { + console.error(error); + } + }, + }, + ); + }); +} diff --git a/apps/planner/app/root.tsx b/apps/planner/app/root.tsx index 29653b5..cb6df2b 100644 --- a/apps/planner/app/root.tsx +++ b/apps/planner/app/root.tsx @@ -3,16 +3,14 @@ import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; import { useTranslation } from "react-i18next"; -import { initI18n } from "@trails-cool/i18n"; import stylesheet from "@trails-cool/ui/styles.css?url"; -initI18n(); - export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; export function Layout({ children }: { children: React.ReactNode }) { + const { i18n } = useTranslation(); return ( - + diff --git a/package.json b/package.json index 66b09f7..3434381 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "drizzle-postgis": "^1.1.1", "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", - "i18next": "^25.10.4", + "i18next": "^26.0.1", "i18next-browser-languagedetector": "^8.2.1", "jsdom": "^29.0.1", "leaflet": "^1.9.4", @@ -59,7 +59,7 @@ "prettier": "^3.8.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-i18next": "^16.6.1", + "react-i18next": "^17.0.1", "react-leaflet": "^5.0.0", "react-router": "^7.13.2", "tailwindcss": "^4.2.2", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index f55bf10..f17b2dc 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -10,7 +10,7 @@ "types": "./src/index.ts", "peerDependencies": { "react": ">=18", - "i18next": ">=23", - "react-i18next": ">=14" + "i18next": ">=26", + "react-i18next": ">=17" } } diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 8f3e652..1582ae9 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -6,26 +6,68 @@ import de from "./locales/de.ts"; export const defaultNS = "common"; +export const supportedLngs = ["en", "de"] as const; +export type SupportedLng = (typeof supportedLngs)[number]; + export const resources = { en: { common: en.common, planner: en.planner, journal: en.journal }, de: { common: de.common, planner: de.planner, journal: de.journal }, } as const; -export function initI18n() { - return i18n +const commonOptions = { + resources, + defaultNS, + fallbackLng: "en" as const, + supportedLngs: [...supportedLngs], + interpolation: { escapeValue: false }, + initAsync: false, +}; + +/** + * Initialize i18next for server-side rendering. + * No language detector — language is determined from the request. + */ +export function initI18nServer(lng: SupportedLng = "en") { + if (i18n.isInitialized) { + i18n.changeLanguage(lng); + return; + } + i18n.use(initReactI18next).init({ ...commonOptions, lng }); +} + +/** + * Initialize i18next for the client. + * Uses LanguageDetector with htmlTag priority so hydration matches the server. + */ +export function initI18nClient() { + if (i18n.isInitialized) return; + i18n .use(LanguageDetector) .use(initReactI18next) .init({ - resources, - defaultNS, - fallbackLng: "en", - supportedLngs: ["en", "de"], - interpolation: { - escapeValue: false, + ...commonOptions, + detection: { + order: ["htmlTag", "localStorage", "navigator"], + caches: ["localStorage"], }, - showSupportNotice: false, - initImmediate: false, }); } +/** + * Detect the best supported language from a request's Accept-Language header. + */ +export function detectLanguage(request: Request): SupportedLng { + const header = request.headers.get("Accept-Language") ?? ""; + for (const part of header.split(",")) { + const lang = part.split(";")[0]?.trim().toLowerCase(); + if (!lang) continue; + for (const supported of supportedLngs) { + if (lang === supported || lang.startsWith(supported + "-")) { + return supported; + } + } + } + return "en"; +} + export { i18n }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f91dbd..8c7afc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,8 +113,8 @@ importers: specifier: ^10.1.8 version: 10.1.8(eslint@10.1.0(jiti@2.6.1)) i18next: - specifier: ^25.10.4 - version: 25.10.4(typescript@5.9.3) + specifier: ^26.0.1 + version: 26.0.1(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 @@ -143,8 +143,8 @@ importers: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) react-i18next: - specifier: ^16.6.1 - version: 16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + specifier: ^17.0.1 + version: 17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-leaflet: specifier: ^5.0.0 version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -382,14 +382,14 @@ importers: packages/i18n: dependencies: i18next: - specifier: '>=23' - version: 25.10.4(typescript@5.9.3) + specifier: '>=26' + version: 26.0.1(typescript@5.9.3) react: specifier: '>=18' version: 19.2.4 react-i18next: - specifier: '>=14' - version: 16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + specifier: '>=17' + version: 17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) packages/map: dependencies: @@ -2744,10 +2744,10 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@25.10.4: - resolution: {integrity: sha512-XsE/6eawy090meuFU0BTY9BtmWr1m9NSwLr0NK7/A04LA58wdAvDsi9WNOJ40Qb1E9NIPbvnVLZEN2fWDd3/3Q==} + i18next@26.0.1: + resolution: {integrity: sha512-vtz5sXU4+nkCm8yEU+JJ6yYIx0mkg9e68W0G0PXpnOsmzLajNsW5o28DJMqbajxfsfq0gV3XdrBudsDQnwxfsQ==} peerDependencies: - typescript: ^5 + typescript: ^5 || ^6 peerDependenciesMeta: typescript: optional: true @@ -3278,14 +3278,14 @@ packages: peerDependencies: react: ^19.2.4 - react-i18next@16.6.1: - resolution: {integrity: sha512-izjXh+AkBLy3h3xe3sh6Gg1flhFHc3UyzsMftMKYJr2Z7WvAZQIdjjpHypctN41zFoeLdJUNGDgP1+Qich2fYg==} + react-i18next@17.0.1: + resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==} peerDependencies: - i18next: '>= 25.6.2' + i18next: '>= 26.0.1' react: '>= 16.8.0' react-dom: '*' react-native: '*' - typescript: ^5 + typescript: ^5 || ^6 peerDependenciesMeta: react-dom: optional: true @@ -6169,7 +6169,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@25.10.4(typescript@5.9.3): + i18next@26.0.1(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 optionalDependencies: @@ -6637,11 +6637,11 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-i18next@16.6.1(i18next@25.10.4(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + react-i18next@17.0.1(i18next@26.0.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 25.10.4(typescript@5.9.3) + i18next: 26.0.1(typescript@5.9.3) react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: