fix(i18n): hydrate in the SSR locale instead of regressing to en
The client's message cache held only en at hydration, so a non-en user rendered English until the locale's catalog chunk loaded — and stayed English if that import failed or lost a race. __root beforeLoad now dehydrates the active locale's compiled catalog with the page and the provider primes its cache from that payload, so first paint is already in the user's locale with no dependency on a client-side chunk load. ensureMessages dedupes concurrent loads and drops rejected ones so a retry can succeed, and the provider's fallback loader retries instead of silently stranding the UI in en.
This commit is contained in:
@@ -18,7 +18,13 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
|
|||||||
import { HeaderConfig } from "@/features/core/types/header-config";
|
import { HeaderConfig } from "@/features/core/types/header-config";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import { DEFAULT_LOCALE, ensureMessages, resolveLocale } from "@/lib/i18n";
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
ensureMessages,
|
||||||
|
getCachedMessages,
|
||||||
|
i18nMessagesQueryKey,
|
||||||
|
resolveLocale,
|
||||||
|
} from "@/lib/i18n";
|
||||||
import { getRootMeta } from "@/lib/i18n/meta";
|
import { getRootMeta } from "@/lib/i18n/meta";
|
||||||
import FullScreenLoader from "@/components/full-screen-loader";
|
import FullScreenLoader from "@/components/full-screen-loader";
|
||||||
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
|
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
|
||||||
@@ -132,7 +138,16 @@ export const Route = createRootRouteWithContext<{
|
|||||||
context.queryClient,
|
context.queryClient,
|
||||||
playerQueries.auth()
|
playerQueries.auth()
|
||||||
);
|
);
|
||||||
await ensureMessages(resolveLocale(auth?.metadata?.locale));
|
const locale = resolveLocale(auth?.metadata?.locale);
|
||||||
|
await ensureMessages(locale);
|
||||||
|
// Dehydrate the catalog with the page so the client hydrates in the
|
||||||
|
// active locale instead of falling back to en until a chunk loads.
|
||||||
|
if (typeof window === "undefined" && locale !== DEFAULT_LOCALE) {
|
||||||
|
context.queryClient.setQueryData(
|
||||||
|
i18nMessagesQueryKey(locale),
|
||||||
|
getCachedMessages(locale)
|
||||||
|
);
|
||||||
|
}
|
||||||
return { auth };
|
return { auth };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (isRedirect(error) || error instanceof Response) throw error;
|
if (isRedirect(error) || error instanceof Response) throw error;
|
||||||
|
|||||||
+19
-1
@@ -32,6 +32,8 @@ export function resolveLocale(candidate: string | null | undefined): AppLocale {
|
|||||||
: DEFAULT_LOCALE;
|
: DEFAULT_LOCALE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inflightLoads: Partial<Record<AppLocale, Promise<Messages>>> = {};
|
||||||
|
|
||||||
export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
||||||
const cached = messageCache[locale];
|
const cached = messageCache[locale];
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -39,11 +41,27 @@ export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
|||||||
const loader = catalogLoaders[`/src/locales/${locale}/messages.po`];
|
const loader = catalogLoaders[`/src/locales/${locale}/messages.po`];
|
||||||
if (!loader) return messageCache[DEFAULT_LOCALE]!;
|
if (!loader) return messageCache[DEFAULT_LOCALE]!;
|
||||||
|
|
||||||
const { messages } = await loader();
|
// Dedupe concurrent loads; a rejected load is dropped so a retry can succeed.
|
||||||
|
const pending = (inflightLoads[locale] ??= loader()
|
||||||
|
.then(({ messages }) => {
|
||||||
messageCache[locale] = messages;
|
messageCache[locale] = messages;
|
||||||
return messages;
|
return messages;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
delete inflightLoads[locale];
|
||||||
|
}));
|
||||||
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function primeMessages(locale: AppLocale, messages: Messages) {
|
||||||
|
messageCache[locale] ??= messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seeded into the SSR-dehydrated query cache so hydration renders the active
|
||||||
|
// locale without depending on a client-side catalog chunk load.
|
||||||
|
export const i18nMessagesQueryKey = (locale: AppLocale) =>
|
||||||
|
["i18n-messages", locale] as const;
|
||||||
|
|
||||||
export function getCachedMessages(locale: AppLocale): Messages {
|
export function getCachedMessages(locale: AppLocale): Messages {
|
||||||
return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!;
|
return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { I18nProvider } from "@lingui/react";
|
import { I18nProvider } from "@lingui/react";
|
||||||
|
import type { Messages } from "@lingui/core";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import {
|
import {
|
||||||
createI18n,
|
createI18n,
|
||||||
ensureMessages,
|
ensureMessages,
|
||||||
hasCachedMessages,
|
hasCachedMessages,
|
||||||
|
i18nMessagesQueryKey,
|
||||||
|
primeMessages,
|
||||||
resolveLocale,
|
resolveLocale,
|
||||||
} from "@/lib/i18n";
|
} from "@/lib/i18n";
|
||||||
|
|
||||||
@@ -29,27 +32,49 @@ export const LinguiProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const locale = resolveLocale(data?.metadata?.locale);
|
const locale = resolveLocale(data?.metadata?.locale);
|
||||||
const [catalogVersion, setCatalogVersion] = useState(0);
|
const [catalogVersion, setCatalogVersion] = useState(0);
|
||||||
|
|
||||||
// Fresh instance per locale (and per SSR request tree). __root beforeLoad
|
// Fresh instance per locale (and per SSR request tree). __root beforeLoad
|
||||||
// preloads the catalog on authed routes; on public routes (login) it may
|
// dehydrates the active locale's catalog with the page, so hydration primes
|
||||||
// not be cached yet — createI18n falls back to en until the effect below
|
// it from the query cache and never regresses to en for the SSR'd locale.
|
||||||
// loads it and bumps catalogVersion.
|
// Public routes (login) may still lack it — createI18n falls back to en
|
||||||
|
// until the effect below loads it and bumps catalogVersion.
|
||||||
const i18n = useMemo(
|
const i18n = useMemo(
|
||||||
() => createI18n(locale),
|
() => {
|
||||||
|
if (!hasCachedMessages(locale)) {
|
||||||
|
const seeded = queryClient.getQueryData<Messages>(
|
||||||
|
i18nMessagesQueryKey(locale)
|
||||||
|
);
|
||||||
|
if (seeded) primeMessages(locale, seeded);
|
||||||
|
}
|
||||||
|
return createI18n(locale);
|
||||||
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[locale, catalogVersion]
|
[locale, catalogVersion, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasCachedMessages(locale)) return;
|
if (hasCachedMessages(locale)) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
ensureMessages(locale).then(() => {
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
// A failed chunk load must not strand the UI in en — retry until the
|
||||||
|
// catalog arrives or the locale changes.
|
||||||
|
const load = () => {
|
||||||
|
ensureMessages(locale).then(
|
||||||
|
() => {
|
||||||
if (!cancelled) setCatalogVersion((v) => v + 1);
|
if (!cancelled) setCatalogVersion((v) => v + 1);
|
||||||
});
|
},
|
||||||
|
() => {
|
||||||
|
if (!cancelled) retryTimer = setTimeout(load, 3000);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
load();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
};
|
};
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user