Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 31 additions & 14 deletions web-next/src/components/LanguageSelect.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, Intl.DisplayNames>();
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<LocaleInfo>((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<LocaleInfo>((locale) => {
const name = dn.of(locale) ?? locale;
const nativeName = getNativeDisplayNames(locale).of(locale) ?? locale;
return {
code: locale,
name,
Expand All @@ -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 (
<Combobox<LocaleInfo>
options={locales()}
Expand Down
30 changes: 25 additions & 5 deletions web-next/src/components/Timestamp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Intl.RelativeTimeFormat>();
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,
Expand All @@ -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;
Expand Down
Loading