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 951ec318 fix: preserve LiteTopic namespace isolation (#677)
951ec318 is described below

commit 951ec318d712fee35479879c8cc0ba994492da06
Author: Rui <[email protected]>
AuthorDate: Fri Jul 31 16:19:38 2026 +0800

    fix: preserve LiteTopic namespace isolation (#677)
---
 .../studio/instance/topic/LiteTopicService.java    |  13 +-
 .../instance/topic/LiteTopicServiceTest.java       |   8 +-
 web/src/api/liteTopic.test.ts                      |   2 +
 web/src/api/liteTopic.ts                           |   1 +
 web/src/pages/studio/LiteTopic.tsx                 | 206 +++++++++------
 web/src/pages/studio/__tests__/LiteTopic.test.tsx  | 280 ++++++++++++++++++++-
 6 files changed, 433 insertions(+), 77 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
index 76a6ca89..af8129fd 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/LiteTopicService.java
@@ -27,8 +27,8 @@ public class LiteTopicService {
 
     public List<LiteTopicItemVO> listLiteTopics(String pattern, String 
namespace) {
         return sampleItems().stream()
-                .filter(item -> matches(pattern, item.getTopicPattern()))
-                .filter(item -> matches(namespace, item.getNamespace()))
+                .filter(item -> matchesPattern(pattern, 
item.getTopicPattern()))
+                .filter(item -> matchesNamespace(namespace, 
item.getNamespace()))
                 .toList();
     }
 
@@ -113,10 +113,17 @@ public class LiteTopicService {
                         .build());
     }
 
-    private boolean matches(String filter, String value) {
+    private boolean matchesPattern(String filter, String value) {
         if (filter == null || filter.isBlank()) {
             return true;
         }
         return value != null && 
value.toLowerCase(Locale.ROOT).contains(filter.toLowerCase(Locale.ROOT));
     }
+
+    private boolean matchesNamespace(String namespace, String value) {
+        if (namespace == null || namespace.isBlank()) {
+            return true;
+        }
+        return value != null && value.equalsIgnoreCase(namespace.trim());
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
index 26a4acee..d2677d11 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/LiteTopicServiceTest.java
@@ -30,13 +30,19 @@ class LiteTopicServiceTest {
 
     @Test
     void listLiteTopicsShouldFilterByPatternAndNamespace() {
-        List<LiteTopicItemVO> result = liteTopicService.listLiteTopics("chat", 
"default");
+        List<LiteTopicItemVO> result = liteTopicService.listLiteTopics("hat", 
" DEFAULT ");
 
         assertThat(result).hasSize(1);
         
assertThat(result.get(0).getTopicPattern()).isEqualTo("chat/{sessionId}");
         assertThat(result.get(0).getNamespace()).isEqualTo("default");
     }
 
+    @Test
+    void listLiteTopicsShouldMatchNamespaceExactly() {
+        assertThat(liteTopicService.listLiteTopics(null, "def")).isEmpty();
+        assertThat(liteTopicService.listLiteTopics(null, "  ")).hasSize(2);
+    }
+
     @Test
     void getQuotaShouldReturnDefaultLimits() {
         LiteTopicQuotaVO quota = liteTopicService.getQuota("default");
diff --git a/web/src/api/liteTopic.test.ts b/web/src/api/liteTopic.test.ts
index 352555ca..a571470f 100644
--- a/web/src/api/liteTopic.test.ts
+++ b/web/src/api/liteTopic.test.ts
@@ -32,6 +32,7 @@ import {
 const mock = new MockAdapter(client);
 
 const sampleItem: LiteTopicItem = {
+  namespace: 'default',
   topicPattern: 'order-*',
   topicCount: 15,
   consumerCount: 3,
@@ -88,6 +89,7 @@ describe('LiteTopic API', () => {
 
     const result = await queryLiteTopicList();
     expect(result).toHaveLength(1);
+    expect(result[0].namespace).toBe('default');
     expect(result[0].topicPattern).toBe('order-*');
   });
 
diff --git a/web/src/api/liteTopic.ts b/web/src/api/liteTopic.ts
index f080ef21..1dfeff3b 100644
--- a/web/src/api/liteTopic.ts
+++ b/web/src/api/liteTopic.ts
@@ -35,6 +35,7 @@ export interface LiteTopicQuota {
 }
 
 export interface LiteTopicItem {
+  namespace: string;
   topicPattern: string;
   topicCount?: number;
   consumerCount?: number;
diff --git a/web/src/pages/studio/LiteTopic.tsx 
b/web/src/pages/studio/LiteTopic.tsx
index 001760de..3aefb2d8 100644
--- a/web/src/pages/studio/LiteTopic.tsx
+++ b/web/src/pages/studio/LiteTopic.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useState, useRef } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import {
   Table,
   Button,
@@ -78,9 +78,30 @@ const getProgressStatus = (percent: number): 'exception' | 
'active' | 'normal' =
   return 'normal';
 };
 
+const collectNamespaces = (items: LiteTopicItem[]): string[] => {
+  const namespaces = new Map<string, string>();
+
+  items.forEach((item) => {
+    const namespace = item.namespace?.trim() || '';
+    if (!namespace) return;
+
+    const normalized = namespace.toLowerCase();
+    if (!namespaces.has(normalized)) {
+      namespaces.set(normalized, namespace);
+    }
+  });
+
+  return Array.from(namespaces.values()).sort((left, right) => {
+    const normalizedOrder = 
left.toLowerCase().localeCompare(right.toLowerCase());
+    return normalizedOrder || left.localeCompare(right);
+  });
+};
+
 const LiteTopicPage: React.FC = () => {
   const { t } = useLang();
   const { message } = App.useApp();
+  const messageRef = useRef(message);
+  const translationRef = useRef(t);
 
   const [loading, setLoading] = useState(false);
   const [topicList, setTopicList] = useState<LiteTopicItem[]>([]);
@@ -88,6 +109,7 @@ const LiteTopicPage: React.FC = () => {
   const [capabilitySupported, setCapabilitySupported] = useState(true);
   const [patternFilter, setPatternFilter] = useState('');
   const [namespaceFilter, setNamespaceFilter] = useState('');
+  const [namespaceOptions, setNamespaceOptions] = useState<string[]>([]);
 
   // Session drawer
   const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
@@ -102,92 +124,121 @@ const LiteTopicPage: React.FC = () => {
   }>({ topicPattern: '', newTTL: null });
   const [extendTTLLoading, setExtendTTLLoading] = useState(false);
 
-  const initialized = useRef<boolean | null>(null);
-  if (initialized.current == null) {
-    initialized.current = true;
-    const init = async () => {
+  const mountedRef = useRef(false);
+  const bootstrapRequestId = useRef(0);
+  const displayRequestId = useRef(0);
+
+  useEffect(() => {
+    messageRef.current = message;
+    translationRef.current = t;
+  }, [message, t]);
+
+  const fetchData = useCallback(
+    async (
+      pattern: string | undefined,
+      namespace: string | undefined,
+      options: { clear?: boolean; collectNamespaceOptions?: boolean } = {},
+    ) => {
+      const requestId = ++displayRequestId.current;
+
+      if (options.clear) {
+        setTopicList([]);
+        setQuota(null);
+      }
+      setLoading(true);
+
+      const [quotaResult, listResult] = await Promise.allSettled([
+        queryLiteTopicQuota(namespace),
+        queryLiteTopicList(pattern, namespace),
+      ]);
+
+      if (!mountedRef.current) return;
+
+      if (listResult.status === 'fulfilled' && 
options.collectNamespaceOptions) {
+        setNamespaceOptions(collectNamespaces(listResult.value));
+      }
+
+      if (requestId !== displayRequestId.current) return;
+
+      if (quotaResult.status === 'fulfilled') {
+        setQuota(quotaResult.value);
+      }
+
+      if (listResult.status === 'fulfilled') {
+        setTopicList(listResult.value);
+      } else {
+        setTopicList([]);
+        
messageRef.current.error(translationRef.current('liteTopic.fetchListFailed'));
+      }
+
+      setLoading(false);
+    },
+    [],
+  );
+
+  useEffect(() => {
+    const mountedState = mountedRef;
+    const bootstrapCounter = bootstrapRequestId;
+    const displayCounter = displayRequestId;
+    mountedState.current = true;
+
+    const bootstrapId = ++bootstrapCounter.current;
+    const initialDisplayRequestId = displayCounter.current;
+    const bootstrapIsActive = () =>
+      mountedState.current && bootstrapId === bootstrapCounter.current;
+
+    const initialize = async () => {
       try {
-        const cap = await queryLiteTopicCapability();
-        if (cap && cap.supported === false) {
+        const capability = await queryLiteTopicCapability();
+        if (!bootstrapIsActive()) return;
+        if (capability && capability.supported === false) {
+          ++displayCounter.current;
           setCapabilitySupported(false);
+          setTopicList([]);
+          setQuota(null);
+          setLoading(false);
           return;
         }
       } catch {
-        // Assume supported on error
-      }
-      try {
-        const q = await queryLiteTopicQuota();
-        setQuota(q);
-      } catch {
-        // ignore
+        if (!bootstrapIsActive()) return;
+        // Assume supported when capability detection is unavailable.
       }
-      setLoading(true);
-      try {
-        const list = await queryLiteTopicList();
-        setTopicList(list);
-      } catch {
-        message.error(t('liteTopic.fetchListFailed'));
-      } finally {
-        setLoading(false);
+
+      if (displayCounter.current === initialDisplayRequestId) {
+        await fetchData(undefined, undefined, { collectNamespaceOptions: true 
});
+      } else {
+        try {
+          const list = await queryLiteTopicList();
+          if (bootstrapIsActive()) {
+            setNamespaceOptions(collectNamespaces(list));
+          }
+        } catch {
+          // The active display request owns user-visible error handling.
+        }
       }
     };
-    init();
-  }
 
-  const fetchQuota = async () => {
-    try {
-      const q = await queryLiteTopicQuota(namespaceFilter || undefined);
-      setQuota(q);
-    } catch {
-      // ignore
-    }
-  };
+    void initialize();
 
-  const fetchTopicList = async () => {
-    setLoading(true);
-    try {
-      const list = await queryLiteTopicList(
-        patternFilter || undefined,
-        namespaceFilter || undefined,
-      );
-      setTopicList(list);
-    } catch {
-      message.error(t('liteTopic.fetchListFailed'));
-      setTopicList([]);
-    } finally {
-      setLoading(false);
-    }
-  };
+    return () => {
+      mountedState.current = false;
+      ++bootstrapCounter.current;
+      ++displayCounter.current;
+    };
+  }, [fetchData]);
 
   const handleSearch = () => {
-    fetchTopicList();
+    void fetchData(patternFilter || undefined, namespaceFilter || undefined);
   };
 
   const handleRefresh = () => {
-    fetchQuota();
-    fetchTopicList();
+    void fetchData(patternFilter || undefined, namespaceFilter || undefined);
   };
 
   const handleNamespaceChange = (val: string | undefined) => {
-    setNamespaceFilter(val || '');
-    // Re-fetch with new namespace
-    const doFetch = async () => {
-      setLoading(true);
-      try {
-        const ns = val || undefined;
-        const [q, list] = await Promise.all([
-          queryLiteTopicQuota(ns),
-          queryLiteTopicList(patternFilter || undefined, ns),
-        ]);
-        setQuota(q);
-        setTopicList(list);
-      } catch {
-        message.error(t('liteTopic.fetchListFailed'));
-      } finally {
-        setLoading(false);
-      }
-    };
-    doFetch();
+    const namespace = val || undefined;
+    setNamespaceFilter(namespace || '');
+    void fetchData(patternFilter || undefined, namespace, { clear: true });
   };
 
   const handleViewSessions = async (sessionId: string) => {
@@ -219,8 +270,7 @@ const LiteTopicPage: React.FC = () => {
       await extendLiteTopicTTL(extendTTLForm.topicPattern, 
extendTTLForm.newTTL);
       message.success(t('liteTopic.extendTtlSuccess'));
       setExtendTTLModalOpen(false);
-      fetchTopicList();
-      fetchQuota();
+      void fetchData(patternFilter || undefined, namespaceFilter || undefined);
     } catch {
       message.error(t('liteTopic.extendTtlFailed'));
     } finally {
@@ -241,6 +291,13 @@ const LiteTopicPage: React.FC = () => {
   // ─── Columns ─────────────────────────────────────────────────
 
   const columns: ColumnsType<LiteTopicItem> = [
+    {
+      title: t('liteTopic.namespace'),
+      dataIndex: 'namespace',
+      key: 'namespace',
+      render: (text: string) => text || '-',
+      ellipsis: true,
+    },
     {
       title: t('liteTopic.pattern'),
       dataIndex: 'topicPattern',
@@ -663,6 +720,11 @@ const LiteTopicPage: React.FC = () => {
             allowClear
           >
             <Select.Option 
value="">{t('liteTopic.allNamespaces')}</Select.Option>
+            {namespaceOptions.map((namespace) => (
+              <Select.Option key={namespace.toLowerCase()} value={namespace}>
+                {namespace}
+              </Select.Option>
+            ))}
           </Select>
           <Button type="primary" icon={<MagnifyingGlass size={14} />} 
onClick={handleSearch}>
             {t('common.search')}
@@ -675,7 +737,7 @@ const LiteTopicPage: React.FC = () => {
         <Table
           columns={columns}
           dataSource={topicList}
-          rowKey={(record) => record.topicPattern || Math.random().toString()}
+          rowKey={(record) => JSON.stringify([record.namespace, 
record.topicPattern])}
           loading={loading}
           pagination={{
             pageSize: 10,
diff --git a/web/src/pages/studio/__tests__/LiteTopic.test.tsx 
b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
index 059a29db..8680eddb 100644
--- a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
+++ b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
@@ -16,10 +16,11 @@
  */
 
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, within } from '@testing-library/react';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import { LangProvider } from '../../../i18n/LangContext';
+import type { LiteTopicItem, LiteTopicQuota } from '../../../api/liteTopic';
 import LiteTopic from '../LiteTopic';
 
 const apiMocks = vi.hoisted(() => ({
@@ -57,6 +58,33 @@ const renderPage = () =>
     </App>,
   );
 
+const createDeferred = <T,>() => {
+  let resolve!: (value: T) => void;
+  let reject!: (reason?: unknown) => void;
+  const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+    resolve = resolvePromise;
+    reject = rejectPromise;
+  });
+  return { promise, resolve, reject };
+};
+
+const createQuota = (currentTopicCount: number): LiteTopicQuota => ({
+  currentTopicCount,
+  maxTopicCount: 100,
+  currentSessionCount: 0,
+  maxSessionCount: 100,
+  currentCreationRate: 0,
+  maxCreationRate: 100,
+});
+
+const selectNamespace = async (name: string) => {
+  const user = userEvent.setup();
+  await user.click(screen.getAllByRole('combobox')[0]);
+  await user.click(
+    await screen.findByText(name, { selector: 
'.ant-select-item-option-content' }),
+  );
+};
+
 describe('LiteTopic Page', () => {
   beforeEach(() => {
     vi.clearAllMocks();
@@ -64,6 +92,7 @@ describe('LiteTopic Page', () => {
     apiMocks.queryLiteTopicQuota.mockResolvedValue(null);
     apiMocks.queryLiteTopicList.mockResolvedValue([
       {
+        namespace: 'default',
         topicPattern: 'order-*',
         sessionIds: ['session-1'],
       },
@@ -86,4 +115,253 @@ describe('LiteTopic Page', () => {
     const popProgressLabel = await screen.findByText('Pop 进度');
     
expect(within(popProgressLabel.parentElement!).getByText('96%')).toBeInTheDocument();
   });
+
+  it('keeps namespace identity in the table and builds stable options from the 
initial list', async () => {
+    const initialItems: LiteTopicItem[] = [
+      { namespace: 'zeta', topicPattern: 'shared-*' },
+      { namespace: 'Alpha', topicPattern: 'shared-*' },
+      { namespace: ' alpha ', topicPattern: 'another-*' },
+      { namespace: '   ', topicPattern: 'blank-*' },
+    ];
+    apiMocks.queryLiteTopicList
+      .mockResolvedValueOnce(initialItems)
+      .mockResolvedValueOnce([{ namespace: 'Alpha', topicPattern: 'filtered-*' 
}]);
+
+    const user = userEvent.setup();
+    const { container } = renderPage();
+
+    expect(await screen.findAllByText('shared-*')).toHaveLength(2);
+    expect(screen.getByRole('columnheader', { name: '命名空间' 
})).toBeInTheDocument();
+
+    const rowKeys = 
Array.from(container.querySelectorAll('tr[data-row-key]')).map((row) =>
+      row.getAttribute('data-row-key'),
+    );
+    expect(rowKeys).toContain(JSON.stringify(['zeta', 'shared-*']));
+    expect(rowKeys).toContain(JSON.stringify(['Alpha', 'shared-*']));
+
+    await user.type(screen.getByPlaceholderText('按 Topic 模式筛选...'), 
'filtered');
+    await user.click(screen.getByRole('button', { name: /搜索/ }));
+    expect(await screen.findByText('filtered-*')).toBeInTheDocument();
+
+    await user.click(screen.getAllByRole('combobox')[0]);
+    const dropdown = 
document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
+    expect(dropdown).not.toBeNull();
+    expect(
+      
Array.from(dropdown!.querySelectorAll('.ant-select-item-option-content')).map(
+        (option) => option.textContent,
+      ),
+    ).toEqual(['全部命名空间', 'Alpha', 'zeta']);
+  });
+
+  it('keeps an early filtered display while a delayed bootstrap supplies 
namespace options', async () => {
+    const capability = createDeferred<{ supported: boolean }>();
+    const bootstrapList = createDeferred<LiteTopicItem[]>();
+
+    apiMocks.queryLiteTopicCapability.mockReturnValue(capability.promise);
+    apiMocks.queryLiteTopicList.mockImplementation((pattern: string | 
undefined) =>
+      pattern === 'needle'
+        ? Promise.resolve([{ namespace: 'Filtered', topicPattern: 
'filtered-result-*' }])
+        : bootstrapList.promise,
+    );
+    apiMocks.queryLiteTopicQuota.mockResolvedValue(createQuota(20));
+
+    const user = userEvent.setup();
+    renderPage();
+
+    await user.type(screen.getByPlaceholderText('按 Topic 模式筛选...'), 'needle');
+    await user.click(screen.getByRole('button', { name: /搜索/ }));
+    expect(await screen.findByText('filtered-result-*')).toBeInTheDocument();
+    expect(screen.getByText('20 / 100')).toBeInTheDocument();
+
+    await act(async () => {
+      capability.resolve({ supported: true });
+    });
+    await waitFor(() => {
+      expect(apiMocks.queryLiteTopicList).toHaveBeenCalledTimes(2);
+      expect(apiMocks.queryLiteTopicList).toHaveBeenLastCalledWith();
+    });
+
+    await act(async () => {
+      bootstrapList.resolve([
+        { namespace: 'Beta', topicPattern: 'bootstrap-beta-*' },
+        { namespace: 'Alpha', topicPattern: 'bootstrap-alpha-*' },
+      ]);
+    });
+
+    expect(screen.getByText('filtered-result-*')).toBeInTheDocument();
+    expect(screen.getByText('20 / 100')).toBeInTheDocument();
+    expect(screen.queryByText('bootstrap-alpha-*')).not.toBeInTheDocument();
+    expect(apiMocks.queryLiteTopicQuota).toHaveBeenCalledTimes(1);
+
+    await user.click(screen.getAllByRole('combobox')[0]);
+    const dropdown = 
document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
+    expect(dropdown).not.toBeNull();
+    expect(
+      
Array.from(dropdown!.querySelectorAll('.ant-select-item-option-content')).map(
+        (option) => option.textContent,
+      ),
+    ).toEqual(['全部命名空间', 'Alpha', 'Beta']);
+  });
+
+  it('collects namespace options from a stale in-flight bootstrap display 
request', async () => {
+    const bootstrapList = createDeferred<LiteTopicItem[]>();
+    const bootstrapQuota = createDeferred<LiteTopicQuota>();
+
+    apiMocks.queryLiteTopicList.mockImplementation((pattern: string | 
undefined) =>
+      pattern === 'needle'
+        ? Promise.resolve([{ namespace: 'Filtered', topicPattern: 
'filtered-result-*' }])
+        : bootstrapList.promise,
+    );
+    apiMocks.queryLiteTopicQuota
+      .mockReturnValueOnce(bootstrapQuota.promise)
+      .mockResolvedValueOnce(createQuota(20));
+
+    const user = userEvent.setup();
+    renderPage();
+
+    await waitFor(() => {
+      expect(apiMocks.queryLiteTopicList).toHaveBeenCalledWith(undefined, 
undefined);
+    });
+    await user.type(screen.getByPlaceholderText('按 Topic 模式筛选...'), 'needle');
+    await user.click(screen.getByRole('button', { name: /搜索/ }));
+    expect(await screen.findByText('filtered-result-*')).toBeInTheDocument();
+    expect(screen.getByText('20 / 100')).toBeInTheDocument();
+
+    await act(async () => {
+      bootstrapQuota.resolve(createQuota(90));
+      bootstrapList.resolve([
+        { namespace: 'Beta', topicPattern: 'bootstrap-beta-*' },
+        { namespace: 'Alpha', topicPattern: 'bootstrap-alpha-*' },
+      ]);
+    });
+
+    expect(screen.getByText('filtered-result-*')).toBeInTheDocument();
+    expect(screen.getByText('20 / 100')).toBeInTheDocument();
+    expect(screen.queryByText('bootstrap-alpha-*')).not.toBeInTheDocument();
+    expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
+
+    await user.click(screen.getAllByRole('combobox')[0]);
+    const dropdown = 
document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
+    expect(dropdown).not.toBeNull();
+    expect(
+      
Array.from(dropdown!.querySelectorAll('.ant-select-item-option-content')).map(
+        (option) => option.textContent,
+      ),
+    ).toEqual(['全部命名空间', 'Alpha', 'Beta']);
+  });
+
+  it('honors a delayed unsupported capability after an early search', async () 
=> {
+    const capability = createDeferred<{ supported: boolean }>();
+    const earlyList = createDeferred<LiteTopicItem[]>();
+    const earlyQuota = createDeferred<LiteTopicQuota>();
+
+    apiMocks.queryLiteTopicCapability.mockReturnValue(capability.promise);
+    apiMocks.queryLiteTopicList.mockReturnValue(earlyList.promise);
+    apiMocks.queryLiteTopicQuota.mockReturnValue(earlyQuota.promise);
+
+    const user = userEvent.setup();
+    renderPage();
+
+    await user.type(screen.getByPlaceholderText('按 Topic 模式筛选...'), 'needle');
+    await user.click(screen.getByRole('button', { name: /搜索/ }));
+    expect(apiMocks.queryLiteTopicList).toHaveBeenCalledWith('needle', 
undefined);
+
+    await act(async () => {
+      capability.resolve({ supported: false });
+    });
+    expect(await screen.findByText('当前集群不支持 LiteTopic,请升级到 RocketMQ 
5.x')).toBeInTheDocument();
+
+    await act(async () => {
+      earlyQuota.resolve(createQuota(20));
+      earlyList.resolve([{ namespace: 'default', topicPattern: 'late-result-*' 
}]);
+    });
+    expect(screen.getByText('当前集群不支持 LiteTopic,请升级到 RocketMQ 
5.x')).toBeInTheDocument();
+    expect(screen.queryByText('late-result-*')).not.toBeInTheDocument();
+  });
+
+  it('ignores stale list and quota responses when namespaces change quickly', 
async () => {
+    const alphaList = createDeferred<LiteTopicItem[]>();
+    const betaList = createDeferred<LiteTopicItem[]>();
+    const alphaQuota = createDeferred<LiteTopicQuota>();
+    const betaQuota = createDeferred<LiteTopicQuota>();
+
+    apiMocks.queryLiteTopicList.mockImplementation(
+      (_pattern: string | undefined, namespace: string | undefined) => {
+        if (namespace === 'Alpha') return alphaList.promise;
+        if (namespace === 'Beta') return betaList.promise;
+        return Promise.resolve([
+          { namespace: 'Alpha', topicPattern: 'initial-alpha-*' },
+          { namespace: 'Beta', topicPattern: 'initial-beta-*' },
+        ]);
+      },
+    );
+    apiMocks.queryLiteTopicQuota.mockImplementation((namespace: string | 
undefined) => {
+      if (namespace === 'Alpha') return alphaQuota.promise;
+      if (namespace === 'Beta') return betaQuota.promise;
+      return Promise.resolve(createQuota(90));
+    });
+
+    renderPage();
+
+    expect(await screen.findByText('initial-alpha-*')).toBeInTheDocument();
+    expect(screen.getByText('90 / 100')).toBeInTheDocument();
+
+    await selectNamespace('Alpha');
+    expect(screen.queryByText('initial-alpha-*')).not.toBeInTheDocument();
+    expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
+
+    await selectNamespace('Beta');
+    expect(apiMocks.queryLiteTopicList).toHaveBeenCalledWith(undefined, 
'Beta');
+    expect(apiMocks.queryLiteTopicQuota).toHaveBeenCalledWith('Beta');
+
+    await act(async () => {
+      betaQuota.resolve(createQuota(20));
+      betaList.resolve([{ namespace: 'Beta', topicPattern: 'beta-result-*' }]);
+    });
+    expect(await screen.findByText('beta-result-*')).toBeInTheDocument();
+    expect(screen.getByText('20 / 100')).toBeInTheDocument();
+
+    await act(async () => {
+      alphaQuota.resolve(createQuota(10));
+      alphaList.resolve([{ namespace: 'Alpha', topicPattern: 'stale-alpha-*' 
}]);
+    });
+    expect(screen.getByText('beta-result-*')).toBeInTheDocument();
+    expect(screen.getByText('20 / 100')).toBeInTheDocument();
+    expect(screen.queryByText('stale-alpha-*')).not.toBeInTheDocument();
+    expect(screen.queryByText('10 / 100')).not.toBeInTheDocument();
+  });
+
+  it('keeps a failed namespace request cleared', async () => {
+    const failedList = createDeferred<LiteTopicItem[]>();
+    const failedQuota = createDeferred<LiteTopicQuota>();
+
+    apiMocks.queryLiteTopicList.mockImplementation(
+      (_pattern: string | undefined, namespace: string | undefined) =>
+        namespace === 'Alpha'
+          ? failedList.promise
+          : Promise.resolve([{ namespace: 'Alpha', topicPattern: 'old-*' }]),
+    );
+    apiMocks.queryLiteTopicQuota.mockImplementation((namespace: string | 
undefined) =>
+      namespace === 'Alpha' ? failedQuota.promise : 
Promise.resolve(createQuota(90)),
+    );
+
+    const { container } = renderPage();
+    expect(await screen.findByText('old-*')).toBeInTheDocument();
+
+    await selectNamespace('Alpha');
+    expect(screen.queryByText('old-*')).not.toBeInTheDocument();
+    expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
+
+    await act(async () => {
+      failedQuota.reject(new Error('quota failed'));
+      failedList.reject(new Error('list failed'));
+    });
+
+    expect(await screen.findByText('获取 LiteTopic 列表失败')).toBeInTheDocument();
+    await waitFor(() => {
+      
expect(container.querySelector('.ant-spin-spinning')).not.toBeInTheDocument();
+    });
+    expect(screen.queryByText('old-*')).not.toBeInTheDocument();
+    expect(screen.queryByText('90 / 100')).not.toBeInTheDocument();
+  });
 });

Reply via email to