diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 7975fb905..1df748e61 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -5,7 +5,9 @@ import { hydrateRoot } from "react-dom/client"; import { I18nextProvider } from "react-i18next"; import { HydratedRouter } from "react-router/dom"; import { Config } from "~/config"; +import type { LanguageCode } from "~/modules/i18n/config"; import { i18nLoader } from "./modules/i18n/loader"; +import { loadDateFnsLocale } from "./utils/dates"; import { logger } from "./utils/logger"; import { getSessionId } from "./utils/session-id"; @@ -125,7 +127,12 @@ if ("serviceWorker" in navigator) { }); } -i18nLoader() +// the server rendered with the page language's date-fns locale, so it must be +// cached before hydration to avoid a markup mismatch +Promise.all([ + i18nLoader(), + loadDateFnsLocale(document.documentElement.lang as LanguageCode), +]) .then(() => hydrateRoot( document, diff --git a/app/entry.server.tsx b/app/entry.server.tsx index e8dc1f9eb..d0f6084c9 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -20,6 +20,7 @@ import { everyHourAt30, everyTwoMinutes, } from "./routines/list.server"; +import { loadAllDateFnsLocales } from "./utils/dates"; import { logger } from "./utils/logger"; // Reject/cancel all pending promises after 5 seconds @@ -27,6 +28,8 @@ export const streamTimeout = 5000; const SENTRY_ENABLED = Config.sentry.enabled; +const dateFnsLocalesLoaded = loadAllDateFnsLocales(); + async function handleRequest( request: Request, responseStatusCode: number, @@ -34,6 +37,8 @@ async function handleRequest( reactRouterContext: EntryContext, loadContext: RouterContextProvider, ) { + await dateFnsLocalesLoaded; + const callbackName = isbot(request.headers.get("user-agent")) ? "onAllReady" : "onShellReady"; diff --git a/app/modules/i18n/useChangeLanguage.ts b/app/modules/i18n/useChangeLanguage.ts index 1249b2078..2bf2dad9e 100644 --- a/app/modules/i18n/useChangeLanguage.ts +++ b/app/modules/i18n/useChangeLanguage.ts @@ -1,15 +1,23 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; +import type { LanguageCode } from "~/modules/i18n/config"; +import { loadDateFnsLocale } from "~/utils/dates"; /** * Detect when the locale returned by the root route loader changes and call * `i18n.changeLanguage` with the new locale so translations load automatically. + * The date-fns locale is loaded first so the re-render triggered by the + * language change already has it available. * * Vendored from remix-i18next (removed in v8). */ export function useChangeLanguage(locale: string) { const { i18n } = useTranslation(); React.useEffect(() => { - if (i18n.language !== locale) i18n.changeLanguage(locale); + if (i18n.language !== locale) { + void loadDateFnsLocale(locale as LanguageCode).then(() => + i18n.changeLanguage(locale), + ); + } }, [locale, i18n]); } diff --git a/app/utils/dates.ts b/app/utils/dates.ts index 453d2dfbf..e3dc8f036 100644 --- a/app/utils/dates.ts +++ b/app/utils/dates.ts @@ -5,44 +5,67 @@ import { } from "@internationalized/date"; import type { Locale } from "date-fns"; import { formatDistanceToNow as dateFnsFormatDistanceToNow } from "date-fns"; -import { da } from "date-fns/locale/da"; -import { de } from "date-fns/locale/de"; import { enUS } from "date-fns/locale/en-US"; -import { es } from "date-fns/locale/es"; -import { fr } from "date-fns/locale/fr"; -import { frCA } from "date-fns/locale/fr-CA"; -import { he } from "date-fns/locale/he"; -import { it } from "date-fns/locale/it"; -import { ja } from "date-fns/locale/ja"; -import { ko } from "date-fns/locale/ko"; -import { nl } from "date-fns/locale/nl"; -import { pl } from "date-fns/locale/pl"; -import { ptBR } from "date-fns/locale/pt-BR"; -import { ru } from "date-fns/locale/ru"; -import { zhCN } from "date-fns/locale/zh-CN"; import type { MonthYear } from "~/features/plus-voting/core"; import type { LanguageCode } from "~/modules/i18n/config"; +import { logger } from "./logger"; import type { DayMonthYear } from "./zod"; -const LOCALE_MAP: Record = { - da, - de, - en: enUS, - "es-ES": es, - "es-US": es, - "fr-CA": frCA, - "fr-EU": fr, - he, - it, - ja, - ko, - nl, - pl, - "pt-BR": ptBR, - ru, - zh: zhCN, +// en-US ships with date-fns core as the default locale, so it costs no extra bytes +const LOCALE_LOADERS: Record Promise> = { + da: () => import("date-fns/locale/da").then((module) => module.da), + de: () => import("date-fns/locale/de").then((module) => module.de), + en: () => Promise.resolve(enUS), + "es-ES": () => import("date-fns/locale/es").then((module) => module.es), + "es-US": () => import("date-fns/locale/es").then((module) => module.es), + "fr-CA": () => import("date-fns/locale/fr-CA").then((module) => module.frCA), + "fr-EU": () => import("date-fns/locale/fr").then((module) => module.fr), + he: () => import("date-fns/locale/he").then((module) => module.he), + it: () => import("date-fns/locale/it").then((module) => module.it), + ja: () => import("date-fns/locale/ja").then((module) => module.ja), + ko: () => import("date-fns/locale/ko").then((module) => module.ko), + nl: () => import("date-fns/locale/nl").then((module) => module.nl), + pl: () => import("date-fns/locale/pl").then((module) => module.pl), + "pt-BR": () => import("date-fns/locale/pt-BR").then((module) => module.ptBR), + ru: () => import("date-fns/locale/ru").then((module) => module.ru), + zh: () => import("date-fns/locale/zh-CN").then((module) => module.zhCN), }; +const loadedLocales = new Map(); + +/** + * Loads the date-fns locale for the given language into the in-memory cache + * used by {@link formatDistanceToNow}. Load failures are logged and result in + * an English fallback instead of rejecting. + */ +export async function loadDateFnsLocale(language: LanguageCode) { + if (loadedLocales.has(language)) return; + + const loader = LOCALE_LOADERS[language]; + if (!loader) return; + + try { + loadedLocales.set(language, await loader()); + } catch (error) { + logger.warn( + `Failed to load date-fns locale for language ${language}`, + error, + ); + } +} + +/** Loads every date-fns locale into the cache (meant for the server where bundle size does not matter). */ +export function loadAllDateFnsLocales() { + return Promise.all( + (Object.keys(LOCALE_LOADERS) as LanguageCode[]).map(loadDateFnsLocale), + ); +} + +/** + * Formats how long ago / until the given date in the given language. The + * language's date-fns locale must be loaded first via + * {@link loadDateFnsLocale}; otherwise falls back to English. + */ export function formatDistanceToNow( date: Parameters[0], options: Omit< @@ -52,7 +75,7 @@ export function formatDistanceToNow( ) { return dateFnsFormatDistanceToNow(date, { ...options, - locale: LOCALE_MAP[options.language], + locale: loadedLocales.get(options.language) ?? enUS, }); }