diff --git a/web-next/src/components/LanguageSelect.tsx b/web-next/src/components/LanguageSelect.tsx index c3216aadb..74e8b81a5 100644 --- a/web-next/src/components/LanguageSelect.tsx +++ b/web-next/src/components/LanguageSelect.tsx @@ -1,5 +1,5 @@ import { POSSIBLE_LOCALES } from "@hackerspub/models/i18n"; -import { Show } from "solid-js"; +import { createMemo, Show } from "solid-js"; import { Combobox, ComboboxContent, @@ -28,20 +28,37 @@ interface LocaleInfo { readonly disabled: boolean; } +// `Intl.DisplayNames` instances are expensive: each one loads CLDR tables for +// its target locale. With ~200 entries in POSSIBLE_LOCALES we'd otherwise +// allocate that many tables on every read of `locales()`. Cache per-locale +// formatters once at module scope and reuse them. +const nativeDisplayNamesCache = new Map(); +function getNativeDisplayNames(locale: string): Intl.DisplayNames { + let dn = nativeDisplayNamesCache.get(locale); + if (dn == null) { + dn = new Intl.DisplayNames(locale, { type: "language" }); + nativeDisplayNamesCache.set(locale, dn); + } + return dn; +} + +const englishNames = new Intl.DisplayNames("en", { type: "language" }); + export function LanguageSelect(props: LanguageSelectProps) { const { t, i18n } = useLingui(); - const displayNames = new Intl.DisplayNames(i18n.locale, { type: "language" }); - const englishNames = new Intl.DisplayNames("en", { type: "language" }); - const locales = () => { + const displayNames = createMemo(() => + new Intl.DisplayNames(i18n.locale, { type: "language" }) + ); + const locales = createMemo(() => { + const dn = displayNames(); const localeCodes: string[] = [...POSSIBLE_LOCALES]; - if (props.value != null && localeCodes.includes(props.value.baseName)) { + if (props.value != null && !localeCodes.includes(props.value.baseName)) { localeCodes.push(props.value.baseName); } - const locales = localeCodes.map((locale) => { - const name = displayNames.of(locale) ?? locale; - const nativeName = - new Intl.DisplayNames(locale, { type: "language" }).of(locale) ?? - locale; + const exclude = props.exclude; + const result = localeCodes.map((locale) => { + const name = dn.of(locale) ?? locale; + const nativeName = getNativeDisplayNames(locale).of(locale) ?? locale; return { code: locale, name, @@ -50,12 +67,12 @@ export function LanguageSelect(props: LanguageSelectProps) { englishNames.of(locale) ?? "" }` .trim(), - disabled: props.exclude?.some((l) => l.baseName === locale) ?? false, + disabled: exclude?.some((l) => l.baseName === locale) ?? false, }; }); - locales.sort((a, b) => a.name.localeCompare(b.name)); - return locales; - }; + result.sort((a, b) => a.name.localeCompare(b.name)); + return result; + }); return ( options={locales()} diff --git a/web-next/src/components/Timestamp.tsx b/web-next/src/components/Timestamp.tsx index 475e3b281..0ec3138db 100644 --- a/web-next/src/components/Timestamp.tsx +++ b/web-next/src/components/Timestamp.tsx @@ -88,6 +88,25 @@ export function getRelativeTimeUpdateDelayMs( return DAY; } +// `Intl.RelativeTimeFormat` is comparatively expensive to construct (it loads +// CLDR data for the locale). The previous implementation built a fresh +// formatter on every render, which is wasteful when 25+ timeline cards each +// re-render their relative timestamp every 1s/30s/1min. Cache by the +// (locale, options) tuple that actually affects output. +const relativeTimeFormatCache = new Map(); +function getRelativeTimeFormat( + locale: string, + options: Intl.RelativeTimeFormatOptions, +): Intl.RelativeTimeFormat { + const key = `${locale}|${options.numeric ?? ""}|${options.style ?? ""}`; + let f = relativeTimeFormatCache.get(key); + if (f == null) { + f = new Intl.RelativeTimeFormat(locale, options); + relativeTimeFormatCache.set(key, f); + } + return f; +} + function formatRelativeTime( currentDate: Date, targetDate: Date, @@ -107,16 +126,17 @@ function formatRelativeTime( for (const { unit, ms } of UNITS) { if (absDiff >= ms) { const value = Math.round(diffMs / ms); - const f = new Intl.RelativeTimeFormat(locale, options).format( - value, - unit, - ); + const f = getRelativeTimeFormat(locale, options).format(value, unit); return options.capitalizeFirstLetter ? f.charAt(0).toUpperCase() + f.slice(1) : f; } } - const f = new Intl.RelativeTimeFormat(locale).format(0, "second"); + // Use the caller-supplied `options` here so the fallback respects + // `numeric` / `style` the same way the loop above does. Hardcoding a + // different `numeric` would change the output for value=0 (e.g. "now" + // under "auto" vs "0 seconds ago" under the Intl default "always"). + const f = getRelativeTimeFormat(locale, options).format(0, "second"); return options.capitalizeFirstLetter ? f.charAt(0).toUpperCase() + f.slice(1) : f;