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

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new aa2c567c8 fix(web): consolidate frontend request lifecycle and storage 
resilience fixes (#2165)
aa2c567c8 is described below

commit aa2c567c89f1d2ef9f4f0f3f3f1dfabcd2b38742
Author: yyqdbngt <[email protected]>
AuthorDate: Mon Aug 31 19:43:55 2026 +0800

    fix(web): consolidate frontend request lifecycle and storage resilience 
fixes (#2165)
    
    * fix(web): tolerate unavailable browser storage
    
    Centralize guarded local-storage operations so blocked storage does not 
prevent API requests or page rendering.
    
    Reuse the helper across persisted UI state and cover request and Proxy 
fallbacks with regression tests.
    
    * fix(web): ignore stale consumer settings responses
    
    * fix(web): refresh deliveries with current filters
    
    * fix(studio): serialize user status mutations
    
    * fix(web): scope instance requests by data mode
---
 web/src/i18n/languagePreference.ts                 | 15 +---
 .../pages/instance/__tests__/ConsumerPage.test.tsx | 96 ++++++++++++++++++++++
 web/src/pages/instance/consumer.tsx                | 16 +++-
 .../__tests__/NotificationDeliveriesPage.test.tsx  | 74 +++++++++++++++++
 web/src/pages/ops/notificationDeliveries.tsx       | 11 +--
 web/src/pages/studio/Proxy.tsx                     | 11 +--
 web/src/pages/studio/UserManagement.tsx            |  9 ++
 web/src/pages/studio/__tests__/Proxy.test.tsx      | 13 +++
 .../pages/studio/__tests__/UserManagement.test.tsx | 23 +++++-
 web/src/services/instanceService.test.ts           | 21 +++++
 web/src/services/instanceService.ts                |  9 +-
 web/src/stores/authStorage.ts                      | 46 ++++-------
 web/src/theme/themePreference.ts                   | 28 ++-----
 web/src/utils/browserStorage.test.ts               | 44 ++++++++++
 .../browserStorage.ts}                             | 25 +++---
 15 files changed, 348 insertions(+), 93 deletions(-)

diff --git a/web/src/i18n/languagePreference.ts 
b/web/src/i18n/languagePreference.ts
index 7354eb664..fa2a3656a 100644
--- a/web/src/i18n/languagePreference.ts
+++ b/web/src/i18n/languagePreference.ts
@@ -16,22 +16,15 @@
  */
 
 import type { Lang } from './translations';
+import { readLocalStorage, writeLocalStorage } from '../utils/browserStorage';
 
 export const LANGUAGE_STORAGE_KEY = 'rocketmq-studio-language';
 
 export function getInitialLanguage(): Lang {
-  try {
-    const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
-    return stored === 'en' || stored === 'zh' ? stored : 'zh';
-  } catch {
-    return 'zh';
-  }
+  const stored = readLocalStorage(LANGUAGE_STORAGE_KEY);
+  return stored === 'en' || stored === 'zh' ? stored : 'zh';
 }
 
 export function persistLanguage(lang: Lang): void {
-  try {
-    localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
-  } catch {
-    // Language selection still works when browser storage is unavailable.
-  }
+  writeLocalStorage(LANGUAGE_STORAGE_KEY, lang);
 }
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 9d760867e..ab1966df9 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -99,6 +99,14 @@ const groupPage = (
   ...overrides,
 });
 
+const deferred = <T,>() => {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((complete) => {
+    resolve = complete;
+  });
+  return { promise, resolve };
+};
+
 const renderWithProviders = (ui: React.ReactElement, initialEntry = 
'/instance/consumer') =>
   render(
     <App>
@@ -931,4 +939,92 @@ describe('Consumer page', () => {
     const dialog = await screen.findByRole('dialog', { name: /unknown-lag-cg/ 
});
     expect(within(dialog).getByText('不可用')).toBeInTheDocument();
   });
+
+  it('sorts groups with an unknown lag after known backlogs in lag order', 
async () => {
+    const user = userEvent.setup();
+    vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
+      groupPage([
+        { ...group, name: 'unknown-lag-cg', totalLag: -1 },
+        { ...group, name: 'known-lag-cg', totalLag: 15000 },
+      ]),
+    );
+    renderWithProviders(<ConsumerPage />);
+    await screen.findByRole('row', { name: /unknown-lag-cg/ });
+
+    const [lagHeader] = screen.getAllByText('总堆积量');
+    await user.click(lagHeader);
+    await waitFor(() => {
+      const rows = Array.from(document.querySelectorAll('tbody tr'));
+      const order = rows
+        .map((row) => row.textContent ?? '')
+        .map((text) =>
+          text.includes('unknown-lag-cg')
+            ? 'unknown'
+            : /\bknown-lag-cg\b/.test(text)
+              ? 'known'
+              : '',
+        )
+        .filter(Boolean);
+      expect(order).toEqual(['known', 'unknown']);
+    });
+  });
+
+  it('ignores settings responses from a previously closed group modal', async 
() => {
+    const otherGroup = { ...group, name: 'other-cg' };
+    const firstSettings = deferred<{
+      groupName: string;
+      retryQueueNums: number;
+      retryMaxTimes: number;
+    }>();
+    const secondSettings = deferred<{
+      groupName: string;
+      retryQueueNums: number;
+      retryMaxTimes: number;
+    }>();
+    vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
+      groupPage([group, otherGroup]),
+    );
+    vi.mocked(consumerService.getConsumerGroupSettings)
+      .mockImplementationOnce(() => firstSettings.promise)
+      .mockImplementationOnce(() => secondSettings.promise);
+    const user = userEvent.setup();
+    renderWithProviders(<ConsumerPage />);
+
+    const firstRow = await screen.findByRole('row', { name: /remote-cg/ });
+    await user.click(within(firstRow).getByRole('button', { name: '详情' }));
+    const firstDialog = await screen.findByRole('dialog', { name: /remote-cg/ 
});
+    await user.click(within(firstDialog).getByRole('tab', { name: '配置' }));
+    await waitFor(() => {
+      expect(consumerService.getConsumerGroupSettings).toHaveBeenCalledWith(
+        'remote-cg',
+        'instance-1',
+      );
+    });
+
+    fireEvent.click(firstDialog.querySelector('.ant-modal-close') as 
HTMLElement);
+
+    const secondRow = screen.getByRole('row', { name: /other-cg/ });
+    await user.click(within(secondRow).getByRole('button', { name: '详情' }));
+    const secondDialog = await screen.findByRole('dialog', { name: /other-cg/ 
});
+    await user.click(within(secondDialog).getByRole('tab', { name: '配置' }));
+    await waitFor(() => {
+      expect(consumerService.getConsumerGroupSettings).toHaveBeenCalledWith(
+        'other-cg',
+        'instance-1',
+      );
+    });
+
+    await act(async () => {
+      secondSettings.resolve({ groupName: 'other-cg', retryQueueNums: 4, 
retryMaxTimes: 12 });
+    });
+    await waitFor(() => {
+      expect(within(secondDialog).getByLabelText('重试队列数')).toHaveValue('4');
+    });
+
+    await act(async () => {
+      firstSettings.resolve({ groupName: 'remote-cg', retryQueueNums: 1, 
retryMaxTimes: 16 });
+    });
+    expect(within(secondDialog).getByLabelText('重试队列数')).toHaveValue('4');
+    expect(within(secondDialog).getByLabelText('最大重试次数')).toHaveValue('12');
+  });
 });
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index 69e5264ff..8cc71b3c7 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -254,6 +254,7 @@ const ConsumerPageContent = ({
 
   const groupRequestIdRef = useRef(0);
   const stackRequestIdRef = useRef(0);
+  const settingsRequestIdRef = useRef(0);
 
   const [autoRefresh, setAutoRefresh] = useState(false);
   const silentRefreshRef = useRef(false);
@@ -414,15 +415,22 @@ const ConsumerPageContent = ({
 
   const loadGroupSettings = async (group: ConsumerGroup) => {
     if (!selectedInstanceId) return;
+    const requestId = ++settingsRequestIdRef.current;
     setSettingsGroup(group);
     setSettingsLoading(true);
     try {
       const settings = await getConsumerGroupSettings(group.name, 
selectedInstanceId);
-      settingsForm.setFieldsValue(settings);
+      if (requestId === settingsRequestIdRef.current) {
+        settingsForm.setFieldsValue(settings);
+      }
     } catch {
-      message.error('加载消费组配置失败,请稍后重试');
+      if (requestId === settingsRequestIdRef.current) {
+        message.error('加载消费组配置失败,请稍后重试');
+      }
     } finally {
-      setSettingsLoading(false);
+      if (requestId === settingsRequestIdRef.current) {
+        setSettingsLoading(false);
+      }
     }
   };
 
@@ -1235,10 +1243,12 @@ const ConsumerPageContent = ({
         }
         open={modalOpen}
         onCancel={() => {
+          settingsRequestIdRef.current += 1;
           setModalOpen(false);
           setSelectedGroup(null);
           setShowOnlyInconsistent(false);
           setSettingsGroup(null);
+          setSettingsLoading(false);
           settingsForm.resetFields();
         }}
         width={detailTab === 'progress' ? 1080 : 800}
diff --git a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx 
b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
index f59e1a6a3..3ec320a68 100644
--- a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
+++ b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
@@ -18,9 +18,18 @@ vi.mock('../../../services/instanceService', () => ({
 }));
 vi.mock('../../../services/opsService', () => ({
   listAlertDeliveriesPage: vi.fn(),
+  retryAlertDeliveries: vi.fn(),
   retryAlertDelivery: vi.fn(),
 }));
 
+const deferred = <T,>() => {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((complete) => {
+    resolve = complete;
+  });
+  return { promise, resolve };
+};
+
 beforeAll(() => {
   Object.defineProperty(window, 'matchMedia', {
     writable: true,
@@ -73,4 +82,69 @@ describe('NotificationDeliveriesPage', () => {
     await waitFor(() => expect(retryAlertDelivery).toHaveBeenCalledWith(7));
     await waitFor(() => 
expect(listAlertDeliveriesPage).toHaveBeenCalledTimes(2));
   });
+
+  it('refreshes a completed retry with the latest filters', async () => {
+    const retry = deferred<void>();
+    vi.mocked(retryAlertDelivery).mockImplementation(() => retry.promise);
+    vi.mocked(listAlertDeliveriesPage).mockImplementation(async (query) =>
+      query?.status === 'DELIVERED'
+        ? {
+            items: [
+              {
+                id: 8,
+                alertId: 4,
+                alertTitle: 'Delivered notification',
+                channel: 'email',
+                status: 'DELIVERED',
+                attemptCount: 1,
+                createdAt: '2026-08-23T10:00:00',
+                deliveredAt: '2026-08-23T10:01:00',
+              },
+            ],
+            total: 1,
+            page: 1,
+            size: 20,
+          }
+        : {
+            items: [
+              {
+                id: 7,
+                alertId: 3,
+                alertTitle: 'Broker disk usage',
+                channel: 'dingtalk',
+                status: 'FAILED',
+                attemptCount: 5,
+                createdAt: '2026-08-23T10:00:00',
+                lastError: 'Webhook rejected the request',
+              },
+            ],
+            total: 1,
+            page: 1,
+            size: 20,
+          },
+    );
+    const user = userEvent.setup();
+    render(
+      <App>
+        <LangProvider>
+          <NotificationDeliveriesPage />
+        </LangProvider>
+      </App>,
+    );
+
+    await screen.findByText('Broker disk usage');
+    await user.click(screen.getByRole('button', { name: '重新投递' }));
+    await user.click(screen.getAllByRole('combobox')[1]);
+    await user.click(await screen.findByText('DELIVERED'));
+    expect(await screen.findByText('Delivered 
notification')).toBeInTheDocument();
+
+    retry.resolve();
+
+    await waitFor(() => 
expect(listAlertDeliveriesPage).toHaveBeenCalledTimes(3));
+    expect(screen.getByText('Delivered notification')).toBeInTheDocument();
+    expect(screen.queryByText('Broker disk usage')).not.toBeInTheDocument();
+    expect(listAlertDeliveriesPage).toHaveBeenLastCalledWith(
+      expect.objectContaining({ status: 'DELIVERED' }),
+    );
+  });
 });
diff --git a/web/src/pages/ops/notificationDeliveries.tsx 
b/web/src/pages/ops/notificationDeliveries.tsx
index 759e2d885..2cd308206 100644
--- a/web/src/pages/ops/notificationDeliveries.tsx
+++ b/web/src/pages/ops/notificationDeliveries.tsx
@@ -55,16 +55,11 @@ const NotificationDeliveriesPage = () => {
   const [selectedDelivery, setSelectedDelivery] = 
useState<NotificationDeliveryRecord>();
   const [retryingIds, setRetryingIds] = useState<Set<number>>(() => new Set());
   const [retryingVisible, setRetryingVisible] = useState(false);
+  const [refreshNonce, setRefreshNonce] = useState(0);
 
   const refresh = () => {
     setLoading(true);
-    void listAlertDeliveriesPage({ channel, status, instanceId, page, pageSize 
})
-      .then((result) => {
-        setItems(result.items);
-        setTotal(result.total);
-      })
-      .catch(() => message.error(t('deliveries.loadFailed')))
-      .finally(() => setLoading(false));
+    setRefreshNonce((current) => current + 1);
   };
 
   const retryDelivery = async (record: NotificationDeliveryRecord) => {
@@ -133,7 +128,7 @@ const NotificationDeliveriesPage = () => {
     return () => {
       cancelled = true;
     };
-  }, [channel, status, instanceId, page, pageSize, t]);
+  }, [channel, status, instanceId, page, pageSize, refreshNonce, t]);
 
   const resetPage = (change: () => void) => {
     setLoading(true);
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index db8fb05ad..5014517d8 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -58,16 +58,13 @@ import {
   type ProxyHomePageData,
   type ProxyNode,
 } from '../../api/proxy';
+import { readLocalStorage, writeLocalStorage } from 
'../../utils/browserStorage';
 
 const { Text } = Typography;
 
 const persistProxyAddress = (address?: string) => {
   if (!address) return;
-  try {
-    localStorage.setItem('proxyAddr', address);
-  } catch {
-    // Proxy discovery remains usable when browser storage is unavailable.
-  }
+  writeLocalStorage('proxyAddr', address);
 };
 
 const ProxyPage: React.FC = () => {
@@ -83,7 +80,7 @@ const ProxyPage: React.FC = () => {
   const [addressMutationLoading, setAddressMutationLoading] = useState(false);
   const [removingProxyAddress, setRemovingProxyAddress] = useState<string | 
null>(null);
   const [clusterId, setClusterId] = useState<string>(
-    localStorage.getItem('clusterId') || 'DefaultCluster',
+    readLocalStorage('clusterId') || 'DefaultCluster',
   );
   const loadRequestId = useRef(0);
 
@@ -187,7 +184,7 @@ const ProxyPage: React.FC = () => {
   const handleClusterIdChange = (value: string) => {
     setClusterId(value);
     if (value) {
-      localStorage.setItem('clusterId', value);
+      writeLocalStorage('clusterId', value);
     }
   };
 
diff --git a/web/src/pages/studio/UserManagement.tsx 
b/web/src/pages/studio/UserManagement.tsx
index de0de99d3..412d5c9bb 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -89,9 +89,11 @@ const UserManagementPage = () => {
   const [createOpen, setCreateOpen] = useState(false);
   const [passwordTarget, setPasswordTarget] = useState<StudioUser | 
null>(null);
   const [userExporting, setUserExporting] = useState(false);
+  const [mutatingUserIds, setMutatingUserIds] = useState<Set<number>>(() => 
new Set());
   const [createForm] = Form.useForm<CreateFormValues>();
   const [passwordForm] = Form.useForm<PasswordFormValues>();
   const requestSeqRef = useRef(0);
+  const mutatingUserIdsRef = useRef(new Set<number>());
 
   useEffect(() => {
     const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 
300);
@@ -163,12 +165,18 @@ const UserManagementPage = () => {
   };
 
   const setEnabled = async (record: StudioUser, enabled: boolean) => {
+    if (mutatingUserIdsRef.current.has(record.id)) return;
+    mutatingUserIdsRef.current.add(record.id);
+    setMutatingUserIds(new Set(mutatingUserIdsRef.current));
     try {
       await setStudioUserEnabled(record.id, enabled);
       message.success(enabled ? '用户已启用' : '用户已禁用,全部会话已注销');
       await loadUsers();
     } catch {
       message.error('更新用户状态失败');
+    } finally {
+      mutatingUserIdsRef.current.delete(record.id);
+      setMutatingUserIds(new Set(mutatingUserIdsRef.current));
     }
   };
 
@@ -237,6 +245,7 @@ const UserManagementPage = () => {
           </Button>
           <Switch
             checked={record.enabled}
+            loading={mutatingUserIds.has(record.id)}
             checkedChildren="启用"
             unCheckedChildren="禁用"
             onChange={(enabled) => void setEnabled(record, enabled)}
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx 
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index 2fb9551ca..4ac5d3a8a 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -101,6 +101,19 @@ describe('ProxyPage', () => {
     storageSpy.mockRestore();
   });
 
+  it('uses the default cluster when stored preferences cannot be read', async 
() => {
+    const storageSpy = vi.spyOn(Storage.prototype, 
'getItem').mockImplementation(() => {
+      throw new DOMException('storage disabled', 'SecurityError');
+    });
+    try {
+      renderPage();
+      await screen.findAllByText('127.0.0.1:8081');
+      expect(screen.getByDisplayValue('DefaultCluster')).toBeInTheDocument();
+    } finally {
+      storageSpy.mockRestore();
+    }
+  });
+
   it('loads Proxy nodes once after the page mounts', async () => {
     renderPage();
 
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx 
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 07812b13d..136cb79b9 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -17,12 +17,14 @@
 
 import { App } from 'antd';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from 
'@testing-library/react';
 import userEvent, { type UserEvent } from '@testing-library/user-event';
 import { MemoryRouter } from 'react-router-dom';
 import {
   listAllStudioUsers as downloadStudioUsers,
   listStudioUsers,
+  setStudioUserEnabled,
+  type StudioUser,
 } from '../../../api/studioUsers';
 import { downloadCsv } from '../../../utils/download';
 import UserManagementPage from '../UserManagement';
@@ -157,4 +159,23 @@ describe('UserManagementPage', () => {
     expect(exportedCsv).toContain('"User"');
     expect(exportedCsv).toContain('"Enabled"');
   });
+
+  it('does not overlap status updates for the same user', async () => {
+    let resolveUpdate!: () => void;
+    vi.mocked(setStudioUserEnabled).mockImplementationOnce(
+      () =>
+        new Promise<StudioUser>((resolve) => {
+          resolveUpdate = () => resolve({ ...studioUserPage.items[0], enabled: 
false });
+        }),
+    );
+    renderPage();
+
+    const toggle = await screen.findByRole('switch');
+    fireEvent.click(toggle);
+    fireEvent.click(toggle);
+
+    expect(setStudioUserEnabled).toHaveBeenCalledTimes(1);
+    expect(setStudioUserEnabled).toHaveBeenCalledWith(7, false);
+    await act(async () => resolveUpdate());
+  });
 });
diff --git a/web/src/services/instanceService.test.ts 
b/web/src/services/instanceService.test.ts
index c7da95009..c5dd08eb7 100644
--- a/web/src/services/instanceService.test.ts
+++ b/web/src/services/instanceService.test.ts
@@ -165,4 +165,25 @@ describe('instanceService list request dedupe', () => {
     await listInstances({});
     expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
   });
+
+  it('does not share inflight requests across data modes', async () => {
+    dataModeMock.isMockMode.mockReturnValue(false);
+    let resolveRealList!: (value: Instance[]) => void;
+    instanceApiMock.listInstances.mockImplementationOnce(
+      () =>
+        new Promise<Instance[]>((resolve) => {
+          resolveRealList = resolve;
+        }),
+    );
+
+    const realRequest = listInstances({});
+    dataModeMock.isMockMode.mockReturnValue(true);
+    const mockResult = await listInstances({});
+
+    expect(mockResult.length).toBeGreaterThan(0);
+    expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(1);
+
+    resolveRealList([]);
+    await expect(realRequest).resolves.toEqual([]);
+  });
 });
diff --git a/web/src/services/instanceService.ts 
b/web/src/services/instanceService.ts
index 751f68b8a..af8f54a28 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -41,18 +41,19 @@ const CLOUD_CAPABILITIES: 
InstanceCapabilities['capabilities'] = [
 const inflightListRequests = new Map<string, Promise<Instance[]>>();
 
 export function listInstances(query: InstanceQuery = {}): Promise<Instance[]> {
-  const key = JSON.stringify([query.type ?? null, query.search?.trim() || 
null]);
+  const mockMode = isMockMode();
+  const key = JSON.stringify([mockMode, query.type ?? null, 
query.search?.trim() || null]);
   const inflight = inflightListRequests.get(key);
   if (inflight) {
     return inflight.then((items) => items.map(copyInstance));
   }
-  const request = fetchInstances(query).finally(() => 
inflightListRequests.delete(key));
+  const request = fetchInstances(query, mockMode).finally(() => 
inflightListRequests.delete(key));
   inflightListRequests.set(key, request);
   return request.then((items) => items.map(copyInstance));
 }
 
-async function fetchInstances(query: InstanceQuery): Promise<Instance[]> {
-  if (isMockMode()) {
+async function fetchInstances(query: InstanceQuery, mockMode: boolean): 
Promise<Instance[]> {
+  if (mockMode) {
     const search = query.search?.trim().toLowerCase();
     return mockInstances
       .filter((instance) => matchesType(instance, query.type))
diff --git a/web/src/stores/authStorage.ts b/web/src/stores/authStorage.ts
index af69f7d88..8f63329ad 100644
--- a/web/src/stores/authStorage.ts
+++ b/web/src/stores/authStorage.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import { readLocalStorage, removeLocalStorage, writeLocalStorage } from 
'../utils/browserStorage';
+
 export const USER_STORAGE_KEY = 'rocketmq-studio-user';
 export const USER_ID_STORAGE_KEY = 'rocketmq-studio-user-id';
 export const USER_ADMIN_STORAGE_KEY = 'rocketmq-studio-user-admin';
@@ -34,39 +36,27 @@ function parseUserId(raw: string | null): number | null {
 }
 
 export function readAuthSession(): AuthSession {
-  try {
-    const admin = localStorage.getItem(USER_ADMIN_STORAGE_KEY);
-    return {
-      user: localStorage.getItem(USER_STORAGE_KEY),
-      userId: parseUserId(localStorage.getItem(USER_ID_STORAGE_KEY)),
-      admin: admin != null ? admin === 'true' : null,
-    };
-  } catch {
-    return { user: null, userId: null, admin: null };
-  }
+  const admin = readLocalStorage(USER_ADMIN_STORAGE_KEY);
+  return {
+    user: readLocalStorage(USER_STORAGE_KEY),
+    userId: parseUserId(readLocalStorage(USER_ID_STORAGE_KEY)),
+    admin: admin != null ? admin === 'true' : null,
+  };
 }
 
 export function persistAuthSession(user: string, userId: number | null, admin: 
boolean): void {
-  try {
-    localStorage.setItem(USER_STORAGE_KEY, user);
-    if (userId != null) {
-      localStorage.setItem(USER_ID_STORAGE_KEY, String(userId));
-    } else {
-      localStorage.removeItem(USER_ID_STORAGE_KEY);
-    }
-    localStorage.setItem(USER_ADMIN_STORAGE_KEY, String(admin));
-  } catch {
-    // The in-memory store remains usable when browser storage is unavailable.
+  writeLocalStorage(USER_STORAGE_KEY, user);
+  if (userId != null) {
+    writeLocalStorage(USER_ID_STORAGE_KEY, String(userId));
+  } else {
+    removeLocalStorage(USER_ID_STORAGE_KEY);
   }
+  writeLocalStorage(USER_ADMIN_STORAGE_KEY, String(admin));
 }
 
 export function clearAuthSession(): void {
-  try {
-    localStorage.removeItem('token');
-    localStorage.removeItem(USER_STORAGE_KEY);
-    localStorage.removeItem(USER_ID_STORAGE_KEY);
-    localStorage.removeItem(USER_ADMIN_STORAGE_KEY);
-  } catch {
-    // The caller still clears the in-memory store.
-  }
+  removeLocalStorage('token');
+  removeLocalStorage(USER_STORAGE_KEY);
+  removeLocalStorage(USER_ID_STORAGE_KEY);
+  removeLocalStorage(USER_ADMIN_STORAGE_KEY);
 }
diff --git a/web/src/theme/themePreference.ts b/web/src/theme/themePreference.ts
index f2c11e741..36b3fbebd 100644
--- a/web/src/theme/themePreference.ts
+++ b/web/src/theme/themePreference.ts
@@ -15,26 +15,20 @@
  * limitations under the License.
  */
 
+import { readLocalStorage, writeLocalStorage } from '../utils/browserStorage';
+
 export type ThemeMode = 'light' | 'dark' | 'system';
 
 export const THEME_STORAGE_KEY = 'rocketmq-studio-theme';
 export const COMPACT_STORAGE_KEY = 'rocketmq-studio-compact';
 
 export function getStoredThemeMode(): ThemeMode {
-  try {
-    const stored = localStorage.getItem(THEME_STORAGE_KEY);
-    return stored === 'dark' || stored === 'light' || stored === 'system' ? 
stored : 'system';
-  } catch {
-    return 'system';
-  }
+  const stored = readLocalStorage(THEME_STORAGE_KEY);
+  return stored === 'dark' || stored === 'light' || stored === 'system' ? 
stored : 'system';
 }
 
 export function persistThemeMode(mode: ThemeMode): void {
-  try {
-    localStorage.setItem(THEME_STORAGE_KEY, mode);
-  } catch {
-    // The active theme still changes when browser storage is unavailable.
-  }
+  writeLocalStorage(THEME_STORAGE_KEY, mode);
 }
 
 export function getSystemDarkMode(): boolean {
@@ -42,17 +36,9 @@ export function getSystemDarkMode(): boolean {
 }
 
 export function getStoredCompact(): boolean {
-  try {
-    return localStorage.getItem(COMPACT_STORAGE_KEY) === 'true';
-  } catch {
-    return false;
-  }
+  return readLocalStorage(COMPACT_STORAGE_KEY) === 'true';
 }
 
 export function persistCompact(compact: boolean): void {
-  try {
-    localStorage.setItem(COMPACT_STORAGE_KEY, String(compact));
-  } catch {
-    // Compact mode still toggles when browser storage is unavailable.
-  }
+  writeLocalStorage(COMPACT_STORAGE_KEY, String(compact));
 }
diff --git a/web/src/utils/browserStorage.test.ts 
b/web/src/utils/browserStorage.test.ts
new file mode 100644
index 000000000..a13be2aaf
--- /dev/null
+++ b/web/src/utils/browserStorage.test.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 { describe, expect, it, vi } from 'vitest';
+import { readLocalStorage, removeLocalStorage, writeLocalStorage } from 
'./browserStorage';
+
+describe('browserStorage', () => {
+  it('reads, writes and removes available storage', () => {
+    expect(writeLocalStorage('key', 'value')).toBe(true);
+    expect(readLocalStorage('key')).toBe('value');
+    expect(removeLocalStorage('key')).toBe(true);
+    expect(readLocalStorage('key')).toBeNull();
+  });
+
+  it('returns safe fallbacks when storage operations throw', () => {
+    vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+      throw new DOMException('blocked', 'SecurityError');
+    });
+    vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+      throw new DOMException('blocked', 'SecurityError');
+    });
+    vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
+      throw new DOMException('blocked', 'SecurityError');
+    });
+
+    expect(readLocalStorage('key')).toBeNull();
+    expect(writeLocalStorage('key', 'value')).toBe(false);
+    expect(removeLocalStorage('key')).toBe(false);
+  });
+});
diff --git a/web/src/i18n/languagePreference.ts 
b/web/src/utils/browserStorage.ts
similarity index 64%
copy from web/src/i18n/languagePreference.ts
copy to web/src/utils/browserStorage.ts
index 7354eb664..e56a53be8 100644
--- a/web/src/i18n/languagePreference.ts
+++ b/web/src/utils/browserStorage.ts
@@ -15,23 +15,28 @@
  * limitations under the License.
  */
 
-import type { Lang } from './translations';
-
-export const LANGUAGE_STORAGE_KEY = 'rocketmq-studio-language';
+export function readLocalStorage(key: string): string | null {
+  try {
+    return localStorage.getItem(key);
+  } catch {
+    return null;
+  }
+}
 
-export function getInitialLanguage(): Lang {
+export function writeLocalStorage(key: string, value: string): boolean {
   try {
-    const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
-    return stored === 'en' || stored === 'zh' ? stored : 'zh';
+    localStorage.setItem(key, value);
+    return true;
   } catch {
-    return 'zh';
+    return false;
   }
 }
 
-export function persistLanguage(lang: Lang): void {
+export function removeLocalStorage(key: string): boolean {
   try {
-    localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
+    localStorage.removeItem(key);
+    return true;
   } catch {
-    // Language selection still works when browser storage is unavailable.
+    return false;
   }
 }

Reply via email to