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 1/3] 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: From 38ada3bd4f9ca78b360f8ce24caf691f36fa1be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 29 Mar 2026 11:46:21 +0200 Subject: [PATCH 2/3] Pass full locale from Accept-Language to ClientDate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detectLocale() that extracts the full locale tag (e.g. "de-DE") from the request, not just the language. The journal root loader passes it via LocaleContext so ClientDate renders with the correct locale on both server and client — no more hardcoded "en-US" fallback. Also fixes settings page hydration mismatch by using ClientDate for passkey creation dates instead of inline toLocaleDateString(). Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/components/ClientDate.tsx | 11 ++++++++--- apps/journal/app/components/LocaleContext.tsx | 17 +++++++++++++++++ apps/journal/app/root.tsx | 10 +++++++--- apps/journal/app/routes/settings.tsx | 6 +++--- packages/i18n/src/index.ts | 19 +++++++++++++++++++ 5 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 apps/journal/app/components/LocaleContext.tsx diff --git a/apps/journal/app/components/ClientDate.tsx b/apps/journal/app/components/ClientDate.tsx index e2fcf06..0bbc775 100644 --- a/apps/journal/app/components/ClientDate.tsx +++ b/apps/journal/app/components/ClientDate.tsx @@ -1,11 +1,16 @@ import { useState, useEffect } from "react"; +import { useLocale } from "./LocaleContext"; /** - * Renders a date only on the client to avoid SSR hydration mismatches. - * Server renders empty, client fills in with user's locale. + * Renders a date using the server-detected locale for the initial render + * (matching SSR output), then updates to the browser's native locale + * after hydration. */ export function ClientDate({ iso }: { iso: string }) { - const [formatted, setFormatted] = useState(new Date(iso).toLocaleDateString("en-US")); + const locale = useLocale(); + const [formatted, setFormatted] = useState( + new Date(iso).toLocaleDateString(locale), + ); useEffect(() => { setFormatted(new Date(iso).toLocaleDateString()); diff --git a/apps/journal/app/components/LocaleContext.tsx b/apps/journal/app/components/LocaleContext.tsx new file mode 100644 index 0000000..15902c8 --- /dev/null +++ b/apps/journal/app/components/LocaleContext.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext } from "react"; + +const LocaleContext = createContext("en"); + +export function LocaleProvider({ + locale, + children, +}: { + locale: string; + children: React.ReactNode; +}) { + return {children}; +} + +export function useLocale() { + return useContext(LocaleContext); +} diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index c041ae7..be4a5f4 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -4,7 +4,9 @@ import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; import { useTranslation } from "react-i18next"; +import { detectLocale } from "@trails-cool/i18n"; import { getSessionUser } from "~/lib/auth.server"; +import { LocaleProvider } from "~/components/LocaleContext"; import stylesheet from "@trails-cool/ui/styles.css?url"; export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; @@ -33,7 +35,8 @@ export function Layout({ children }: { children: React.ReactNode }) { export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); - return { user: user ? { id: user.id, username: user.username } : null }; + const locale = detectLocale(request); + return { user: user ? { id: user.id, username: user.username } : null, locale }; } function NavBar({ user }: { user: { id: string; username: string } | null }) { @@ -110,6 +113,7 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) { export default function App({ loaderData }: Route.ComponentProps) { const user = loaderData?.user; + const locale = loaderData?.locale ?? "en"; useEffect(() => { if (user) { Sentry.setUser({ id: user.id, username: user.username }); @@ -119,10 +123,10 @@ export default function App({ loaderData }: Route.ComponentProps) { }, [user]); return ( - <> + - + ); } diff --git a/apps/journal/app/routes/settings.tsx b/apps/journal/app/routes/settings.tsx index fda39b5..11b75b7 100644 --- a/apps/journal/app/routes/settings.tsx +++ b/apps/journal/app/routes/settings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; +import { ClientDate } from "~/components/ClientDate"; import { eq } from "drizzle-orm"; import type { Route } from "./+types/settings"; import { getSessionUser } from "~/lib/auth.server"; @@ -217,9 +218,8 @@ export default function Settings({ loaderData }: Route.ComponentProps) { {transportLabel(passkey.transports, t)}

- {t("settings.security.addedOn", { - date: new Date(passkey.createdAt).toLocaleDateString(), - })} + {t("settings.security.addedOn", { date: "" })} +

Date: Sun, 29 Mar 2026 11:48:01 +0200 Subject: [PATCH 3/3] Remove useEffect flicker from ClientDate Now that both server and client use the same locale from context, the post-hydration useEffect that re-formatted with the browser's OS locale was causing a visible flicker. Just render with the context locale directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/components/ClientDate.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/journal/app/components/ClientDate.tsx b/apps/journal/app/components/ClientDate.tsx index 0bbc775..af210ae 100644 --- a/apps/journal/app/components/ClientDate.tsx +++ b/apps/journal/app/components/ClientDate.tsx @@ -1,20 +1,12 @@ -import { useState, useEffect } from "react"; import { useLocale } from "./LocaleContext"; /** - * Renders a date using the server-detected locale for the initial render - * (matching SSR output), then updates to the browser's native locale - * after hydration. + * Renders a date formatted with the server-detected locale, + * ensuring SSR and client output match (no hydration flicker). */ export function ClientDate({ iso }: { iso: string }) { const locale = useLocale(); - const [formatted, setFormatted] = useState( - new Date(iso).toLocaleDateString(locale), + return ( + ); - - useEffect(() => { - setFormatted(new Date(iso).toLocaleDateString()); - }, [iso]); - - return ; }