mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
perf: reuse date formatters in the message list
This commit is contained in:
@@ -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<typeof Intl.DateTimeFormat>) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
+31
-12
@@ -69,6 +69,25 @@ function resolveDateLocale<T extends string | undefined>(
|
||||
}
|
||||
}
|
||||
|
||||
const dateFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user