From f159f360caa270a23ed51a344e7453cec306bbc9 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 3 Nov 2026 08:32:46 -0800 Subject: [PATCH] perf(web): defer timezone indexing until picker opens --- .changeset/lazy-timezone-index.md | 5 + apps/web/e2e/timezone-picker.e2e.ts | 119 +++++++++++++++++ .../src/lib/components/TimezoneDialog.svelte | 125 ++++++++++++------ apps/web/src/lib/timezones.test.ts | 90 ++++++++++++- apps/web/src/lib/timezones.ts | 55 +++++--- 5 files changed, 335 insertions(+), 59 deletions(-) create mode 100644 .changeset/lazy-timezone-index.md create mode 100644 apps/web/e2e/timezone-picker.e2e.ts diff --git a/.changeset/lazy-timezone-index.md b/.changeset/lazy-timezone-index.md new file mode 100644 index 00000000..7fd2508e --- /dev/null +++ b/.changeset/lazy-timezone-index.md @@ -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. diff --git a/apps/web/e2e/timezone-picker.e2e.ts b/apps/web/e2e/timezone-picker.e2e.ts new file mode 100644 index 00000000..475733be --- /dev/null +++ b/apps/web/e2e/timezone-picker.e2e.ts @@ -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([]); +}); diff --git a/apps/web/src/lib/components/TimezoneDialog.svelte b/apps/web/src/lib/components/TimezoneDialog.svelte index 600830a8..bcecb9f1 100644 --- a/apps/web/src/lib/components/TimezoneDialog.svelte +++ b/apps/web/src/lib/components/TimezoneDialog.svelte @@ -1,7 +1,7 @@