Merge pull request #132 from trails-cool/chore/upgrade-i18next-v26
Upgrade i18next v26 + react-i18next v17 with SSR-safe init
This commit is contained in:
commit
26dd1a4d83
13 changed files with 219 additions and 56 deletions
|
|
@ -1,15 +1,12 @@
|
|||
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 formatted with the server-detected locale,
|
||||
* ensuring SSR and client output match (no hydration flicker).
|
||||
*/
|
||||
export function ClientDate({ iso }: { iso: string }) {
|
||||
const [formatted, setFormatted] = useState(new Date(iso).toLocaleDateString("en-US"));
|
||||
|
||||
useEffect(() => {
|
||||
setFormatted(new Date(iso).toLocaleDateString());
|
||||
}, [iso]);
|
||||
|
||||
return <time dateTime={iso}>{formatted}</time>;
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<time dateTime={iso}>{new Date(iso).toLocaleDateString(locale)}</time>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
17
apps/journal/app/components/LocaleContext.tsx
Normal file
17
apps/journal/app/components/LocaleContext.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { createContext, useContext } from "react";
|
||||
|
||||
const LocaleContext = createContext<string>("en");
|
||||
|
||||
export function LocaleProvider({
|
||||
locale,
|
||||
children,
|
||||
}: {
|
||||
locale: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <LocaleContext value={locale}>{children}</LocaleContext>;
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
return useContext(LocaleContext);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -4,17 +4,17 @@ 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 { 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";
|
||||
|
||||
initI18n();
|
||||
|
||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { i18n } = useTranslation();
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang={i18n.language}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
|
@ -35,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 }) {
|
||||
|
|
@ -112,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 });
|
||||
|
|
@ -121,10 +123,10 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
|||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LocaleProvider locale={locale}>
|
||||
<NavBar user={user ?? null} />
|
||||
<Outlet />
|
||||
</>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("settings.security.addedOn", {
|
||||
date: new Date(passkey.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
{t("settings.security.addedOn", { date: "" })}
|
||||
<ClientDate iso={passkey.createdAt} />
|
||||
</p>
|
||||
</div>
|
||||
<deleteFetcher.Form
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
79
apps/planner/app/entry.server.tsx
Normal file
79
apps/planner/app/entry.server.tsx
Normal file
|
|
@ -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<typeof setTimeout> | undefined = setTimeout(
|
||||
() => abort(),
|
||||
streamTimeout + 1000,
|
||||
);
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter context={routerContext} url={request.url} />,
|
||||
{
|
||||
[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);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<html lang="en">
|
||||
<html lang={i18n.language}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"types": "./src/index.ts",
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"i18next": ">=23",
|
||||
"react-i18next": ">=14"
|
||||
"i18next": ">=26",
|
||||
"react-i18next": ">=17"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,26 +6,87 @@ 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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the full locale (e.g. "de-DE") from a request's Accept-Language header.
|
||||
* Falls back to the supported language (e.g. "en") if no region tag is present.
|
||||
*/
|
||||
export function detectLocale(request: Request): string {
|
||||
const header = request.headers.get("Accept-Language") ?? "";
|
||||
for (const part of header.split(",")) {
|
||||
const tag = part.split(";")[0]?.trim();
|
||||
if (!tag) continue;
|
||||
const lang = tag.toLowerCase();
|
||||
for (const supported of supportedLngs) {
|
||||
if (lang === supported || lang.startsWith(supported + "-")) {
|
||||
return tag; // preserve original casing (e.g. "de-DE")
|
||||
}
|
||||
}
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
export { i18n };
|
||||
|
|
|
|||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue