diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index b9eb8f0..3c20be8 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -18,7 +18,13 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import { HeaderConfig } from "@/features/core/types/header-config"; import { playerQueries } from "@/features/players/queries"; 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 FullScreenLoader from "@/components/full-screen-loader"; import { CHROME_COLORS } from "@/lib/mantine/theme-colors"; @@ -132,7 +138,16 @@ export const Route = createRootRouteWithContext<{ context.queryClient, 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 }; } catch (error: any) { if (isRedirect(error) || error instanceof Response) throw error; diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index d73d472..af9eafa 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -32,6 +32,8 @@ export function resolveLocale(candidate: string | null | undefined): AppLocale { : DEFAULT_LOCALE; } +const inflightLoads: Partial>> = {}; + export async function ensureMessages(locale: AppLocale): Promise { const cached = messageCache[locale]; if (cached) return cached; @@ -39,11 +41,27 @@ export async function ensureMessages(locale: AppLocale): Promise { const loader = catalogLoaders[`/src/locales/${locale}/messages.po`]; if (!loader) return messageCache[DEFAULT_LOCALE]!; - const { messages } = await loader(); - messageCache[locale] = messages; - return messages; + // Dedupe concurrent loads; a rejected load is dropped so a retry can succeed. + const pending = (inflightLoads[locale] ??= loader() + .then(({ messages }) => { + messageCache[locale] = 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 { return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!; } diff --git a/src/lib/i18n/provider.tsx b/src/lib/i18n/provider.tsx index 424aef5..312a653 100644 --- a/src/lib/i18n/provider.tsx +++ b/src/lib/i18n/provider.tsx @@ -1,11 +1,14 @@ 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 type { Messages } from "@lingui/core"; import { playerQueries } from "@/features/players/queries"; import { createI18n, ensureMessages, hasCachedMessages, + i18nMessagesQueryKey, + primeMessages, resolveLocale, } from "@/lib/i18n"; @@ -29,27 +32,49 @@ export const LinguiProvider = ({ children }: { children: React.ReactNode }) => { refetchOnWindowFocus: false, }); + const queryClient = useQueryClient(); const locale = resolveLocale(data?.metadata?.locale); const [catalogVersion, setCatalogVersion] = useState(0); // Fresh instance per locale (and per SSR request tree). __root beforeLoad - // preloads the catalog on authed routes; on public routes (login) it may - // not be cached yet — createI18n falls back to en until the effect below - // loads it and bumps catalogVersion. + // dehydrates the active locale's catalog with the page, so hydration primes + // it from the query cache and never regresses to en for the SSR'd locale. + // Public routes (login) may still lack it — createI18n falls back to en + // until the effect below loads it and bumps catalogVersion. const i18n = useMemo( - () => createI18n(locale), + () => { + if (!hasCachedMessages(locale)) { + const seeded = queryClient.getQueryData( + i18nMessagesQueryKey(locale) + ); + if (seeded) primeMessages(locale, seeded); + } + return createI18n(locale); + }, // eslint-disable-next-line react-hooks/exhaustive-deps - [locale, catalogVersion] + [locale, catalogVersion, queryClient] ); useEffect(() => { if (hasCachedMessages(locale)) return; let cancelled = false; - ensureMessages(locale).then(() => { - if (!cancelled) setCatalogVersion((v) => v + 1); - }); + let retryTimer: ReturnType | 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) retryTimer = setTimeout(load, 3000); + } + ); + }; + load(); return () => { cancelled = true; + if (retryTimer) clearTimeout(retryTimer); }; }, [locale]);