From bccad4dddfde8a5a24a2dda211154ea7ccf29f1f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:41:08 +0200 Subject: [PATCH] perf: reuse date formatters in the message list --- lib/__tests__/format-date-cache.test.ts | 62 +++++++++++++++++++++++++ lib/utils.ts | 43 ++++++++++++----- 2 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 lib/__tests__/format-date-cache.test.ts diff --git a/lib/__tests__/format-date-cache.test.ts b/lib/__tests__/format-date-cache.test.ts new file mode 100644 index 000000000..7ab7995af --- /dev/null +++ b/lib/__tests__/format-date-cache.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { formatDate } from '../utils'; +import { useSettingsStore } from '@/stores/settings-store'; + +// The list formats every row's date on every render. formatDate keeps its +// Intl.DateTimeFormat instances instead of building one per call (which each +// toLocale*String call does), without changing a single output. + +describe('formatDate formatters', () => { + afterEach(() => { + vi.restoreAllMocks(); + useSettingsStore.setState({ dateFormat: 'smart', timeFormat: '24h', dateLocale: 'auto' }); + }); + + it('builds a formatter once per locale and option set, not once per row', () => { + useSettingsStore.setState({ dateFormat: 'smart', timeFormat: '12h', dateLocale: 'auto', timeZone: 'Europe/Berlin' }); + const old = new Date(Date.now() - 40 * 86_400_000); + formatDate(old); // warm + const Real = Intl.DateTimeFormat; + let built = 0; + vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(((...args: ConstructorParameters) => { + built++; + return new Real(...args); + }) as never); + vi.spyOn(Date.prototype, 'toLocaleDateString').mockImplementation(function (this: Date, ...args) { built++; return Real.prototype.format.call(new Real(...args), this); }); + vi.spyOn(Date.prototype, 'toLocaleTimeString').mockImplementation(function (this: Date, ...args) { built++; return Real.prototype.format.call(new Real(...args), this); }); + vi.spyOn(Date.prototype, 'toLocaleString').mockImplementation(function (this: Date, ...args) { built++; return Real.prototype.format.call(new Real(...args), this); }); + + for (let i = 0; i < 50; i++) formatDate(new Date(old.getTime() - i * 60_000)); + + expect(built).toBe(0); + }); + + it.each([ + ['smart', '24h', 'auto'], + ['smart', '12h', 'en-GB'], + ['smart', '24h', 'iso'], + ['full', '12h', 'auto'], + ['full', '24h', 'en-US'], + ['relative', '24h', 'auto'], + ] as const)('formats as the toLocale* calls did (%s, %s, %s)', (dateFormat, timeFormat, dateLocale) => { + useSettingsStore.setState({ dateFormat, timeFormat, dateLocale, timeZone: 'America/New_York' }); + const now = Date.now(); + const zone = 'America/New_York'; + const hour12 = timeFormat === '12h'; + const numeric = dateLocale === 'auto' ? 'en-US' : dateLocale === 'iso' ? 'en-CA' : dateLocale; + for (const ago of [5 * 60_000, 3 * 86_400_000, 40 * 86_400_000, 400 * 86_400_000]) { + const d = new Date(now - ago); + const got = formatDate(d); + if (dateFormat === 'full') { + expect(got).toBe(d.toLocaleString(numeric, { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12 })); + } else if (dateFormat === 'smart' && ago > 7 * 86_400_000) { + expect(got).toBe(d.toLocaleDateString(numeric, { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' })); + } else if (dateFormat === 'smart' && ago > 86_400_000) { + const weekday = d.toLocaleDateString('en-US', { timeZone: zone, weekday: 'short' }); + expect(got).toBe(`${weekday} ${d.toLocaleTimeString('en-US', { timeZone: zone, hour: '2-digit', minute: '2-digit', hour12 })}`); + } else { + expect(got.length).toBeGreaterThan(0); + } + } + }); +}); diff --git a/lib/utils.ts b/lib/utils.ts index 76f40cfbc..6d30d4514 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -69,6 +69,25 @@ function resolveDateLocale( } } +const dateFormatters = new Map(); + +/** + * An Intl.DateTimeFormat for `locale` and `options`, built once. Each + * toLocale*String call constructs one afresh, and the list formats every + * row's date on every render. Only for option sets that name a date or time + * field: then toLocaleString, toLocaleDateString and toLocaleTimeString all + * format exactly as the formatter does. + */ +function cachedDateFormat(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { + const key = `${locale}|${JSON.stringify(options)}`; + let formatter = dateFormatters.get(key); + if (!formatter) { + formatter = new Intl.DateTimeFormat(locale, options); + dateFormatters.set(key, formatter); + } + return formatter; +} + /** * Formats a received-at date for the email list. The output style is * controlled by the `dateFormat` user setting: @@ -131,16 +150,16 @@ export function formatDate( if (minutes < 60) return rtf.format(-minutes, "minute"); if (hours < 24) return rtf.format(-hours, "hour"); if (days < 7) return rtf.format(-days, "day"); - return d.toLocaleDateString(uiLocale, { + return cachedDateFormat(uiLocale, { timeZone, month: "short", day: "numeric", year: getWallClock(d, timeZone).year !== getWallClock(now, timeZone).year ? "numeric" : undefined, - }); + }).format(d); } if (dateFormat === "full") { - return d.toLocaleString(numericLocale, { + return cachedDateFormat(numericLocale, { timeZone, year: "numeric", month: "2-digit", @@ -148,16 +167,16 @@ export function formatDate( hour: "2-digit", minute: "2-digit", hour12, - }); + }).format(d); } // 'smart' (default) - const timeStr = d.toLocaleTimeString(uiLocale, { + const timeStr = cachedDateFormat(uiLocale, { timeZone, hour: "2-digit", minute: "2-digit", hour12, - }); + }).format(d); // Calendar-day comparison in the display zone, not the browser zone. const dWall = getWallClock(d, timeZone); @@ -170,27 +189,27 @@ export function formatDate( if (overrides?.listStyle === 'mountain-view') { if (dWall.year === nowWall.year) { - return d.toLocaleDateString(uiLocale, { timeZone, month: "short", day: "numeric" }); + return cachedDateFormat(uiLocale, { timeZone, month: "short", day: "numeric" }).format(d); } - return d.toLocaleDateString(numericLocale, { timeZone, year: "2-digit", month: "numeric", day: "numeric" }); + return cachedDateFormat(numericLocale, { timeZone, year: "2-digit", month: "numeric", day: "numeric" }).format(d); } const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000); 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(uiLocale, { timeZone, weekday: "short" }) + const weekday = cachedDateFormat(uiLocale, { timeZone, weekday: "short" }) + .format(d) .replace(/\.$/, ""); return `${weekday} ${timeStr}`; } - return d.toLocaleDateString(numericLocale, { + return cachedDateFormat(numericLocale, { timeZone, year: "numeric", month: "2-digit", day: "2-digit", - }); + }).format(d); } /**