perf(web): defer timezone indexing until picker opens #123

Merged
eleith merged 1 commits from perf/lazy-timezone-index into main 2026-11-03 16:36:03 +00:00
5 changed files with 335 additions and 59 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@when/web': patch
---
Build the timezone search index in small client-side batches only when the picker is first opened, rather than during server rendering or page hydration. Show a loading state while keeping search and close controls usable, reuse the completed index, and allow retrying failed initialization.
+119
View File
@@ -0,0 +1,119 @@
import { expect, test, type Page } from './support/test.ts';
import { CHAT_MEETING } from './support/seed.ts';
type ProbedWindow = typeof window & { timezoneBuilds: number };
async function trackTimezoneBuilds(page: Page, options: { failFirst?: boolean } = {}) {
await page.addInitScript(({ failFirst }) => {
const probe = window as ProbedWindow;
probe.timezoneBuilds = 0;
const supportedValuesOf = Intl.supportedValuesOf.bind(Intl);
Intl.supportedValuesOf = (key) => {
if (key === 'timeZone') {
probe.timezoneBuilds++;
if (failFirst && probe.timezoneBuilds === 1) throw new Error('Timezone lookup failed');
}
return supportedValuesOf(key);
};
}, options);
}
const buildCount = (page: Page) => page.evaluate(() => (window as ProbedWindow).timezoneBuilds);
for (const width of [390, 1280]) {
test(`timezone indexing is first-open only and stays usable while loading at ${width}px`, async ({
page,
context
}) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await trackTimezoneBuilds(page);
const now = new Date();
await page.clock.install({ time: now });
await page.goto(`/schedule/${CHAT_MEETING.slug}`);
const trigger = page.getByRole('button', { name: /^Change timezone, currently/ });
await expect(trigger).toBeVisible();
await page.clock.pauseAt(new Date(now.getTime() + 60_000));
expect(await buildCount(page)).toBe(0);
const requests: string[] = [];
page.on('request', (request) => {
if (['fetch', 'xhr'].includes(request.resourceType())) requests.push(request.url());
});
await trigger.click();
const dialog = page.getByRole('dialog', { name: 'Choose timezone' });
const search = dialog.getByRole('searchbox', { name: 'Search timezones' });
const loading = dialog.getByRole('status').filter({ hasText: 'Loading timezones…' });
await expect(loading).toBeVisible();
await page.clock.runFor(16);
await expect(loading).toBeVisible();
await expect(search).toBeFocused();
expect(await buildCount(page)).toBe(1);
const loadingHeight = (await dialog.boundingBox())!.height;
// Typing and closing must work before initialization completes.
await search.fill('Tokyo');
await expect(dialog.getByText('No matches', { exact: true })).toHaveCount(0);
await page.keyboard.press('Escape');
// Bits UI finishes closing through animation frames, even with reduced motion.
await page.clock.runFor(32);
await expect(dialog).not.toBeVisible();
await trigger.click();
await expect(loading).toBeVisible();
await expect(search).toHaveValue('Tokyo');
expect(await buildCount(page)).toBe(1);
const close = dialog.getByRole('button', { name: 'Close', exact: true });
await close.focus();
await expect
.poll(async () => {
await page.clock.runFor(32);
return loading.count();
})
.toBe(0);
await expect(close).toBeFocused();
await expect(dialog.locator('.tz-option')).toHaveCount(1);
expect((await dialog.boundingBox())!.height).toBe(loadingHeight);
expect(requests).toEqual([]);
await dialog.getByRole('button', { name: /^Tokyo/ }).click();
await page.clock.runFor(32);
await expect(dialog).not.toBeVisible();
await expect(trigger).toHaveAccessibleName(/currently Tokyo/);
const cookies = await context.cookies();
expect(decodeURIComponent(cookies.find((cookie) => cookie.name === 'tz')!.value)).toBe(
'Asia/Tokyo'
);
await trigger.click();
await expect(dialog).toBeVisible();
await expect(loading).toHaveCount(0);
await expect(search).toHaveValue('');
await expect(dialog.locator('.tz-option.selected')).toContainText('Tokyo');
expect(await buildCount(page)).toBe(1);
});
}
test('a failed timezone lookup offers a retry without breaking the booking page', async ({
page
}) => {
await trackTimezoneBuilds(page, { failFirst: true });
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(`/schedule/${CHAT_MEETING.slug}`);
const trigger = page.getByRole('button', { name: /^Change timezone, currently/ });
await expect(trigger).toBeVisible();
expect(await buildCount(page)).toBe(0);
await trigger.click();
const dialog = page.getByRole('dialog', { name: 'Choose timezone' });
await expect(dialog.getByRole('status')).toHaveText('Couldn’t load timezones.');
expect(await buildCount(page)).toBe(1);
await dialog.getByRole('button', { name: 'Try again', exact: true }).click();
const search = dialog.getByRole('searchbox', { name: 'Search timezones' });
await expect(search).toBeFocused();
await search.fill('PST');
await expect(dialog.getByRole('button', { name: /^Los Angeles/ })).toBeVisible();
expect(await buildCount(page)).toBe(2);
await page.keyboard.press('Escape');
await expect(trigger).toBeFocused();
expect(errors).toEqual([]);
});
@@ -1,7 +1,7 @@
<script lang="ts">
import { tick } from 'svelte';
import { Dialog } from 'bits-ui';
import { getTimezoneIndex, searchTimezones } from '#lib/timezones.js';
import { getTimezoneIndex, searchTimezones, type TimezoneIndex } from '#lib/timezones.js';
import { getPreferredTimezone } from '#lib/preferredTimezone.svelte.js';
interface Props {
@@ -10,26 +10,48 @@
let { open = $bindable(false) }: Props = $props();
const ptz = getPreferredTimezone();
const tzIndex = getTimezoneIndex();
const preferredTimezone = getPreferredTimezone();
let timezoneIndex = $state.raw<TimezoneIndex | null>(null);
let loadError = $state(false);
let search = $state('');
let searchInput = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLUListElement | null>(null);
let filtered = $derived(searchTimezones(search, tzIndex));
let matchingTimezones = $derived(timezoneIndex ? searchTimezones(search, timezoneIndex) : []);
async function loadTimezones() {
if (timezoneIndex) return;
loadError = false;
try {
timezoneIndex = await getTimezoneIndex();
} catch {
loadError = true;
}
}
function handleOpen(event: Event) {
event.preventDefault();
searchInput?.focus();
void loadTimezones();
}
function retryLoadTimezones() {
searchInput?.focus();
void loadTimezones();
}
$effect(() => {
if (!open) return;
if (!open || !timezoneIndex) return;
tick().then(() => {
searchInput?.focus();
if (!open) return;
const selected = listEl?.querySelector('.tz-option.selected');
(selected as HTMLElement | null)?.scrollIntoView({ block: 'center' });
});
});
function select(tz: string) {
ptz.set(tz, { persist: true });
preferredTimezone.set(tz, { persist: true });
open = false;
search = '';
}
@@ -42,12 +64,7 @@
<div {...props} class="dialog-overlay"></div>
{/snippet}
</Dialog.Overlay>
<Dialog.Content
onOpenAutoFocus={(e) => {
e.preventDefault();
searchInput?.focus();
}}
>
<Dialog.Content onOpenAutoFocus={handleOpen}>
{#snippet child({ props })}
<div {...props} class="dialog-content tz-dialog">
<header class="tz-dialog-header">
@@ -62,41 +79,53 @@
{/snippet}
</Dialog.Close>
</header>
<!-- svelte-ignore a11y_autofocus -->
<input
class="tz-search"
type="search"
aria-label="Search timezones"
placeholder="Search timezone or city…"
bind:value={search}
bind:this={searchInput}
autocomplete="off"
autofocus
/>
<ul class="tz-list" bind:this={listEl}>
{#each filtered as item (item.tz)}
<li>
<button
type="button"
class="tz-option"
class:selected={item.tz === ptz.current}
onclick={() => select(item.tz)}
<div class="tz-results">
{#if loadError}
<div class="tz-empty">
<p role="status">Couldn’t load timezones.</p>
<button type="button" class="tz-retry" onclick={retryLoadTimezones}
>Try again</button
>
<div class="tz-option-main">
<span class="tz-option-city">{item.city}</span>
{#if item.tzName}
<span class="tz-option-name">{item.tzName}</span>
{/if}
</div>
{#if item.offset}
<span class="tz-option-offset">{item.offset}</span>
{/if}
</button>
</li>
{/each}
{#if filtered.length === 0}
<li class="tz-empty">No matches</li>
</div>
{:else if !timezoneIndex}
<p class="tz-empty" role="status">Loading timezones…</p>
{:else}
<ul class="tz-list" bind:this={listEl}>
{#each matchingTimezones as item (item.tz)}
<li>
<button
type="button"
class="tz-option"
class:selected={item.tz === preferredTimezone.current}
onclick={() => select(item.tz)}
>
<div class="tz-option-main">
<span class="tz-option-city">{item.city}</span>
{#if item.tzName}
<span class="tz-option-name">{item.tzName}</span>
{/if}
</div>
{#if item.offset}
<span class="tz-option-offset">{item.offset}</span>
{/if}
</button>
</li>
{/each}
{#if matchingTimezones.length === 0}
<li class="tz-empty">No matches</li>
{/if}
</ul>
{/if}
</ul>
</div>
</div>
{/snippet}
</Dialog.Content>
@@ -120,6 +149,7 @@
z-index: 201;
display: flex;
flex-direction: column;
height: 80vh;
max-height: 80vh;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
@@ -138,6 +168,7 @@
right: auto;
width: 400px;
max-width: calc(100vw - var(--space-7) * 2);
height: 70vh;
max-height: min(70vh, 520px);
transform: translate(-50%, -50%);
border: 1px solid var(--color-border);
@@ -221,6 +252,24 @@
box-shadow: var(--shadow-focus);
}
.tz-results {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.tz-retry {
margin-top: var(--space-3);
padding: var(--space-3) var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--when-color-text);
font: inherit;
cursor: pointer;
}
.tz-list {
list-style: none;
margin: 0;
+87 -3
View File
@@ -1,5 +1,10 @@
import { describe, test, expect } from 'vitest';
import { buildTimezoneEntry, getTimezoneIndex, searchTimezones } from '#lib/timezones.js';
import { afterEach, beforeAll, beforeEach, describe, test, expect, vi } from 'vitest';
import {
buildTimezoneEntry,
getTimezoneIndex,
searchTimezones,
type TimezoneIndex
} from '#lib/timezones.js';
describe('buildTimezoneEntry', () => {
test('extracts city, offset, and timezone name for America/Los_Angeles', () => {
@@ -54,7 +59,10 @@ describe('searchTimezones', () => {
'UTC'
];
const index = getTimezoneIndex(sampleTimezones);
let index: TimezoneIndex;
beforeAll(async () => {
index = await getTimezoneIndex(sampleTimezones);
});
test('returns all entries on empty or whitespace query', () => {
expect(searchTimezones('', index)).toHaveLength(sampleTimezones.length);
@@ -114,3 +122,79 @@ describe('searchTimezones', () => {
expect(results).toHaveLength(0);
});
});
describe('getTimezoneIndex', () => {
let loadIndex: typeof getTimezoneIndex;
beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
({ getTimezoneIndex: loadIndex } = await import('#lib/timezones.js'));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
test('yields before building entries and between batches, without changing the result', async () => {
const zones = Intl.supportedValuesOf('timeZone').slice(0, 20);
const now = new Date();
const formatters = vi.spyOn(Intl, 'DateTimeFormat');
const pending = loadIndex(zones, now);
expect(formatters).not.toHaveBeenCalled();
await vi.advanceTimersToNextTimerAsync();
const firstBatchCalls = formatters.mock.calls.length;
expect(firstBatchCalls).toBeGreaterThan(0);
await vi.runAllTimersAsync();
expect(formatters.mock.calls.length).toBeGreaterThan(firstBatchCalls);
const index = await pending;
expect(index.entries).toEqual(zones.map((tz) => buildTimezoneEntry(tz, now)));
expect([...index.byTz.keys()]).toEqual(zones);
});
test('shares pending work and reuses the completed default index', async () => {
const supported = vi.spyOn(Intl, 'supportedValuesOf').mockReturnValue(['Asia/Tokyo']);
const formatters = vi.spyOn(Intl, 'DateTimeFormat');
const first = loadIndex();
const concurrent = loadIndex();
expect(concurrent).toBe(first);
await vi.runAllTimersAsync();
const index = await first;
const calls = formatters.mock.calls.length;
expect(index.entries.map((entry) => entry.tz)).toEqual(['Asia/Tokyo']);
await expect(loadIndex()).resolves.toBe(index);
expect(formatters).toHaveBeenCalledTimes(calls);
expect(supported).toHaveBeenCalledTimes(1);
});
test('keeps explicit subsets and empty lists out of the shared cache', async () => {
vi.spyOn(Intl, 'supportedValuesOf').mockReturnValue(['Asia/Tokyo']);
const subset = loadIndex(['UTC']);
const empty = loadIndex([]);
const full = loadIndex();
await vi.runAllTimersAsync();
expect((await subset).entries.map((entry) => entry.tz)).toEqual(['UTC']);
expect((await empty).entries).toEqual([]);
expect((await full).entries.map((entry) => entry.tz)).toEqual(['Asia/Tokyo']);
await expect(loadIndex()).resolves.toBe(await full);
});
test('clears failed work so the next request can retry', async () => {
const supported = vi.spyOn(Intl, 'supportedValuesOf');
supported.mockImplementationOnce(() => {
throw new Error('Timezone lookup failed');
});
await expect(loadIndex()).rejects.toThrow('Timezone lookup failed');
supported.mockReturnValue(['Asia/Tokyo']);
const retry = loadIndex();
await vi.runAllTimersAsync();
expect((await retry).byTz.has('Asia/Tokyo')).toBe(true);
expect(supported).toHaveBeenCalledTimes(2);
});
});
+37 -18
View File
@@ -116,25 +116,47 @@ export function buildTimezoneEntry(tz: string, now: Date = new Date()): Timezone
};
}
let cachedIndex: TimezoneIndex | null = null;
let cachedIndexPromise: Promise<TimezoneIndex> | null = null;
async function buildTimezoneIndex(
allTimezones: string[] | undefined,
now: Date
): Promise<TimezoneIndex> {
const list = allTimezones ?? Intl.supportedValuesOf('timeZone');
const entries: TimezoneEntry[] = [];
const batchSize = 8;
for (let start = 0; start < list.length; start += batchSize) {
// Let the dialog paint before the first batch and yield between batches.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
for (const tz of list.slice(start, start + batchSize)) {
entries.push(buildTimezoneEntry(tz, now));
}
}
return {
entries,
byTz: new Map(entries.map((entry) => [entry.tz, entry]))
};
}
/**
* Returns a search index for all supported IANA timezones (or a provided subset).
* Builds the search index on demand, yielding between small batches of Intl work.
* Default callers share pending work and the completed result; subsets are uncached.
*/
export function getTimezoneIndex(allTimezones?: string[], now: Date = new Date()): TimezoneIndex {
if (!allTimezones && cachedIndex) {
return cachedIndex;
}
export function getTimezoneIndex(
allTimezones?: string[],
now: Date = new Date()
): Promise<TimezoneIndex> {
if (allTimezones) return buildTimezoneIndex(allTimezones, now);
const list = allTimezones ?? Intl.supportedValuesOf('timeZone');
const entries = list.map((tz) => buildTimezoneEntry(tz, now));
const byTz = new Map<string, TimezoneEntry>(entries.map((e) => [e.tz, e]));
const index = { entries, byTz };
if (!allTimezones) {
cachedIndex = index;
if (!cachedIndexPromise) {
cachedIndexPromise = buildTimezoneIndex(undefined, now).catch((error) => {
cachedIndexPromise = null;
throw error;
});
}
return index;
return cachedIndexPromise;
}
/**
@@ -142,10 +164,7 @@ export function getTimezoneIndex(allTimezones?: string[], now: Date = new Date()
* Supports multi-word queries, abbreviations (e.g. PST, EST, CET, BST),
* city names, regions, and GMT/UTC offsets.
*/
export function searchTimezones(
query: string,
index: TimezoneIndex = getTimezoneIndex()
): TimezoneEntry[] {
export function searchTimezones(query: string, index: TimezoneIndex): TimezoneEntry[] {
const qTerms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (qTerms.length === 0) {
return index.entries;