This is an automated email from the ASF dual-hosted git repository.

jli pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 1f19ef92cb9 refactor(TimezoneSelector): Enhance timezone selection 
logic and improve performance (#36486)
1f19ef92cb9 is described below

commit 1f19ef92cb9f6d9deeaf190c5ece37aa79bf18ad
Author: Luis Sánchez <[email protected]>
AuthorDate: Tue Jan 20 00:46:56 2026 -0300

    refactor(TimezoneSelector): Enhance timezone selection logic and improve 
performance (#36486)
---
 .../TimezoneSelector/TimezoneOptionsCache.test.tsx | 256 +++++++++++++++++++++
 .../TimezoneSelector/TimezoneOptionsCache.ts       | 159 +++++++++++++
 .../TimezoneSelector.DaylightSavingTime.test.tsx   |  12 +-
 .../TimezoneSelector/TimezoneSelector.test.tsx     |  36 ++-
 .../src/components/TimezoneSelector/index.tsx      | 237 +++++++++----------
 .../src/components/TimezoneSelector/types.ts       |  29 +++
 .../src/features/alerts/AlertReportModal.test.tsx  |   3 +-
 .../src/features/alerts/AlertReportModal.tsx       |  50 +++-
 8 files changed, 647 insertions(+), 135 deletions(-)

diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.test.tsx
 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.test.tsx
new file mode 100644
index 00000000000..0c86e4f9ea9
--- /dev/null
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.test.tsx
@@ -0,0 +1,256 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TimezoneOptionsCache } from './TimezoneOptionsCache';
+import type { OffsetsToName, GetOffsetKeyFn } from './types';
+
+const mockGetOffsetKey: GetOffsetKeyFn = (timezoneName: string) => {
+  // Simplified mock for testing - returns different values for different zones
+  if (timezoneName.includes('New_York')) return '-300-240';
+  if (timezoneName.includes('Los_Angeles')) return '-480-420';
+  if (timezoneName.includes('Chicago')) return '-360-300';
+  if (timezoneName.includes('London')) return '060';
+  return '00';
+};
+
+const mockOffsetsToName: OffsetsToName = {
+  '-300-240': ['Eastern Standard Time', 'Eastern Daylight Time'],
+  '-480-420': ['Pacific Standard Time', 'Pacific Daylight Time'],
+  '-360-300': ['Central Standard Time', 'Central Daylight Time'],
+  '060': ['GMT Standard Time - London', 'British Summer Time'],
+  '00': ['GMT Standard Time', 'GMT Standard Time'],
+};
+
+// Mock Intl.supportedValuesOf to return a controlled set of timezones
+const mockTimezones = [
+  'America/New_York',
+  'America/Los_Angeles',
+  'America/Chicago',
+  'Europe/London',
+  'Africa/Abidjan',
+];
+
+beforeAll(() => {
+  global.Intl.supportedValuesOf = jest.fn(() => mockTimezones);
+});
+
+afterAll(() => {
+  jest.restoreAllMocks();
+});
+
+test('initializes with empty cache', () => {
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  expect(cache.isCached()).toBe(false);
+  expect(cache.getOptions()).toBeNull();
+});
+
+test('isCached returns true after options are computed', async () => {
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  expect(cache.isCached()).toBe(false);
+
+  await cache.getOptionsAsync();
+
+  expect(cache.isCached()).toBe(true);
+});
+
+test('getOptions returns null before caching', () => {
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  expect(cache.getOptions()).toBeNull();
+});
+
+test('getOptions returns cached options after computation', async () => {
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  await cache.getOptionsAsync();
+  const options = cache.getOptions();
+
+  expect(options).not.toBeNull();
+  expect(Array.isArray(options)).toBe(true);
+  expect(options!.length).toBeGreaterThan(0);
+});
+
+test('getOptionsAsync computes and returns timezone options', async () => {
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  const options = await cache.getOptionsAsync();
+
+  expect(Array.isArray(options)).toBe(true);
+  expect(options.length).toBeGreaterThan(0);
+
+  // Check that each option has the required structure
+  options.forEach(option => {
+    expect(option).toHaveProperty('label');
+    expect(option).toHaveProperty('value');
+    expect(option).toHaveProperty('offsets');
+    expect(option).toHaveProperty('timezoneName');
+    expect(typeof option.label).toBe('string');
+    expect(typeof option.value).toBe('string');
+    expect(typeof option.offsets).toBe('string');
+    expect(typeof option.timezoneName).toBe('string');
+  });
+});
+
+test('getOptionsAsync returns cached options on subsequent calls', async () => 
{
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  const firstCall = await cache.getOptionsAsync();
+  const secondCall = await cache.getOptionsAsync();
+
+  // Should return the exact same array reference (cached)
+  expect(secondCall).toBe(firstCall);
+});
+
+test('getOptionsAsync deduplicates concurrent calls', async () => {
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  // Make multiple concurrent calls
+  const promise1 = cache.getOptionsAsync();
+  const promise2 = cache.getOptionsAsync();
+  const promise3 = cache.getOptionsAsync();
+
+  // All promises should be the same instance
+  expect(promise2).toBe(promise1);
+  expect(promise3).toBe(promise1);
+
+  const [result1, result2, result3] = await Promise.all([
+    promise1,
+    promise2,
+    promise3,
+  ]);
+
+  // All results should be the same
+  expect(result2).toBe(result1);
+  expect(result3).toBe(result1);
+});
+
+test('options have correct label format', async () => {
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  const options = await cache.getOptionsAsync();
+
+  // Check that labels follow the GMT format
+  options.forEach(option => {
+    expect(option.label).toMatch(/^GMT [+-]\d{2}:\d{2} \(.+\)$/);
+  });
+});
+
+test('options include offset information', async () => {
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  const options = await cache.getOptionsAsync();
+
+  // Verify that options have offset keys
+  options.forEach(option => {
+    expect(option.offsets).toBeTruthy();
+    expect(option.offsets.length).toBeGreaterThan(0);
+  });
+});
+
+test('options deduplicate by label', async () => {
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  const options = await cache.getOptionsAsync();
+  const labels = options.map(opt => opt.label);
+  const uniqueLabels = new Set(labels);
+
+  // All labels should be unique
+  expect(labels.length).toBe(uniqueLabels.size);
+});
+
+test('each option has timezone name matching its value', async () => {
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  const options = await cache.getOptionsAsync();
+
+  options.forEach(option => {
+    expect(option.timezoneName).toBe(option.value);
+  });
+});
+
+test('handles errors during computation', async () => {
+  const errorGetOffsetKey: GetOffsetKeyFn = () => {
+    throw new Error('Test error');
+  };
+
+  const cache = new TimezoneOptionsCache(errorGetOffsetKey, mockOffsetsToName);
+
+  await expect(cache.getOptionsAsync()).rejects.toThrow('Test error');
+
+  // Cache should remain empty after error
+  expect(cache.isCached()).toBe(false);
+  expect(cache.getOptions()).toBeNull();
+});
+
+test('allows retry after failed computation', async () => {
+  let shouldFail = true;
+  const conditionalGetOffsetKey: GetOffsetKeyFn = (timezoneName: string) => {
+    if (shouldFail) {
+      throw new Error('Temporary error');
+    }
+    return mockGetOffsetKey(timezoneName);
+  };
+
+  const cache = new TimezoneOptionsCache(
+    conditionalGetOffsetKey,
+    mockOffsetsToName,
+  );
+
+  // First call should fail
+  await expect(cache.getOptionsAsync()).rejects.toThrow('Temporary error');
+  expect(cache.isCached()).toBe(false);
+
+  // Allow success on retry
+  shouldFail = false;
+
+  // Second call should succeed
+  const options = await cache.getOptionsAsync();
+  expect(Array.isArray(options)).toBe(true);
+  expect(options.length).toBeGreaterThan(0);
+  expect(cache.isCached()).toBe(true);
+});
+
+test('uses queueMicrotask when available', async () => {
+  const queueMicrotaskSpy = jest.spyOn(global, 'queueMicrotask');
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  await cache.getOptionsAsync();
+
+  expect(queueMicrotaskSpy).toHaveBeenCalled();
+
+  queueMicrotaskSpy.mockRestore();
+});
+
+test('falls back to setTimeout when queueMicrotask is not available', async () 
=> {
+  const originalQueueMicrotask = global.queueMicrotask;
+  // @ts-ignore - temporarily remove queueMicrotask for testing
+  delete global.queueMicrotask;
+
+  const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
+  const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+  await cache.getOptionsAsync();
+
+  expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0);
+
+  setTimeoutSpy.mockRestore();
+  global.queueMicrotask = originalQueueMicrotask;
+});
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.ts
 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.ts
new file mode 100644
index 00000000000..44e180a14a9
--- /dev/null
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.ts
@@ -0,0 +1,159 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { isDST, extendedDayjs } from '../../utils/dates';
+import type { TimezoneOption, OffsetsToName, GetOffsetKeyFn } from './types';
+
+// Import dayjs plugin types for TypeScript support
+import 'dayjs/plugin/timezone';
+
+const DEFAULT_TIMEZONE = {
+  name: 'GMT Standard Time',
+  value: 'Africa/Abidjan', // timezones are deduped by the first alphabetical 
value
+};
+
+const JANUARY_REF = extendedDayjs.tz('2021-01-01');
+const JULY_REF = extendedDayjs.tz('2021-07-01');
+
+const offsetsToName: OffsetsToName = {
+  '-300-240': ['Eastern Standard Time', 'Eastern Daylight Time'],
+  '-360-300': ['Central Standard Time', 'Central Daylight Time'],
+  '-420-360': ['Mountain Standard Time', 'Mountain Daylight Time'],
+  '-420-420': [
+    'Mountain Standard Time - Phoenix',
+    'Mountain Standard Time - Phoenix',
+  ],
+  '-480-420': ['Pacific Standard Time', 'Pacific Daylight Time'],
+  '-540-480': ['Alaska Standard Time', 'Alaska Daylight Time'],
+  '-600-600': ['Hawaii Standard Time', 'Hawaii Daylight Time'],
+  '60120': ['Central European Time', 'Central European Daylight Time'],
+  '00': [DEFAULT_TIMEZONE.name, DEFAULT_TIMEZONE.name],
+  '060': ['GMT Standard Time - London', 'British Summer Time'],
+};
+
+export function getOffsetKey(timezoneName: string): string {
+  return (
+    JANUARY_REF.tz(timezoneName).utcOffset().toString() +
+    JULY_REF.tz(timezoneName).utcOffset().toString()
+  );
+}
+
+export { DEFAULT_TIMEZONE };
+
+function getTimezoneDisplayName(
+  timezoneName: string,
+  currentDate: ReturnType<typeof extendedDayjs>,
+  getOffsetKey: GetOffsetKeyFn,
+  offsetsToName: OffsetsToName,
+): string {
+  const offsetKey = getOffsetKey(timezoneName);
+  const dateInZone = currentDate.tz(timezoneName);
+  const isDSTActive = isDST(dateInZone, timezoneName);
+  const namePair = offsetsToName[offsetKey];
+  return namePair ? (isDSTActive ? namePair[1] : namePair[0]) : timezoneName;
+}
+
+export class TimezoneOptionsCache {
+  private cachedOptions: TimezoneOption[] | null = null;
+
+  private computePromise: Promise<TimezoneOption[]> | null = null;
+
+  constructor(
+    private getOffsetKey: GetOffsetKeyFn,
+    private offsetsToName: OffsetsToName,
+  ) {}
+
+  public isCached(): boolean {
+    return this.cachedOptions !== null;
+  }
+
+  public getOptions(): TimezoneOption[] | null {
+    return this.cachedOptions;
+  }
+
+  private computeOptions(): TimezoneOption[] {
+    const currentDate = extendedDayjs(new Date());
+    const allZones = Intl.supportedValuesOf('timeZone');
+    const seenLabels = new Set<string>();
+    const options: TimezoneOption[] = [];
+
+    for (const zone of allZones) {
+      const offsetKey = this.getOffsetKey(zone);
+      const displayName = getTimezoneDisplayName(
+        zone,
+        currentDate,
+        this.getOffsetKey,
+        this.offsetsToName,
+      );
+      const offset = currentDate.tz(zone).format('Z');
+      const label = `GMT ${offset} (${displayName})`;
+
+      if (!seenLabels.has(label)) {
+        seenLabels.add(label);
+        options.push({
+          label,
+          value: zone,
+          offsets: offsetKey,
+          timezoneName: zone,
+        });
+      }
+    }
+
+    this.cachedOptions = options;
+    return options;
+  }
+
+  public getOptionsAsync(): Promise<TimezoneOption[]> {
+    if (this.cachedOptions) {
+      return Promise.resolve(this.cachedOptions);
+    }
+
+    if (this.computePromise) {
+      return this.computePromise;
+    }
+
+    // Use queueMicrotask for better performance than setTimeout(0)
+    // Falls back to setTimeout for older browsers
+    this.computePromise = new Promise<TimezoneOption[]>((resolve, reject) => {
+      const run = () => {
+        try {
+          const result = this.computeOptions();
+          resolve(result);
+        } catch (err) {
+          reject(err);
+        } finally {
+          this.computePromise = null;
+        }
+      };
+      if (typeof queueMicrotask === 'function') {
+        queueMicrotask(run);
+      } else {
+        setTimeout(run, 0);
+      }
+    });
+
+    return this.computePromise;
+  }
+}
+
+// Export singleton instance
+export const timezoneOptionsCache = new TimezoneOptionsCache(
+  getOffsetKey,
+  offsetsToName,
+);
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx
 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx
index 21fa1fc9abf..3115ccccdd9 100644
--- 
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx
@@ -18,7 +18,8 @@
  */
 
 import { FC } from 'react';
-import { render, waitFor, screen, userEvent } from '@superset-ui/core/spec';
+import { render, screen, userEvent } from '@superset-ui/core/spec';
+import '@testing-library/jest-dom';
 import type { TimezoneSelectorProps } from './index';
 
 const loadComponent = (mockCurrentTime?: string) => {
@@ -46,12 +47,15 @@ test('render timezones in correct order for daylight saving 
time', async () => {
     />,
   );
 
+  // Wait for loading to complete by waiting for expected timezone text
+  await screen.findByText('GMT -04:00 (Eastern Daylight Time)');
+
   const searchInput = screen.getByRole('combobox');
   await userEvent.click(searchInput);
 
-  const options = await waitFor(() =>
-    document.querySelectorAll('.ant-select-item-option-content'),
-  );
+  // Wait for options to appear by finding one of the expected timezone texts
+  await screen.findByText('GMT -11:00 (Pacific/Midway)');
+  const options = document.querySelectorAll('.ant-select-item-option-content');
 
   // first option is always current timezone
   expect(options[0]).toHaveTextContent('GMT -04:00 (Eastern Daylight Time)');
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.test.tsx
 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.test.tsx
index ce31337aa31..1be15b93163 100644
--- 
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.test.tsx
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.test.tsx
@@ -17,7 +17,8 @@
  * under the License.
  */
 import { FC } from 'react';
-import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
+import { render, screen, userEvent } from '@superset-ui/core/spec';
+import '@testing-library/jest-dom';
 import { extendedDayjs } from '../../utils/dates';
 import type { TimezoneSelectorProps } from './index';
 
@@ -33,7 +34,7 @@ const loadComponent = (mockCurrentTime?: string) => {
 };
 
 const getSelectOptions = () =>
-  waitFor(() => document.querySelectorAll('.ant-select-item-option-content'));
+  document.querySelectorAll('.ant-select-item-option-content');
 
 const openSelectMenu = () => {
   const searchInput = screen.getByRole('combobox');
@@ -50,7 +51,8 @@ test('use the timezone from `dayjs` if no timezone provided', 
async () => {
   const TimezoneSelector = await loadComponent('2022-01-01');
   const onTimezoneChange = jest.fn();
   render(<TimezoneSelector onTimezoneChange={onTimezoneChange} />);
-  expect(onTimezoneChange).toHaveBeenCalledTimes(1);
+  // Wait for async loading and default timezone to be set
+  await screen.findByText('GMT -05:00 (Eastern Standard Time)');
   expect(onTimezoneChange).toHaveBeenCalledWith('America/Detroit');
 });
 
@@ -63,8 +65,14 @@ test('update to closest deduped timezone when timezone is 
provided', async () =>
       timezone="America/Tijuana"
     />,
   );
-  expect(onTimezoneChange).toHaveBeenCalledTimes(1);
-  expect(onTimezoneChange).toHaveBeenLastCalledWith('America/Los_Angeles');
+  // Wait for async loading to complete by waiting for a timezone option to 
appear
+  await screen.findByText('GMT -08:00 (Pacific Standard Time)');
+  // When timezone is provided, onTimezoneChange is not called automatically
+  // The component just displays the matching timezone
+  expect(onTimezoneChange).not.toHaveBeenCalled();
+  // Verify the component displays the deduped timezone by checking the select 
value
+  const selectInput = screen.getByRole('combobox');
+  expect(selectInput).toBeInTheDocument();
 });
 
 test('use the default timezone when an invalid timezone is provided', async () 
=> {
@@ -73,8 +81,14 @@ test('use the default timezone when an invalid timezone is 
provided', async () =
   render(
     <TimezoneSelector onTimezoneChange={onTimezoneChange} timezone="UTC" />,
   );
-  expect(onTimezoneChange).toHaveBeenCalledTimes(1);
-  expect(onTimezoneChange).toHaveBeenLastCalledWith('Africa/Abidjan');
+  // Wait for async loading to complete by waiting for default timezone text 
to appear
+  await screen.findByText('GMT +00:00 (GMT Standard Time)');
+  // When timezone is provided (even if invalid), onTimezoneChange is not 
called automatically
+  // The component uses findMatchingTimezone to find the closest match or 
default
+  expect(onTimezoneChange).not.toHaveBeenCalled();
+  // Verify the component displays the default timezone by checking the select 
value
+  const selectInput = screen.getByRole('combobox');
+  expect(selectInput).toBeInTheDocument();
 });
 
 test('render timezones in correct order for standard time', async () => {
@@ -86,6 +100,8 @@ test('render timezones in correct order for standard time', 
async () => {
       timezone="America/Nassau"
     />,
   );
+  // Wait for loading to complete by waiting for expected timezone text
+  await screen.findByText('GMT -05:00 (Eastern Standard Time)');
   openSelectMenu();
   const options = await getSelectOptions();
   expect(options[0]).toHaveTextContent('GMT -05:00 (Eastern Standard Time)');
@@ -102,6 +118,8 @@ test('can select a timezone values and returns canonical 
timezone name', async (
       timezone="Africa/Abidjan"
     />,
   );
+  // Wait for loading to complete by waiting for timezone text to appear
+  await screen.findByText('GMT +00:00 (GMT Standard Time)');
   openSelectMenu();
 
   const searchInput = screen.getByRole('combobox');
@@ -123,6 +141,8 @@ test('can update props and rerender with different values', 
async () => {
       timezone="Asia/Dubai"
     />,
   );
+  // Wait for loading to complete and timezone to be displayed
+  await screen.findByTitle('GMT +04:00 (Asia/Dubai)');
   expect(screen.getByTitle('GMT +04:00 (Asia/Dubai)')).toBeInTheDocument();
   rerender(
     <TimezoneSelector
@@ -130,5 +150,7 @@ test('can update props and rerender with different values', 
async () => {
       timezone="Australia/Perth"
     />,
   );
+  // Wait for rerender to complete
+  await screen.findByTitle('GMT +08:00 (Australia/Perth)');
   expect(screen.getByTitle('GMT +08:00 
(Australia/Perth)')).toBeInTheDocument();
 });
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx
 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx
index 7eef109a3a2..aedd9f6c1f1 100644
--- 
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx
@@ -17,146 +17,149 @@
  * under the License.
  */
 
-import { useEffect, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { t } from '@apache-superset/core';
 import { Select } from '@superset-ui/core/components';
-import { isDST, extendedDayjs } from '../../utils/dates';
+import { extendedDayjs } from '../../utils/dates';
+import {
+  timezoneOptionsCache,
+  getOffsetKey,
+  DEFAULT_TIMEZONE,
+} from './TimezoneOptionsCache';
+import type { TimezoneOption } from './types';
 
-const DEFAULT_TIMEZONE = {
-  name: 'GMT Standard Time',
-  value: 'Africa/Abidjan', // timezones are deduped by the first alphabetical 
value
-};
-
-const MIN_SELECT_WIDTH = '400px';
-
-const offsetsToName = {
-  '-300-240': ['Eastern Standard Time', 'Eastern Daylight Time'],
-  '-360-300': ['Central Standard Time', 'Central Daylight Time'],
-  '-420-360': ['Mountain Standard Time', 'Mountain Daylight Time'],
-  '-420-420': [
-    'Mountain Standard Time - Phoenix',
-    'Mountain Standard Time - Phoenix',
-  ],
-  '-480-420': ['Pacific Standard Time', 'Pacific Daylight Time'],
-  '-540-480': ['Alaska Standard Time', 'Alaska Daylight Time'],
-  '-600-600': ['Hawaii Standard Time', 'Hawaii Daylight Time'],
-  '60120': ['Central European Time', 'Central European Daylight Time'],
-  '00': [DEFAULT_TIMEZONE.name, DEFAULT_TIMEZONE.name],
-  '060': ['GMT Standard Time - London', 'British Summer Time'],
-};
+// Import dayjs plugin types for TypeScript support
+import 'dayjs/plugin/timezone';
 
 export type TimezoneSelectorProps = {
   onTimezoneChange: (value: string) => void;
   timezone?: string | null;
   minWidth?: string;
+  placeholder?: string;
 };
 
+const MIN_SELECT_WIDTH = '400px';
+
+function findMatchingTimezone(
+  timezone: string | null | undefined,
+  options: TimezoneOption[],
+): string {
+  const targetTimezone = timezone || extendedDayjs.tz.guess();
+  const targetOffsetKey = getOffsetKey(targetTimezone);
+  let fallbackValue: string | undefined;
+
+  for (const option of options) {
+    if (
+      option.offsets === targetOffsetKey &&
+      option.timezoneName === targetTimezone
+    ) {
+      return option.value;
+    }
+    if (!fallbackValue && option.offsets === targetOffsetKey) {
+      fallbackValue = option.value;
+    }
+  }
+
+  return fallbackValue || DEFAULT_TIMEZONE.value;
+}
+
 export default function TimezoneSelector({
   onTimezoneChange,
   timezone,
-  minWidth = MIN_SELECT_WIDTH, // smallest size for current values
+  minWidth = MIN_SELECT_WIDTH,
+  placeholder,
   ...rest
 }: TimezoneSelectorProps) {
-  const { TIMEZONE_OPTIONS, TIMEZONE_OPTIONS_SORT_COMPARATOR, validTimezone } =
-    useMemo(() => {
-      const currentDate = extendedDayjs();
-      const JANUARY = extendedDayjs.tz('2021-01-01');
-      const JULY = extendedDayjs.tz('2021-07-01');
-
-      const getOffsetKey = (name: string) =>
-        JANUARY.tz(name).utcOffset().toString() +
-        JULY.tz(name).utcOffset().toString();
-
-      const getTimezoneName = (name: string) => {
-        const offsets = getOffsetKey(name);
-        return (
-          (isDST(currentDate.tz(name), name)
-            ? offsetsToName[offsets as keyof typeof offsetsToName]?.[1]
-            : offsetsToName[offsets as keyof typeof offsetsToName]?.[0]) || 
name
-        );
-      };
-
-      // TODO: remove this ts-ignore when typescript is upgraded to 5.1
-      // @ts-ignore
-      const ALL_ZONES: string[] = Intl.supportedValuesOf('timeZone');
-
-      const labels = new Set<string>();
-      const TIMEZONE_OPTIONS = ALL_ZONES.map(zone => {
-        const label = `GMT ${extendedDayjs
-          .tz(currentDate, zone)
-          .format('Z')} (${getTimezoneName(zone)})`;
-
-        if (labels.has(label)) {
-          return null; // Skip duplicates
-        }
-        labels.add(label);
-        return {
-          label,
-          value: zone,
-          offsets: getOffsetKey(zone),
-          timezoneName: zone,
-        };
-      }).filter(Boolean) as {
-        label: string;
-        value: string;
-        offsets: string;
-        timezoneName: string;
-      }[];
-
-      const TIMEZONE_OPTIONS_SORT_COMPARATOR = (
-        a: (typeof TIMEZONE_OPTIONS)[number],
-        b: (typeof TIMEZONE_OPTIONS)[number],
-      ) =>
-        extendedDayjs.tz(currentDate, a.timezoneName).utcOffset() -
-        extendedDayjs.tz(currentDate, b.timezoneName).utcOffset();
-
-      // pre-sort timezone options by time offset
-      TIMEZONE_OPTIONS.sort(TIMEZONE_OPTIONS_SORT_COMPARATOR);
-
-      const matchTimezoneToOptions = (timezone: string) => {
-        const offsetKey = getOffsetKey(timezone);
-        let fallbackValue: string | undefined;
-
-        for (const option of TIMEZONE_OPTIONS) {
-          if (
-            option.offsets === offsetKey &&
-            option.timezoneName === timezone
-          ) {
-            return option.value;
-          }
-          if (!fallbackValue && option.offsets === offsetKey) {
-            fallbackValue = option.value;
-          }
-        }
-        return fallbackValue || DEFAULT_TIMEZONE.value;
-      };
+  const [timezoneOptions, setTimezoneOptions] = useState<
+    TimezoneOption[] | null
+  >(timezoneOptionsCache.getOptions());
+  const [isLoadingOptions, setIsLoadingOptions] = useState(false);
+  const hasSetDefaultRef = useRef(false);
+
+  const handleOpenChange = useCallback(
+    (isOpen: boolean) => {
+      if (isOpen && !timezoneOptions && !isLoadingOptions) {
+        setIsLoadingOptions(true);
+        timezoneOptionsCache
+          .getOptionsAsync()
+          .then(options => {
+            setTimezoneOptions(options);
+          })
+          .finally(() => setIsLoadingOptions(false));
+      }
+    },
+    [timezoneOptions, isLoadingOptions],
+  );
+
+  const sortComparator = useMemo(() => {
+    if (!timezoneOptions) return undefined;
+    const currentDate = extendedDayjs();
+    const comparator = (a: TimezoneOption, b: TimezoneOption) =>
+      currentDate.tz(a.timezoneName).utcOffset() -
+      currentDate.tz(b.timezoneName).utcOffset();
+    return comparator;
+  }, [timezoneOptions]);
+
+  const validTimezone = useMemo(() => {
+    let result: string | undefined;
+    if (!timezoneOptions) {
+      // Don't call tz.guess() synchronously to avoid blocking render
+      // Return timezone if provided, otherwise undefined (will be set after 
options load)
+      result = timezone || undefined;
+    } else {
+      result = findMatchingTimezone(timezone, timezoneOptions);
+    }
+    return result;
+  }, [timezone, timezoneOptions]);
+
+  // Load timezone options asynchronously when component mounts
+  // Parent component (AlertReportModal) already delays mounting until panel 
opens
+  useEffect(() => {
+    if (timezoneOptions || isLoadingOptions) return;
+
+    setIsLoadingOptions(true);
 
-      const validTimezone = matchTimezoneToOptions(
-        timezone || extendedDayjs.tz.guess(),
-      );
+    timezoneOptionsCache
+      .getOptionsAsync()
+      .then(options => {
+        setTimezoneOptions(options);
 
-      return {
-        TIMEZONE_OPTIONS,
-        TIMEZONE_OPTIONS_SORT_COMPARATOR,
-        validTimezone,
-      };
-    }, [timezone]);
+        // Set default value if no timezone is provided and we haven't set it 
yet
+        if (!timezone && !hasSetDefaultRef.current) {
+          const defaultTz = findMatchingTimezone(null, options);
+          onTimezoneChange(defaultTz);
+          hasSetDefaultRef.current = true;
+        }
+      })
+      .finally(() => {
+        setIsLoadingOptions(false);
+      });
+  }, [timezoneOptions, isLoadingOptions, timezone, onTimezoneChange]);
 
-  // force trigger a timezone update if provided `timezone` is not invalid
+  // Set default value when cached options are available on mount
   useEffect(() => {
-    if (validTimezone && timezone !== validTimezone) {
-      onTimezoneChange(validTimezone);
-    }
-  }, [validTimezone, onTimezoneChange, timezone]);
+    if (!timezoneOptions || timezone || hasSetDefaultRef.current) return;
+
+    const defaultTz = findMatchingTimezone(null, timezoneOptions);
+    onTimezoneChange(defaultTz);
+    hasSetDefaultRef.current = true;
+  }, [timezoneOptions, timezone, onTimezoneChange]);
+
+  const selectValue = timezoneOptions ? validTimezone : undefined;
 
   return (
     <Select
       ariaLabel={t('Timezone selector')}
-      onChange={tz => onTimezoneChange(tz as string)}
-      value={validTimezone}
-      options={TIMEZONE_OPTIONS}
-      sortComparator={TIMEZONE_OPTIONS_SORT_COMPARATOR}
-      {...rest}
+      onChange={tz => {
+        onTimezoneChange(tz as string);
+      }}
+      onOpenChange={handleOpenChange}
+      value={selectValue}
+      options={timezoneOptions || []}
+      sortComparator={sortComparator}
+      loading={isLoadingOptions}
+      placeholder={isLoadingOptions ? t('Loading timezones...') : placeholder}
+      {...{ placement: 'topLeft', ...rest }}
     />
   );
 }
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/types.ts
 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/types.ts
new file mode 100644
index 00000000000..b8446499c05
--- /dev/null
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/types.ts
@@ -0,0 +1,29 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export type TimezoneOption = {
+  label: string;
+  value: string;
+  offsets: string;
+  timezoneName: string;
+};
+
+export type OffsetsToName = Record<string, [string, string]>;
+
+export type GetOffsetKeyFn = (timezoneName: string) => string;
diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx 
b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
index 8b288ad3212..6e78540f6ce 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
@@ -543,7 +543,8 @@ test('renders default Schedule fields', async () => {
   const scheduleType = screen.getByRole('combobox', {
     name: /schedule type/i,
   });
-  const timezone = screen.getByRole('combobox', {
+  // Wait for timezone selector to render after delay
+  const timezone = await screen.findByRole('combobox', {
     name: /timezone selector/i,
   });
   const logRetention = screen.getByRole('combobox', {
diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx 
b/superset-frontend/src/features/alerts/AlertReportModal.tsx
index 050e3cfaea4..fddbff73a9d 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx
@@ -50,6 +50,7 @@ import {
   InfoTooltip,
   Input,
   InputNumber,
+  Loading,
   Select,
   Switch,
   TreeSelect,
@@ -57,6 +58,7 @@ import {
 } from '@superset-ui/core/components';
 
 import TimezoneSelector from '@superset-ui/core/components/TimezoneSelector';
+import { timezoneOptionsCache } from 
'@superset-ui/core/components/TimezoneSelector/TimezoneOptionsCache';
 import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
 import { useCommonConf } from 'src/features/databases/state';
 import {
@@ -92,6 +94,7 @@ import { NotificationMethod } from 
'./components/NotificationMethod';
 import { buildErrorTooltipMessage } from './buildErrorTooltipMessage';
 
 const TIMEOUT_MIN = 1;
+const COLLAPSE_ANIMATION_DURATION = 220;
 const TEXT_BASED_VISUALIZATION_TYPES = [
   VizType.PivotTable,
   'table',
@@ -494,6 +497,14 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
   const [currentAlert, setCurrentAlert] =
     useState<Partial<AlertObject> | null>();
   const [isHidden, setIsHidden] = useState<boolean>(true);
+
+  const [activeCollapsePanel, setActiveCollapsePanel] = useState<
+    string | string[]
+  >('general');
+  // Only delay TimezoneSelector for new alerts; render immediately for 
existing ones
+  const [shouldRenderTimezoneSelector, setShouldRenderTimezoneSelector] =
+    useState<boolean>(false);
+
   const [contentType, setContentType] = useState<string>('dashboard');
   const [reportFormat, setReportFormat] = useState<string>(
     DEFAULT_NOTIFICATION_FORMAT,
@@ -1859,6 +1870,9 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
 
   useEffect(() => {
     if (resource) {
+      // Render TimezoneSelector immediately in edit mode (data is already 
loaded)
+      setShouldRenderTimezoneSelector(true);
+
       // Add native filter settings
       if (resource.extra?.dashboard?.nativeFilters) {
         const filters = resource.extra.dashboard.nativeFilters;
@@ -2025,7 +2039,27 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
       <div css={AdditionalStyles}>
         <Collapse
           expandIconPosition="end"
-          defaultActiveKey="general"
+          activeKey={activeCollapsePanel}
+          onChange={key => {
+            setActiveCollapsePanel(key);
+            // Delay rendering TimezoneSelector until after panel animation 
completes
+            // Skip delay if options are already cached (instant render on 
subsequent opens)
+            const isSchedulePanel = Array.isArray(key)
+              ? key.includes('schedule')
+              : key === 'schedule';
+            if (isSchedulePanel) {
+              const isCached = timezoneOptionsCache.isCached();
+              if (isCached) {
+                // Options are cached, render immediately
+                setShouldRenderTimezoneSelector(true);
+              } else {
+                // First time, delay to avoid blocking panel animation
+                setTimeout(() => {
+                  setShouldRenderTimezoneSelector(true);
+                }, COLLAPSE_ANIMATION_DURATION); // Match Collapse animation 
duration
+              }
+            }
+          }}
           accordion
           modalMode
           items={[
@@ -2530,11 +2564,15 @@ const AlertReportModal: 
FunctionComponent<AlertReportModalProps> = ({
                     <div className="control-label">
                       {t('Timezone')} <span className="required">*</span>
                     </div>
-                    <TimezoneSelector
-                      onTimezoneChange={onTimezoneChange}
-                      timezone={currentAlert?.timezone}
-                      minWidth="100%"
-                    />
+                    {shouldRenderTimezoneSelector ? (
+                      <TimezoneSelector
+                        onTimezoneChange={onTimezoneChange}
+                        timezone={currentAlert?.timezone}
+                        minWidth="100%"
+                      />
+                    ) : (
+                      <Loading size="s" muted position="normal" />
+                    )}
                   </StyledInputContainer>
                   <StyledInputContainer>
                     <div className="control-label">


Reply via email to