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 <html lang> 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 <html lang> dynamic instead of hardcoded "en". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4be716687a
commit
79f000fcc5
10 changed files with 166 additions and 40 deletions
|
|
@ -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,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 (
|
||||
<html lang="en">
|
||||
<html lang={i18n.language}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue