perf(web): defer timezone indexing until picker opens #123
No files matched your search
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user