mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: reuse the list's date formatters instead of building them per row
Every mail row formatted its date through `toLocaleTimeString` and `toLocaleDateString`, which build a new Intl formatter on each call; a row from the last week built two, on every render. On Hermes that goes through ICU over JNI and is the costly part of formatting a date. `formatListDate` now keeps one `Intl.DateTimeFormat` per locale and format in a module-level map. The output is unchanged, since those methods format exactly as `Intl.DateTimeFormat` with the same options; a test checks the two against each other across locales, formats and ages. The cache starts over when the device's UTC offset changes, so a new time zone still shows as it did when every call picked it up.
This commit is contained in:
@@ -3,6 +3,7 @@ import { formatListDate } from '../date-format';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('formatListDate', () => {
|
||||
@@ -51,3 +52,108 @@ describe('formatListDate', () => {
|
||||
expect(out).toContain('2026');
|
||||
});
|
||||
});
|
||||
|
||||
// `formatListDate` as it was before it cached its Intl formatters: every call
|
||||
// went through `toLocale*String`, which builds a new formatter each time. The
|
||||
// cached version has to format exactly like it.
|
||||
function formatListDateUncached(
|
||||
date: Date,
|
||||
opts: { dateFormat: 'smart' | 'relative' | 'full'; timeFormat: '12h' | '24h'; locale: string },
|
||||
): string {
|
||||
const d = date;
|
||||
const now = new Date();
|
||||
const locale = opts.locale && opts.locale.length > 0 ? opts.locale : 'en';
|
||||
const intlLocale = locale === 'en' ? 'en-US' : locale;
|
||||
const hour12 = opts.timeFormat === '12h';
|
||||
if (opts.dateFormat === 'relative') {
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
if (opts.dateFormat === 'full') {
|
||||
return d.toLocaleString(intlLocale, {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12,
|
||||
});
|
||||
}
|
||||
const timeStr = d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit', hour12 });
|
||||
const isSameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
if (isSameDay) return timeStr;
|
||||
const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
|
||||
if (daysAgo < 7) {
|
||||
const weekday = d.toLocaleDateString(intlLocale, { weekday: 'short' }).replace(/\.$/, '');
|
||||
return `${weekday} ${timeStr}`;
|
||||
}
|
||||
return d.toLocaleDateString(intlLocale, { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
describe('formatListDate with cached formatters', () => {
|
||||
const NOW = new Date('2026-05-30T12:00:00Z');
|
||||
const HOUR = 3600000;
|
||||
const DAY = 24 * HOUR;
|
||||
const ages = [
|
||||
30_000, 20 * 60000, 2 * HOUR, 11 * HOUR, 20 * HOUR, DAY, 3 * DAY, 6.9 * DAY, 8 * DAY, 40 * DAY,
|
||||
160 * DAY, 400 * DAY, -DAY,
|
||||
];
|
||||
const locales = ['en', '', 'de', 'fr', 'es', 'nl', 'pt-BR', 'ru', 'ja', 'zh', 'ko', 'ar', 'he', 'hi'];
|
||||
|
||||
it('formats exactly as the per-call toLocale*String methods did', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
for (const locale of locales) {
|
||||
for (const dateFormat of ['smart', 'relative', 'full'] as const) {
|
||||
for (const timeFormat of ['12h', '24h'] as const) {
|
||||
for (const age of ages) {
|
||||
const d = new Date(NOW.getTime() - age);
|
||||
const opts = { dateFormat, timeFormat, locale };
|
||||
expect(formatListDate(d, opts), `${locale} ${dateFormat} ${timeFormat} ${age}`)
|
||||
.toBe(formatListDateUncached(d, opts));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('builds each formatter once per locale and format', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
const ctor = vi.spyOn(Intl, 'DateTimeFormat');
|
||||
const opts = { dateFormat: 'smart' as const, timeFormat: '24h' as const, locale: 'sv' };
|
||||
const threeDaysAgo = new Date(NOW.getTime() - 3 * DAY);
|
||||
// A weekday row needs the time and the weekday formatter.
|
||||
const first = formatListDate(threeDaysAgo, opts);
|
||||
expect(ctor).toHaveBeenCalledTimes(2);
|
||||
for (let i = 0; i < 50; i++) expect(formatListDate(threeDaysAgo, opts)).toBe(first);
|
||||
expect(ctor).toHaveBeenCalledTimes(2);
|
||||
// Another locale gets its own.
|
||||
formatListDate(threeDaysAgo, { ...opts, locale: 'da' });
|
||||
expect(ctor).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('builds them again when the UTC offset changes', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
const opts = { dateFormat: 'smart' as const, timeFormat: '24h' as const, locale: 'fi' };
|
||||
const earlier = new Date(NOW.getTime() - HOUR);
|
||||
formatListDate(earlier, opts);
|
||||
const ctor = vi.spyOn(Intl, 'DateTimeFormat');
|
||||
formatListDate(earlier, opts);
|
||||
expect(ctor).not.toHaveBeenCalled();
|
||||
const offset = NOW.getTimezoneOffset();
|
||||
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(offset + 60);
|
||||
formatListDate(earlier, opts);
|
||||
expect(ctor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
+50
-26
@@ -16,6 +16,50 @@ import type { DateFormat, TimeFormat } from '../stores/settings-store';
|
||||
*/
|
||||
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
type FormatName =
|
||||
| 'time12' | 'time24' | 'full12' | 'full24' | 'weekday' | 'date' | 'monthDay' | 'monthDayYear';
|
||||
|
||||
const FORMATS: Record<FormatName, Intl.DateTimeFormatOptions> = {
|
||||
time12: { hour: '2-digit', minute: '2-digit', hour12: true },
|
||||
time24: { hour: '2-digit', minute: '2-digit', hour12: false },
|
||||
full12: { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: true },
|
||||
full24: { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false },
|
||||
weekday: { weekday: 'short' },
|
||||
date: { year: 'numeric', month: '2-digit', day: '2-digit' },
|
||||
monthDay: { month: 'short', day: 'numeric' },
|
||||
monthDayYear: { month: 'short', day: 'numeric', year: 'numeric' },
|
||||
};
|
||||
|
||||
// Building an Intl formatter is the expensive part of formatting a date (on
|
||||
// Hermes it goes through ICU over JNI), and `toLocaleString`,
|
||||
// `toLocaleDateString` and `toLocaleTimeString` build a new one on every
|
||||
// call. The list formats a date in every row render, so keep one formatter
|
||||
// per locale and format. The output is the same: when the options name at
|
||||
// least one field, those methods format exactly as `Intl.DateTimeFormat`
|
||||
// does with the same options.
|
||||
const formatters = new Map<string, Intl.DateTimeFormat>();
|
||||
// A formatter fixes the device time zone when it is built, where
|
||||
// `toLocale*String` picked it up on every call. Start over when the UTC offset
|
||||
// moves (travel, a DST switch) so a changed zone still shows.
|
||||
let formattersOffset: number | undefined;
|
||||
|
||||
function syncFormatterZone(now: Date): void {
|
||||
const offset = now.getTimezoneOffset();
|
||||
if (offset === formattersOffset) return;
|
||||
formatters.clear();
|
||||
formattersOffset = offset;
|
||||
}
|
||||
|
||||
function formatWith(d: Date, locale: string, name: FormatName): string {
|
||||
const key = `${name}|${locale}`;
|
||||
let formatter = formatters.get(key);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat(locale, FORMATS[name]);
|
||||
formatters.set(key, formatter);
|
||||
}
|
||||
return formatter.format(d);
|
||||
}
|
||||
|
||||
// Relative strings ("Just now", "5m ago") through the locale catalog when a
|
||||
// translate function is supplied; the compact English form otherwise.
|
||||
function relativeLabel(
|
||||
@@ -35,6 +79,7 @@ export function formatListDate(
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
syncFormatterZone(now);
|
||||
|
||||
const { dateFormat, timeFormat } = opts;
|
||||
const localeRaw = opts.locale;
|
||||
@@ -53,30 +98,15 @@ export function formatListDate(
|
||||
if (minutes < 60) return relativeLabel(opts.t, 'minute', minutes);
|
||||
if (hours < 24) return relativeLabel(opts.t, 'hour', hours);
|
||||
if (days < 7) return relativeLabel(opts.t, 'day', days);
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
return formatWith(d, intlLocale, d.getFullYear() !== now.getFullYear() ? 'monthDayYear' : 'monthDay');
|
||||
}
|
||||
|
||||
if (dateFormat === 'full') {
|
||||
return d.toLocaleString(intlLocale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12,
|
||||
});
|
||||
return formatWith(d, intlLocale, hour12 ? 'full12' : 'full24');
|
||||
}
|
||||
|
||||
// 'smart' (default)
|
||||
const timeStr = d.toLocaleTimeString(intlLocale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12,
|
||||
});
|
||||
const timeStr = formatWith(d, intlLocale, hour12 ? 'time12' : 'time24');
|
||||
|
||||
const isSameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
@@ -88,15 +118,9 @@ export function formatListDate(
|
||||
if (daysAgo < 7) {
|
||||
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
|
||||
// strip it so the result reads cleanly next to the time.
|
||||
const weekday = d
|
||||
.toLocaleDateString(intlLocale, { weekday: 'short' })
|
||||
.replace(/\.$/, '');
|
||||
const weekday = formatWith(d, intlLocale, 'weekday').replace(/\.$/, '');
|
||||
return `${weekday} ${timeStr}`;
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return formatWith(d, intlLocale, 'date');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user